<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Tim Nan</title>
    <description>The latest articles on DEV Community by Tim Nan (@timinan).</description>
    <link>https://dev.to/timinan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4006137%2F92e2ae51-2b3b-4ff5-be10-8de0206cacec.png</url>
      <title>DEV Community: Tim Nan</title>
      <link>https://dev.to/timinan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/timinan"/>
    <language>en</language>
    <item>
      <title>Every frame counts: Meowcert a Phaser 4 rhythm game inside a Reddit post</title>
      <dc:creator>Tim Nan</dc:creator>
      <pubDate>Wed, 15 Jul 2026 17:51:03 +0000</pubDate>
      <link>https://dev.to/timinan/every-frame-counts-meowcert-a-phaser-4-rhythm-game-inside-a-reddit-post-kam</link>
      <guid>https://dev.to/timinan/every-frame-counts-meowcert-a-phaser-4-rhythm-game-inside-a-reddit-post-kam</guid>
      <description>&lt;p&gt;&lt;em&gt;Reddit Games with a Hook hackathon · Phaser 4.1 · Devvit&lt;/em&gt;&lt;br&gt;
&lt;em&gt;Play it live: &lt;a href="https://www.reddit.com/r/Meowcert" rel="noopener noreferrer"&gt;r/Meowcert&lt;/a&gt; · App listing: &lt;a href="https://developers.reddit.com/apps/meowcert" rel="noopener noreferrer"&gt;developers.reddit.com/apps/meowcert&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;update()&lt;/code&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the core gameplay loop works
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozacrhurqniyjbf0bwdx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fozacrhurqniyjbf0bwdx.png" alt="The game itself: three lanes, fuzzy " width="800" height="1422"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Standard Phaser usage
&lt;/h2&gt;

&lt;p&gt;Most of the game is just Phaser doing its job. Scenes, atlases, tweens, the sound manager. The interesting stuff comes later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scenes
&lt;/h3&gt;

&lt;p&gt;Eight scenes, one mode each: Boot, Preloader, Splash, VisitPost, Decorate, ChartEditor, Game, and the TutorialOrchestrator. Scenes hand off with &lt;code&gt;scene.start()&lt;/code&gt; + init data, and nothing lives in module-level globals.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh7k7poj5iv7px4q24izr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh7k7poj5iv7px4q24izr.png" alt="diagram" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Scene flow. Every path lands in Game eventually, which is the only scene that actually runs the rhythm loop.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Atlases + animation manager
&lt;/h3&gt;

&lt;p&gt;Every cat, cosmetic, and note element ships in TexturePacker atlases registered with &lt;code&gt;this.load.atlas()&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6xnmb0amj3eyk6e42s0s.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6xnmb0amj3eyk6e42s0s.png" alt="diagram" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Atlas → Preloader → Animation manager → playback. Standard Phaser asset pipeline, used as documented.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Per-lane input
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnrgomimt5ulc4vcvs7tl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnrgomimt5ulc4vcvs7tl.png" alt="diagram" width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Tweens + sound
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;scene.sound&lt;/code&gt;; the per-lane tap tones bypass it and hit WebAudio directly for sample-accurate timing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Chart authoring → playback
&lt;/h3&gt;

&lt;p&gt;The ChartEditor draws its authoring grid on the same &lt;code&gt;msPerStep&lt;/code&gt; the runtime plays. Tap places a note, drag down makes a hold, drag across makes a slide, and the grid serializes to the exact &lt;code&gt;Chart&lt;/code&gt; object &lt;code&gt;ChartPlayer&lt;/code&gt; consumes. What you draw is what you hear.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn46ul3ok3vqreuz6bb9i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fn46ul3ok3vqreuz6bb9i.png" alt="diagram" width="799" height="343"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Authoring and playback share the same &lt;code&gt;msPerStep&lt;/code&gt; grid. The &lt;code&gt;Chart&lt;/code&gt; object is the only handoff. Editor saves it, Game scene reads it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I had to stretch it
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The first is &lt;strong&gt;latency&lt;/strong&gt;, 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 &lt;code&gt;update()&lt;/code&gt; is tied to &lt;code&gt;requestAnimationFrame&lt;/code&gt;, 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. &lt;/p&gt;

&lt;p&gt;The second is the &lt;strong&gt;Reddit WebView&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  01 — The chart engine runs on its own clock
&lt;/h2&gt;

