1970s Arcade — Teaches brick grids, angle-based collision, power-ups, and progressive difficulty. Natural next step after Pong.

01

Bricks & Paddle

Complete

Why learn this?

Breakout takes Pong’s paddle-and-ball physics and adds structured collision targets. Instead of bouncing endlessly between two paddles, the ball now destroys things on contact. This introduces grid-based level design, object destruction as core mechanic, and layered difficulty through color-coded value tiers. Every brick-breaking game since 1976 follows the same pattern. Understanding brick grids unlocks match-3, tile-based puzzles, and any game where objects are arranged in rows and columns.

What you built

A Breakout court with 50 bricks arranged in a 10×5 grid. Five color-coded rows descend from red (7 points) to blue (1 point). The paddle follows the mouse or keyboard. The ball launches from the paddle, bounces off walls and bricks, and destroys bricks on contact. Lose the ball below the paddle and you lose a life. Three lives, then game over. Bot mode watches the AI play itself.

Prompt used

Show prompt — Step 1
Create a Breakout game in Phaser 3.60 with:
- A paddle controlled by A/D keys and arrow keys
- A ball that bounces off walls and the paddle
- 50 bricks (10x5 grid) with 5 color-coded point value tiers
- Score display at top-left, lives counter at top-right
- 3 lives, game over when all lost
- Angle-based bounce off the paddle (ball.x - paddle.x)
- Bot mode via ?mode=bot URL parameter
- The game must be playable immediately with no errors
- Use generateTexture() in preload for all textures
- NO setCollideWorldBounds on the ball (use manual wall bounces)
- Ball radius = 7px, paddle = 80x14px, bricks = 60x20px
- Canvas: 680x480, dark background #0f1120

Key concepts

  • Static physics groupsthis.physics.add.staticGroup() creates a group of immovable sprites. Bricks in a static group don't need per-frame velocity or physics updates, making 50+ bricks efficient to render.
  • Sprite tintingbrick.setTint(0xff4444) changes a sprite's color at runtime without loading multiple textures. One white brick texture, five tints.
  • Custom sprite databrick.setData('value', 7) attaches arbitrary metadata to a sprite. On collision, brick.getData('value') reads the point value. No separate lookup table needed.
  • Grid layout mathbx = col * 64 + 30 spaces bricks evenly. The 64px horizontal stride (60px brick + 4px gap) and 24px vertical stride create a tight grid. Changing these values changes the game difficulty.
  • Mouse inputthis.input.on('pointermove') reads cursor position directly. Unlike keyboard polling, mouse input is event-driven: the callback fires on every pixel of movement. Phaser.Math.Clamp() keeps the paddle on-screen.
  • Angle-based deflection — The ball's outgoing angle is calculated from (ball.x - paddle.x) * 1.8. Hit center = flat trajectory. Hit edge = steep angle. This gives the player control over where the ball goes next.

Design decisions & tradeoffs

  • generateTexture() vs external brick sprites — One white rectangle texture, tinted per row. Tradeoff: all bricks are the same shape. Different brick sizes (wide, tall, round) would need separate textures or external assets.
  • Static group vs dynamic group — Static bricks don't need velocity calculations. Dynamic would let bricks fall, animate, or slide. Overhead: static groups skip per-frame physics checks on inactive members.
  • setBounce(1) on ball — Perfect elasticity means the ball never slows down from wall/paddle hits. Tradeoff: the ball can feel too fast on long rallies. A slight inelasticity (0.95) would slow the game gradually.
  • worldbounds for bottom detection — The body.blocked.down check on world bounds fires once when the ball escapes. Alternative: a sensor zone (invisible rectangle) below the paddle. Worldbounds is simpler and catches edge cases.
  • Mouse + keyboard dual input — Both input methods work. Keyboard for precision, mouse for speed. Tradeoff: mouse input on mobile works via touch events, but touch gameplay on a 680px canvas is cramped.

Alternative approaches

  • Pre-built brick texture atlas — Instead of generating one texture and tinting, load a sprite sheet with pre-colored bricks. More visual variety (shiny, cracked, gradient bricks) at the cost of asset management.
  • Matter.js physics — Phaser's arcade physics handles rectangular collisions well. Matter.js would give you polygon collision (angled bricks, curved surfaces) but adds 10x the physics overhead. Not worth it for a brick grid.
  • Paddle-relative ball launch — Current: ball launches at a random angle from center. Alternative: ball sticks to the paddle top until launched (like modern Breakout). The sticky launch gives the player positioning control before the first hit.
  • Canvas API without Phasercanvas.getContext('2d') with manual requestAnimationFrame loop. More code but no library dependency. Good for learning, bad for shipping.

Browser compatibility

  • Phaser 3.60 uses Canvas2D or WebGL — same as Pong. All modern browsers.
  • URLSearchParams for bot mode: Chrome 49+, Firefox 44+, Safari 10.1+, Edge 17+.
  • AudioContext oscillator: supported in all browsers since 2014. Silent fallback via try/catch.

Performance notes

  • 50 static sprites + 1 dynamic sprite + 1 paddle = minimal draw calls. Phaser batches static group renders.
  • Physics step: arcade physics for one moving object is near-zero cost. Static group collision is pre-computed.
  • Bot mode: adds a subtraction and clamp per frame. Immeasurable.

⚠️ Common pitfalls

  • Ball stuck in brick grid — When the ball hits a brick at a shallow angle, it can pass through or get stuck inside. The setBounce(1) + arcade physics combination handles most cases, but very thin angle collisions can glitch. Fix: increase ball speed or add a minimum deflection angle.
  • Lives decrement on wall bounce — The worldbounds event fires for ALL world boundaries (top, left, right, bottom). Without checking body.blocked.down, the ball hitting the top wall triggers loseLife(). Always check which wall was hit.
  • Brick count check after destructionthis.bricks.countActive() must be called AFTER the brick is destroyed, not before. The collision callback fires before Phaser removes the brick from the group.
  • AudioContext suspended on first launch — Same as Pong: browsers suspend audio until user gesture. The ctx.resume() call in playTone() handles this.
  • Paddle position on restartscene.restart() resets everything, including paddle position. If the player restarts while the ball is in play, the paddle snaps to center. This is expected behaviour but can be disorienting.

Next up

Step 2 adds refined angle physics — the bounce angle maps precisely to paddle-contact zone with dead-zone centering. The ball responds to where you hit, not just random deflection.

Brick grid generation ▶ Play
<!-- Brick grid: 10 columns x 5 rows -->
const brickColors = [0xff4444, 0xff8844,
  0xffcc44, 0x44cc44, 0x4488ff];
const brickValues = [7, 5, 3, 1, 1];

for (let row = 0; row < 5; row++) {
  for (let col = 0; col < 10; col++) {
    let bx = col * 64 + 30;
    let by = row * 24 + 50;
    let brick = this.bricks.create(
      bx, by, 'brick');
    brick.setTint(brickColors[row]);
    brick.setData('value',
      brickValues[row]);
  }
}
Angle-based paddle hit
hitPaddle(ball, paddle) {
  this.playTone(520, 0.08, 'square');
  let diff = ball.x - paddle.x;
  let angle = diff * 1.8;
  angle = Phaser.Math.Clamp(
    angle, -70, 70);
  let rad = Phaser.Math.DegToRad(
    angle - 90);
  let speed = Math.sqrt(
    ball.body.velocity.x ** 2 +
    ball.body.velocity.y ** 2);
  speed = Math.min(speed, 400);
  ball.body.setVelocity(
    Math.cos(rad) * speed,
    Math.sin(rad) * speed);
}
✓ Done. Step 1 complete. 50 bricks, paddle input, scoring, lives, and bot mode. Continue to Step 2 →
02

Angle Physics

Complete

Why learn this?

In Step 1, the ball's bounce angle was a rough diff * 1.8 calculation — it worked, but the player had no predictability. Step 2 replaces that with a 5-zone paddle mapping system. Each paddle segment maps to a specific launch angle: far edges send the ball steep, center sends it straight up. This is the difference between RNG bounce and skill-based aiming. Understanding zone-based collision response is essential for any game with player-controlled deflection — from pinball flippers to hockey game passing.

What you built

The 80px-wide paddle is divided into five 16px zones. Hitting the far-left zone launches the ball at -65° (steep left), far-right at +65° (steep right). Center zone sends it straight up. A zone label flashes briefly above the paddle (e.g. “<< STEEP” or “^ FLAT”) so the player sees exactly which zone they hit. A colored overlay on the paddle flashes the corresponding zone color — red for edges, green for center.

Prompt used

