1970s Arcade — Teaches brick grids, angle-based collision, power-ups, and progressive difficulty. Natural next step after Pong.
Bricks & Paddle
CompleteWhy 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 groups —
this.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 tinting —
brick.setTint(0xff4444)changes a sprite's color at runtime without loading multiple textures. One white brick texture, five tints. - Custom sprite data —
brick.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 math —
bx = col * 64 + 30spaces 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 input —
this.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.downcheck 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 Phaser —
canvas.getContext('2d')with manualrequestAnimationFrameloop. 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.
URLSearchParamsfor 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
worldboundsevent fires for ALL world boundaries (top, left, right, bottom). Without checkingbody.blocked.down, the ball hitting the top wall triggersloseLife(). Always check which wall was hit. - Brick count check after destruction —
this.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 inplayTone()handles this. - Paddle position on restart —
scene.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: 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]);
}
}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);
}Angle Physics
CompleteWhy 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 mapping —
Math.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 continuousdiff * 1.8formula. 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 cap —
speed = 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
fillRectcall 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 bounds —
Math.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
hitOffsetuses the ball's center, so this is usually correct. For precise edge detection, useball.body.leftandball.body.rightrelative to paddle zone boundaries. - Zone text after restart —
scene.restart()creates a newzoneTextobject. 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.
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);
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());
Power-ups
CompleteWhy 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 tables —
Math.random() < 0.2gives 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 scaling —
paddle.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 timers —
this.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 inupdate(). - 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 withball.isBall = truefor collision identification. - Sticky catch state — A boolean flag (
this.stickyBall) plus velocity check inhitPaddle. When sticky, the ball's velocity is zeroed andcanMoveis false. Clicking callsreleaseSticky()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
strokeRectper frame during wide mode. Near-zero cost. - Power-up cleanup in
update(): iterates ~3 items per frame. Free.
⚠️ Common pitfalls
- Ball array reference after destroy —
ball.destroy()removes the sprite but the array reference still exists. Always filter:this.balls = this.balls.filter(b => b.active). Checkingball.activebefore any operation is also essential. - Multi-ball + worldbounds — The worldbounds event fires for ALL balls. Without the
isBallcheck, paddle or brick collisions can trigger life loss. Tag every ball withball.isBall = trueand check it in the handler. - Timer reference on scene restart —
this.paddleWideTimerholds a Phaser timer reference. Onscene.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
overlapcheck 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.
// 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
});
}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('');
});
}Level Progression
CompleteWhy 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 iteratesrows * colsand 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 curve —
ball.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 machine —
this.currentLevel,this.maxLevel, andthis.levelTransitionflag 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 layout —
var 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[]andvalues[]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 transitions —
this.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
speedMultfrom 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
.jsonfiles. 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()andPhaser.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 bounds —
this.layouts[this.currentLevel - 1]assumes level numbers start at 1. IfcurrentLevelis 0 or exceedslayouts.length, the result isundefined. Always use a guard:var layout = this.layouts[idx]; if (!layout) return;. - Ball array state on level transition —
createLevel()stops all balls but doesn't destroy them. If multi-ball was active, dead balls remain in the array. This is fine becausethis.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
levelTransitionflag is set to true during createLevel. If the player mashes SPACE during the 1.5s delay,keySpace.isDown && !this.gameStartedfires, butballinlaunchBall(false)is undefined because no ball has velocity. Fix: checkthis.levelTransitionbefore allowing SPACE to launch. - Victory condition on exact brick count —
this.bricks.countActive() === 0triggers 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 athis.levelCompleteflag before the delayedCall and check it inhitBrick. - 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.
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]
}
];// 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;
}Complete Game
CompleteWhy 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 CRUD —
loadScores()readsJSON.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 inJSON.stringify. The?reset=1check on page load callslocalStorage.removeItem(key). - Polyfill-safe JSON with try/catch —
try { 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 phases —
this.bricksDestroyedincrements inhitBrick(),this.powerupsCollectedincatchPowerup(). These are plain counters, no special state management needed. The stats are displayed whenshowGameOver()orshowVictory()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()andshowVictory()both calladdScore()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 supportJSON.URLSearchParamsfor 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 aQuotaExceededError. The try/catch inaddScore()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 assumesscoresis 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 increate(). 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=trueclears 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 = []) withthis.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);
}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);
}5 steps
Build Metrics per Step
| Step | Game File | Est. Tokens | Est. Cost | Time |
|---|---|---|---|---|
| 01 — Bricks & Paddle | 13.5 KB | ~6K | $0.012 | ~5 min |
| 02 — Angle Physics | 15.1 KB | ~5K | $0.010 | ~6 min |
| 03 — Power-ups | 19.9 KB | ~5K | $0.015 | ~8 min |
| 04 — Level Progression | 21.1 KB | ~5K | $0.015 | ~8 min |
| 05 — Complete Game | 23.4 KB | ~5K | $0.015 | ~10 min |
| Total | 93 KB | ~26K | $0.07 | ~37 min |
Complete Game
CompleteWhy 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 lifecycle —
this.ballsis a plain JS array. InremoveBall(),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 viacreateBall()only existed in the new array — the collider never saw them. Fix: fully recreate the paddle collider instartLevel()alongside the brick collider, so both colliders always reference the current array. - Array mutation vs reassignment —
this.balls = this.balls.filter(b => b.active)replaces the array reference. Any code holding the old reference is now stale. Fix: use reverse-loopsplice()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) andgap(6 vs 4).startLevel()regenerates the brick texture ifbrickWchanged, then calculatespitch = brickW + gapandleftMargin = (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 fixedlayout.colsto a per-row column count. A newpyramid: trueflag 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 systematiczoneOffsets[-48, -24, 0, 24, 48]sweep based on_paddleHitscount, 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 pattern —
physics.world.on('worldbounds', function(body) { if (body.gameObject.isBall && body.blocked.down) this.removeBall(body.gameObject); }). Critical details: checkbody.blocked.downto only respond to bottom-edge exits (not wall bounces), andbody.gameObject.y > H-30to 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 play —
zoneOffsetsadd 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 arrays —
splice()overfilter()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 requiregetChildren()for iteration,getLength()for count, andgroup.add(sprite)instead ofarray.push(sprite). In Phaser 3.60, groups also have a rendering bug with WebGL when destructively iterated (the group stopped rendering afterfilter). 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).
localStoragefor 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. CSSmax-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 viathis._lastBrickWcheck — only regenerates when brick width changes.
⚠️ Common pitfalls
- Array reassignment breaks colliders — Any code that does
this.balls = this.balls.filter(...),this.balls = [...], orthis.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.brickWand regenerate. - Pyramid centering per row — Each pyramid row has a different number of bricks, so each row needs its own
leftMargincalculation. Using a single margin for all rows shifts the top rows off-center. Always recalculaterowLeftMargin = (W - colsInRow * pitch) / 2per 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.
// 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);
}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);
...
}// 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
}Lessons Learned — Build Process
How Step 4 broke and what we changed to stop it from happening again.
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).
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 build process was split into 4 isolated stages, each with its own tool budget:
- 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.
- Verify — Check script tag balance, Phaser.Game instantiation, no embedded line numbers. Fail here doesn’t lose the next stages.
- Update — Patch the roadmap page. Only starts after the game file is confirmed clean.
- Deploy — Build and ship.
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.”
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.
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.
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).
brick.destroy() in Physics CallbackBug: 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().
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:
- Dropped
setCollideWorldBounds(true)+setBounce(1)fromcreateBall()— balls had no boundaries at all. The first pass of manual velocity flips tried to fix this but introduced double-bounce. - Dropped
body.onWorldBounds = true— theworldboundsevent handler was registered but silently received no events. Without the handler, balls bounced off the bottom wall and never got lost. - Added
Phaser.Scale.FITto game config — this was NOT in the original working code. In Phaser 3.60,Scale.FITcan resize the game internally during startup, but the physics world bounds are initialized once early inPhaser.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:
- In
createBall():setCollideWorldBounds(true),body.setBounce(1),body.onWorldBounds = true - In
create():var _self=this;+physics.world.on('worldbounds', ...)handler checkingbody.blocked.down - Phaser config: remove
scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }— the original working config had no scale property - No manual velocity flips in
update()— sound effects usebody.blockedflags
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)
#!/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
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.
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'))"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.
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.
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)
| Aspect | Flash (guided) | Pro (minimal) |
|---|---|---|
| Style | ES6 class | IIFE + plain funcs |
| Scene config | [GameScene] ✅ | { scene: ... } |
| Level transition | Destroy+recreate StaticGroup | bricks.clear() ❌ stale bodies |
| Ball bounds | worldbounds handler | y-check in update() |
| Ball tracking | Plain JS array | Phaser physics group |
| Sticky catch | Group-based flag | N/A |
| Bot prediction | predictLanding + power-up chase | Lowest ball + sin wobble |
| Sounds | ✅ AudioContext | ❌ |
| HUD | Mode bar UI | Phaser text |
| Particles | No | Yes |
| Input style | Velocity-based | Delta-based |
| Size / Time | 24 KB / ~10 min | 23 KB / ~6 min |
| Benchmark | 32.8 pts avg (90s, 1024px, zone sweeping) | — |
| Functional? | ⚠️ Group bug (v2 fixed) | ❌ Scale.FIT corrupts bricks, bot stuck |
Known issues — Pro v1
Scale.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 wallsv2 — Tightened (+no Scale.FIT, destroy+recreate)
| Aspect | Pro v2 |
|---|---|
| Style | IIFE + plain funcs |
| Scene config | { scene: ... } |
| Brick death | disableBody() |
| Level transition | Destroy group + remove collider + recreate |
| Ball bounds | No world bounds + y-check |
| Sticky catch | s.stuckBalls[] |
| Bot prediction | Multi-bounce + sin wobble |
| Sounds | ❌ |
| HUD | Phaser text |
| Faked stats? | No |
| Size / Time | 24 KB / ~3 min |
| Benchmark | — |
| Functional? | ⚠️ Bricks pass through, bot stuck |
v3 — +Sounds + scene array fix
| Aspect | Mimo 2.5 | Hy3 |
|---|---|---|
| Style | ES6 class | ES6 class + global vars |
| Scene config | [GameScene] ✅ | Bare key ref |
| Brick death | destroy() | destroy() |
| Level transition | Destroy+recreate (clean) ✅ | Destroy+recreate (colliders accumulate) |
| Ball bounds | worldBounds + bottom disabled | worldBounds + bottom disabled |
| Sticky catch | stickyActive flag | Global sticky |
| Bot prediction | Lead-time + velocity | While-loop + reflection |
| Sounds | ✅ AudioContext (native) | ✅ AudioContext |
| HUD | HTML DOM | HTML DOM |
| Faked stats? | No | Yes (Claude 3.5) |
| Size / Time | 23 KB / ~5s | 9 KB / ~2s |
| Benchmark | — | — |
| Functional? | ✅ Working | ⚠️ Colliders accumulate per level |
v4 — Bot mode first, concise
| Aspect | Nemotron 3U |
|---|---|
| Style | ES6 class + property init |
| Scene config | [GameScene] ✅ |
| Brick death | destroy() |
| Level transition | Destroy group + remove collider + recreate ✅ |
| Ball bounds | worldBounds + bottom disabled |
| Sticky catch | Per-ball stuck data flag |
| Bot prediction | While-loop + wall reflection |
| Sounds | ✅ AudioContext + win fanfare |
| HUD | Phaser text |
| Faked stats? | No |
| Size / Time | 10 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