&lt;p&gt;Phaser's &lt;code&gt;update()&lt;/code&gt; 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 &lt;code&gt;update()&lt;/code&gt;, those notes spawn late, and the rhythm slides. Multiply by a long song and the player's taps land progressively further off the beat.&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;ChartPlayer&lt;/code&gt; runs on a fixed step grid. &lt;code&gt;msPerStep = 60000 / (bpm × 2)&lt;/code&gt;. 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 &lt;code&gt;update()&lt;/code&gt; only drives rendering. Frame jitter is a visual problem, not a timing problem.&lt;/p&gt;

&lt;p&gt;The editor authors charts on the same grid the runtime plays. What you draw is bar-perfect what you hear.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdjwuaq39s2tswkiz6gnu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdjwuaq39s2tswkiz6gnu.png" alt="diagram" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The chart grid ticks at &lt;code&gt;msPerStep&lt;/code&gt;. Phaser's &lt;code&gt;update()&lt;/code&gt; only paints; it doesn't schedule. Frame stutters become a visual issue, not a rhythm issue.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rhythm precision survives frame jitter. The editor and the runtime share the grid.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  02 — Hit grading reads pixel distance, not time
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8wvzdvwn8co8m1srgir.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8wvzdvwn8co8m1srgir.png" alt="diagram" width="800" height="454"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Fair grading on phones that stutter. The hit windows scale naturally with note fall speed, because both are in screen space.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  03 — Notes never allocate during a round
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;Note&lt;/code&gt; instances in &lt;code&gt;create()&lt;/code&gt; 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, &lt;code&gt;activeNoteInLane()&lt;/code&gt;, iterates the pool with no closure capture and no allocation per call.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvtk0qele96verzbyinrn.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvtk0qele96verzbyinrn.gif" alt="Live: 18-cell pool, real PSElement_ball sprites" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Live: 18-cell pool, real &lt;code&gt;PSElement_ball&lt;/code&gt; 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 &lt;code&gt;Note&lt;/code&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;0 allocations during a round. The pool is sized once at scene init and never grows.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  04 — One cosmetic frame covers four cat animations
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;With 807 cosmetics and 4 animations of roughly 9 frames each, thats &lt;code&gt;807 × 4 × 9 = ~29,000&lt;/code&gt; cosmetic frame variants. Painted by hand. For an asset multiplier on top of an already-strained art budget.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;cat-frame-offsets.json&lt;/code&gt;. At runtime, the &lt;code&gt;Cat&lt;/code&gt; entity reads the active frame's offset on every Phaser &lt;code&gt;POST_UPDATE&lt;/code&gt; event and shifts the cosmetic sprite's y by that amount. One cosmetic frame rides the head bob across every animation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1yf6h58s49jxt33e0a4q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1yf6h58s49jxt33e0a4q.png" alt="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." width="799" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnk37ychvdy7vpwnxppn5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnk37ychvdy7vpwnxppn5.png" alt="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." width="799" height="170"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;4× fewer cosmetic frames. The difference between shipping 807 cosmetics and ~200.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  05 — Tap sounds, synthesized at runtime, zero sample files
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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: &lt;code&gt;upbeat&lt;/code&gt; is a square wave on a C5/E5/G5 triad, &lt;code&gt;melodic&lt;/code&gt; is a triangle on A4/C5/E5, &lt;code&gt;smooth&lt;/code&gt; 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 &lt;code&gt;AudioContext.currentTime&lt;/code&gt; so the timing is sample-accurate. The vibe swap is instantaneous because theres no audio to reload.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkco9qaga4te8qwxuvk37.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkco9qaga4te8qwxuvk37.png" alt="diagram" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;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.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;9 tap audio files saved. The vibe palette stays in code, not in the bundle.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  06 — One mask clips every hold-note tail
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I build a single Phaser &lt;code&gt;GeometryMask&lt;/code&gt; shaped to the lane band at scene setup. Every hold tail attaches to that mask via &lt;code&gt;setMask()&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk2jq2q7xvkkrlwvyzwwu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk2jq2q7xvkkrlwvyzwwu.png" alt="tail overflows the cat-stage strip ✗" width="144" height="132"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk2jq2q7xvkkrlwvyzwwu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk2jq2q7xvkkrlwvyzwwu.png" alt="clipped at the lane edge ✓" width="144" height="132"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Same real &lt;code&gt;PSTube&lt;/code&gt; tail sprite and &lt;code&gt;PSElement_ball&lt;/code&gt; head in both panels. The right panel clips at the lane band, which is also where the mask sits.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;No per-frame CPU clipping checks. Tail polish, like alpha pulses and engagement tints, is safe by default.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  07 — Squeezing the cold load and the render budget
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Phaser ships as its own cacheable chunk.&lt;/strong&gt; The vite build splits &lt;code&gt;node_modules/phaser&lt;/code&gt; into a separate bundle via &lt;code&gt;manualChunks&lt;/code&gt;. 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 &lt;em&gt;for&lt;/em&gt; us instead of against us was the single biggest cold-load win.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backgrounds load twice, and the player only sees it once.&lt;/strong&gt; Full venue backgrounds are the heaviest assets in the game. &lt;code&gt;background-manager.ts&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every &lt;code&gt;this.add.text()&lt;/code&gt; is retina-sharp for free.&lt;/strong&gt; &lt;code&gt;game.ts&lt;/code&gt; patches &lt;code&gt;Phaser.GameObjects.GameObjectFactory.prototype.text&lt;/code&gt; once at boot to set a 2-3× resolution from &lt;code&gt;devicePixelRatio&lt;/code&gt;. One patch, every scene, zero per-callsite boilerplate. And no blurry text on the phones where the game actually gets played.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Animations register lazily.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;2.1MB → &amp;lt;800KB main bundle, ~119MB → ~5MB background cold load. The engine cache works for us, not against us.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The math, in one panel
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4×&lt;/strong&gt; — fewer cosmetic frames (offset table)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;9 → 0&lt;/strong&gt; — tap audio files (web audio synth)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0&lt;/strong&gt; — allocations per spawned note (pool)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;±15px&lt;/strong&gt; — perfect window — frame-independent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1&lt;/strong&gt; — scene serves tutorial + real play&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1&lt;/strong&gt; — mask clips every hold tail&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2.1 → 0.8MB&lt;/strong&gt; — main bundle (Phaser split into its own cacheable chunk)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;119 → 5MB&lt;/strong&gt; — background cold load (thumbs + silent full-res swap)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it adds up to
&lt;/h2&gt;

