Pipeline Lessons — Aggregated from every game build. Each lesson includes the game it came from, the symptom, root cause, and verified fix.

Process & Pipeline

Pong
#1
Generated Code Fragility

The AI consistently got initialization order wrong — calling methods that depended on state not yet defined. Patch-based fixes accumulated until the file was too tangled to safely edit.

Fix: Rebuild from scratch after 2 failed patches. Use a Python generator script for transformations instead of patching generated HTML.

Breakout
#2
Stage-Gated Pipeline

Step 4 (Level Progression) hit the tool call limit mid-generation. The file was left half-written — broken script tags, missing initialization, wrong CSS classes.

Fix: Split builds into 4 isolated stages: Generate (Python script transforms previous step) → Verify (check script tag balance, Phaser.Game instantiation) → Update (patch roadmap) → Deploy (build + ship). Each stage has its own tool budget.

Breakout
#3
Rollback Before Every Edit

Patching generated code is fragile — every patch assumed the file was clean. A broken patch could corrupt the entire game file.

Fix: Save a cp backup to /tmp/ before any change. One cp restores the last known-good state. No more "delete everything and start over."

Breakout
#4
Feature-Scaling Rule

Step 4 bundled 4 features: level layouts, speed ramp, 3 layout types, level transitions. When generation hit the tool call limit, all 4 were lost simultaneously.

Fix: If a step introduces 3+ new systems, split into smaller steps. One new system per step keeps each build focused, verifiable, and recoverable.

Gun Fight
#5
Switch Model After 2 Failed Rewrites

DeepSeek V4 Flash produced 4 partial rewrites, each fixing some bugs but introducing new ones. The iteration tax accumulated faster than the bug fix rate.

Fix: After 2 failed rewrites on the same step, switch from Flash to Pro. 3× cost << iteration tax of 4+ buggy rewrites. Once verified working, lesson saved to skill.

Gun Fight
#6
Pending Physics State in Callbacks

onHitP1/2 disables THREE things (setActive(false), setVisible(false), body.enable = false), but pendingReset only restored position + alpha. setAlpha(1) does NOT undo setVisible(false).

Fix: pendingReset must unconditionally re-enable all three: setActive(true), setVisible(true), body.enable = true — plus position, velocity, and alpha. Verified by 42-assertion Playwright test suite.

Phaser 3.60 Physics

Pong
#7
setBounce(1) Is Essential

Without it, the ball loses speed on every wall/paddle hit and eventually stops. 1 = perfect elasticity (100% energy retained).

Fix: Always call ball.setBounce(1) on every physics-enabled ball. Non-negotiable for ball-based games.

Pong
#8
Colliders in create(), Not update()

Adding physics.add.collider() inside update() creates infinite new colliders every frame, tanking performance.

Fix: Register all colliders once in create(). Phaser manages them for the scene lifetime.

Pong
#9
Clamp Paddles to Play Area

Without Phaser.Math.Clamp(), paddles can slide off-screen. Always set min/max bounds.

Fix: paddle.y = Phaser.Math.Clamp(paddle.y, 40, H - 40) after every movement update.

Breakout
#10
Vertical Bounce Loop — Paddle Center Fix

When the ball hits the center of the paddle, bounce angle computes to 0° (straight up). cos(-90°) = 0, so horizontal velocity is zero. Ball loops straight up/down forever — no bricks touched.

Fix: After computing bounce velocity, check if |vx| < 60 and boost it to 60 while recalculating vy to preserve speed. Applied globally to hitPaddle().

Breakout
#11
Multi-Ball Passes Through Paddle — Array vs Group

this.balls was a plain JS array. physics.add.collider(this.balls, ...) captures the array contents at collider creation time — when the array was empty. New balls pushed in by activateMulti() were invisible to the collider.

Fix: Use this.physics.add.group() for dynamically-added physics objects. Phaser groups are dynamically tracked by collider pairs — new sprites are auto-checked.

Breakout
#12
Level Progression — brick.destroy() in Physics Callback

brick.destroy() called inside an overlap callback defers the group's child-list cleanup. countActive() never reaches 0, so level transitions never fire.

Fix: Replace brick.destroy() with brick.disableBody(true, true) inside callbacks. disableBody() deactivates the physics body without removing the child from the group — safe during physics steps.

Breakout
#13
Three Bugs Stacked — Group Conversion Reverse Lesson

Refactoring to a physics group introduced 3 bugs: dropped setCollideWorldBounds(true), dropped body.onWorldBounds = true, and added Phaser.Scale.FIT. The Scale.FIT silently broke world-bound coordinates.

