Reddit Games with a Hook hackathon · Phaser 4.1 · Devvit
Play it live: r/Meowcert · App listing: developers.reddit.com/apps/meowcert
A rhythm game inside a Reddit post gets no slack: no frame budget, no asset budget, no cold-load patience. So I pushed Phaser's own primitives to cover the gap. A chart engine decoupled from update(). Hit grading in screen space. An offset table that turned 17 hand-drawn cosmetics into 807. One mask for every hold tail. Standard usage first, then the stretches, then the cold-load squeeze.
How the core gameplay loop works
Theres a lot wrapped around the game (decorating, cosmetic boxes, music upload, the chart editor, the tutorial, the visit / leaderboard / comment loop), but the main play is the chart. A meowcert post is a show: notes fall down three lanes toward a hit line, and you tap, hold, slide, and slide-return to the beat. Scores post to a per-post leaderboard and both sides earn: the visitor for playing, the host for the visit. The DNA is Guitar Hero's falling lanes plus osu!'s drag-to-hit, tuned for thumbs. What both ancestors share is that high-level play collapses the moment input latency crosses a threshold. Players chasing perfect runs can feel a single missed frame. Clearing that bar inside a Reddit WebView is what the rest of this doc is about.
The game itself: three lanes, fuzzy "ps" notes, slides crossing lanes mid-fall, the band reacting up top. Everything below is about making this feel tight.
Standard Phaser usage
Most of the game is just Phaser doing its job. Scenes, atlases, tweens, the sound manager. The interesting stuff comes later.
Scenes
Eight scenes, one mode each: Boot, Preloader, Splash, VisitPost, Decorate, ChartEditor, Game, and the TutorialOrchestrator. Scenes hand off with scene.start() + init data, and nothing lives in module-level globals.
Scene flow. Every path lands in Game eventually, which is the only scene that actually runs the rhythm loop.
Atlases + animation manager
Every cat, cosmetic, and note element ships in TexturePacker atlases registered with this.load.atlas(). The Preloader pre-creates the four animations per cat breed by globbing frame names; cosmetic animations register lazily on first equip, since there are too many combinations to build upfront.
Atlas → Preloader → Animation manager → playback. Standard Phaser asset pipeline, used as documented.
Per-lane input
Three invisible tap zones, one per lane. A pointer-down looks up that lane's active note, measures its distance to the hit line, and grades it. No global listeners to clean up on scene swap.
One pointer-down handler per lane. Resolution is local: lane → active note → distance grade → score + side effects. No global listeners to clean up on scene swap.
Tweens + sound
Every motion in the game is a tween: falling notes, pulsing hit targets, floating hit-feedback text, lane bars on the beat. The backing track loads through scene.sound; the per-lane tap tones bypass it and hit WebAudio directly for sample-accurate timing.
Chart authoring → playback
The ChartEditor draws its authoring grid on the same msPerStep the runtime plays. Tap places a note, drag down makes a hold, drag across makes a slide, and the grid serializes to the exact Chart object ChartPlayer consumes. What you draw is what you hear.
Authoring and playback share the same msPerStep grid. The Chart object is the only handoff. Editor saves it, Game scene reads it.
Why I had to stretch it
Standard Phaser usage gets you to a playable game. It does not get you to a rhythm game that holds up at hard charts on a mid-tier phone running inside Reddit's WebView. Two constraints showed up early and never let up.
The first is latency, and its the one that shaped the most code. If a tap registers 50ms late, the player perceives misses on inputs that felt perfect. Phaser's update() is tied to requestAnimationFrame, which jitters on mobile. WebAudio scheduling has its own clock that seems more reliable. Mix the two the wrong way and you get drift. So the engine is deliberate about which clock every system reads from. I'm not going to claim true zero latency. Nobody can inside Devvit's WebView, on whatever device a Reddit user shows up on. What I can say is that every part of the engine where I control the clock is built to shave milliseconds, not concede them.
The second is the Reddit WebView itself. Devvit caches the bundle aggressively, runs on a tight memory budget, and gates certain APIs behind trusted DOM gestures. Cold-load weight matters more than on a normal web build, GC pauses are more visible, and any audio that doesnt preload cleanly glitches on first play.
What follows is the playbook. Latency patterns come first, because without them the hard charts arent playable. Then the asset multiplier, the render wins, and the cold load. Each pattern names the Phaser primitive it leans on, so the framework's defaults are easy to find again.
01 — The chart engine runs on its own clock
Phaser's update() is bound to the frame loop. On a phone that just stuttered for 50ms, that loop ticks late. If the chart engine spawns notes on update(), those notes spawn late, and the rhythm slides. Multiply by a long song and the player's taps land progressively further off the beat.
My ChartPlayer runs on a fixed step grid. msPerStep = 60000 / (bpm × 2). Notes are scheduled at exact step boundaries against the music system's audio clock, not against Phaser's frame clock. Hold expirations, slide windows, and page boundaries all read from the same grid. Phaser's update() only drives rendering. Frame jitter is a visual problem, not a timing problem.
The editor authors charts on the same grid the runtime plays. What you draw is bar-perfect what you hear.
The chart grid ticks at msPerStep. Phaser's update() only paints; it doesn't schedule. Frame stutters become a visual issue, not a rhythm issue.
Rhythm precision survives frame jitter. The editor and the runtime share the grid.
02 — Hit grading reads pixel distance, not time
Rhythm games usually grade a tap by the difference between the tap time and the note's scheduled hit time. That works on hardware with stable frame pacing. It breaks badly on the median mobile Reddit session, where a 30ms stutter is routine. A perfect tap on a dropped frame gets misgraded as great or miss.
meowcert grades by the vertical distance between the note's current sprite y and the hit-line y. The perfect window is 15 pixels above or below. The great window is asymmetric: 60 pixels on the way down (committed early tap) and 30 pixels on the way out (you slept on it). The asymmetry exists because tapping a ball thats already halfway past the hit line should grade harshly. The screen position is the truth, not the wall clock. Frame jitter doesnt enter the grading.
The asymmetric great window is the giveaway that this is pixel-space grading: a time-based window would be symmetric, since time is symmetric around the hit moment. Screen space lets me reward early-but-committed taps and punish half-cleared ones.
Fair grading on phones that stutter. The hit windows scale naturally with note fall speed, because both are in screen space.
03 — Notes never allocate during a round
Garbage collection pauses kill rhythm games. A 50ms GC hiccup right as a chord lands is a guaranteed miss. The standard fix is an object pool, sized to the worst-case concurrent count. I pre-warm 18 Note instances in create() before the first spawn fires. A spawn pulls an idle pool entry and resets it. A hit or a miss recycles. The tap hot path, activeNoteInLane(), iterates the pool with no closure capture and no allocation per call.
The pool size was tuned after notes mysteriously "vanished" mid-round on longer sessions. The pool was previously growing ad-hoc and tripping over its own GC. Pre-sizing at 18 (the peak concurrent count plus headroom for fall-time overlap) fixed it.
Live: 18-cell pool, real PSElement_ball sprites. Filled cells are active notes on screen; empty cells are idle in the pool. Spawns and recycles cycle through without allocating a single new Note.
0 allocations during a round. The pool is sized once at scene init and never grows.
04 — One cosmetic frame covers four cat animations
Every cat in meowcert has four animations: idle, meow, hiss, lick. The head moves differently in each one. The lick animation in particular dips the head several pixels for the actual licking motion. If a hat or a pair of glasses is supposed to stay glued to the cat's head through all of that, the obvious solution is to draw a separate cosmetic frame for every cat animation frame.
With 807 cosmetics and 4 animations of roughly 9 frames each, thats 807 × 4 × 9 = ~29,000 cosmetic frame variants. Painted by hand. For an asset multiplier on top of an already-strained art budget.
Instead, I do the work once at build time. A Python pass through the cat sprites finds the head landmark on every frame: the y-coordinate of the topmost row of cat pixels, with a thick-edge filter that rejects stray brush marks. Those per-frame head Y values get serialized into cat-frame-offsets.json. At runtime, the Cat entity reads the active frame's offset on every Phaser POST_UPDATE event and shifts the cosmetic sprite's y by that amount. One cosmetic frame rides the head bob across every animation.
Real Butters (cat13) lick animation, real Bread Head (c37) cosmetic. Without the offset table the bread floats free while the cat ducks for the lick. Covers the eyes one frame, eaten by the cat the next. With the table, the bread is glued. Head movement exaggerated 5× for print clarity, but the cumulative drift across long animations is exactly this kind of failure mode.
4× fewer cosmetic frames. The difference between shipping 807 cosmetics and ~200.
05 — Tap sounds, synthesized at runtime, zero sample files
A rhythm game wants per-lane tap feedback that sits inside the backing track musically. Three backing-track vibes (upbeat, melodic, smooth) means three palettes of three tap tones, or nine sample files. Audio caches poorly in Devvit's WebView, and every kilobyte counts on a Reddit-embedded bundle.
I dont ship any tap audio files. Every tap sound comes out of the Web Audio API at runtime. The synth defines three presets, one per vibe: upbeat is a square wave on a C5/E5/G5 triad, melodic is a triangle on A4/C5/E5, smooth is a sine on F4/A4/C5. The miss cue is a detuned sawtooth with a pitch glide through a low-pass filter. Each tap fires one oscillator and one gain envelope, scheduled directly against AudioContext.currentTime so the timing is sample-accurate. The vibe swap is instantaneous because theres no audio to reload.
Three vibe presets × three lane tones × zero sample files. Lane 0 maps to the root, lane 1 to the third, lane 2 to the fifth.
9 tap audio files saved. The vibe palette stays in code, not in the bundle.
06 — One mask clips every hold-note tail
A hold note has a head and a tail. The head is the fuzzy ps ball that the player taps and holds. The tail is a vertical column that extends from the head all the way up to the spawn line, marking how long the player needs to keep their finger down. Without clipping, the tail bleeds out the top of the lane band into the cat-stage area above, where it has no business being.
I build a single Phaser GeometryMask shaped to the lane band at scene setup. Every hold tail attaches to that mask via setMask() on spawn. The mask Graphics object itself is invisible: it exists only for the GPU clip operation. One mask object serves every hold tail in the round.
Same real PSTube tail sprite and PSElement_ball head in both panels. The right panel clips at the lane band, which is also where the mask sits.
No per-frame CPU clipping checks. Tail polish, like alpha pulses and engagement tints, is safe by default.
07 — Squeezing the cold load and the render budget
Everything above is about frames during play. This one is about the seconds before play, because a Reddit visitor gives an embedded game about one cold load's worth of patience, and Devvit's webview caches whatever you ship them first.
Phaser ships as its own cacheable chunk. The vite build splits node_modules/phaser into a separate bundle via manualChunks. Game code changes every deploy; Phaser doesnt. Returning players re-download only the game chunk, and the main bundle went from 2.1MB to under 800KB. On a webview that aggressively caches, letting the cache work for us instead of against us was the single biggest cold-load win.
Backgrounds load twice, and the player only sees it once. Full venue backgrounds are the heaviest assets in the game. background-manager.ts shows a quarter-res thumbnail immediately, using the exact same shift/scale layout math as the full image, fires the full-res fetch in the background, and swaps it in silently when it lands. Because both resolutions share one layout pipeline, the swap is pixel-stable. No jump, no reflow. Eager-loading 74 venues went from ~119MB of cold load to ~5MB of thumbs plus on-demand finish.
Every this.add.text() is retina-sharp for free. game.ts patches Phaser.GameObjects.GameObjectFactory.prototype.text once at boot to set a 2-3× resolution from devicePixelRatio. One patch, every scene, zero per-callsite boilerplate. And no blurry text on the phones where the game actually gets played.
Animations register lazily. Pre-creating an animation set for all 807 cosmetics at boot would burn startup time on combinations most sessions never see. Cat animations are pre-created (13 base breeds, always needed); cosmetic animations register on first equip. The atlas metadata is minified too, because frame names are the bytes you pay for on every load.
2.1MB → <800KB main bundle, ~119MB → ~5MB background cold load. The engine cache works for us, not against us.
The math, in one panel
- 4× — fewer cosmetic frames (offset table)
- 9 → 0 — tap audio files (web audio synth)
- 0 — allocations per spawned note (pool)
- ±15px — perfect window — frame-independent
- 1 — scene serves tutorial + real play
- 1 — mask clips every hold tail
- 2.1 → 0.8MB — main bundle (Phaser split into its own cacheable chunk)
- 119 → 5MB — background cold load (thumbs + silent full-res swap)
What it adds up to
Phaser carried most of the game: scenes, atlases, tweens, animations, sound, input, all standard. Where I stepped off the documented path, the fix was always a Phaser primitive applied a little unusually, never a new dependency. POST_UPDATE plus a JSON lookup. The WebAudio context Phaser already wraps. A step counter decoupled from update(). One mask shared by every tail.
A Phaser game on Devvit doesnt get to play loose with assets or frames. The runtime gave me something I trusted enough to push hard against, and it still shipped a 681-cat, 807-cosmetic, 74-venue game inside a Reddit post. To me thats what using an engine well looks like.












Top comments (3)
I was particularly impressed by how you decoupled the chart engine from the
update()method, allowing for more precise control over the gameplay loop. The use of a separateChartPlayerobject to handle the chart playback also seems like a clever way to keep the game logic organized. I've worked on similar rhythm games in the past, and I can attest to the challenges of achieving low-latency input handling - your approach of using pointer-down handlers per lane and bypassing Phaser's sound manager for sample-accurate timing is really interesting. Did you encounter any significant performance bottlenecks during development, and if so, how did you optimize the game to run smoothly on mid-tier phones?Thanks! The biggest bottleneck actually wasn't the chart engine at all, it was the cosmetics. Cat effects (orbiters, floor puddles, weather) each register a POST_UPDATE handler, and with three decked-out cats on stage that's about 36 of them, some rebuilding Graphics objects every single frame. Totally fine on desktop. On phones it ate the frame budget.
Three fixes did most of the work. Heavy redraws now rebuild on a ~90ms timer instead of every frame, ambient effects drop to half rate during a live round, and anything that spawned-and-destroyed sprites per hold tick got pooled. The cats bob a little lazier mid-round and nobody has ever noticed, since your eyes are on the lanes anyway.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.