๐ŸŸจ My First Browser Game ยท Episode 3 of 6 ยท See All Episodes
๐Ÿงฑ Episode 3 ยท Improver ยท Canvas Physics

Smash the Wall!
Browser Breakout

The canvas classic: mouse-driven paddle, spin physics, a glowing brick wall, lives and levels, plus canvas gradients and particle effects that make the browser version the prettiest one yet.

๐Ÿ‘ถ Ages 10+ โฑ๏ธ ~1.5 Hours ๐ŸŸจ JavaScript โœ“ Free
๐Ÿ–ฑ๏ธ Mouse control โšช Bounce physics ๐Ÿงฑ Brick objects โœจ Particles ๐ŸŽจ Canvas gradients โค๏ธ Lives & levels
โญ
0 XP
Level 1
๐Ÿ”ฅ0
Your Progress 0 / 6 steps
๐Ÿงฉ
Need the game setup? Each episode builds a new game on the same JavaScript skeleton, the <canvas><canvas>, the 2D ctxctx, the draw()draw() function and the requestAnimationFramerequestAnimationFrame loop. If you're starting here or need that boilerplate, grab it in one click: Episode 1 full template โ†’ ยท JS Cheatsheet โ†’
1
๐Ÿ–ฑ๏ธ
Canvas, Loop & Mouse Paddle
Setup plus mousemove-driven paddle
Active
๐ŸŽฏ
Goal for this step

Standard canvas setup with a paddle glued to the mouse.

  • 1New breakout.html: 700ร—520 canvas, requestAnimationFrame loop (Episode 1 muscle memory).
  • 2Track the mouse: a mousemove listener stores the x relative to the canvas (subtract canvas.getBoundingClientRect().left).
  • 3Paddle x = mouseX โˆ’ 55, clamped 0-590.
  • 4Draw the paddle with ctx.roundRect, canvas has rounded rectangles built in now!
breakout.html
let mouseX = 350;
canvas.addEventListener("mousemove", function(e) {
    const rect = canvas.getBoundingClientRect();
    mouseX = e.clientX - rect.left;
});

// in the loop:
const padX = Math.max(0, Math.min(590, mouseX - 55));
ctx.fillStyle = "#ff7700";
ctx.beginPath();
ctx.roundRect(padX, 480, 110, 14, 7);
ctx.fill();let mouseX = 350;
canvas.addEventListener("mousemove", function(e) {
    const rect = canvas.getBoundingClientRect();
    mouseX = e.clientX - rect.left;
});

// in the loop:
const padX = Math.max(0, Math.min(590, mouseX - 55));
ctx.fillStyle = "#ff7700";
ctx.beginPath();
ctx.roundRect(padX, 480, 110, 14, 7);
ctx.fill();
๐Ÿ“ฆ Full starter file , paste this into breakout.html and press play

A complete, runnable page: the <canvas>, the 2D ctxctx and the requestAnimationFrame loop. It opens to a blank board , every step below draws into this same file.