&lt;p&gt;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. &lt;code&gt;POST_UPDATE&lt;/code&gt; plus a JSON lookup. The WebAudio context Phaser already wraps. A step counter decoupled from &lt;code&gt;update()&lt;/code&gt;. One mask shared by every tail.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>beginners</category>
    </item>
    <item>
      <title>The Terrible Tap-to-Earn Game I Made to Honor My Cat (and Why I Came Back to It 2 Years Later)</title>
      <dc:creator>Tim Nan</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/timinan/the-terrible-tap-to-earn-game-i-made-to-honor-my-cat-and-why-i-came-back-to-it-2-years-later-3c8i</link>
      <guid>https://dev.to/timinan/the-terrible-tap-to-earn-game-i-made-to-honor-my-cat-and-why-i-came-back-to-it-2-years-later-3c8i</guid>
      <description>&lt;p&gt;Ok so this is going to be more of a personal one. I hope that's ok. &lt;/p&gt;

&lt;p&gt;If you werent paying attention to Telegram in like 2023, heres what was happening. They opened up their bot platform, and a lot of crypto teams started to build these little "tap-to-earn" games. You tap your screen to earn fake coins, and the fake coins maybe became real crypto via airdrops later. Wild stuff. People were just going on their phones and tapping for hours without thinking much, just so they could get some airdrop. Simple enough right?&lt;/p&gt;

&lt;p&gt;I was between things at the time, looking for something new to build, and a tap to earn game seemed accessible. Like, OK, its so simple, why not. The mechanic is literally one tap. Telegram had a huge user base. So I figured maybe if I could capture even a tiny bit of that user base, it would be fine. Low effort, high upside if the airdrop ever happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Butters
&lt;/h2&gt;

&lt;p&gt;I've always had a lot of different pets growing up, but we never had a cat, and I never really understood why people love cats. That changed when my ex's roommate got a cat. After getting the chance to be around one I was like, wow thats wild, cats are great creatures.&lt;/p&gt;

&lt;p&gt;When I was living in an apartment, I wanted to get a pet, and I thought it was the perfect time to try getting a cat. I was really lucky to have Butters come into my life. He was an adorable grey British Shorthair, he was cute, he had personality, and he was there for me through the rough times. There were times where I was very depressed and he would just be by my side. He was a really good emotional support buddy for me.&lt;/p&gt;

