1970s Sports — The simplest possible video game: two paddles, a bouncing ball, and the physics that make it feel real. Every game developer's first project. Each step adds one new system.

01

Ball & Paddles

Complete

Why learn this?

Player input and collision detection are the foundation of every game ever made. When you press a key and a paddle moves on screen, that's the game loop responding to input. When the ball hits the paddle and bounces back, that's collision physics at work. Every game — from Mario to DOOM — layers more complexity on top of these two core systems. Master them in Pong, and you can build anything.

Prompt used

Show prompt — Step 1
Build a two-player Pong game in Phaser 3.60 with:

- Two paddles (12x80 white rectangles): left at x=30, right at x=W-30, both at y=H/2
- A ball (16x16 white circle sprite) starting at center
- Use make.graphics() + generateTexture() in preload() for all sprites
- Left paddle: W (up) and S (down), speed 350px/s
- Right paddle: Arrow keys (UP/DOWN), speed 350px/s
- Paddles clamped to canvas bounds: Phaser.Math.Clamp(y, 40, H-40)
- Ball physics: setBounce(1), setCollideWorldBounds(true), body.onWorldBounds = true
- Ball starts with random velocity ~200px/s
- physics.add.collider(ball, paddle, hitPaddle) with angle reflection:
  diff = ball.y - paddle.y, setVelocityY(diff * 8)
  Speed ramping: multiply velocity by 1.05x on each hit, cap at 400
- Worldbounds handler detects ball escaping left/right walls
- Canvas: 680x480, background #0f1120
- Class GameScene extends Phaser.Scene, scene: [GameScene], parent: 'game'
- Phaser 3.60 CDN
- Bot mode via ?bot=true: both paddles AI with speed-limited tracking

What you built

A two-paddle Pong court with a bouncing ball. The left paddle responds to W/S, the right to arrow keys. The ball starts with random velocity, bounces off paddles with angle reflection, and resets when it escapes the playfield. Click Bot mode to watch an AI play against itself.

Key concepts

  • Game loop — Phaser's update() runs at 60fps. Every frame: read input, update positions, check collisions, render. This is the heartbeat of every game.
  • Sprite texturesgenerateTexture() creates game objects from Graphics at load time. No external image files needed — just code.
  • Arcade physicssetVelocity() moves objects, setBounce(1) makes the ball perfectly elastic, setImmovable(true) keeps paddles from being pushed by the ball.
  • Collision callbacksphysics.add.collider(ball, paddle, hitPaddle) runs custom logic when ball meets paddle. The callback calculates the reflection angle from where the ball hits.
  • World boundssetCollideWorldBounds(true) keeps everything on screen. The worldbounds event detects when the ball tries to leave.

Design decisions & tradeoffs

  • generateTexture() vs external sprites — Generating textures in code means zero asset files to manage. Tradeoff: only simple shapes (rectangles, circles) are practical. Complex art needs external images loaded via this.load.image().
  • Immovable paddles vs equal mass — Setting paddles as immovable prevents the ball from pushing them on collision. Tradeoff: the ball changes velocity instantly rather than transferring momentum. Fine for Pong, wrong for a physics sim.
  • Angle reflection vs fixed bounce — The ball's outgoing angle depends on where it hits the paddle (top = goes up, bottom = goes down). This makes the game feel skill-based rather than random. Tradeoff: the math is slightly more complex than a flat 180-degree reflection.
  • Speed ramping vs constant velocity — Each paddle hit multiplies velocity by 1.05x, making rallies faster over time. Tradeoff: very long rallies become unplayable fast. The 400 cap prevents this.

Alternative approaches

  • Raw Canvas API — Building without Phaser: canvas.getContext('2d'), manual requestAnimationFrame loop, hand-rolled collision math. More control, but you re-invent physics, input handling, and sprite management. Good for learning, bad for shipping.
  • Matter.js physics — A full physics engine (forces, friction, restitution) instead of Phaser's simple arcade physics. Overkill for Pong — you don't need rigid-body dynamics for paddles. Useful for Breakout where brick angles matter more.
  • Keyboard events vs polling — The model used Phaser's addKey() polling (check if key is down each frame). Alternative: keydown/keyup DOM events with state flags. Polling is simpler and avoids sticky-key bugs. DOM events are more responsive for fighting games.

Browser compatibility

  • Phaser 3.60 uses Canvas2D or WebGL automatically via Phaser.AUTO. Falls back to Canvas2D if WebGL isn't available.
  • All modern browsers (Chrome 80+, Firefox 75+, Safari 14+, Edge 80+) support Phaser 3.60. The CDN link works everywhere.
  • requestAnimationFrame (what Phaser uses internally) is supported in all browsers since IE10. No polyfill needed.

Performance notes

  • Two sprites + one ball = negligible draw calls. Phaser's renderer batches these into a single draw call.
  • The physics step runs at 60fps by default. No need to tweak for a game this simple.
  • Bot mode adds a tiny math overhead (angle calculations per frame) — immeasurable on modern hardware.

⚠️ Common pitfalls

  • setBounce(1) is essential — Without it, the ball loses speed on every wall/paddle hit and eventually stops. 1 = perfect elasticity (100% energy retained).
  • Don't reset on top/bottom wall hits — Only left/right walls should reset the ball. Top/bottom bounces are normal gameplay. The model got this wrong initially.
  • Clamp paddles to the play area — Without Phaser.Math.Clamp(), paddles can slide off-screen. Always set min/max Y bounds.
  • Colliders in create(), not update() — Adding physics.add.collider() inside update() creates infinite new colliders every frame, tanking performance.