Fix: Three mandatory properties per ball: setBounce(1), setCollideWorldBounds(true), body.onWorldBounds = true. Never add Scale.FIT — it resizes the canvas after physics bounds are set, creating coordinate mismatch.

Breakout
#14
Phaser Physics Groups Silently Fail WebGL Render

Minified Phaser 3.60's WebGL renderer crashes on groups that are destructively iterated (e.g. filter(getChildren()) reassigned back). The canvas renders black — no errors in console.

Fix: Use plain JS array (this.balls = []) with this.physics.add.sprite() per ball, pushed to the array. physics.add.collider() handles arrays correctly.

Gun Fight
#15
staticGroup.clear(true, true) Corrupts Physics

Calling clear(true, true) on a Phaser static group leaves stale physics bodies in the world. New bodies added afterward behave unpredictably — collision bounds misaligned.

Fix: Destroy the entire group with group.destroy() and create a fresh one. Never reuse a cleared static group.

Gun Fight
#16
Physics State in Overlap Callbacks Is Frozen

Calling body.setVelocity(0,0) or body.enable = false inside a physics overlap callback doesn't take effect until the next physics step. The overlap callback re-fires on the stale state, creating infinite loops.

Fix: Set a pendingReset flag in the callback, then apply the state change in update() where it's safe.

AI & Bot Strategy

Pong
#17
Speed Limit vs Reaction Delay Timer

The AI moves at a fixed slower speed, which naturally creates delay. Timer-based delay creates jerky, stop-start AI behaviour.

Fix: Speed limiting (slower paddle velocity) is simpler and produces smoother movement than timer-based pause delays.

Pong
#18
AI Should Not Track the Ball Perfectly

The AI opponent was tuned too aggressively on first pass, tracking the ball perfectly instead of simulating human reaction lag. Unbeatable AI is no fun.

Fix: Add a speed cap to AI paddle velocity, a dead zone (8px threshold) to prevent oscillation, and asymmetric speeds for bot-vs-bot matches.

Breakout
#19
Wobble Offset Was Accidentally Solving Lateral Coverage

The [48,24,-24,-48] oscillating offset was thought to make the bot "look human-like." In reality, it was the only thing preventing the vertical bounce loop from locking the ball to one brick column.

Fix: Replace random wobble with systematic zone sweeping: cycle through [-48,-24,0,24,48] offsets per paddle hit to distribute ball angles across all 5 paddle zones. Avg score improved 28% (24 → 32.8).

Breakout
#20
Benchmark Duration Matters — Short Windows Amplify Variance

20s benchmarks had 2-4× score variance between runs compared to 90s benchmarks. The wobble offset creates run-to-run luck that a short window can't smooth out.

Fix: Run benchmarks at 90s minimum. 5 trials × 90s gives stable averages. Always document canvas resolution (640px vs 1024px) — smaller canvas = more bounces = inflated scores.

Gun Fight
#21
AI State Machine: Random Y in Every Call

moveTowardXY(sprite, hideX, 600 + Math.random() * 60) calls Math.random() every 100ms tick, producing jittery movement. The AI vibrates instead of moving to a fixed Y.

Fix: Pick the Y target once on state entry and reuse it. Store it as a property on the state object.

Gun Fight
#22
AI Cover Navigation: Fixed Targets Beat Dynamic Positions

Dynamic cover navigation (getCoverPosition()) caused the AI to get pinned against crate collision bodies, cycling HIDE→PEEK→HIDE without ever firing.

Fix: Replace dynamic navigation with fixed target positions per state: HIDE at x=920, PEEK at x=530, RETREAT at x=920. No crate collision fights, no random direction.

Audio

Pong
#23
AudioContext Suspended on First Play

Browsers don't allow audio autoplay. The oscillator connects but produces silence without resume.

Fix: Always call ctx.resume() before playing the first sound. Call it on the first user interaction (click/keypress).

Pong
#24
Multiple AudioContexts Hit Browser Limits

Chrome limits to 6 AudioContexts per tab. Creating a new one per scene restart hits the limit and audio breaks silently.

Fix: Store the AudioContext as a class property outside create() to reuse across scene restarts. One context for the game lifetime.

Pong
#25
Exponential Ramp Can't Reach Zero

exponentialRampToValueAtTime(0, ...) throws an error because the ramp function can't approach absolute zero.

Fix: Use 0.001 as the floor value for exponential ramps. Wrap in try/catch as a safety net.

25 lessons across 3 games