techlifeadventuresVol. 03 · Aug 2026
·11 min read·Development

How I Built Duck Samurai with Phaser and AI Agents

A build log from two browser games on this site — Phaser scene architecture, React lifecycle bugs AI could not fix, and what a deterministic sim taught me.

Most of what I write here is about AI-assisted development in the abstract. This one is a receipt.

There are games on this site. Real ones, playable in a browser, no install. Duck Samurai: Banana Bane is a four-level 2D platformer with a three-phase boss fight. Mythic Lane Battler is a lane battler built on an Indian mythology roster. They were built months apart, with AI doing most of the typing, and they are architected completely differently — not because I changed my mind about best practices, but because the second one taught me something the first one could not.

Here is what actually happened in the code.

The First Problem: Phaser Cannot Run on a Server

Duck Samurai runs on Phaser 3, a mature HTML5 game framework. Phaser wants a real DOM. It creates a element, queries WebGL support, attaches keyboard and pointer listeners to document, and starts a requestAnimationFrame loop the moment you construct it.

Next.js App Router, meanwhile, renders everything on the server by default. Every page in this site is pre-rendered at build time — that is the whole reason it loads fast and ranks well. So when the build process tries to render a Phaser game during static generation, it hits document is not defined and the entire build fails. Not the page. The build.

The fix is four lines, and it is the most load-bearing file in the whole game:

tsx
const PhaserGame = dynamic(() => import('./phaser/PhaserGame'), {
  ssr: false,
  loading: () => <div>Loading game…</div>,
});

That is essentially all of game.tsx — 16 lines including the JSX. ssr: false tells Next.js to never touch this module during server rendering or static generation. The Phaser bundle is code-split out and only fetched in the browser, which also means the ~1MB of framework does not land in the shared JS chunk that every other page on the site has to download.

This is exactly the kind of constraint AI is good at knowing and bad at anticipating. When I asked for a Phaser component, I got a Phaser component. It was correct React. It broke npm run build immediately, because nothing in the prompt said "this has to survive static generation." I knew to look for it. A less suspicious developer would have shipped a broken build and spent an evening reading stack traces.

The Second Problem: Two Frameworks, Two Lifecycles

Phaser owns a render loop, a canvas, and a pile of global event listeners. React owns a component lifecycle that mounts, unmounts, and — in development with Strict Mode — deliberately does all of that twice to catch exactly this class of bug.

If you naively construct a Phaser game inside useEffect, Strict Mode gives you two games. Two canvases stacked on top of each other, two keyboard handlers, and a duck that jumps twice as high as it should because both instances are reading the same arrow key. The whole bridge in PhaserGame.tsx comes down to a ref guard and a real teardown:

tsx
useEffect(() => {
  if (!parentRef.current || gameRef.current) return;
  gameRef.current = new Phaser.Game(createGameConfig(parentRef.current));
  return () => {
    gameRef.current?.destroy(true);
    gameRef.current = null;
  };
}, []);

The gameRef.current check is the guard against the double-invoke. The destroy(true) is the part people skip — the true means "also remove the canvas from the DOM," and without it you accumulate orphaned canvases on every navigation. The = null afterwards is what lets a remount succeed instead of silently no-oping forever.

None of that is clever. All of it is the kind of thing you only write after you have watched a duck double-jump and had to reason about why.

The other integration seam is mobile input, and I am not going to pretend it is elegant. types.ts exports a plain mutable object:

ts
export const MobileInput = { left: false, right: false, up: false, attack: false };

React's on-screen touch buttons flip those booleans. The Player entity reads them inside Phaser's update loop, OR'd together with the keyboard state. It is a module-level singleton doing cross-framework message passing, which would get flagged in any code review I ran at work. It is also about eight lines total and has never once caused a bug, because exactly one writer and one reader exist. Some hacks earn their keep.

Scene Architecture: What Each One Actually Does

Phaser organises a game into scenes, which are independent update loops that can run concurrently, pause each other, and pass data on transition. Duck Samurai registers six in config.ts, and the split is more meaningful than the usual tutorial version:

BootScene is 17 lines and does something slightly unusual: it calls SpriteFactory.generateAll() and then starts the menu. There are no image files in this game. Not one. Every texture — the duck, the sword, the monkeys, the boss crown, the coins, the water, the particle — is drawn at boot with Phaser.Graphics primitives and baked into a texture with generateTexture(). SpriteFactory.ts is 223 lines of fillEllipse and fillTriangle calls. Zero network requests for assets, zero loading bar, zero art budget.

MenuScene is presentation plus two input handlers, and it reads the high score out of Phaser's registry — a global key-value store that survives scene transitions, which BootScene seeded from localStorage.

GameScene is the 422-line monster. It builds the level from a declarative LevelDef, wires eight separate physics colliders and overlaps, runs the boss AI, and owns score, lives, and level progression. More on this file in a minute.

HUDScene runs concurrently with GameScene rather than on top of it, and this is the piece I like most. It never reads game state directly. GameScene emits events — HEALTH_CHANGED, SCORE_CHANGED, BOSS_HEALTH — and the HUD subscribes. It also unsubscribes from all six on SHUTDOWN, which is the difference between a working game and one that leaks a listener every time you die.

PauseScene is 21 lines: dim rectangle, "PAUSED" text, and a one-shot key handler that resumes GameScene and stops itself.