Next up

Step 2 adds an AI opponent — a CPU paddle that tracks the ball with human-like reaction delay. Step 3 introduces scoring, serves, and win conditions.

public/games/pong/pong-step-01.html ▶ Play
<script src="https://cdn.jsdelivr.net/npm/phaser@3.60.0/dist/phaser.min.js"></script>
<div id="game"></div>
<script>
const W = 680, H = 480;

class GameScene extends Phaser.Scene {
  constructor() { super({ key: 'GameScene' }); }

  preload() {
    let g = this.make.graphics({ add: false });
    g.fillStyle(0xffffff);
    g.fillRect(0, 0, 12, 80);
    g.generateTexture('paddle', 12, 80);
    g.destroy();

    g = this.make.graphics({ add: false });
    g.fillStyle(0xffffff);
    g.fillCircle(8, 8, 7);
    g.generateTexture('ball', 16, 16);
    g.destroy();
  }

  create() {
    this.paddleL = this.physics.add.sprite(30, H/2, 'paddle');
    this.paddleL.setImmovable(true);
    this.paddleR = this.physics.add.sprite(W-30, H/2, 'paddle');
    this.paddleR.setImmovable(true);

    this.ball = this.physics.add.sprite(W/2, H/2, 'ball');
    this.ball.setBounce(1);
    this.ball.body.setVelocity(200, 150);

    this.physics.add.collider(this.ball, this.paddleL,
      this.hitPaddle, null, this);
    this.physics.add.collider(this.ball, this.paddleR,
      this.hitPaddle, null, this);

    // Input
    this.keyW = this.input.keyboard.addKey(
      Phaser.Input.Keyboard.KeyCodes.W);
    this.keyS = this.input.keyboard.addKey(
      Phaser.Input.Keyboard.KeyCodes.S);
    this.cursors = this.input.keyboard.createCursorKeys();
  }

  hitPaddle(ball, paddle) {
    let diff = ball.y - paddle.y;
    ball.setVelocityY(diff * 8);
    let speed = Math.sqrt(ball.body.velocity.x ** 2 +
      ball.body.velocity.y ** 2);
    if (speed < 400) {
      ball.body.velocity.x *= 1.05;
      ball.body.velocity.y *= 1.05;
    }
  }

  update() {
    this.paddleL.body.setVelocityY(0);
    if (this.keyW.isDown) this.paddleL.body.setVelocityY(-350);
    if (this.keyS.isDown) this.paddleL.body.setVelocityY(350);
    this.paddleL.y = Phaser.Math.Clamp(
      this.paddleL.y, 40, H - 40);

    this.paddleR.body.setVelocityY(0);
    if (this.cursors.up.isDown) this.paddleR.body.setVelocityY(-350);
    if (this.cursors.down.isDown) this.paddleR.body.setVelocityY(350);
    this.paddleR.y = Phaser.Math.Clamp(
      this.paddleR.y, 40, H - 40);
  }
}

new Phaser.Game({
  type: Phaser.AUTO, width: W, height: H,
  parent: 'game', backgroundColor: '#0f1120',
  physics: { default: 'arcade',
    arcade: { gravity: { y: 0 } } },
  scene: [GameScene]
});
</script>
✅ Done. Step 1 complete. The ball bounces, paddles move, and the physics feels right. Continue to Step 2 →
02

AI Opponent

Complete

Why learn this?

Game AI doesn't need to be smart — it needs to feel fair. The right paddle doesn't use machine learning or neural networks. It uses a single line of math: move toward ball.y, capped at a slower speed than the human player. The illusion of intelligence comes from a speed limit, not complex logic. This is the same technique used in Breakout, Space Invaders, and every arcade game of the 1970s. Understanding this teaches you that most game AI is deliberate limitation, not simulation.

Prompt used

Show prompt — Step 2
Add an AI opponent to the existing Pong game in Phaser 3.60:

- Right paddle is AI-controlled by default
- AI tracks ball.y - paddle.y difference each frame
- AI moves at 220px/s (slower than player's 350px/s)
- Dead zone: if abs(ball.y - paddle.y) < 8, AI stops moving (prevents jitter)
- Player can override AI by pressing arrow keys — paddle returns to AI when keys released
- Bot mode (?bot=true): left paddle also becomes AI at 280px/s speed
- AI paddle still clamped to world bounds: Phaser.Math.Clamp(y, 40, H-40)
- Keep existing: ball physics, paddle movement, collision, angle reflection, speed ramping
- Keep generateTexture() for all sprites
- Use URLSearchParams to detect ?bot=true

What you built

The right paddle is now AI-controlled by default. It tracks the ball's y-position and moves toward it at 220px/s (slower than the player's 350px/s). The speed limit creates a natural reaction delay — the AI can't teleport, so it takes time to cross the screen. Press arrow keys to override the AI and take control. In Bot vs Bot mode (?bot=true), both paddles are AI with different difficulty levels.

Key concepts

  • Proportional tracking — The AI computes ball.y - paddle.y each frame and moves in the direction of the difference. Bigger difference = longer travel = natural delay.
  • Speed-limited AI — The AI paddle moves at 220px/s vs the player's 350px/s. This single constraint makes the AI beatable. Without it, the AI would never miss.
  • Dead zone — If the ball is within 8px of the paddle center, the AI stops moving. Without this, the AI would jitter constantly trying to center perfectly.
  • Player override — Arrow keys override the AI when pressed. This lets the player step in for defensive plays or test the AI's limits by swapping sides.
  • Bot vs Bot — Both paddles AI with different speeds (left: 280, right: 220). Creates an asymmetric match you can watch.