breakout.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
  body { display:flex; justify-content:center; background:#0d1117; padding:16px; }
  canvas { background:#0a0e14; border:2px solid #1e2733; border-radius:8px; }
</style>
</head>
<body>
<canvas id="game" width="700" height="520"></canvas>
<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");    // your drawing toolkit

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // everything you build in the steps below is drawn here
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
  body { display:flex; justify-content:center; background:#0d1117; padding:16px; }
  canvas { background:#0a0e14; border:2px solid #1e2733; border-radius:8px; }
</style>
</head>
<body>
<canvas id="game" width="700" height="520"></canvas>
<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");    // your drawing toolkit

function loop() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  // everything you build in the steps below is drawn here
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
</script>
</body>
</html>
โœ๏ธ
Fill in the Blanks
+15 XP
Mouse position must subtract the canvas offset from (). The paddle centre sits at the cursor by subtracting (half its width).
๐Ÿง 
Knowledge Check
+15 XP
Why subtract getBoundingClientRect().left from e.clientX?
ATo slow the mouse
BclientX is relative to the PAGE, subtracting the canvas position converts it to canvas coordinates
CTo keep the number positive
2
โšช
Ball & Wall Physics
Velocity object, three-wall bounce
Locked
๐ŸŽฏ
Goal for this step

A ball object bouncing off the walls and ceiling.

  • 1Ball object: { x, y, vx: 4, vy: -4, r: 7 }, a circle this time, drawn with arc().
  • 2Move, then bounce: x edges flip vx, top flips vy, accounting for the radius on each side.
  • 3Floor = miss (lives later).
  • 4Draw with a radial gradient for a glossy sphere, canvas can do shading!
breakout.html
const ball = { x: 350, y: 300, vx: 4, vy: -4, r: 7 };

ball.x += ball.vx;
ball.y += ball.vy;
if (ball.x - ball.r <= 0 || ball.x + ball.r >= 700) ball.vx = -ball.vx;
if (ball.y - ball.r <= 0) ball.vy = -ball.vy;

const g = ctx.createRadialGradient(ball.x-2, ball.y-2, 1, ball.x, ball.y, ball.r);
g.addColorStop(0, "#fff");
g.addColorStop(1, "#bbb");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
ctx.fill();const ball = { x: 350, y: 300, vx: 4, vy: -4, r: 7 };

ball.x += ball.vx;
ball.y += ball.vy;
if (ball.x - ball.r <= 0 || ball.x + ball.r >= 700) ball.vx = -ball.vx;
if (ball.y - ball.r <= 0) ball.vy = -ball.vy;

const g = ctx.createRadialGradient(ball.x-2, ball.y-2, 1, ball.x, ball.y, ball.r);
g.addColorStop(0, "#fff");
g.addColorStop(1, "#bbb");
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2);
ctx.fill();
โŒจ๏ธ
Code Challenge
+20 XP
Bounce off the side walls, respecting the radius:
breakout.html
if (ball.x - ball.if (ball.x - ball. <= 0 || ball.x + ball.r >=  <= 0 || ball.x + ball.r >= )
    ball.vx = )
    ball.vx = ;;
๐Ÿ’ก Hint: A circle touches the wall when its centre is one radius away; then the horizontal velocity flips.
๐Ÿง 
Knowledge Check
+15 XP
ctx.arc(x, y, r, 0, Math.PI * 2) drawsโ€ฆ
AA square
BA full circle, the angles run from 0 to 2ฯ€ radians (360ยฐ)
CHalf a circle
3
๐Ÿ“
Paddle Rebound with Spin
The aim-with-your-catch mechanic, browser edition
Locked
๐ŸŽฏ
Goal for this step

Rebound with player-controlled angle off the paddle.

  • 1Circle-rectangle collision, simplified: ball bottom inside the paddle strip while falling.
  • 2Flip vy, then spin: (ball.x โˆ’ paddleCentre) / 12 becomes vx (min magnitude 1).
  • 3Third language writing this, you could do it in your sleep. THAT is transferable skill.
  • 4Cap |vx| at 7 to keep the game playable.
breakout.html
if (ball.vy > 0 &&
    ball.y + ball.r >= 480 && ball.y + ball.r <= 494 &&
    ball.x >= padX && ball.x <= padX + 110) {
    ball.vy = -ball.vy;
    let spin = (ball.x - (padX + 55)) / 12;
    spin = Math.max(-7, Math.min(7, spin));
    ball.vx = spin === 0 ? 1 : spin;
}if (ball.vy > 0 &&
    ball.y + ball.r >= 480 && ball.y + ball.r <= 494 &&
    ball.x >= padX && ball.x <= padX + 110) {
    ball.vy = -ball.vy;
    let spin = (ball.x - (padX + 55)) / 12;
    spin = Math.max(-7, Math.min(7, spin));
    ball.vx = spin === 0 ? 1 : spin;
}
โœ๏ธ
Fill in the Blanks
+15 XP
The rebound only happens while ball.vy > (falling). The spin is the distance from the paddle divided by 12, capped at ยฑ.
๐Ÿง 
Knowledge Check
+15 XP
You have now written paddle spin in Java, Python/C++ and JavaScript. What ACTUALLY differed?
AThe physics idea
BOnly syntax, the game design logic was identical every time
CThe direction of gravity
4
๐Ÿงฑ
Bricks & Neon Glow
The wall as objects, drawn with shadowBlur
Locked
๐ŸŽฏ
Goal for this step

Build the brick wall and give it a neon glow with canvas shadows.

  • 1Bricks: array of objects from nested loops, x, y, colour by row, alive.
  • 2Canvas glow is one property: ctx.shadowBlur = 12; ctx.shadowColor = brickColour; before fillRect.
  • 3IMPORTANT: reset shadowBlur to 0 after the bricks or EVERYTHING glows.
  • 4Collision + destruction: same alive-flag pattern as C++ Episode 3, flip vy, score by row.
breakout.html
const COLOURS = ["#f87171","#fb923c","#facc15","#4ade80","#60a5fa"];
let bricks = [];
for (let r = 0; r < 5; r++)
    for (let c = 0; c < 10; c++)
        bricks.push({ x: c*68+12, y: r*26+50, row: r, alive: true });

// drawing:
for (const b of bricks) {
    if (!b.alive) continue;
    ctx.shadowBlur = 12;
    ctx.shadowColor = COLOURS[b.row];
    ctx.fillStyle = COLOURS[b.row];
    ctx.fillRect(b.x, b.y, 64, 22);
}
ctx.shadowBlur = 0;      // stop the glow leaking!const COLOURS = ["#f87171","#fb923c","#facc15","#4ade80","#60a5fa"];
let bricks = [];
for (let r = 0; r < 5; r++)
    for (let c = 0; c < 10; c++)
        bricks.push({ x: c*68+12, y: r*26+50, row: r, alive: true });

// drawing:
for (const b of bricks) {
    if (!b.alive) continue;
    ctx.shadowBlur = 12;
    ctx.shadowColor = COLOURS[b.row];
    ctx.fillStyle = COLOURS[b.row];
    ctx.fillRect(b.x, b.y, 64, 22);
}
ctx.shadowBlur = 0;      // stop the glow leaking!
โŒจ๏ธ
Code Challenge
+20 XP
Give bricks a neon glow, then turn it off:
breakout.html
ctx.ctx. = 12;
ctx.shadowColor = COLOURS[b.row];
ctx.fillRect(b.x, b.y, 64, 22);
// after the brick loop:
ctx.shadowBlur =  = 12;
ctx.shadowColor = COLOURS[b.row];
ctx.fillRect(b.x, b.y, 64, 22);
// after the brick loop:
ctx.shadowBlur = ;;
๐Ÿ’ก Hint: The blur property creates the glow; setting it back to zero stops it applying to the paddle and ball.
๐Ÿง 
Knowledge Check
+15 XP
Canvas state (fillStyle, shadowBlurโ€ฆ) persists between draw calls. That meansโ€ฆ
AYou set it once ever
BWhatever you set STAYS until changed, forget to reset and later drawings inherit it. Classic canvas bug
CEach shape resets it
5
โœจ
Particle Explosions
Every brick bursts into flying sparks
Locked
๐ŸŽฏ
Goal for this step

Spawn a burst of physics particles when a brick dies.

  • 1A particle: { x, y, vx, vy, life }, spawned 8 per broken brick with random velocities.
  • 2Each frame: move, add slight gravity, life โˆ’= 1, remove dead ones (filter).
  • 3Draw as 3px squares in the brickโ€™s colour with alpha = life/30 (fade out via ctx.globalAlpha, reset it after!).
  • 4This is THE effect that makes players say "juicy". 15 lines.
breakout.html
let particles = [];

function burst(x, y, colour) {
    for (let i = 0; i < 8; i++)
        particles.push({
            x: x, y: y,
            vx: (Math.random() - 0.5) * 6,
            vy: (Math.random() - 0.5) * 6 - 1,
            life: 30, colour: colour
        });
}

// each frame:
for (const p of particles) {
    p.x += p.vx;  p.y += p.vy;
    p.vy += 0.15;                 // particle gravity
    p.life--;
}
particles = particles.filter(p => p.life > 0);

for (const p of particles) {
    ctx.globalAlpha = p.life / 30;
    ctx.fillStyle = p.colour;
    ctx.fillRect(p.x, p.y, 3, 3);
}
ctx.globalAlpha = 1;let particles = [];

function burst(x, y, colour) {
    for (let i = 0; i < 8; i++)
        particles.push({
            x: x, y: y,
            vx: (Math.random() - 0.5) * 6,
            vy: (Math.random() - 0.5) * 6 - 1,
            life: 30, colour: colour
        });
}

// each frame:
for (const p of particles) {
    p.x += p.vx;  p.y += p.vy;
    p.vy += 0.15;                 // particle gravity
    p.life--;
}
particles = particles.filter(p => p.life > 0);

for (const p of particles) {
    ctx.globalAlpha = p.life / 30;
    ctx.fillStyle = p.colour;
    ctx.fillRect(p.x, p.y, 3, 3);
}
ctx.globalAlpha = 1;
โœ๏ธ
Fill in the Blanks
+15 XP
Each burst spawns particles with random velocities. Fading uses ctx. scaled by remaining life, reset to afterwards.
๐Ÿง 
Knowledge Check
+15 XP
Particles are pure decoration, no gameplay effect. Why bother?
AThey increase score
BFeedback: the brain reads bursts as impact. Games FEEL better with consequence made visible
CBrowsers require them
6
โค๏ธ
Lives, Levels & Ship It
The complete game loop with speed-up levels
Locked
๐ŸŽฏ
Goal for this step

Finish with lives, escalating levels and the end screens.

  • 1Lives 3; floor miss โ†’ recentre and serve; 0 โ†’ GAME OVER overlay, click to restart.
  • 2Wall cleared โ†’ level++, rebuild bricks, ball speed ร— 1.15, a proper difficulty ladder.
  • 3HUD: score left, level centre, hearts right.
  • 4Zip through the checklist: mouse paddle โœ“ spin โœ“ glow โœ“ particles โœ“ levels โœ“. Send the file to a friend, it just WORKS in their browser. That is the JS advantage.
breakout.html
let lives = 3, level = 1;

// wall cleared:
if (bricks.every(b => !b.alive)) {
    level++;
    bricks = buildWall();
    ball.x = 350; ball.y = 300;
    ball.vx *= 1.15;
    ball.vy = -Math.abs(ball.vy) * 1.15;
}let lives = 3, level = 1;

// wall cleared:
if (bricks.every(b => !b.alive)) {
    level++;
    bricks = buildWall();
    ball.x = 350; ball.y = 300;
    ball.vx *= 1.15;
    ball.vy = -Math.abs(ball.vy) * 1.15;
}
โŒจ๏ธ
Code Challenge
+20 XP
Detect the cleared wall with an array method:
breakout.html
if (bricks.if (bricks.(b => !(b => !)) {
    level++;
    bricks = buildWall();
})) {
    level++;
    bricks = buildWall();
}
๐Ÿ’ก Hint: The method asks "is this true for ALL items?", and the condition is that each brick is not alive.
๐Ÿง 
Knowledge Check
+15 XP
bricks.every(b => !b.alive) reads asโ€ฆ
ADelete every brick
B"Is every brick dead?", true only when the whole wall is destroyed
CRevive all bricks
โœ… See the complete finished Breakout , all 6 steps assembled

Mouse paddle, ball physics, paddle spin, the neon brick wall, particle bursts, lives and escalating levels , one file. Save it as breakout.htmlbreakout.html, open it, and play. Stuck on a step? Compare your file against this.

breakout.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Neon Breakout</title>
<style>
  body { display:flex; flex-direction:column; align-items:center; gap:10px;
         background:#0d1117; color:#e8eaf2; font-family:system-ui, sans-serif; padding:16px; }
  canvas { background:#0a0e14; border:2px solid #1e2733; border-radius:8px; cursor:none; }
</style>
</head>
<body>
  <h2>๐Ÿงฑ Neon Breakout</h2>
  <canvas id="game" width="700" height="520"></canvas>
  <div>Move the mouse to steer ยท click to restart after Game Over</div>

<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");

const COLOURS = ["#f87171","#fb923c","#facc15","#4ade80","#60a5fa"];
let mouseX = 350, ball, bricks, particles, lives, level, score, gameOver;

canvas.addEventListener("mousemove", function(e) {
  const rect = canvas.getBoundingClientRect();
  mouseX = e.clientX - rect.left;
});
canvas.addEventListener("click", function() { if (gameOver) start(); });

function buildWall() {
  const arr = [];
  for (let r = 0; r < 5; r++)
    for (let c = 0; c < 10; c++)
      arr.push({ x: c*68+12, y: r*26+50, row: r, alive: true });
  return arr;
}

function serve() { ball = { x: 350, y: 300, vx: 4, vy: -4, r: 7 }; }

function start() {
  bricks = buildWall();
  particles = [];
  lives = 3; level = 1; score = 0; gameOver = false;
  serve();
}

function burst(x, y, colour) {
  for (let i = 0; i < 8; i++)
    particles.push({ x:x, y:y, vx:(Math.random()-0.5)*6, vy:(Math.random()-0.5)*6-1, life:30, colour:colour });
}

function update() {
  if (gameOver) return;
  const padX = Math.max(0, Math.min(590, mouseX - 55));

  ball.x += ball.vx;
  ball.y += ball.vy;

  // walls + ceiling
  if (ball.x - ball.r <= 0 || ball.x + ball.r >= 700) ball.vx = -ball.vx;
  if (ball.y - ball.r <= 0) ball.vy = -ball.vy;

  // paddle rebound with spin
  if (ball.vy > 0 && ball.y + ball.r >= 480 && ball.y + ball.r <= 494 &&
      ball.x >= padX && ball.x <= padX + 110) {
    ball.vy = -ball.vy;
    let spin = (ball.x - (padX + 55)) / 12;
    spin = Math.max(-7, Math.min(7, spin));
    ball.vx = spin === 0 ? 1 : spin;
  }

  // brick collision (one per frame)
  for (const b of bricks) {
    if (!b.alive) continue;
    if (ball.x + ball.r > b.x && ball.x - ball.r < b.x + 64 &&
        ball.y + ball.r > b.y && ball.y - ball.r < b.y + 22) {
      b.alive = false;
      ball.vy = -ball.vy;
      score += (5 - b.row) * 10;
      burst(ball.x, ball.y, COLOURS[b.row]);
      break;
    }
  }

  // floor miss
  if (ball.y - ball.r > 520) {
    lives--;
    if (lives <= 0) { gameOver = true; }
    else serve();
  }

  // wall cleared -> next level
  if (bricks.every(b => !b.alive)) {
    level++;
    bricks = buildWall();
    serve();
    ball.vx *= 1.15;
    ball.vy = -Math.abs(ball.vy) * 1.15;
  }

  // particles
  for (const p of particles) { p.x += p.vx; p.y += p.vy; p.vy += 0.15; p.life--; }
  particles = particles.filter(p => p.life > 0);
}

function draw() {
  ctx.clearRect(0, 0, 700, 520);
  const padX = Math.max(0, Math.min(590, mouseX - 55));

  // bricks with glow
  for (const b of bricks) {
    if (!b.alive) continue;
    ctx.shadowBlur = 12;
    ctx.shadowColor = COLOURS[b.row];
    ctx.fillStyle = COLOURS[b.row];
    ctx.fillRect(b.x, b.y, 64, 22);
  }
  ctx.shadowBlur = 0;

  // particles
  for (const p of particles) {
    ctx.globalAlpha = p.life / 30;
    ctx.fillStyle = p.colour;
    ctx.fillRect(p.x, p.y, 3, 3);
  }
  ctx.globalAlpha = 1;

  // paddle
  ctx.fillStyle = "#ff7700";
  ctx.beginPath();
  ctx.roundRect(padX, 480, 110, 14, 7);
  ctx.fill();

  // glossy ball
  const g = ctx.createRadialGradient(ball.x-2, ball.y-2, 1, ball.x, ball.y, ball.r);
  g.addColorStop(0, "#fff"); g.addColorStop(1, "#bbb");
  ctx.fillStyle = g;
  ctx.beginPath();
  ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI*2);
  ctx.fill();

  // HUD
  ctx.fillStyle = "#e8eaf2";
  ctx.font = "16px system-ui";
  ctx.textAlign = "left";   ctx.fillText("Score " + score, 12, 30);
  ctx.textAlign = "center"; ctx.fillText("Level " + level, 350, 30);
  ctx.textAlign = "right";  ctx.fillText("โค๏ธ".repeat(Math.max(0, lives)), 688, 30);

  if (gameOver) {
    ctx.fillStyle = "rgba(0,0,0,.65)";
    ctx.fillRect(0, 0, 700, 520);
    ctx.fillStyle = "#fff";
    ctx.textAlign = "center";
    ctx.font = "bold 40px system-ui";
    ctx.fillText("Game Over", 350, 250);
    ctx.font = "18px system-ui";
    ctx.fillText("Score " + score + " ยท click to play again", 350, 290);
  }
}

function loop() {
  update();
  draw();
  requestAnimationFrame(loop);
}

start();
requestAnimationFrame(loop);
</script>
</body>
</html><!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Neon Breakout</title>
<style>
  body { display:flex; flex-direction:column; align-items:center; gap:10px;
         background:#0d1117; color:#e8eaf2; font-family:system-ui, sans-serif; padding:16px; }
  canvas { background:#0a0e14; border:2px solid #1e2733; border-radius:8px; cursor:none; }
</style>
</head>
<body>
  <h2>๐Ÿงฑ Neon Breakout</h2>
  <canvas id="game" width="700" height="520"></canvas>
  <div>Move the mouse to steer ยท click to restart after Game Over</div>

<script>
const canvas = document.getElementById("game");
const ctx = canvas.getContext("2d");

const COLOURS = ["#f87171","#fb923c","#facc15","#4ade80","#60a5fa"];
let mouseX = 350, ball, bricks, particles, lives, level, score, gameOver;

canvas.addEventListener("mousemove", function(e) {
  const rect = canvas.getBoundingClientRect();
  mouseX = e.clientX - rect.left;
});
canvas.addEventListener("click", function() { if (gameOver) start(); });

function buildWall() {
  const arr = [];
  for (let r = 0; r < 5; r++)
    for (let c = 0; c < 10; c++)
      arr.push({ x: c*68+12, y: r*26+50, row: r, alive: true });
  return arr;
}

function serve() { ball = { x: 350, y: 300, vx: 4, vy: -4, r: 7 }; }

function start() {
  bricks = buildWall();
  particles = [];
  lives = 3; level = 1; score = 0; gameOver = false;
  serve();
}

function burst(x, y, colour) {
  for (let i = 0; i < 8; i++)
    particles.push({ x:x, y:y, vx:(Math.random()-0.5)*6, vy:(Math.random()-0.5)*6-1, life:30, colour:colour });
}

function update() {
  if (gameOver) return;
  const padX = Math.max(0, Math.min(590, mouseX - 55));

  ball.x += ball.vx;
  ball.y += ball.vy;

  // walls + ceiling
  if (ball.x - ball.r <= 0 || ball.x + ball.r >= 700) ball.vx = -ball.vx;
  if (ball.y - ball.r <= 0) ball.vy = -ball.vy;

  // paddle rebound with spin
  if (ball.vy > 0 && ball.y + ball.r >= 480 && ball.y + ball.r <= 494 &&
      ball.x >= padX && ball.x <= padX + 110) {
    ball.vy = -ball.vy;
    let spin = (ball.x - (padX + 55)) / 12;
    spin = Math.max(-7, Math.min(7, spin));
    ball.vx = spin === 0 ? 1 : spin;
  }

  // brick collision (one per frame)
  for (const b of bricks) {
    if (!b.alive) continue;
    if (ball.x + ball.r > b.x && ball.x - ball.r < b.x + 64 &&
        ball.y + ball.r > b.y && ball.y - ball.r < b.y + 22) {
      b.alive = false;
      ball.vy = -ball.vy;
      score += (5 - b.row) * 10;
      burst(ball.x, ball.y, COLOURS[b.row]);
      break;
    }
  }

  // floor miss
  if (ball.y - ball.r > 520) {
    lives--;
    if (lives <= 0) { gameOver = true; }
    else serve();
  }

  // wall cleared -> next level
  if (bricks.every(b => !b.alive)) {
    level++;
    bricks = buildWall();
    serve();
    ball.vx *= 1.15;
    ball.vy = -Math.abs(ball.vy) * 1.15;
  }

  // particles
  for (const p of particles) { p.x += p.vx; p.y += p.vy; p.vy += 0.15; p.life--; }
  particles = particles.filter(p => p.life > 0);
}

function draw() {
  ctx.clearRect(0, 0, 700, 520);
  const padX = Math.max(0, Math.min(590, mouseX - 55));

  // bricks with glow
  for (const b of bricks) {
    if (!b.alive) continue;
    ctx.shadowBlur = 12;
    ctx.shadowColor = COLOURS[b.row];
    ctx.fillStyle = COLOURS[b.row];
    ctx.fillRect(b.x, b.y, 64, 22);
  }
  ctx.shadowBlur = 0;

  // particles
  for (const p of particles) {
    ctx.globalAlpha = p.life / 30;
    ctx.fillStyle = p.colour;
    ctx.fillRect(p.x, p.y, 3, 3);
  }
  ctx.globalAlpha = 1;

  // paddle
  ctx.fillStyle = "#ff7700";
  ctx.beginPath();
  ctx.roundRect(padX, 480, 110, 14, 7);
  ctx.fill();

  // glossy ball
  const g = ctx.createRadialGradient(ball.x-2, ball.y-2, 1, ball.x, ball.y, ball.r);
  g.addColorStop(0, "#fff"); g.addColorStop(1, "#bbb");
  ctx.fillStyle = g;
  ctx.beginPath();
  ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI*2);
  ctx.fill();

  // HUD
  ctx.fillStyle = "#e8eaf2";
  ctx.font = "16px system-ui";
  ctx.textAlign = "left";   ctx.fillText("Score " + score, 12, 30);
  ctx.textAlign = "center"; ctx.fillText("Level " + level, 350, 30);
  ctx.textAlign = "right";  ctx.fillText("โค๏ธ".repeat(Math.max(0, lives)), 688, 30);

  if (gameOver) {
    ctx.fillStyle = "rgba(0,0,0,.65)";
    ctx.fillRect(0, 0, 700, 520);
    ctx.fillStyle = "#fff";
    ctx.textAlign = "center";
    ctx.font = "bold 40px system-ui";
    ctx.fillText("Game Over", 350, 250);
    ctx.font = "18px system-ui";
    ctx.fillText("Score " + score + " ยท click to play again", 350, 290);
  }
}

function loop() {
  update();
  draw();
  requestAnimationFrame(loop);
}

start();
requestAnimationFrame(loop);
</script>
</body>
</html>
๐ŸŽ‰๐Ÿ†๐ŸŽฎโœจ๐ŸŽ‰
Workshop Complete!
Breakout with particles and gradients, the prettiest wall-smasher of the series. Episode 4: Snake, the grid returns!
0
Total XP
1
Level
0
Best Streak
0%
Accuracy
โ–ถ Episode 4: Snake โ†’
โญ View My Progress & Certificates

This workshop was free and took many hours to build. If it helped you learn something new, consider supporting the project.

☕ Support on Ko-fi