Show prompt — Step 2
Add 5-zone paddle angle mapping to the Breakout game:
- Divide the paddle into 5 equal zones
- Zone angles: [-65, -35, 0, 35, 65] degrees (after -90 offset)
- Zone 0 = far left (steep left), Zone 2 = center (straight up), Zone 4 = far right
- Calculate zone from (ball.x - paddle.x + paddleHalf) / zoneWidth
- Clamp zone to [0, 4] to prevent out-of-bounds
- Flash a colored overlay on the paddle showing which zone was hit
- Show a zone label text that fades out over 400ms
- Zone colors: red (#ff4444) for edges, orange (#ff8844) for mid, green (#44cc44) for center
- Preserve all existing Step 1 functionality
- Keep the same canvas size, textures, and game structure

Key concepts

  • Zone mappingMath.floor((hitOffset + paddleHalf) / zoneWidth) maps the ball's contact point (range -40 to +40px) to a zone index 0-4. Division-based mapping is resolution-independent: wider paddles still get 5 equal zones.
  • Explicit angle presets[-65, -35, 0, 35, 65] replaces the continuous diff * 1.8 formula. Discrete zones give the player clear, repeatable outcomes. Hitting the same spot always produces the same angle.
  • Visual feedback — A zone label flashes above the paddle using this.tweens.add() to fade from full alpha to zero over 400ms. A colored rectangle overlay on the paddle segment appears for 200ms. Feedback happens within 1 frame of collision.
  • Zone color coding — Outer zones flash red (#ff4444), mid zones orange (#ff8844), center green (#44cc44). The player learns the color-zone mapping subconsciously — red means danger/steep angle, green means safe/straight.
  • Speed capspeed = Math.min(speed, 420) prevents the ball from going infinitely fast on long rallies. Slightly higher than Step 1's 400 to account for angle-based speed splitting.

Design decisions & tradeoffs

  • 5 zones vs continuous angle mapping — Discrete zones (5 fixed angles) vs continuous (every pixel = different angle). Discrete is more predictable for the player — you learn 5 outcomes, not 80. Continuous feels more organic but harder to master.
  • Zone indicator overlay vs nothing — Visual feedback adds ~15 lines of code but dramatically improves the learning loop. Without it, the player can't tell which zone they hit. With it, they connect paddle position to ball trajectory in one rally.
  • Fixed zone width vs proportional — 16px per zone regardless of paddle size. Alternative: calculate zones as percentage of paddle width. Fixed is simpler and works for a single paddle size. Proportional would be needed if power-ups change paddle width.
  • Zone flash via Phaser Graphics vs sprite — Graphics overlay is procedural (draw at runtime). Alternative: pre-made zone highlight sprites. Graphics is simpler and adapts to any zone width without asset work.

Alternative approaches

  • Tutorial mode — Show zone labels permanently during the first few seconds of play, then fade them to on-demand (only visible after a hit). Helps beginners learn the zones without overwhelming them.
  • Zone text at paddle vs center screen “The zone indicator currently floats above the paddle. Alternative: show it in the HUD area with a mini paddle diagram. Paddle-relative is more intuitive (the feedback is where the action is) but can be hard to read during fast play.
  • Screen-edge indicators — Tiny colored dots at the top edge showing the ball's predicted trajectory. Overkill for Step 2 but useful for accessibility.

Browser compatibility

  • Phaser tweens are built on requestAnimationFrame. Supported everywhere Phaser runs.
  • Graphics.fillRect() for zone overlay uses Canvas2D — all browsers since IE9.
  • Same CDN, AudioContext, and URLSearchParams support as Step 1.

Performance notes

  • Zone mapping: one division, one floor, one clamp, one array lookup = ~0.002ms. Free.
  • Zone Graphics overlay: one fillRect call per frame during flash (200ms = 12 frames at 60fps). Cleared after flash. Negligible.
  • Tween for zone text: single property animation. Phaser's tween manager batches all active tweens into one update pass.

⚠️ Common pitfalls

  • Zone index out of boundsMath.floor((hitOffset + paddleHalf) / zoneWidth) can produce -1 or 5 if the ball clips the paddle edge. Always clamp zone to [0, 4] before array access.
  • Zone overlap on paddle edges — The ball's hitbox (12px diameter) may overlap two zones simultaneously. The hitOffset uses the ball's center, so this is usually correct. For precise edge detection, use ball.body.left and ball.body.right relative to paddle zone boundaries.
  • Zone text after restartscene.restart() creates a new zoneText object. Any lingering tweens on the old object are destroyed with the scene. No memory leak.
  • Zone flash cleared before next hit — The time.delayedCall(200, clear) fires even if the player hits the ball again within 200ms. The new hit draws a new zone flash while the old clear timer still exists — it clears the new flash prematurely. Fix: store the timer reference and cancel it before starting a new flash: if (this.zoneTimer) this.zoneTimer.remove(); this.zoneTimer = this.time.delayedCall(...).

Next up

Step 3 adds power-ups — wide paddle, multi-ball, sticky catch, and laser shot. Random drops from destroyed bricks introduce game-changing mechanics.

Paddle-zone angle mapping ▶ Play
let paddleHalf = paddle.displayWidth / 2;
let zoneWidth = paddle.displayWidth / 5;
let hitOffset = ball.x - paddle.x;

let zone = Math.floor(
  (hitOffset + paddleHalf) / zoneWidth);
zone = Phaser.Math.Clamp(zone, 0, 4);

const zoneAngles = [-65, -35, 0, 35, 65];
let angle = zoneAngles[zone];
let rad = Phaser.Math.DegToRad(angle - 90);

let speed = Math.sqrt(
  ball.body.velocity.x ** 2 +
  ball.body.velocity.y ** 2);
speed = Math.min(speed, 420);
ball.body.setVelocity(
  Math.cos(rad) * speed,
  Math.sin(rad) * speed);
Zone indicator flash
const zoneLabels = ['<< STEEP',
  '< MID', '^ FLAT', 'MID >',
  'STEEP >>'];

this.zoneText.setText(
  zoneLabels[zone]);
this.zoneText.setAlpha(1);
this.tweens.add({
  targets: this.zoneText,
  alpha: 0,
  duration: 400,
  ease: 'Power2'
});

// Colored overlay on paddle
let zoneColors = [0xff4444,
  0xff8844, 0x44cc44,
  0xff8844, 0xff4444];
this.zoneGraphics.clear();
this.zoneGraphics.fillStyle(
  zoneColors[zone], 0.5);
this.zoneGraphics.fillRect(
  zx, zy, zoneWidth, 14);
this.time.delayedCall(200,
  () => this.zoneGraphics.clear());
✓ Done. Step 2 complete. 5-zone paddle mapping with visual feedback. Continue to Step 3 →
03

Power-ups

Complete

Why learn this?

Power-ups transform Breakout from a fixed challenge into a dynamic one. Every destroyed brick has a 20% chance to drop a pickup that changes how the game works. This introduces random drop systems, temporary state effects with timers, and sprite scaling — mechanics used in every modern game from Mario Kart to Destiny.

What you built

Three power-ups drop from destroyed bricks, each lasting 10 seconds or until depleted. The Wide Paddle (orange) stretches the paddle from 80px to 140px with a glowing orange outline. Multi-ball (blue) clones each active ball into 2 more, flooding the court. Sticky Catch (green) catches the ball on the paddle — click to relaunch in any direction. A HUD indicator shows the active power-up. The bot automatically collects and uses power-ups.

Prompt used

Show prompt — Step 3
Add 3 power-ups to the Breakout game:
- Wide Paddle (orange #ff8844): stretches paddle to 140px for 10 seconds with glow
- Multi-ball (blue #4488ff): clones each active ball into 2 extra balls with random angles
- Sticky Catch (green #44cc44): ball sticks to paddle on contact, click to relaunch
- Power-ups drop from bricks with 20% chance on destruction
- Power-ups fall downward at 120px/s velocity
- Catch with physics.add.overlap(paddle, powerups, catchPowerup)
- Show active power-up text at top center of screen
- Auto-expire Wide Paddle after 10 seconds with timer
- Bot mode should auto-collect all power-ups and auto-release sticky
- Clean up fallen power-ups (y > H+30) each frame
- Preserve all Step 1-2 functionality

Key concepts

  • Random drop tablesMath.random() < 0.2 gives each brick a 20% drop chance. Three power-up types are picked randomly. Drop rate and type pool are parameters you can tune — harder levels can lower drop rate or remove the best power-ups.
  • Sprite scalingpaddle.setDisplaySize(140, 14) stretches the paddle sprite. body.setSize(140, 14) updates the physics body to match. Without the body resize, collisions still use the original 80px hitbox.
  • Temporary state with timersthis.time.delayedCall(10000, revert) reverts the wide paddle after 10 seconds. Timer cancellation (.remove()) on re-pickup prevents stacking — collecting a second Wide Paddle resets the 10s timer instead of running two timers in parallel.
  • Power-up group physics — Falling power-ups use a dynamic physics group with setVelocityY(80). physics.add.overlap(paddle, powerups, catchPowerup) detects pickup. Power-ups that miss the paddle and fall off-screen are cleaned up in update().
  • Multi-object management — Instead of tracking one ball, the game now tracks an array of balls. removeBall() handles destruction and checks if any balls remain. Zero active balls = lose a life. Each ball is tagged with ball.isBall = true for collision identification.
  • Sticky catch state — A boolean flag (this.stickyBall) plus velocity check in hitPaddle. When sticky, the ball's velocity is zeroed and canMove is false. Clicking calls releaseSticky() which sets all stuck balls in motion.

Design decisions & tradeoffs

  • 20% drop rate vs fixed drops — 20% per brick averages 10 power-ups per level (50 bricks × 0.2). Fixed drops (e.g. specific bricks always drop) would be more predictable for strategy. Random is more exciting per-session but harder to balance.
  • Tween flash vs static label — Power-ups pulse with an alpha tween (500ms cycle) so they're visible against the dark background. Alternative: static icon with outline. The pulse is harder to miss during fast play.
  • Timer cancellation on re-pickup — Collecting a Wide Paddle while already wide resets the timer rather than stacking. Stacking would create overlap bugs (two timers trying to revert the same sprite). Cancellation is simpler and prevents edge cases.
  • Multi-ball: clone vs split — Current approach: each existing ball spawns 2 new balls at its position. Alternative: split the ball into 2 identical trajectories (one slightly offset). Cloning creates more chaos, which is the point.

Alternative approaches

  • Laser shot — Paddle fires projectiles upward. Requires projectile management (own collision group, speed, lifetime). Not implemented in Step 3 because multi-ball already adds enough chaos. Good for a future power-up or Step 5.
  • Shrink paddle (negative power-up) — Opposite of Wide: paddle shrinks to 40px for 10 seconds. Adds risk-reward to catching power-ups. Implement with the same setDisplaySize() system, just a different preset.
  • Power-up cycling — Instead of random drops, a single power-up cycles through types every 5 seconds above the paddle. Catcher chooses when to grab. More skill-based but less chaotic.

Browser compatibility

  • Phaser tweens for power-up pulsing: all browsers supporting Phaser 3.60.
  • setDisplaySize() uses Canvas2D scaling — supported everywhere. No WebGL dependency.
  • Array-based ball management: pure JS, no browser API dependencies.

Performance notes

  • Up to 9 active balls (initial + 4 clones × 2) = negligible. Phaser renders sprites in batches.
  • Power-up group: 0-3 active items on screen. Each has a tween and a velocity check.
  • Wide paddle glow: one strokeRect per frame during wide mode. Near-zero cost.
  • Power-up cleanup in update(): iterates ~3 items per frame. Free.

⚠️ Common pitfalls

  • Ball array reference after destroyball.destroy() removes the sprite but the array reference still exists. Always filter: this.balls = this.balls.filter(b => b.active). Checking ball.active before any operation is also essential.
  • Multi-ball + worldbounds — The worldbounds event fires for ALL balls. Without the isBall check, paddle or brick collisions can trigger life loss. Tag every ball with ball.isBall = true and check it in the handler.
  • Timer reference on scene restartthis.paddleWideTimer holds a Phaser timer reference. On scene.restart(), the timer is destroyed with the scene but the reference still exists. Always null-check before accessing: if (this.paddleWideTimer) this.paddleWideTimer.remove().
  • Sticky ball on scene restart — If the scene restarts while a ball is stuck, the new scene creates new balls. No sticky state carries over. Test: press R during sticky mode, verify the new game starts clean.
  • Power-up fall through paddle — Fast-falling power-ups can pass through the paddle in one frame if the paddle is moving. The overlap check fires per-frame, so a high-velocity power-up moving 80px/frame can skip past a 14px-tall paddle. Fix: increase the physics iteration rate or add a thicker overlap zone below the paddle.

Next up

Step 4 adds level progression — multiple brick layouts per level, speed increases, and procedural generation from layout templates.

Power-up drop system ▶ Play
// 20% drop chance per brick
if (Math.random() < 0.2) {
  let types = ['pu-wide',
    'pu-multi', 'pu-sticky'];
  let type = Phaser.Utils.Array
    .GetRandom(types);
  let pu = this.powerups.create(
    brick.x, brick.y, type);
  pu.body.setVelocityY(80);
  pu.puType = type;
  // Pulsing tween
  this.tweens.add({
    targets: pu,
    alpha: { from: 1, to: 0.5 },
    duration: 500, yoyo: true,
    repeat: -1
  });
}
Wide paddle activation
activateWidePaddle() {
  this.paddleWide = true;
  this.paddle.setDisplaySize(
    140, 14);
  this.paddle.body.setSize(
    140, 14);
  this.powerupText.setText(
    'WIDE PADDLE');

  // Cancel previous timer
  if (this.paddleWideTimer)
    this.paddleWideTimer.remove();
  this.paddleWideTimer =
    this.time.delayedCall(
      10000, () => {
    this.paddle.setDisplaySize(
      80, 14);
    this.paddle.body.setSize(
      80, 14);
    this.paddleWide = false;
    this.powerupText.setText('');
  });
}
✓ Done. Step 3 complete. Three power-ups: wide paddle, multi-ball, sticky catch. Continue to Step 4 →
04

Level Progression

Complete

Why learn this?

Until now, every restart showed the same brick layout. Step 4 makes the game change as you play. Each completed level advances to a harder layout with faster balls. This introduces template-driven level generation, difficulty curves via speed multipliers, and state management across game phases — the same systems that drive level progression in Peggle, Angry Birds, and any game with escalating challenge.

What you built

Three level layouts encoded as data objects: Classic (5 rows x 10 cols, normal speed), Pyramid (8 rows x 10 cols, 1.2x speed, staggered offsets), and Fortress (6 rows x 12 cols, 1.4x speed, dense packing with high-value bricks). Each completed level triggers a 1.5s transition screen, then auto-advances. A HUD shows the current level number and layout label. Clearing all three levels triggers a victory fanfare (three ascending tones) and a "You Win!" screen.

Prompt used

Show prompt — Step 4
Add 3 level layouts and progression to the Breakout game:
- Classic: 5 rows x 10 cols, speedMult: 1.0, margin: 10
- Pyramid: 8 rows x 10 cols, speedMult: 1.2, margin: 10
- Fortress: 6 rows x 12 cols, speedMult: 1.4, margin: 6
- Each layout has its own color tiers and point values
- Ball launch speed = base_speed * layout.speedMult
- After clearing all bricks, show "Level N Complete!" overlay
- Wait 1.5 seconds, then call startLevel(N+1)
- Show current level and layout label in HUD
- On completing all 3 levels, show "You Win!" with victory fanfare
- balls must use physics group (not array) for dynamic tracking
- Use disableBody(true, true) not destroy() in hitBrick callback
- Every ball must have setCollideWorldBounds(true) + body.setBounce(1) + body.onWorldBounds=true
- Preserve all Step 1-3 functionality

Key concepts

  • Layout-as-data — Each level is a plain object: { rows, cols, speedMult, label, colors[], values[] }. The generation loop iterates rows * cols and positions bricks using column/row math. Adding a new level means adding one object to the array — no new code. This decouples level design from game logic.
  • Speed multiplier curveball.body.setVelocity(cos * 300 * layout.speedMult, sin * 300 * layout.speedMult). The 300 base speed gets scaled per level. The multiplier (1.0 → 1.2 → 1.4) creates a predictable difficulty ramp without hardcoding speeds. The same multiplier also caps paddle-hit speed: Math.min(speed, 420 * layout.speedMult).
  • Level state machinethis.currentLevel, this.maxLevel, and this.levelTransition flag track which phase the game is in. On level completion: ball velocity zeroed → "Level Complete" text shown → 1.5s delay → createLevel(nextLevel) → bricks rebuilt → balls stopped → "Press SPACE to start". Each state is explicit and testable.
  • Grid margin per layoutvar margin = level === 3 ? 4 : 7. Fortress has 12 columns, so the margin shrinks from 7 to 4 to fit all bricks within the 680px canvas. Column count and margin are coupled — a design constraint that makes every layout feel different.
  • Per-row tint and value arrays — Each layout has its own colors[] and values[] arrays. Classic uses 5 rows with descending values (7,5,3,1,1). Pyramid uses 8 rows with higher top values (10,8,5,3,2,1,1,1). Fortress uses 6 rows with the biggest top value (15). The difficulty isn't just brick count — it's brick value distribution.
  • Delayed state transitionsthis.time.delayedCall(1500, () => this.createLevel(...)) creates a gap between clearing a level and starting the next. During this gap, the "Level Complete" text is visible and balls are frozen. The player gets a 1.5s breather before the next layout loads.

Design decisions & tradeoffs

  • 3 fixed levels vs procedural generation — Hardcoded layout objects give precise control over difficulty progression. Alternative: procedural generation using RNG + constraints (e.g. "generate 8 rows, 60% high-value bricks, no gaps larger than 2"). Procedural is replayable but unpredictable — a player might get an impossible layout.
  • Speed multiplier vs flat speed increase — 300 * 1.4 = 420 is the same as setting speed to 420 directly. But the multiplier approach makes it easy to tweak the whole curve: change speedMult from 1.4 to 1.3 and all levels adjust proportionally. Flat speeds would need retuning each value individually.
  • Level transition delay vs instant — 1.5s gives the player time to register "I cleared that level." Instant transitions are jarring. Too long (3s+) feels like waiting. 1.5s is long enough to read the text, short enough to maintain flow.
  • Brick values per row vs per layout — Each layout defines its own value gradient. Pyramid's top rows are worth 10 points vs Classic's 7 because Pyramid has more rows and higher density. Per-layout values let you make early levels forgiving and late levels punishing without changing the scoring system.

Alternative approaches

  • JSON level files — Instead of JS objects, load layouts from external .json files. This lets designers add levels without touching game code. Downside: network request per level. For 3 levels, the overhead isn't worth it. For 100 levels, JSON files are essential.
  • Level editor — A drag-and-drop editor that outputs layout objects. Lets non-programmers design levels. Overengineering for Step 4, but worth considering if the Breakout game ships with 20+ levels.
  • Infinite mode — After 3 levels, keep generating new ones with random layouts and increasing speed. No win state. Good for arcade replayability. Step 4 has an explicit win because the learning goal is finite — you learn level progression, then move on.
  • Lives carry-over vs per-level reset — Current: lives carry across levels (3 lives total). Alternative: reset lives per level (3 lives per level). Carry-over creates tension (low lives entering a hard level). Per-level reset is more forgiving for learning.

Browser compatibility

  • Layout objects and array iteration: pure JS ES5. No API dependencies.
  • Phaser.Math.Clamp() and Phaser.Math.DegToRad(): Phaser built-ins, same compatibility as Steps 1-3.
  • time.delayedCall(): Phaser timer API, works on all platforms Phaser 3.60 supports.

Performance notes

  • Level rebuild destroys all bricks and creates new ones. At 60 bricks (Fortress), the entire grid is regenerated in ~2ms. No memory leak because bricks.clear(true, true) destroys all game objects and frees their physics bodies.
  • Transition state: zero physics updates during transition (all balls velocity-zeroed). The 1.5s delay is effectively a free frame pause.
  • Speed multiplier: one extra multiplication per ball per frame. Immeasurable.

⚠️ Common pitfalls

  • Level index out of boundsthis.layouts[this.currentLevel - 1] assumes level numbers start at 1. If currentLevel is 0 or exceeds layouts.length, the result is undefined. Always use a guard: var layout = this.layouts[idx]; if (!layout) return;.
  • Ball array state on level transitioncreateLevel() stops all balls but doesn't destroy them. If multi-ball was active, dead balls remain in the array. This is fine because this.balls.forEach(b => if (b.active) b.body.setVelocity(0,0)) only touches active ones. But unused ball sprites linger in memory until the next death or scene restart.
  • Multiple SPACE presses during transition — The levelTransition flag is set to true during createLevel. If the player mashes SPACE during the 1.5s delay, keySpace.isDown && !this.gameStarted fires, but ball in launchBall(false) is undefined because no ball has velocity. Fix: check this.levelTransition before allowing SPACE to launch.
  • Victory condition on exact brick countthis.bricks.countActive() === 0 triggers the level-complete sequence. If a power-up destroys a brick during the transition delay, countActive() returns 0 again and the win sequence re-fires. Guard: set a this.levelComplete flag before the delayedCall and check it in hitBrick.
  • Scene restart during transition — If the player presses R during the 1.5s transition delay, scene.restart() cancels the delayedCall and resets everything. This is correct behavior but the "Level Complete" text flashes briefly before the restart completes.

Next up

Step 5 adds high score persistence (localStorage), game-over stats screen, sound effect polish, and the complete Breakout experience. This is the final step before moving to Space Invaders.

Level layout templates ▶ Play
this.layouts = [
  {
    rows: 5, cols: 10,
    speedMult: 1.0,
    label: 'CLASSIC',
    colors: [0xff4444,
      0xff8844, 0xffcc44,
      0x44cc44, 0x4488ff],
    values: [7,5,3,1,1]
  },
  {
    rows: 8, cols: 10,
    speedMult: 1.2,
    label: 'PYRAMID',
    colors: [0xff4444,
      0xff4444, 0xff8844,
      0xff8844, 0xffcc44,
      0x44cc44, 0x4488ff,
      0x4488ff],
    values: [10,8,5,3,2,
      1,1,1]
  },
  {
    rows: 6, cols: 12,
    speedMult: 1.4,
    label: 'FORTRESS',
    colors: [0xff4444,
      0xff4444, 0xff4444,
      0xff8844, 0xffcc44,
      0x44cc44],
    values: [15,10,7,4,2,1]
  }
];
Level transition + auto-advance
// In hitBrick when grid empty
if (this.currentLevel
    < this.maxLevel) {
  this.currentLevel++;
  this.winText.setText(
    'Level ' + (currentLevel - 1)
    + ' Complete!');
  this.balls.forEach(b => {
    if (b.active)
      b.body.setVelocity(0, 0);
  });
  this.time.delayedCall(
    1500, () =>
    this.createLevel(
      this.currentLevel));
} else {
  // All levels cleared
  this.winText.setText('You Win!');
  this.gameOver = true;
}
✓ Done. Step 4 complete. Three layouts, speed ramp, transitions, victory. Continue to Step 5 →
05

Complete Game

Complete

Why learn this?

Until now, the game resets to zero on every page reload. Step 5 makes scores persist across sessions using localStorage, adds a game-over stats screen, and gives each power-up a distinct audio signature. Persistence is what transforms a demo into a game people want to beat. The same localStorage pattern is used by every HTML5 game with high scores, from 2048 to Cookie Clicker.

What you built

A high score system that saves the top 5 scores to localStorage with score, level reached, brick count, and date. On game over, a stats panel shows your score, highest level reached, bricks destroyed, and power-ups collected — plus the all-time high score table sorted by score. On winning all 3 levels, the same screen shows with "You Win!" and a victory fanfare. Each power-up now plays a distinct tone (triangle wave for Wide, sine wave for Multi, triangle for Sticky). Game-over plays a descending two-tone sequence. The ?reset=1 URL parameter clears saved scores for testing.

Prompt used

Show prompt — Step 5
Complete the Breakout game with polish:
- High score system: top 5 scores saved to localStorage
- Game over screen: score, stats (bricks, power-ups), high score table
- Victory screen: final score, stats, celebration text
- Sound effects: tone-based audio with playTone(freq, dur, type)
- Power-up collect sounds: 660Hz triangle for Wide, 880Hz sine for Multi, 520Hz triangle for Sticky
- Game over: descending tones (150Hz then 120Hz sawtooth)
- Victory fanfare: ascending C, E, G (523, 659, 784Hz sine)
- Bot mode: trajectory-predicting AI that aims for densest brick clusters
- Stale-state detection for benchmarking (4-frame snapshot comparison)
- ?reset=1 URL parameter to clear high scores
- All 5 steps must share the same canvas resolution 1020x720
- Dark theme with polished HUD

Key concepts

  • localStorage CRUDloadScores() reads JSON.parse(localStorage.getItem(key)) with a try/catch for corrupted data. addScore() inserts a new entry, sorts descending, caps at 5, and writes back. saveScores() wraps in JSON.stringify. The ?reset=1 check on page load calls localStorage.removeItem(key).
  • Polyfill-safe JSON with try/catchtry { return JSON.parse(...) } catch(e) { return [] }. localStorage can be corrupted (user clears site data mid-write, quota exceeded, or an older version of the app stored a different schema). The fallback to an empty array means the app never crashes on corrupt data.
  • Stats tracking across game phasesthis.bricksDestroyed increments in hitBrick(), this.powerupsCollected in catchPowerup(). These are plain counters, no special state management needed. The stats are displayed when showGameOver() or showVictory() is called.
  • Overlay layout for stacked text — Game-over overlay uses three separate Phaser text objects at descending Y-positions: overlayText (title, Y: H/2-40), overlaySub (score, Y: H/2+15), highScoreText (table, Y: H/2+48, .setOrigin(0.5,0) for top-anchored left-align), statsText (details, Y: H/2+105). Stacking text objects avoids building a single multi-line string with formatting.
  • Audio palette per action — Each power-up type uses a different oscillator type: 'triangle' for Wide (660Hz), 'sine' for Multi (880Hz), 'triangle' for Sticky (520Hz). Game-over uses descending sawtooth (180Hz → 120Hz). Victory uses ascending sine sequence (523Hz → 659Hz → 784Hz). The oscillator type changes the timbre, not just the pitch — triangle sounds hollow, sine sounds pure, sawtooth sounds harsh.

Design decisions & tradeoffs

  • localStorage vs server-side leaderboard — localStorage costs nothing, works offline, and needs no auth. But every player's high scores are local-only — there's no global leaderboard. Server-side (Firebase, Supabase) would add social competition at the cost of latency, auth, and moderation.
  • Top 5 vs single score — Showing 5 scores lets the player see their progress over multiple sessions (\"last week I scored 2,000, today I got 4,500\"). Single-score leaderboards only show the personal best. 5 entries fits on screen without scrolling.
  • Separate text objects vs one big string — Three Phaser text objects (title, scores table, stats) means each can be styled independently (font size, color, alignment). One big string with manual spacing is fragile — changing the score format would need re-calculating the entire layout.
  • Stats on both win and game over — The same showGameOver() and showVictory() both call addScore() and display highscores. Win gets green text and a fanfare. Game over gets red text and descending tones. Both persist to the same leaderboard — a game-over score can still be the high score.

Alternative approaches

  • IndexedDB — More storage (50MB+ vs 5MB) and supports structured queries. Overkill for 5 scores — adding IndexedDB async code for a simple array doesn't improve the player experience.
  • Server-side leaderboard via Webhook — Send scores to a server endpoint that stores in a DB and returns a rank. Requires a backend and anti-cheat logic (signed payloads). Worth it for competitive multiplayer games, not for a single-player Breakout.
  • Cookie-based persistence — Cookies work across subdomains and have automatic expiry. But they're sent with every HTTP request (wasteful for game state) and have the same 4KB size limit. localStorage is simpler for client-only data.
  • URL fragment scores — Encoding the high score in the URL hash (#score=4500) lets players share their score by sharing the link. No persistence between browser tabs. Fun for sharing but not a replacement for localStorage.

Browser compatibility

  • localStorage: Chrome 4+, Firefox 3.5+, Safari 4+, IE 8+, Opera 10.5+. Universal.
  • JSON.parse() / JSON.stringify(): IE 8+, all modern browsers. The try/catch fallback handles older browsers that may not support JSON.
  • URLSearchParams for reset flag: Chrome 49+, Firefox 44+, Safari 10.1+, Edge 17+.
  • All audio via Web Audio API oscillators — same as Steps 1-4. Silent fallback via try/catch.

Performance notes

  • localStorage operations are synchronous and take ~0.1ms per read/write. Called once per game-over/win event.
  • Stats counters: three integer additions per game action. Zero cost.
  • High score text rebuild: called once per game-over. Creating 3-5 text strings from memory is sub-millisecond.
  • Audio oscillators create and destroy Web Audio nodes per sound. At most 3 simultaneous oscillator nodes. No node leaks because osc.stop() garbage-collects.

⚠️ Common pitfalls

  • localStorage quota exceeded — If the user has 5MB of other site data, setItem() throws a QuotaExceededError. The try/catch in addScore() prevents a crash, but the score won't save. Larger games should check quota before writing.
  • Corrupt localStorage data — If an older version saved a different format, JSON.parse() succeeds but the array might have unexpected structure. The current code assumes scores is an array with {score, level, bricks, date} objects. Version the key: LS_KEY = 'breakout-highscores-v1' makes migrations explicit.
  • Scene restart clears overlay texts — When the player presses R, scene.restart() destroys all game objects including overlay texts. On game-over, pressing R restarts the scene the same way. The overlay texts are re-created in create(). No memory leak, but the game-over stats flash briefly before disappearing.
  • Multiple scores from same session — If the player loses all lives on Level 1, a score is saved. They press R and restart, play through all 3 levels and win, a second score is saved. Both entries appear in the leaderboard. This is correct behavior — each game session is a separate entry.
  • reset=1 also clears on bot mode — The reset check runs before the game scene loads, so even bot mode pages respect the reset flag. To test: ?reset=1&bot=true clears scores AND runs bot mode.
  • Phaser physics groups silently fail WebGL render — Step 05 v2 originally used this.physics.add.group() for balls. Minified Phaser 3.60's WebGL renderer crashes on groups that are destructively iterated (e.g. filter(getChildren()) reassigned back). The fix: use a plain JS array (this.balls = []) with this.physics.add.sprite() per ball, pushed to the array. physics.add.collider() handles arrays correctly. Step 05 v2 has this fix applied.

Next up

Breakout is complete! Move on to Space Invaders — the next era of arcade game design. You'll build enemy waves, shooting mechanics, shields, and the classic descending formation.

const LS_KEY = 'breakout-highscores';

function loadScores() {
  try {
    return JSON.parse(
      localStorage.getItem(LS_KEY))
      || [];
  } catch(e) { return []; }
}

function addScore(score, level, bricks) {
  var scores = loadScores();
  scores.push({
    score: score,
    level: level,
    bricks: bricks,
    date: new Date()
      .toISOString().slice(0,10)
  });
  scores.sort(
    function(a,b) {
      return b.score - a.score;
    });
  if (scores.length > 5)
    scores = scores.slice(0, 5);
  localStorage.setItem(LS_KEY,
    JSON.stringify(scores));
  return scores;
}

// Reset: ?reset=1
if (new URLSearchParams(
    window.location.search)
    .get('reset') === '1') {
  localStorage.removeItem(LS_KEY);
}
Game-over stats overlay
showGameOver() {
  var hs = addScore(
    this.score,
    this.currentLevel + 1,
    this.bricksDestroyed);

  var hsText = '\\nHIGH SCORES\\n';
  for (var i = 0; i < hs.length; i++) {
    hsText += (i+1) + '. '
      + hs[i].score + ' pts '
      + hs[i].date + '\\n';
  }

  this.overlayText.setText(
    'Game Over');
  this.overlayText.setColor('#ff4444');
  this.overlaySub.setText(
    'Score: ' + this.score);
  this.highScoreText.setText(hsText);
  this.statsText.setText(
    'Level ' + (idx+1)
    + ' | Bricks: '
    + this.bricksDestroyed
    + ' | Power-ups: '
    + this.powerupsCollected);
}
✓ Done. Step 5 complete. Step 05 v2 (fixed) resolves a Phaser 3.60 WebGL render issue with physics groups — see pitfalls below. Breakout roadmap finished! Continue to Space Invaders →

5 steps

Model
DeepSeek V4 Flash
Tokens
~26K
Cost
$0.07
Steps
5/5
Output measured at ~0.28 tok/byte. Input estimated from turns × system context. DeepSeek V4 Flash: $0.15/M in, $0.60/M out.

Build Metrics per Step

StepGame FileEst. TokensEst. CostTime
01 — Bricks & Paddle13.5 KB~6K$0.012~5 min
02 — Angle Physics15.1 KB~5K$0.010~6 min
03 — Power-ups19.9 KB~5K$0.015~8 min
04 — Level Progression21.1 KB~5K$0.015~8 min
05 — Complete Game23.4 KB~5K$0.015~10 min
Total93 KB~26K$0.07~37 min
05

Complete Game

Complete

Why learn this?

Step 5 is where every previous system converges — brick grids, angle physics, power-ups, level progression — into a single playable game with 3 levels, high scores, and a bot that plays itself. But the real lesson is how to debug when physics breaks. The v2 game had two bugs we fixed together: the ball stopped colliding with the paddle after losing a life, and the pyramid level was just a flat grid. Fixing these required understanding Phaser's collider registration, JavaScript array reference semantics, and how to build dynamic brick layouts that actually look like their names. These patterns carry into every Phaser game you build from here.

What you built

A complete Breakout with 3 levels (Classic 5×10, Pyramid 8×10 — actual pyramid shape, Fortress 6×12). Ball launches from paddle, bounces off walls and bricks, power-ups drop with 20% chance. Complete level transitions with 1.5s overlay. Bot mode predicts landing with wall-bounce reflection and sweeps paddle zones for lateral coverage. localStorage top-5 high scores. All sounds via AudioContext oscillator. 1020×720 canvas, header nav, mode toggle (Player/Bot). No Phaser.Scale.FIT — it silently breaks physics world bounds.

Key concepts

  • Paddle collider lifecyclethis.balls is a plain JS array. In removeBall(), this.balls.filter(...) created a new array, but the paddle collider (this.physics.add.collider(this.balls, ...)) held a reference to the old, now-empty array. New balls added via createBall() only existed in the new array — the collider never saw them. Fix: fully recreate the paddle collider in startLevel() alongside the brick collider, so both colliders always reference the current array.
  • Array mutation vs reassignmentthis.balls = this.balls.filter(b => b.active) replaces the array reference. Any code holding the old reference is now stale. Fix: use reverse-loop splice() to remove inactive balls in-place: for (var i = this.balls.length - 1; i >= 0; i--) { if (!this.balls[i].active) this.balls.splice(i, 1); }. The array reference never changes, so all colliders continue working.
  • Dynamic brick textures per level — Fortress has 12 columns, which overflows the 1020px canvas at 96px pitch. Each layout now carries brickW (90 for Classic/Pyramid, 76 for Fortress) and gap (6 vs 4). startLevel() regenerates the brick texture if brickW changed, then calculates pitch = brickW + gap and leftMargin = (W - cols * pitch) / 2. Fortress bricks are slightly narrower (76px vs 90px) to fit 12 columns centered.
  • Pyramid row geometry — The Pyramid level uses colsInRow = Math.min(10, 3 + row) per row: row 0 has 3 bricks, row 7 has 10. Each row is independently centered. This required changing the inner loop from a fixed layout.cols to a per-row column count. A new pyramid: true flag on the layout triggers the variable-column logic.
  • Bot zone sweeping — The bot updateBot() predicts the ball's landing position using a while-loop + wall reflection simulation. Then it adds a systematic zoneOffsets[-48, -24, 0, 24, 48] sweep based on _paddleHits count, cycling through 5 positions. This gives the bot lateral coverage — it doesn't just sit at the predicted point, it actively sweeps the paddle across the court.
  • World bounds handler patternphysics.world.on('worldbounds', function(body) { if (body.gameObject.isBall && body.blocked.down) this.removeBall(body.gameObject); }). Critical details: check body.blocked.down to only respond to bottom-edge exits (not wall bounces), and body.gameObject.y > H-30 to prevent removing balls that bounce exactly at the bottom edge during regular play.

Design decisions & tradeoffs

  • Recreate colliders vs reuse — Current approach: destroy + recreate both paddle and brick colliders every level transition. Alternative: one collider that lives forever and always references a group (not array). Groups auto-track new members. But group-based ball management has its own pitfalls (see lessons). Recreating colliders is more explicit and easier to debug.
  • Dynamic brick width vs uniform — Fortress uses 76px bricks to fit 12 columns in 1020px. Tradeoff: narrower bricks look slightly different from Classic/Pyramid but all 12 columns are fully on-screen. Alternative: keep 90px bricks and accept 2 columns of overflow. The overflow was reported as a bug by players — hidden bricks make the level unfairly hard.
  • Bot jitter vs perfect playzoneOffsets add jitter so the bot doesn't play perfectly. Without jitter, a bot on easy levels would never miss. With static jitter, the bot still performs well on harder levels. Alternative: dynamic jitter that increases with ball speed (bot gets worse as the game gets harder).
  • Mutate-in-place vs immutable arrayssplice() over filter() keeps the reference alive. Tradeoff: more verbose code and a manual reverse-loop. But it prevents a class of bugs (stale collider references) that took hours to debug.

Alternative approaches

  • Phaser physics group for balls — Instead of a plain array, use this.physics.add.group(). Groups are dynamically tracked by colliders — adding a sprite to the group auto-registers it with all colliders. But groups require getChildren() for iteration, getLength() for count, and group.add(sprite) instead of array.push(sprite). In Phaser 3.60, groups also have a rendering bug with WebGL when destructively iterated (the group stopped rendering after filter). Arrays + collider recreation is safer.
  • Per-level tilemaps — Instead of code-defined layouts, load brick positions from a JSON tilemap. Each level is a 2D array where 1 = brick, 0 = empty. More flexible (arbitrary patterns, not just rows) but adds a file-loading step. Good for games with 20+ levels.
  • Score-based difficulty scaling — Instead of fixed level layouts, scale difficulty based on the player's score. Higher score = faster ball, narrower paddle, more power-up drops for the AI. Dynamic difficulty keeps the game challenging for any skill level.

Browser compatibility

  • Same as Steps 1-4: Phaser 3.60 Canvas2D/WebGL, AudioContext oscillator (fallback via try/catch).
  • localStorage for high scores: Chrome 4+, Firefox 3.5+, Safari 4+, IE 8+. Graceful degradation via try/catch.
  • No Phaser.Scale.FIT — canvas renders at native 1020×720. CSS max-width: 100% on the canvas element handles mobile scaling without corrupting physics coordinates.

Performance notes

  • Up to 9 active balls (multi-ball), 72 static bricks (Fortress), 3 falling power-ups = ~85 active objects. Phaser's WebGL batching handles this effortlessly.
  • Bot prediction: while-loop up to 600 iterations per frame during bot prediction. Each iteration does 6 arithmetic ops + 2 conditionals + 1 velocity reversal if bouncing. Total: ~3600 ops per bot frame, ~0.03ms. Negligible.
  • Texture regeneration per level: generateTexture() is called once per level transition. The 76×30px brick texture is generated in <1ms. Cached via this._lastBrickW check — only regenerates when brick width changes.

⚠️ Common pitfalls

  • Array reassignment breaks colliders — Any code that does this.balls = this.balls.filter(...), this.balls = [...], or this.balls = [] creates a new array. Every collider created with the old array is now working with stale data. Always mutate in-place, or recreate colliders after reassignment.
  • Brick texture stale between levels — If you change the brick width per level without regenerating the texture, Phaser renders the old texture at the old size. The collision body matches the old texture, not the new display size. Always check this.lastBrickW !== layout.brickW and regenerate.
  • Pyramid centering per row — Each pyramid row has a different number of bricks, so each row needs its own leftMargin calculation. Using a single margin for all rows shifts the top rows off-center. Always recalculate rowLeftMargin = (W - colsInRow * pitch) / 2 per row.
  • Bot timer not initialized — The bot's delayedCall(500, launchBall) reference must be stored. Without the timer reference, the bot can't be cancelled if the player presses SPACE during the delay.

Summary

Step 5 isn't a single new feature — it's the culmination of everything built in Steps 1-4, plus the fixes that made it all work together. The critical lesson: arrays in Phaser collider pairs are live references, not snapshots. Reassigning the array unmounts the collider. Always mutate in-place or recreate colliders. This pattern applies to any Phaser game with dynamic physics objects — bullets, enemies, collectibles — that are added or removed after scene creation.

Ball array: mutate in-place ▶ Play v2
// BAD: reassigns array, breaks colliders
this.balls = this.balls.filter(
  b => b.active);

// GOOD: mutate in-place via splice
for (var i = this.balls.length - 1;
     i >= 0; i--) {
  if (!this.balls[i].active)
    this.balls.splice(i, 1);
}
Paddle collider — recreate per level
startLevel(idx) {
  // Remove old colliders
  if (this.brickCollider)
    this.physics.world.colliders
      .remove(this.brickCollider);
  if (this.paddleCollider)
    this.physics.world.colliders
      .remove(this.paddleCollider);

  // Create fresh colliders
  this.paddleCollider =
    this.physics.add.collider(
      this.balls, this.paddle,
      this.hitPaddle, null, this);
  this.brickCollider =
    this.physics.add.collider(
      this.balls, this.bricks,
      this.hitBrick, null, this);
  ...
}
Dynamic brick widths per level
// Layout data
{rows:6, cols:12, brickW:76,
 gap:4, label:'FORTRESS', ...}

// In startLevel():
var pitch = layout.brickW
          + layout.gap;
var gridW = layout.cols * pitch;
var leftMargin = Math.max(8,
  Math.floor((W - gridW) / 2));

// Regenerate texture if needed
if (!this.textures.exists('brick')
 || this._lastBrickW
  !== layout.brickW) {
  // generateTexture with
  // layout.brickW width
}
✓ Done. All 5 steps complete. Full game with 3 levels, power-ups, bot mode, high scores, and all physics bugs fixed. Play the final version →

🏆 Breakout Complete — All 5 Steps

From a single paddle to a full Breakout with 3 layouts, power-ups, high scores, and bot mode. Ready for the next game:

Space InvadersEnemy waves, shooting, shields — the next era of arcade

Lessons Learned — Build Process

How Step 4 broke and what we changed to stop it from happening again.

🎮
Why Breakout Was Popular

Breakout succeeds because destruction is inherently satisfying. Hitting a brick gives instant visual feedback (flash + pop) and score validation. The color-coded rows create natural risk/reward: aim for the high-value red bricks at the top, but missing the return means the ball escapes. Pong was about keeping the ball in play; Breakout is about clearing the screen — a different, more directed motivation. The shrinking row count builds visible progress: one fewer row to clear. Every brick removed changes the level geometry, so the difficulty evolves naturally without explicit ramp-up. This "level geometry as difficulty curve" principle transfers directly to any game with destructible environments (Arkanoid, Peggle, even Minecraft mining).

⚠️
The Problem

Step 4 (Level Progression) hit the tool call limit during generation. The game file was left in a half-written state — broken script tags, missing initialization, wrong CSS classes. Patching generated code is fragile: every patch assumed the file was clean, but it was already corrupted. After 6 failed fix attempts, the only option was to wipe and rebuild from scratch.

🛠️
The Fix — Stage-Gated Pipeline

The build process was split into 4 isolated stages, each with its own tool budget:

  1. Generate — Write a Python script that reads the previous step, transforms it, and writes the new file. No iterative tool calls needed for code generation.
  2. Verify — Check script tag balance, Phaser.Game instantiation, no embedded line numbers. Fail here doesn’t lose the next stages.
  3. Update — Patch the roadmap page. Only starts after the game file is confirmed clean.
  4. Deploy — Build and ship.
💾
Rollback Before Every Edit

A cp backup of the game file is saved to /tmp/ before any change. If the edit breaks the game, one cp restores the last known-good state. No more “delete everything and start over.”

📚
Feature-Scaling Rule

Step 4 bundled 4 features: level layouts, speed ramp, 3 layout types, level transitions. If a step introduces 3+ new systems, it gets split into smaller steps. One new system per step keeps each build focused, verifiable, and recoverable.

🔄
Vertical Bounce Loop — Paddle Center Fix

When the ball hits the center of the paddle, the bounce angle computes to 0° (straight up). cos(-90°) = 0, so horizontal velocity is zero. The ball goes straight up, hits the ceiling, bounces straight down, and repeats the cycle forever — no bricks touched, no game progress. This affects all 5 steps because both the diff * 1.8 mapping (Step 1) and the zone-based angle arrays (Steps 2-5, middle zone = 0°) have the same bug. Fix: after computing the bounce velocity, check if |vx| < 60 and boost it to 60 while recalculating vy to preserve speed. Applied globally to hitPaddle() in all 5 step files.

🔬
Multi-Ball Passes Through Paddle — Array vs Group Collision

Bug: When the multi-ball power-up spawned new balls, they flew straight through the paddle. The balls passed through bricks too, but bricks didn't matter because they were already being hit by the original ball. The paddle issue was critical — a multi-ball that passes through the paddle cannot be returned, and multiple such balls drain lives in seconds.

Root cause: this.balls was a plain JavaScript array ([]). In Phaser 3, physics.add.collider(this.balls, ...) captures the array contents at the time the collider is created — which is in create(), when this.balls is empty. New balls pushed into the array by activateMulti() are invisible to the collider. They exist in the scene and move via their own velocity, but Phaser never checks them against the paddle or bricks because they were never registered with the collider system.

Fix: Changed this.balls from a plain array to a Phaser group (this.physics.add.group({allowGravity:false})). Phaser groups are dynamically tracked by collider pairs — any sprite added to the group after collider setup is automatically checked against the collider's other target. All array methods (push, filter, forEach, length) were replaced with group equivalents (add, getChildren().filter, getChildren().forEach, getLength()).

Lesson: Never use plain arrays for physics objects that are added after scene creation. Phaser groups are the correct abstraction — they're designed for exactly this case. The pattern "push new sprite into array" seems natural in JavaScript but breaks Phaser's collider registration. If you add sprites dynamically, use physics.add.group() and group.add(sprite).

📌
Level Progression Broken — brick.destroy() in Physics Callback

Bug: After clearing all bricks on a level, the game didn't transition to the next level. The level-complete check (this.bricks.countActive() === 0) never fired, so the overlay text stayed blank and the game loop continued with no bricks.

Root cause: brick.destroy() was called inside the hitBrick() overlap callback, which fires during Phaser's physics step. When destroy() is called on a sprite inside a staticGroup during a physics callback, Phaser defers the group's internal child-list cleanup. The static group still "sees" the destroyed child as active, so countActive() never reaches 0. The level-cleared logic depends on countActive() being 0, so it never executes.

Fix: Replaced brick.destroy() with brick.disableBody(true, true). disableBody() deactivates the physics body (so countActive() ignores it) and hides the sprite — without removing it from the static group's internal list. The group doesn't need cleanup because the disabled child remains as a placeholder. This is the correct pattern for group-managed sprites that need to "die" inside collision callbacks.

Lesson: destroy() and disableBody() have different cleanup semantics in Phaser 3. destroy() removes the object from the scene and group, but cleanup is deferred during physics steps. disableBody(true, true) is safe during callbacks because it only toggles state flags — no structural changes to parent containers. Use disableBody whenever you need a sprite to "disappear" inside a physics callback. After the physics step completes, destroyed children can be cleaned up with group.children.iterate() + destroy().

🎱
Ball Physics Broken After Group Conversion — Three Bugs Stacked

Bug: After the multi-ball group fix, balls flew through walls, bounced off the bottom forever, and had double-bounce on the ceiling. Three separate bugs stacked on top of each other, each introduced during the same group conversion, making the root cause impossible to find until all three were identified and reverted individually.

Root cause: The group conversion introduced three distinct failures that compounded:

  1. Dropped setCollideWorldBounds(true) + setBounce(1) from createBall() — balls had no boundaries at all. The first pass of manual velocity flips tried to fix this but introduced double-bounce.
  2. Dropped body.onWorldBounds = true — the worldbounds event handler was registered but silently received no events. Without the handler, balls bounced off the bottom wall and never got lost.
  3. Added Phaser.Scale.FIT to game config — this was NOT in the original working code. In Phaser 3.60, Scale.FIT can resize the game internally during startup, but the physics world bounds are initialized once early in Phaser.Game.init(). If the Scale Manager resizes the game afterward, the world bounds no longer match the game dimensions. setCollideWorldBounds(true) checks against stale bounds — balls pass through walls because the boundary rectangle is in the wrong coordinate space.

Each bug produced a distinct symptom: #1 = balls fly off screen, #2 = balls never die at bottom, #3 = balls go through walls. Because the symptoms overlapped (ball not bouncing = could be #1 or #3), each successive "fix" only addressed one bug while the others remained.

Fix: Restored the original working pattern identically — no additions, no substitutions:

  1. In createBall(): setCollideWorldBounds(true), body.setBounce(1), body.onWorldBounds = true
  2. In create(): var _self=this; + physics.world.on('worldbounds', ...) handler checking body.blocked.down
  3. Phaser config: remove scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH } — the original working config had no scale property
  4. No manual velocity flips in update() — sound effects use body.blocked flags

Lesson: When refactoring physics code, preserve the ENTIRE original setup identically — do not add or remove anything you can't justify. Every unrelated change (like adding Scale.FIT to the game config) becomes a hidden variable that makes debugging impossible. Three bugs stacked from one refactor took four rounds of debugging to fully resolve. The original working code was simple and correct — the fix was to stop changing it.

Python Generator Template (click to expand)
references/game-step-generator-template.py
#!/usr/bin/env python3
"""Generate game step N from step N-1."""
import re, shutil, os

prev_path = "/path/to/step-N-1.html"
out_path  = "/path/to/step-N.html"

with open(prev_path) as f:
    html = f.read()

# Backup existing file
if os.path.exists(out_path):
    shutil.copy2(out_path, f"/tmp/backup-step-N.html")

# Extract header (up to opening script tag)
h = re.search(r"(.*?)<script>\s*const W =", html, re.DOTALL)
header = h.group(1) if h else ""

# Extract footer (from closing script tag to end)
f = re.search(r"(</script>.*)", html, re.DOTALL)
footer = f.group(1) if f else ""

# Replace content fields
header = header.replace("Old Step Name", "New Step Name")
header = header.replace("Old feature list", "New feature list")

# Game JS (no script wrapper - header has it)
game_js = """const W = 680, H = 480;
// ... new step game code ...
"""

with open(out_path, "w") as f:
    f.write(header + "\n" + game_js + "\n" + footer)

# Verify
c = open(out_path).read()
assert c.count("<script") == c.count("</script>")
Prompt History — click to expand
v1 — Minimal (12 lines, no guardrails) — used for deepseek-v4-pro
Build a complete Breakout game as a single HTML file using Phaser 3.60.

Filename: /home/techgeek/aigamingdev.com/public/games/breakout/breakout [MODEL_NAME].html

Canvas: 1020×720. CDN: https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js

Features:
- Paddle follows mouse + A/D/arrows
- Ball bounces off walls, paddle, bricks. Dies when it falls off bottom
- 3 levels: Classic (5×10), Pyramid (8×10), Fortress (6×12). Speed increases. Level transitions when all bricks cleared
- 3 power-ups (20% drop from bricks): wide paddle, multi-ball, sticky catch
- Bot mode: ?bot=true URL param makes it play automatically
- Score display, 3 lives, level indicator, localStorage high scores (top 5)
- Brick spacing: bx=(col+margin)*96+45, by=row*36+75
- Procedural textures via make.graphics only — no external image files
- R to restart, SPACE to launch ball
- Dark theme, header nav with links to Blog / Play / Roadmaps

Include a stats table below the game showing model name, provider, tokens, build time, cost.
Include a "How to Play" section with controls and game rules.
v2 — Tightened (+2 critical rules) — ready for re-run
Build a complete Breakout game as a single HTML file using Phaser 3.60.

Filename: /home/techgeek/aigamingdev.com/public/games/breakout/breakout [MODEL_NAME].html

Canvas: 1020×720. CDN: https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js

CRITICAL — do NOT use Phaser.Scale.FIT in the config. It breaks physics world bounds.

CRITICAL — for level transitions, destroy the entire StaticGroup and create a fresh one.
Do NOT use bricks.clear(). Destroy the old group, create a new one, create a new collider.

Features:
- Paddle follows mouse + A/D/arrows
- Ball bounces off walls, paddle, bricks. Dies when it falls off bottom
- 3 levels: Classic (5×10), Pyramid (8×10), Fortress (6×12). Speed increases on each level.
  Level transitions when all bricks are cleared
- 3 power-ups (20% drop from bricks): wide paddle, multi-ball, sticky catch
- Bot mode: ?bot=true URL param makes it play automatically. Bot should launch ball and play.
  Initialize any timer variables the bot needs in create().
- Score display, 3 lives, level indicator, localStorage high scores (top 5)
- Brick spacing: bx=(col+margin)*96+45, by=row*36+75
- Procedural textures via make.graphics only — no external image files
- R to restart, SPACE to launch ball
- Dark theme, header nav with links to Blog / Play / Roadmaps

Include a stats table below the game showing model name, provider, tokens, build time, cost.
Include a "How to Play" section with controls and game rules.

After writing, verify JS syntax: node -e "JSON.parse(require('fs').readFileSync('PATH','utf8'))"
v3 — Sounds + bot mode spec + scene array fix — used for Mimo v3
Build a complete Breakout game as a single HTML file using Phaser 3.60.

Canvas: 1020×720. CDN: https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js

CRITICAL — do NOT use Phaser.Scale.FIT in the config. It breaks physics world bounds.

CRITICAL — for level transitions, destroy the entire StaticGroup and create a fresh one.
Do NOT use bricks.clear(). Destroy the old group, create a new one, create a new collider.

CRITICAL — use scene: [GameScene] array format (NOT bare scene: GameScene).

Features:
- Paddle follows mouse + A/D/arrows
- Ball bounces off walls, paddle, bricks. Dies when it falls off bottom
- 3 levels: Classic (5×10), Pyramid (8×10), Fortress (6×12). Speed increases on each level.
  Level transitions when all bricks are cleared
- 3 power-ups (20% drop from bricks): wide paddle, multi-ball, sticky catch
- Bot mode: ?bot=true URL param makes it play automatically. Bot should launch ball and play.
  Initialize any timer variables the bot needs in create().
- Score display, 3 lives, level indicator, localStorage high scores (top 5)
- Brick spacing: bx=(col+margin)*96+45, by=row*36+75
- Procedural textures via make.graphics only — no external image files
- R to restart, SPACE to launch ball
- Dark theme, header nav with links to Blog / Play / Roadmaps

Sound effects via AudioContext:
- Brick hit: 440Hz square wave, 60ms
- Paddle hit: 520Hz square wave, 80ms
- Wall bounce: 330Hz triangle wave, 60ms
- Power-up collect: 660Hz sine wave, 150ms
- Life loss: descending 180Hz to 120Hz sawtooth, 300ms
- Victory fanfare: ascending C-E-G (523Hz, 659Hz, 784Hz) sine wave, 200ms each

Bot mode (detailed):
- Auto-launch ball within 1 second of game start
- Predict ball landing position with wall-bounce reflection
- Track the lowest ball on screen (closest to paddle)
- Automatically collect power-ups that fall near the paddle path
- Auto-release sticky balls after 400ms
- Add jitter (+/- 20px) so bot doesn't look perfect

Include a stats table and How to Play section.
v4 — Bot mode first, concise hint — blocked by Mimo reasoning tokens
Build a complete Breakout game in a single HTML file using Phaser 3.60.

Canvas 1020×720. CDN: https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js

CRITICAL rules:
1. NO Phaser.Scale.FIT in config
2. Level transitions: destroy StaticGroup + create new + new collider (NOT bricks.clear())
3. scene: [GameScene] array format (NOT bare scene: GameScene)
4. Keep output under 15KB — concise code

Features (ALL MANDATORY):
- Bot mode via ?bot=true: auto-launch within 1s, predict landing with wall-bounce reflection,
  track lowest ball, auto-collect power-ups in range, auto-release sticky after 400ms, jitter +/-20px
- Paddle follows mouse + A/D/arrows
- Ball bounces off walls/paddle/bricks, dies at bottom
- 3 levels: Classic 5×10 speed 320, Pyramid 8×10 speed 380, Fortress 6×12 speed 440
- 3 power-ups 20% drop: wide paddle 10s, multi-ball, sticky catch
- Score, 3 lives, level indicator, localStorage top 5
- Procedural textures, R to restart, SPACE to launch
- Dark theme, header nav (Blog / Play / Roadmaps)
- Sounds via AudioContext (6 tones listed)
- Stats table and How to Play

Bot mode MUST work. Test: ?bot=true launches ball and plays.
v5 — Step 05 v2 fix: Phaser group → array for ball tracking
Fix: Step 05 flash-guided game used this.physics.add.group() for balls, which silently
failed to render on Phaser 3.60 WebGL when the group was destructively iterated
(e.g. filter on getChildren() reassigned back to this.balls).

Fix: Replace physics group with plain JS array + this.physics.add.sprite() per ball.
Colliders work with arrays — no group needed. Verified working on all levels.

Lesson: Phaser 3.60 with WebGL silently fails to render physics groups when the group
is destructively iterated. Always use plain arrays for dynamically created/destroyed
physics objects (balls, bullets, projectiles).

Each pipeline bug found here was saved as a formal lesson in the game-build-progression skill. Future games (Space Invaders, Pac-Man, etc.) start with these guardrails already in place — including the three-ball-property rule (setCollideWorldBounds + setBounce + onWorldBounds), the worldbounds handler for bottom detection, and the critical rule that Phaser.Scale.FIT must never be added to a game that relies on setCollideWorldBounds — it silently breaks the coordinate system.

Code Analysis — By Prompt Version

🔬

Builds grouped by prompt version. v1: 12-line minimal. v2: +no Scale.FIT, destroy+recreate. v3: +scene array, sounds spec. v4: bot first, concise.

v1 — Minimal (12 lines, no rules)

AspectFlash (guided)Pro (minimal)
StyleES6 classIIFE + plain funcs
Scene config[GameScene]{ scene: ... }
Level transitionDestroy+recreate StaticGroupbricks.clear() ❌ stale bodies
Ball boundsworldbounds handlery-check in update()
Ball trackingPlain JS arrayPhaser physics group
Sticky catchGroup-based flagN/A
Bot predictionpredictLanding + power-up chaseLowest ball + sin wobble
Sounds✅ AudioContext
HUDMode bar UIPhaser text
ParticlesNoYes
Input styleVelocity-basedDelta-based
Size / Time24 KB / ~10 min23 KB / ~6 min
Benchmark32.8 pts avg (90s, 1024px, zone sweeping)
Functional?⚠️ Group bug (v2 fixed)❌ Scale.FIT corrupts bricks, bot stuck
Known issues — Pro v1
Balls pass through bricksScale.FIT corrupts world-bounds coords + bricks.clear() leaves stale physics bodies
Bot never launches_botWaitStart never initialized in create()
No worldbounds handler — y-check in update() replaces it, but Scale.FIT still breaks side walls

v2 — Tightened (+no Scale.FIT, destroy+recreate)

AspectPro v2
StyleIIFE + plain funcs
Scene config{ scene: ... }
Brick deathdisableBody()
Level transitionDestroy group + remove collider + recreate
Ball boundsNo world bounds + y-check
Sticky catchs.stuckBalls[]
Bot predictionMulti-bounce + sin wobble
Sounds
HUDPhaser text
Faked stats?No
Size / Time24 KB / ~3 min
Benchmark
Functional?⚠️ Bricks pass through, bot stuck

v3 — +Sounds + scene array fix

AspectMimo 2.5Hy3
StyleES6 classES6 class + global vars
Scene config[GameScene]Bare key ref
Brick deathdestroy()destroy()
Level transitionDestroy+recreate (clean) ✅Destroy+recreate (colliders accumulate)
Ball boundsworldBounds + bottom disabledworldBounds + bottom disabled
Sticky catchstickyActive flagGlobal sticky
Bot predictionLead-time + velocityWhile-loop + reflection
Sounds✅ AudioContext (native)✅ AudioContext
HUDHTML DOMHTML DOM
Faked stats?NoYes (Claude 3.5)
Size / Time23 KB / ~5s9 KB / ~2s
Benchmark
Functional?✅ Working⚠️ Colliders accumulate per level

v4 — Bot mode first, concise

AspectNemotron 3U
StyleES6 class + property init
Scene config[GameScene]
Brick deathdestroy()
Level transitionDestroy group + remove collider + recreate ✅
Ball boundsworldBounds + bottom disabled
Sticky catchPer-ball stuck data flag
Bot predictionWhile-loop + wall reflection
Sounds✅ AudioContext + win fanfare
HUDPhaser text
Faked stats?No
Size / Time10 KB / ~5s
Benchmark
Functional?⚠️ AudioFX syntax flaw

Hy3 was the fastest (2s, 9KB, $0.002) but accumulates colliders on each level transition and fabricated its stats. Mimo is the most structurally sound — clean level progression, all features working. Nemotron v4 follows all rules but has a JS syntax flaw in AudioFX. Note: v4 (step-05) used a 640px canvas (ball traverses faster) — its 53.6 avg is not comparable to the 1024px results.

🔗 Flash step 5 · Step 5 v2 (fixed) · Pro v1 · Pro v2 · Mimo v2.5 · Mimo v3 · Hy3 · Nemotron