Design decisions & tradeoffs

  • Speed limit vs reaction delay timer — The AI moves at a fixed slower speed, which naturally creates delay. Alternative: a timer that pauses AI updates for N milliseconds. Speed limiting is simpler and produces smoother movement. Timer-based delay creates jerky, stop-start AI behaviour.
  • Tracking ball.y vs predicting intercept — The simplest AI just follows the ball's current y. More advanced: predict where the ball will be when it reaches the paddle's x, accounting for bounces. Overkill for Step 2 — the basic tracker already produces fair rallies. Prediction becomes relevant in Breakout where you need to position for brick bounces.
  • Left AI (280) faster than Right AI (220) — Asymmetric difficulty makes bot-vs-bot matches interesting. If both AIs were equal speed, the game would always be a draw. Different speeds mean one AI eventually wins.
  • Arrow keys override vs full AI takeover — Keeping arrow key override lets players jump in and out. Alternative: a dedicated "AI toggle" button. The override approach is zero-UI — no extra widgets needed.

Alternative approaches

  • Perfect tracking + random miss chance — The AI always centers on the ball, but randomly fails to respond 10% of the time. Produces frustrating gameplay (unfair when it works, cheap when it misses). Speed-limited tracking is more consistent.
  • Paddle momentum — The AI paddle has acceleration/deceleration instead of instant velocity changes. Smoother movement but more code. Worth adding in a polish pass (Step 4).
  • Learning AI — The paddle adjusts its speed based on rally length: faster when the player is doing well, slower when the player is losing. Dynamic difficulty — keeps the game in the "flow state." Good for a future enhancement but adds complexity that obscures the basic AI concept.

Browser compatibility

  • Same as Step 1 — Phaser 3.60, all modern browsers. The AI logic is pure math, no browser-specific APIs.
  • URLSearchParams for bot mode detection requires Chrome 49+, Firefox 44+, Safari 10.1+, Edge 17+. Supported everywhere Phaser runs.

Performance notes

  • AI math adds ~0.01ms per frame — imperceptible. Three subtraction operations and a clamp.
  • No additional objects, no memory allocation. Zero GC pressure.
  • Pong will run at 60fps on any hardware from the last 15 years, AI included.

⚠️ Common pitfalls

  • Don't make the AI too fast — 220px/s feels fair. Try playing against 300px/s and you'll lose every rally. Test your AI speed before shipping — what feels easy in testing is frustrating at release.
  • Dead zone is essential — Without it, the paddle oscillates around the ball's y-position. The 8px threshold prevents this jitter.
  • AI should clamp to world bounds — Same as player paddles. Phaser.Math.Clamp(paddle.y, 40, H - 40). Without it, the AI can push the paddle off-screen if the ball is near the edge.
  • Don't disable AI when human presses arrow keys — The override should be temporary (reverts when keys are released). A toggle would require extra state management.

Next up

Step 3 adds scoring — track points, serve after each rally, first-to-11 win condition. The game becomes a real game with a goal.

updateAI() — the entire AI in 5 lines ▶ Play
updateAI(paddle, speed) {
  let diff = this.ball.y - paddle.y;
  if (Math.abs(diff) > 8) {
    paddle.body.setVelocityY(
      diff > 0 ? speed : -speed);
  } else {
    paddle.body.setVelocityY(0);
  }
  paddle.y = Phaser.Math.Clamp(
    paddle.y, 40, H - 40);
}
Right paddle dispatch (update)
if (this.botMode) {
  // Both AI
  this.updateAI(this.paddleL, 280);
  this.updateAI(this.paddleR, 220);
} else if (this.keyUp.isDown ||
           this.keyDown.isDown) {
  // Player override
  this.paddleR.body.setVelocityY(0);
  if (this.keyUp.isDown)
    this.paddleR.body.setVelocityY(-350);
  if (this.keyDown.isDown)
    this.paddleR.body.setVelocityY(350);
  this.paddleR.y = Phaser.Math.Clamp(
    this.paddleR.y, 40, H - 40);
} else {
  // Default: AI
  this.updateAI(this.paddleR, 220);
}
✅ Done. Step 2 complete. The AI opponent is beatable but challenging. Continue to Step 3 →
03

Scoring & Win

Complete

Why learn this?

A game without a goal is a toy. Scoring transforms Pong from a physics demo into a real game with winners and losers. The system you'll build here — state management, UI text, win condition, and serve mechanic — is the foundation of every competitive game ever made. From Pong to Street Fighter, every game tracks some kind of score and decides when someone wins.

Prompt used

Show prompt — Step 3
Add scoring and win conditions to the existing Pong game in Phaser 3.60:

- Track scores for left and right players (scoreL, scoreR)
- Score text centered at top: "0 - 0" format, monospace font, setDepth(10)
- When ball escapes left wall (x < 30): scoreR++, ball resets to center
- When ball escapes right wall (x > W-30): scoreL++, ball resets to center
- After scoring: ball stops (velocity 0,0), positioned at center
- 800ms delay, then ball launches in random direction (Phaser.Math.Between(0,360))
- Serving flag prevents double-serves during the delay
- First to 11 wins (scoreL >= 11 or scoreR >= 11)
- Win text displayed at center: "Player 1 Wins!" or "AI Wins!"
- "Press R to play again" hint below win text
- R key calls this.scene.restart()
- gameOver flag: when true, skip all input and physics processing
- Keep existing: AI, bot mode, ball physics, paddle movement, collision
- Win text setDepth(15), restart hint setDepth(15)