GameOverScene handles three different modes — gameover, win, levelComplete — from one class, and hooks into this site's shared leaderboard UI.

Where AI Genuinely Earned Its Keep

Scaffolding, and it is not close.

Six scene classes with the right super('SceneName') constructor, the right create/update signatures, and the right lifecycle hooks — that is pure ceremony, well-documented, and AI produces it correctly on the first try. Same for the entity classes. Enemy extends Phaser.Physics.Arcade.Sprite, sets up its body size and offset, and exposes spawn/updatePatrol/killEnemy. Fifty lines, all of them predictable. Banana is 43 lines of projectile pooling. I described the shape and reviewed the output.

SpriteFactory was the single biggest time save. Describing "a yellow duck in profile with a red headband, a beak, and a sword strapped to its back" and getting back working Graphics calls with sensible coordinates is genuinely a superpower. I tweaked positions; I did not write the geometry.

The level data in types.ts — four levels of platform, enemy, coin, and spike coordinates — is roughly 140 lines of hand-tuned numbers that AI laid out and I then moved around by feel for an hour.

Where I Had to Take Over

Game feel. There is a comment in types.ts that tells the real story:

ts
// Speeds in px/sec. Original used px/frame at 60fps.
export const JUMP_VELOCITY = -700;   // -17 * ~60 (tuned)

That "(tuned)" is doing heavy lifting. The mechanical conversion from a frame-based loop to Phaser's delta-based physics produced numbers that were arithmetically correct and felt awful. Jump arcs were floaty, gravity felt like the moon. Getting from correct to good meant playing the game forty times and nudging one constant at a time. No model can do that, because the success criterion exists only in my hands.

Collision edge cases. Player attacks do not use a physics body at all. Player.attackHits() is a manual AABB check against a 50px reach box offset by facing direction, and it is called from GameScene's update loop rather than from a collider. That looks wrong until you hit the actual problem: the same overlap that damages you when you touch an enemy also fires on the frame you kill it. The fix is a guard in onPlayerTouchesEnemy that bails out if an attack is already landing this frame. Two lines, discovered only by dying unfairly.

The 422-line file. GameScene is where AI's first drafts needed the most restructuring. Each individual piece — spawn coins, add an overlap handler, emit an event — came back fine. What came back wrong was how they fit together at runtime: initial state emitted before HUDScene existed to hear it, boss damage applied in the collision callback where it double-fired, respawn logic that reset health but not the invincibility timer. Those are integration bugs, and they are invisible in a diff. You find them by playing.

The Contrast: What Mythic Lane Battler Does Differently

Six months later I built a lane battler, and I structured it inside out.

The entire game logic lives in src/games/mythic-lane-battler/sim/ and imports Phaser exactly zero times. It imports nothing from React, Canvas, or the DOM. It is a pure function:

ts
export function tick(state: MatchState, inputs: readonly InputEvent[]): MatchState

Thirty ticks per second, fixed. Randomness comes from a seeded mulberry32 PRNG in rng.ts, never Math.random(). A match is fully described by a seed and a list of timestamped inputs, which means runMatch(config, inputs) replays byte-identically forever.

What that buys is not theoretical. There are seven test files next to the sim — engine.test.ts, units.test.ts, towers.test.ts, cards.test.ts, rng.test.ts, types.test.ts, and determinism.test.ts, which runs the same seed a hundred times and asserts the outcome, end tick, and every log entry match. I can assert that prana regenerates at exactly 1/sec and doubles in the last 30 seconds without rendering a single pixel. Compare that to Duck Samurai, where verifying the boss enters phase 2 at 66% health means launching a browser and hitting it forty times.

Writing those tests is also where AI is at its absolute best — the sim's contracts are explicit in types.ts, so "write a test that a deck of the wrong size throws" is a request with exactly one right answer.

And to be fair to the honesty standard: one determinism test is currently it.skip, with a comment explaining that the engine does not yet consume the seeded RNG during tick, so different seeds produce identical traces and the assertion would be meaningless. That is a real TODO sitting in the repo, not a polished narrative.

The Lesson, With Caveats

The tempting conclusion is "the deterministic sim is the right way." It is not that simple.

A platformer genuinely needs what Phaser gives you. Arcade physics, collision resolution, tweens, particle emitters, sprite flipping — reimplementing those to sit behind a pure engine would be months of work to arrive somewhere worse. Duck Samurai's scene-owns-everything design is the correct shape for the game it is.

A lane battler is different. Its rules are arithmetic — hit points, cooldowns, positions on a line. Nothing about it requires a physics engine, which means the coupling buys you nothing and costs you every test you cannot write.

The lesson is narrower and more useful than "always decouple": the boundary should follow how much of your game is rules versus how much is physics. When the rules dominate, pull them out where you can test them. When the physics dominates, let the framework own it and accept that verification means playing.

AI wrote most of both games. It could not have made that call for either of them, because the call depends on knowing what the game is supposed to feel like — and that is still the part you do not get to delegate.

Go break the boss fight. If the jump feels right, that was the forty attempts. If it does not, tell me which constant to change.


Related Reading:

Enjoying this article?

Get posts like this in your inbox. No spam, unsubscribe anytime.

Share this article
VK

Vinod Kurien Alex

Engineering Manager with 20+ years in software. Writing about AI, careers, and the Indian tech industry.

Related Articles

© 2026 TechLife AdventuresBuilt with care · v3.2.1