Devvit Web gives your post two surfaces: a default entrypoint (splash.html) rendered inline in the feed, and an expanded entrypoint (game.html) that opens when the player taps in. The trick is making splash.html boot your real engine in a stripped-down attract mode — and surviving a platform where almost every failure is invisible.
The architecture
- A bare-attract flag in your engine. A global (ours is
TIPSEY_ATTRACT_BARE) checked at load. When set: no network calls, no HUD chrome, and an attract driver plays the game by itself — ours steers the robot through a real procedurally generated route. - Stub elements. Your engine wires up dozens of DOM ids at load. Instead of forking it, the splash builds a hidden
#attract-stubscontainer with every element the engine expects (ours needs 64). The engine boots believing its chrome exists. - A boot script that runs before the engine. It builds the stubs, sets the flag, and defines the ready callback. Load order is load-bearing:
<script src="splash-boot.js"></script> <!-- stubs + flag + callback -->
<script src="phaser.min.js"></script>
<script src="game-logic.js"></script>
<script src="splash.js" type="module"></script> <!-- splash chrome -->
- A handshake, then a cross-fade. The engine's first attract frame calls a global callback; the splash adds an
attract-liveclass to<body>; CSS cross-fades the static art into the live canvas. - The static splash IS the fallback. The page paints a full static splash first — city map, robot, leaderboard, button — with plain canvas drawing. If the engine never comes up, players see a perfectly normal splash. This principle saved us from ever shipping a visibly broken card. It is also, fair warning, why every failure below was silent.
The iframe that could never work
First version: splash.html contained <iframe src="game.html">. Never came up. Devvit entrypoints are addressed by name through the Devvit API — a raw iframe to a sibling file doesn't resolve on the platform. There is no fix; the design is wrong. That forced the same-document architecture above, which turned out better anyway.
The engine file that wasn't in the repo
phaser.min.js looked present locally but was being deleted by our clean npm script and — worse — swallowed by a .gitignore line (/public/*.js*) meant for build products. Deploys shipped without the engine. Audit your gitignore and clean scripts against every runtime asset, and re-audit every time you add a file to public/. This exact line bit us again later with the boot script. Same trap, same line.
postMessage aimed at the wrong window
The engine announced attract-ready with parent.postMessage(...). Locally, a file opened directly is top-level, so parent === window and it worked. On Reddit, Devvit frames your entrypoint — parent is Devvit's host frame, so the signal was delivered to the platform instead of to our own listener. Fixed by posting to window. (Foreshadowing: this handshake wasn't done hurting us.)
The attract stage hidden behind the card
The live canvas mounted outside <main> and sat behind the card's opaque background. Painted every frame, visible never. Moved inside <main>. A boring bug that cost a full on-device round trip, because headless WebGL screenshots weren't an option for us.
After fixing 2–4: still static on Reddit, still perfect locally. Which brings us to the one that explains everything.
Devvit's CSP refuses ALL inline scripts
Fable 5 pulled the actual response headers from webview.devvit.net:
script-src 'self' webview.devvit.net webview-dev.devvit.net 'wasm-unsafe-eval'
No 'unsafe-inline'. Every inline <script> block in every Devvit entrypoint is refused. Silently, on device. Our boot logic — stubs, flag, callback — lived in an inline block. It never ran on Reddit, not once. The stubs never existed, the engine threw on its first getElementById, and the designed fallback showed… a normal static splash. Reddit's own template docs do say "use a script tag and separate js/ts file" — we found that line only after finding the header.
Why every local test lied: python -m http.server sends no CSP. The fix for the bug was moving the inline block verbatim into splash-boot.js. The fix for the process was a test server that sends Reddit's exact captured CSP header, plus headless Playwright. Under that server, the broken splash reproduces the on-device failure verbatim — Refused to execute inline script — and the fixed one goes live.
game.html that had never executed on Reddit either. Silent, remember?The native app swallows window.postMessage
After the CSP fix: live on web reddit, still static in the Reddit app. Same post, same deployed bundle. And the app's webview takes no inspector.
So we made the splash diagnose itself: a one-line strip that renders only if attract isn't live six seconds after load. Web goes live in ~2s and never shows it; a broken client prints exactly which link died — engine version, flag, canvas count, a WebGL probe, a requestAnimationFrame tick counter, visibility state, first two captured errors. On device it read:
Everything alive — with only the handshake missing. The last link was window.postMessage to the page's own window. Native apps inject JS bridges into their webviews, and wrapping window.postMessage is the standard way those bridges are built — a wrapped postMessage swallows the message with no error. A headless control with postMessage neutered reproduced the device strip character-for-character.
The fix is realizing you don't need a messaging API at all: since the iframe died at stop 1, poster and listener share one document. The engine now calls a plain global — window.TIPSEY_ON_ATTRACT_READY() — which nothing can intercept at either end. postMessage stays only as a fallback for genuine framed embedders. Live everywhere.
The workflow, distilled
- Same document, not an iframe. Boot the real engine inside
splash.htmlbehind a bare-attract flag, with stub elements for every DOM id it touches. - Zero inline
<script>blocks. Everything external via<script src>—'self'is allowed, inline is not, and the refusal is silent. - Signal readiness with a direct global callback, not postMessage — native app bridges eat postMessage without a trace.
- Static splash as the fallback layer, cross-fade on the callback. Your failure mode should be indistinguishable from a design choice.
- Test under a local server that sends Devvit's real CSP header + headless Playwright. A plain dev server will pass code that is dead on Reddit.
- Ship a self-hiding diagnostic strip (render only if not live after N seconds) while bringing up any uninspectable webview. Pull it once stable.
- Check
.gitignoreand clean scripts against every runtime asset inpublic/, every time you add one. - Keep the inline bundle fast — paint the static layer immediately and let the engine catch up behind it.
The meta-lesson: five of the six failures shared one shape — code that worked locally and died silently on the platform. Local parity (real CSP) plus on-device observability (the strip) is the whole game. Once we had both, the last two bugs — the ones that had survived everything — each took a single session to kill.
We packaged this whole workflow — the six platform rules, the boot-script and CSP-test-server templates, the self-hiding diagnostic strip, and a symptom→cause→fix table — into a Claude skill: a reusable instruction file Claude loads automatically when your task matches. With it installed, "my Devvit splash works locally but not on Reddit" gets the right diagnosis on the first try instead of after six deploys.
It's engine-agnostic (the templates use MYGAME_* names) and triggers on the failure phrasings you'd actually type mid-struggle.
to install: claude.ai → Settings → Capabilities → Skills → upload · or drop it in ~/.claude/skills/devvit-live-splash/ for Claude Code
Questions welcome on r/aigamedevs — and the splash itself is running right now on r/tipsey. The robot thanks you for your tips.