What you built

A scoreboard renders at the top of the court showing both players' points (first-to-11). When the ball escapes past a paddle, the opposing player scores, the ball resets to center, and after 800ms it launches in a random direction for the next serve. When a player reaches 11 points, the game freezes and displays a win message. Press R to play again.

Key concepts

  • Phaser text objectsthis.add.text(x, y, string, style) creates on-screen text. setOrigin(0.5) centers it on the x,y point. setDepth(10) ensures it renders above all game sprites.
  • Game state machine — The gameOver flag in update(): when true, paddle input and ball physics are skipped. The serving flag prevents double-serves during the delay timer. State machines prevent race conditions in games.
  • Score tracking — Two variables (scoreL, scoreR) increment in the worldbounds handler. The score text updates via setText() after every point.
  • Win threshold — After each point, check if either score >= 11. If so, set gameOver = true and display the win message. The threshold is a single >= 11 comparison — simple but effective.
  • Serve delaythis.time.delayedCall(800, callback) pauses 800ms, then launches the ball. Without this delay, the ball would launch instantly after a point, making the game feel frantic.
  • Scene restartthis.scene.restart() resets every variable, text object, and sprite to its create() state. Pressing R calls this, resetting scores along with everything else.

Design decisions & tradeoffs

  • Score position: top-center vs top-left/right — The score renders at the top center of the court. Alternative: separate left/right scores above each paddle. Centered is simpler (one text object) and matches arcade Pong. Split scores are better for multiplayer where each player looks at their own side.
  • First-to-11 vs timed game — 11 points is the classic Pong win threshold. Alternative: 2-minute timer, highest score wins. Point-based is simpler and gives clear feedback. Timed games need a clock HUD and sudden-death tiebreaker logic.
  • Serve delay: 800ms vs variable — Fixed 800ms delay after every point gives the player time to reposition. Alternative: variable delay based on rally length. Fixed is simpler and more predictable.
  • Win text as Phaser text vs DOM overlay — Phaser's text system renders inside the canvas. Alternative: a CSS overlay on top of the canvas. Phaser text is simpler (no DOM coordination) but less stylable. DOM overlays allow CSS animations and custom fonts.
  • Scene.restart() vs manual resetscene.restart() destroys and recreates the entire scene. Alternative: manually reset scores, ball, and state. restart() is simpler but slightly wasteful for complex games.

Alternative approaches

  • Rally scoring — Points only awarded on your own serve (like volleyball side-out scoring). More complex state (tracking who served). Traditional Pong uses rally scoring where either player can score regardless of serve.
  • Sudden death at 10-10 — Standard ping-pong rules: if both players reach 10, win by 2. The simplest implementation (first to 11 with no deuce) avoids win-by-2 tracking. Deuce logic adds state to afterPoint(): check for 10-10, raise threshold to 12, etc.
  • Best-of-3 match — First to 11 wins the game, first to 2 games wins the match. Adds match-level state tracking and a round transition. Overkill for Step 3 but useful for tournament-style games.
  • Speed increase on score, not paddle hit — Current approach speeds up ball on every paddle hit. Alternative: speed resets to baseline after each point. Serve-reset rewards tactical play with consistent ball speed.

Browser compatibility

  • Phaser text uses Canvas2D's fillText() — supported in all browsers since IE9. No WebGL dependency for text.
  • this.time.delayedCall() relies on requestAnimationFrame, supported everywhere.
  • Monospace font rendering is identical across all platforms. No web font loading needed.

Performance notes

  • Two text objects add ~0.005ms to the render pass. No measurable impact.
  • State checks (if (this.gameOver) return;) actually IMPROVE performance by skipping update logic when the game is over.
  • The 800ms timer creates a single deferred function reference — zero GC pressure.

⚠️ Common pitfalls

  • setDepth() for text objects — Without setDepth(10), the score text renders BEHIND the ball sprite. Always set depth for UI elements above game objects (depth 0-5 for game, 10+ for UI).
  • Game over guards in update() — Missing if (this.gameOver) return; allows the player to keep controlling paddles after the match ends. Always guard input processing with the game state flag.
  • Score text update after every pointsetText() must be called in afterPoint(). If you forget, the score display stays at 0 - 0 even though the variables increment internally.
  • Double-serve from worldbounds event — Without the !this.gameOver guard, a point scored on the same frame as a win can trigger a serve AND a win simultaneously.
  • Scene.restart() destroys timers — Any running time.delayedCall() is auto-cancelled on restart. You don't need to manually clear it, but be aware to avoid debugging phantom callbacks.

Next up

Step 4 adds polish — a center line, wall bounce sounds (Web Audio API), screen shake on point loss, and visual feedback that makes the game feel complete.