&lt;p&gt;That was Butters. He meant the world to me. However, about a year before the whole Telegram tap-to-earn thing kicked off, he suddenly died of HCM (where his heart literally grew too big). It was absolutely devastating. I was filled with grief, but in the back of my mind I've always wanted to do something in his honour, because I've always had this feeling that I should've done something sooner to prevent it, and it weighs heavily on me a lot of times.&lt;/p&gt;

&lt;p&gt;So I decided to make a tap to earn game for him, wow how unique right? Looking back, that was just disrespectful.&lt;/p&gt;

&lt;h2&gt;
  
  
  The original concept
&lt;/h2&gt;

&lt;p&gt;The original concept for this game was very simple. You basically had this neighbourhood of cats. There's a button you press to call your cat over. The cat would come out to you, and you could pet it, scratch it, give it whatever. Based on your actions youd earn coins, and those coins you could use to buy cosmetics. Eventually it would lead to an airdrop. That was the whole premise. Really high concept stuff, never before seen groundbreaking stuff... Just kidding.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftnw64jh1ejq39daelxcv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftnw64jh1ejq39daelxcv.png" alt="Truely a thing of beauty lol" width="464" height="878"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;But I was committed. I hired a pixel artist, spent real money on it, and the most important thing was, I got Butters immortalised in pixel form. That was the one good thing that came out.&lt;/p&gt;

&lt;p&gt;By the time we built out a prototype and it was playable, the tap to earn craze was gone. The whole Telegram crypto cycle moves fast. My game wasn't going to ride that wave because I was at least a couple months late, if not more.&lt;/p&gt;

&lt;p&gt;But honestly, looking back, the timing wasn't even the real problem. It was just a really shitty game. I would've been upset if this was put out as a tribute to Butters. Thats just not how his memory should be honoured. It would tarnish his memory.&lt;/p&gt;

&lt;p&gt;So after reflecting, I just shelved it. Closed the laptop, didn't touch the game, didn't show it to anyone, didn't really think about it much, to be honest.&lt;/p&gt;

&lt;p&gt;But on the days I did think about it, it felt sad. I felt like I let myself down for making such a horrible, rushed game, and not even really committing to getting it out into the world. There was a huge amount of shame there for me.&lt;/p&gt;

&lt;p&gt;Two years passed. Went by fast. Not gonna lie, these past two years, things haven't gotten better. It's been hard to keep steady employment. There were a lot of times it was really hard to get out of bed. And without Butters there for me, it was getting harder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The return
&lt;/h2&gt;

&lt;p&gt;But then recently I saw something that gave me hope. A Reddit hackathon called Games with a Hook. The pitch: games that people want to keep coming back to. The whole hook angle.&lt;/p&gt;

&lt;p&gt;I've been an avid Redditor for years. And actually, I've always gone onto the various cat subreddits just to get my cat fix in. To fill that paw shaped hole Butters left.&lt;/p&gt;

&lt;p&gt;So when I saw the hackathon, I thought: maybe I could properly do what I had originally meant to do, which was to create a game I could be proud of and will have people coming back for more, finally a fitting tribute to my sweet Butters.&lt;/p&gt;

&lt;p&gt;So yeah, here I am excited to share how I got from that terrible tap-to-earn game to where I am today, which is a rhythm-based game featuring adorable cats called Meowcert. Even at this stage it already feels different from the first attempt. Better. More like the tribute Butters deserved.&lt;/p&gt;

&lt;p&gt;In the coming days I'll be posting more about the games that shaped meowcert, the engineering work to keep a rhythm game low-latency inside Devvit on Phaser, the asset tooling that took 6 cats × 17 cosmetics (102 combos) up to 116 cats × 498 cosmetics (57,768), how I tried to apply my PM instincts to working with AI agents. And probably some other stuff that comes up along the way.&lt;/p&gt;

&lt;p&gt;Thank you for staying with me through my rant, and I will be sure to keep this updated as I go along! &lt;/p&gt;

&lt;p&gt;Oh btw, cat tax. Here's my sweet boy. He really was the best :')&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5jtcy621bnmum297yyu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh5jtcy621bnmum297yyu.png" alt="The bestest boy that existed" width="800" height="941"&gt;&lt;/a&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>buildinpublic</category>
      <category>beginners</category>
      <category>sideprojects</category>
    </item>
  </channel>
</rss>
