Rocket Launcher Games//Devlog//Costa Palma, after dark

Getting a live gameplay splash into the Reddit feed

The post card for Tipsey (r/tipsey) runs the actual Phaser 3 engine — a self-playing delivery demo behind the Start button. Not a screenshot, not a video. It took six silent failures to get there, and every one of them looked exactly like a working splash. This is the whole route.

built with Claude Opus 5 (architecture, failures 1–4) + Claude Fable 5 (root causes 5–6) · solo dev: Cal-Starfur

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

  1. 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.
  2. Stub elements. Your engine wires up dozens of DOM ids at load. Instead of forking it, the splash builds a hidden #attract-stubs container with every element the engine expects (ours needs 64). The engine boots believing its chrome exists.
  3. 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 -->
  1. A handshake, then a cross-fade. The engine's first attract frame calls a global callback; the splash adds an attract-live class to <body>; CSS cross-fades the static art into the live canvas.
  2. 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.
1
First failure

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.

2
Second failure

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.

3
Third failure

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 entrypointparent 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.)

4
Fourth failure

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.

5
Fifth failure · the big one

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.

Do this todayIf you build for Devvit, put that CSP header in your local dev server now. Ours also revealed a viewport-fix inline block in expanded-mode game.html that had never executed on Reddit either. Silent, remember?
6
Sixth failure · the last one

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:

diag t+6.0s phaser:3.85.2 logic:flag-set canvases:2 gl:yes raf:120 vis:visible | no-errors
the actual strip, photographed off the Reddit app — a completely healthy engine

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.

proof of delivery — the live engine running behind the splash, in the Reddit app feed, votes and all
Delivered

The workflow, distilled

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.

One-shot it with a Claude skill

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.

Download SKILL.md

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.