Scoring system — afterPoint() ▶ Play
afterPoint() {
  this.scoreText.setText(
    this.scoreL + ' - ' + this.scoreR);
  this.ball.body.setVelocity(0, 0);
  this.ball.setPosition(W / 2, H / 2);

  if (this.scoreL >= 11) {
    this.gameOver = true;
    this.winText.setText('Player 1 Wins!');
    this.restartHint.setText(
      'Press R to play again');
  } else if (this.scoreR >= 11) {
    this.gameOver = true;
    this.winText.setText('AI Wins!');
    this.restartHint.setText(
      'Press R to play again');
  } else {
    this.serving = true;
    this.time.delayedCall(800, () => {
      let angle = Phaser.Math.Between(0, 360);
      this.ball.body.setVelocity(
        Math.cos(angle) * 200,
        Math.sin(angle) * 200);
      this.serving = false;
    });
  }
}
Worldbounds handler with scoring
this.physics.world.on('worldbounds',
  (body) => {
    if (body.gameObject === this.ball
        && !this.gameOver) {
      if (this.ball.x < 30) {
        this.scoreR++;
        this.afterPoint();
      } else if (this.ball.x
          > W - 30) {
        this.scoreL++;
        this.afterPoint();
      }
    }
  });
✅ Done. Step 3 complete. Score tracking, serve mechanic, and win condition working. Continue to Step 4 →
04

Screen Wrap & Polish

Complete

Why learn this?

Sounds and visual feedback separate a finished game from a prototype. The difference between Pong's quiet, sterile court and the arcade cabinet's satisfying beep-boop is about 20 lines of Web Audio code. Audio programming teaches you about oscillator waveforms, gain envelopes, and how the browser's audio pipeline works. Screen shake gives the player visceral feedback — a tiny camera jolt tells them something important happened. These techniques apply to every game you'll ever build.

Prompt used

Show prompt — Step 4
Add polish and audio to the existing Pong game in Phaser 3.60:

- Add a center dashed line: loop drawing small white rectangles at 24px intervals down the middle
- Add AudioContext-based sound system (playTone function):
  * Create AudioContext once, store as this.audioCtx
  * playTone(freq, duration, waveform) creates OscillatorNode + GainNode
  * Gain envelope: setValueAtTime(0.12) -> exponentialRampToValueAtTime(0.001, duration)
  * Wrap in try/catch for silent fallback
- Sound triggers:
  * Paddle hit: playTone(440, 0.1, 'square')
  * Wall bounce: playTone(220, 0.08, 'triangle')
  * Score: playTone(160, 0.3, 'sawtooth')
- Camera shake on score: this.cameras.main.shake(200, 0.008)
- AudioContext.resume() on first sound if context is suspended
- Keep all existing: scoring, AI, bot mode, ball physics, paddles, collision

What you built

A center dashed line splits the court visually. Paddle hits produce a 440Hz square wave beep, wall bounces make a lower 220Hz triangle tone, and scoring triggers a 160Hz sawtooth buzz with a screen shake. The AudioContext is created once and reused — every paddle hit, wall bounce, and point scores a sound. The try/catch wrapper ensures silent fallback if audio isn't available.

Key concepts

  • Web Audio API oscillatorsctx.createOscillator() generates a tone at a given frequency. osc.type = 'square' changes the waveform: square sounds beepy (Pong-like), triangle sounds hollow (wall bounce), sawtooth sounds buzzy (score).
  • Gain envelopegain.gain.setValueAtTime(0.12, now) sets volume, then exponentialRampToValueAtTime(0.001, now + duration) fades out. Without the fade, you get a click/pop artefact when the oscillator stops.
  • AudioContext lifecycle — Browsers suspend the AudioContext until a user gesture (key press or click). ctx.resume() on first sound call wakes it up. The try/catch wrapper prevents crashes on audio-less devices.
  • Camera shakethis.cameras.main.shake(duration, intensity) shudders the viewport. Intensity of 0.008 with 200ms duration is a subtle jolt — strong enough to feel, weak enough to not cause motion sickness.
  • Procedural graphicsthis.add.graphics() draws shapes directly to the canvas. The center line is a loop drawing small white rectangles at 24px intervals down the middle of the court. No image files needed.
  • Sound differentiation — Each game event uses a different frequency + waveform combo: 440Hz square (paddle hit), 220Hz triangle (wall bounce), 160Hz sawtooth (score). The player subconsciously learns what each sound means.

Design decisions & tradeoffs

  • Web Audio oscillators vs pre-recorded audio files — Synthesised sounds are zero KB to load and never fail to download. Tradeoff: they sound synthetic (which is actually period-appropriate for Pong). Pre-recorded WAV files would sound richer but add asset management overhead.
  • Graphics center line vs sprite texture — Drawing the center line with this.add.graphics() uses zero sprite memory. Tradeoff: procedural graphics are harder to animate (pulsing, colour cycling). A sprite-based line could flash on scoring.
  • Camera shake vs sprite displacement — Shaking the entire camera is one function call. Alternative: manually offset all game objects by random amounts. Camera shake is simpler and affects the HUD too (intentional — adds drama). Sprite-only shake keeps the score readable.
  • Try/catch vs feature detection — Wrapping playTone in try/catch silently handles missing AudioContext. Alternative: if (window.AudioContext) check. Try/catch is simpler and also catches edge cases like AudioContext creation limits (Chrome caps at 6 per tab).
  • Single AudioContext vs create per sound — One AudioContext reused for all sounds. Alternative: create-and-destroy per sound. Reusing is more efficient (no context creation overhead). The oscillators themselves are short-lived and auto-garbage-collected.

Alternative approaches

  • Phaser sound manager — Instead of raw Web Audio, use this.sound.play('sfx') with preloaded audio files. Simpler API but requires audio file generation/loading. Web Audio is better for procedural sounds.
  • Howler.js — A third-party audio library with sprite sheets, spatial audio, and cross-fading. Overkill for Pong's three beeps. Useful for complex games with ambient tracks and positional audio.
  • Particle effects instead of camera shake — Emit particles on paddle hit (white sparks) and score (orange burst). Phaser's particle system is built-in: this.add.particles(x, y, 'particle', config). More visually interesting but more complex. Camera shake is simpler and more dramatic.
  • MIDI-style note sequences — Instead of single tones, play a short melody on score (ascending arpeggio for win, descending for loss). Requires note scheduling (sequencing oscillator starts across time). Overkill for Step 4 but worth exploring for Step 5.

Browser compatibility

  • Web Audio API (AudioContext + OscillatorNode + GainNode) supported since Chrome 33, Firefox 25, Safari 7, Edge 12. All browsers used by 98% of users support it.
  • AudioContext suspension on page load: Chrome requires user gesture to resume. iOS Safari requires a touch event. The ctx.resume() call on first paddle hit handles this.
  • Phaser's camera.shake() works in Canvas2D and WebGL modes — no browser-specific caveats.
  • Graphics.drawRect() is Canvas2D, supported everywhere. No WebGL dependency for the center line.

Performance notes

  • OscillatorNode + GainNode creation is ~0.01ms per call. Three sounds per rally = negligible.
  • AudioContext.run() is a fast path in Chrome/FF — no audio thread blocking.
  • Camera shake mathematically offsets the viewport matrix each frame during the shake duration. The 200ms shake at 60fps = 12 frames of matrix modification. Undetectable CPU cost.
  • The center line graphic is drawn once in create() — zero per-frame cost. The graphics object has no physics body, no update cycle.

⚠️ Common pitfalls

  • AudioContext suspended on first play — Browsers don't allow audio autoplay. Always call ctx.resume() before playing the first sound. Without it, the oscillator connects but produces silence.
  • Exponential ramp from zeroexponentialRampToValueAtTime(0, ...) throws an error because the ramp function can't approach absolute zero. Use 0.001 as the floor value. The try/catch in playTone silently swallows this, but it's better to avoid it.
  • Multiple AudioContexts — Chrome limits to 6 AudioContexts per tab. If you create a new one per scene restart, you'll hit the limit and audio breaks. Store the context outside create() (class property) to reuse across restarts.
  • Oscillator frequency sweep for scores — A fixed 160Hz note for scoring is fine. A frequency sweep (e.g. 160Hz → 80Hz over 0.3s) sounds more dramatic: osc.frequency.linearRampToValueAtTime(80, ctx.currentTime + 0.3). Try it!
  • Camera shake during game over — Shaking on a point that ends the game contextually makes sense (dramatic moment). But if the game over text also shakes, it can be hard to read. Consider separate shake intensity for game-ending vs regular points.

Next up

Step 5 adds two-player mode — split keyboard controls (W/S vs arrows), versus intro screen, player labels. The final step completes the Pong roadmap.

Sound system — playTone() ▶ Play
playTone(freq, duration, type) {
  try {
    let ctx = this.audioCtx;
    if (ctx.state === 'suspended')
      ctx.resume();
    let osc = ctx.createOscillator();
    let gain = ctx.createGain();
    osc.type = type || 'square';
    osc.connect(gain);
    gain.connect(ctx.destination);
    osc.frequency.setValueAtTime(
      freq, ctx.currentTime);
    gain.gain.setValueAtTime(
      0.12, ctx.currentTime);
    gain.gain.exponentialRampToValueAtTime(
      0.001, ctx.currentTime + duration);
    osc.start(ctx.currentTime);
    osc.stop(ctx.currentTime + duration);
  } catch (e) {
    // Audio not available
  }
}
Sound triggers per event
// Paddle hit — 440Hz square wave beep
hitPaddle(ball, paddle) {
  this.playTone(440, 0.1, 'square');
  // ball reflection math...
}

// Wall bounce — 220Hz triangle tone
// (in worldbounds handler, else branch)
this.playTone(220, 0.08, 'triangle');

// Score — 160Hz sawtooth + screen shake
afterPoint() {
  this.cameras.main.shake(200, 0.008);
  this.playTone(160, 0.3, 'sawtooth');
  // score logic...
}
✅ Done. Step 4 complete. Procedural audio + screen shake + center line. Continue to Step 5 →
05

Two-Player Mode

Complete

Why learn this?

Local multiplayer is the original social gaming experience. Before online matchmaking, before Xbox Live, before Wi-Fi — two players sat side by side, shared a keyboard, and competed. Split-keyboard design teaches you about input arbitration (two players, one keyboard, no conflicts), versus mode state machines, and the social dynamics of couch gaming. These patterns reappear in every fighting game, racing game, and party game ever made. Building two-player Pong completes the arc — from solitary physics demo to social game.

Prompt used

Show prompt — Step 5
Add two-player mode and intro screen to the existing Pong game in Phaser 3.60:

- Intro screen shown before game starts: "PONG" title, "Player 1: W/S" and "Player 2: Arrow Keys", "Press any key to play"
- gameStarted flag: all input and physics blocked until a key is pressed
- input.keyboard.on('keydown') fires once to start game, destroys intro text
- Both paddles human-controlled by default (no AI unless ?bot=true)
- Left paddle: W/S. Right paddle: Arrow keys
- Player labels at bottom of court: "P1" in cyan (#00d4ff), "P2" in orange (#ff6b35), setAlpha(0.4)
- Dynamic score labels: "P1 X - Y P2" in multiplayer, "You X - Y AI" in bot mode
- Win text adapts: "Player 1 Wins!" / "Player 2 Wins!" (multiplayer), "You Wins!" / "AI Wins!" (bot mode)
- ?bot=true still works as opt-in: right paddle becomes AI
- Keep all existing: sounds, camera shake, center line, scoring, serve delay

What you built

The game now opens with a versus intro screen showing "PONG" in large text, Player 1 (W/S) and Player 2 (↑↓) controls, and a "Press any key to play" prompt. In two-player mode, both paddles are human-controlled — no AI. Player labels ("P1" in cyan, "P2" in orange) appear at the bottom of the court. The score display shows "P1 X - Y P2". Add ?bot=true to play against AI (right paddle becomes CPU). All previous features (sounds, shake, center line, scoring) are preserved.

Key concepts

  • Intro screen state — The gameStarted flag blocks all input and physics until the player presses a key. The input.keyboard.on('keydown') listener fires once to transition to the game state, then destroys the intro text objects.
  • Dual human input — Both paddles respond to keyboard input in update(). Left paddle: W/S. Right paddle: ↑/↓. No AI dispatch logic unless botMode is true. This is the simplest multiplayer architecture — two independent input handlers, no conflicts.
  • Dynamic score labelsupdateScoreText() switches between "P1/P2" (multiplayer) and "You/AI" (bot mode) based on the botMode flag. A single function handles both modes — the labels are data, not separate code paths.
  • Player labels — Two text objects at the bottom of the court (P1 in cyan, P2 in orange) with setAlpha(0.4) provide constant visual reference. Alpha 0.4 ensures they're visible but don't distract. Destroying and recreating them per scene restart is handled by create().
  • Win text adapts to mode — The win message reads "Player 1 Wins!" or "Player 2 Wins!" in two-player mode, vs "You Wins!" or "AI Wins!" in bot mode. Same afterPoint() function, conditional label assignment.
  • Bot mode preserved as opt-in — The ?bot=true parameter still works, making the right paddle AI. This lets a single player practice against the CPU without needing a second person. The intro screen shows "Bot mode" when active so the player knows what to expect.

Design decisions & tradeoffs

  • Intro vs drop-in-start — The intro screen announces the mode and controls. Alternative: start the game immediately, players figure out controls. Intro is better for new players (reduces confusion). Drop-in is better for repeat players (less waiting).
  • Keyboard event vs polling for startinput.keyboard.on('keydown') fires once per key press. Alternative: check a flag in update(). The event approach is cleaner — no per-frame check, auto-destroyed when the scene restarts.
  • Text objects vs DOM overlay for intro — Phaser text objects (canvas-rendered) vs HTML/CSS overlay (DOM-rendered). Canvas text can't be selected or copied, but it lives inside the game canvas with consistent styling. DOM overlays give better typography but require coordination between Phaser and HTML layout.
  • P1/P2 labels vs no labels — Player labels at the bottom of the court remind each player which side they're on. Alternative: rely on player memory (they already know which keys they're pressing). Labels reduce confusion in fast-paced rallies where visual focus shifts rapidly.
  • Bot mode URL param vs UI toggle?bot=true is the simplest way to switch modes — no UI widgets, no state management. Alternative: an in-game menu button or keyboard toggle (e.g. B for bot mode). URL param is zero-maintenance but less discoverable.

Alternative approaches

  • Controller API (Gamepad) — Instead of split keyboard, use navigator.getGamepads() for physical controllers. Both players get their own joystick. More authentic arcade feel but requires gamepad hardware and the Gamepad API (Chrome 35+, Firefox 29+, Safari 10.1+). Worth exploring for the Breakout roadmap.
  • Online multiplayer (WebRTC) — Peer-to-peer with WebRTC data channels. Each player sees the game on their own screen. Orders of magnitude more complex — NAT traversal, latency compensation, state sync. Overkill for Pong but the foundational architecture for any networked game.
  • AI difficulty tiers — Instead of a single bot speed (220px/s), offer easy/medium/hard via URL params: ?bot=easy (160px/s), ?bot=medium (220px/s), ?bot=hard (300px/s). Easy to add — just map the param value to a speed constant.
  • Custom key remapping — Let each player choose their keys via an options screen. Requires persistent storage (localStorage) and a key-binding UI. Adds significant complexity for a marginal gain in Pong — the default W/S + arrow keys are well-established.

Browser compatibility

  • Split keyboard input (dual addKey() listeners) works in all browsers — they're just multiple key bindings on the same KeyboardPlugin.
  • keydown event listener for intro screen requires Chrome 49+, Firefox 52+, Safari 10+. Phaser's internal keyboard handling covers this.
  • Player labels and score text use the same Canvas2D text system as previous steps — no new compatibility concerns.
  • Bot mode via URL param works in all browsers. URLSearchParams requires Chrome 49+, Firefox 44+, Safari 10.1+.

Performance notes

  • Two human paddles = the same physics as one human + one AI. Zero additional CPU cost.
  • Intro screen adds 4-5 text objects. They're destroyed on game start — no memory leak.
  • Player labels are static text (update once per point via updateScoreText()). No per-frame text rendering.
  • Final game weighs ~13KB (all 5 steps of features) — loads in under 200ms on 4G.

⚠️ Common pitfalls

  • Intro listener not cleaned up — The keydown listener registered with this.input.keyboard.on() persists until the scene is destroyed (scene.restart() destroys it). If you use window.addEventListener instead, you must manually remove it in startGame() or you'll get duplicate listeners on restart.
  • Both paddles using the same key set — If both players share the same keys, the game is unplayable. The W/S and arrow key sets are physically separated on the keyboard — no key conflicts. This is why Pong's two-player mode traditionally uses opposite ends of the keyboard.
  • Worldbounds handler without gameStarted guard — Without this.gameStarted in the worldbounds check, the ball bouncing during the intro screen (if physics is active) can trigger scoring before the game begins. Always guard worldbounds handlers with the game state flag.
  • Score text not updating for mode — The label format (P1 X - Y P2 vs You X - Y AI) depends on botMode. If you forget to call updateScoreText() after mode detection, the score shows generic labels. Call it at the end of startGame() and in afterPoint().
  • Restart in intro state — Pressing R during the intro screen works because scene.restart() re-runs create(), which calls showIntro() again. The intro screen reappears cleanly. No special restart logic needed.

🏁 Pong Complete

All 5 steps are built. From a blank screen to a two-player arcade Pong with AI opponents, scoring, sounds, and screen shake — every system was added one step at a time. The full source is on GitHub. Ready for Breakout — where bricks, row physics, and multi-ball power-ups await.

Dual human input in update() ▶ Play
// Left paddle (Player 1)
this.paddleL.body.setVelocityY(0);
if (this.keyW.isDown)
  this.paddleL.body.setVelocityY(-350);
if (this.keyS.isDown)
  this.paddleL.body.setVelocityY(350);
this.paddleL.y = Phaser.Math.Clamp(
  this.paddleL.y, 40, H - 40);

// Right paddle
if (this.botMode) {
  this.updateAI(this.paddleR, 220);
} else {
  // Player 2
  this.paddleR.body.setVelocityY(0);
  if (this.keyUp.isDown)
    this.paddleR.body.setVelocityY(-350);
  if (this.keyDown.isDown)
    this.paddleR.body.setVelocityY(350);
  this.paddleR.y = Phaser.Math.Clamp(
    this.paddleR.y, 40, H - 40);
}
Dynamic score labels
updateScoreText() {
  let p1 = this.botMode
    ? 'You' : 'P1';
  let p2 = this.botMode
    ? 'AI' : 'P2';
  this.scoreText.setText(
    p1 + '  ' + this.scoreL
    + ' - ' + this.scoreR
    + '  ' + p2);
}
🏁 Complete. Step 5 is the final step. Pong is done — two-player mode, versus intro, all systems integrated. Start Breakout →
Model
DeepSeek V4 Flash
Tokens
~26K
Cost
$0.07
Steps
5/5
Output measured at ~0.28 tok/byte. Input estimated from turns × system context. DeepSeek V4 Flash: $0.15/M in, $0.60/M out.

Build Metrics per Step

StepGame FileEst. TokensEst. CostTime
01 — Ball & Paddles7.4 KB~6K$0.012~8 min
02 — AI Opponent8.3 KB~5K$0.013~7 min
03 — Scoring & Win9.8 KB~5K$0.015~6 min
04 — Screen Wrap & Polish10.9 KB~5K$0.015~5 min
05 — Two-Player Mode13.5 KB~5K$0.015~6 min
Total49.9 KB~26K$0.07~32 min

🏁 Pong Complete — All 5 Steps

From a blank screen to a full two-player Pong with AI, scoring, sounds, and screen shake. Ready for the next game:

BreakoutBricks, ball physics, paddle — one player, more targets

Lessons Learned — Build Process

The AI challenges, game design insights, and pipeline improvements from building Pong with AI.

🎮
Why Pong Was Popular

Pong succeeded because competition is primal. Two players face off on a single screen with zero instructions — the rules are self-evident. The paddle-relative angle system gives the player agency: hit the ball left of center and it goes left, hit right and it goes right. This creates a skill curve without any explicit tutorial. Every rally is a micro-narrative: the ball crosses the net, you return it, the angle sends it back, your opponent lunges. Pong proved that a game doesn't need graphics, story, or sound effects to be compelling — it just needs one good mechanic that rewards practice. The transferable principle: one simple mechanic with depth through angle/velocity modulation is the foundation of every successful arcade game that followed.

⚠️
The Problem — Generated Code Fragility

Pong was the first game built with this AI pipeline, so every bug was discovered fresh. The AI consistently got initialization order wrong — calling methods that depended on state not yet defined. Ball physics needed multiple corrections: the AI defaulted to fixed 45-degree bounces instead of paddle-relative angle mapping. The AI opponent was tuned too aggressively on first pass, tracking the ball perfectly instead of simulating human reaction lag. Patch-based fixes on generated code accumulated until the file was too tangled to safely edit — each patch assumed the structure was clean, but the layers of fixes made it brittle.

🛠️
The Fix — Patterns That Carried to Breakout

The Pong build established the patterns that became the formal pipeline for Breakout:

  1. Rebuild from scratch after 2 failed patches — game HTML is too fragile for accumulated fixes. Use a Python generator script instead.
  2. Verify initialization order — define ALL state variables before calling any method that reads them. this.paddle, this.ball, this.cursors must exist before create() calls anything.
  3. Test game mechanics in isolation — verify ball physics, AI behavior, and scoring as separate units, not all at once after the full game is generated.

Pong was built without guardrails. The bugs found here became the lessons that made Breakout's pipeline resilient. Every future game inherits these fixes.