Testing is the biggest gap in indie game development. Most browser-game tutorials skip it entirely — you build, you deploy, you hope. But without a mechanical verification layer, every bug that survives your manual testing becomes a reader's first impression.

The Testing Pyramid for Browser Games

        /\
       /  \          Manual / Exploratory
      /    \         (play through, feel for broken UX)
     /──────\
    /        \       Structural / Integration
   /          \      (script tag balance, Phaser config checks)
  /────────────\
 /                \  Unit / Headless Playwright
/                  \ (game state, collisions, AI, score)

Most indie game projects stop at the top of the pyramid — manual play-testing. This works until the game has 3+ modes (PvP, AI, Bot) with different code paths. Gun Fight proved this: 7 distinct bugs survived manual testing because they only triggered under specific conditions (Phaser arg swap only affects group-vs-sprite overlaps; maxSize only bites after the first bullet hits; pendingReset only breaks AI tracking when both players are hit).

The fix is a two-layer automated testing system:

LayerToolCatches
Headless Playwrightscripts/<game>-test.cjsGame logic bugs: collision, scoring, AI, reset, game-over
Structural verifierscripts/verify-game-step.pyCode quality: script tag balance, line-number corruption, missing Scale.FIT

Layer 1: Headless Playwright Tests

Playwright launches a real Chromium browser (with SwiftShader for WebGL rendering), loads the game, and reads game state directly via window.__scene. This catches bugs that structural checks can't see.

Canonical Pattern: gun-fight-test.cjs

The Gun Fight test (42 assertions) serves as the template for all future game tests. Key patterns:

1. Expose game state via window.__scene

Every game's create() must expose the scene object so Playwright can read state:

create() {
  // ... game setup ...
  window.__scene = this; // ← enables headless verification
}

2. Declare expected results BEFORE test code

Each test block starts with a comment documenting what should happen and why:

// Test 5 — AI mode loads and tracks player in X and Y
//   expected: mode='ai', P2 x < 570 (moves left toward P1),
//     P2 y < 410 after P1 moves up+left (staying out of AI fire range),
//     game NOT over (P1 survives when out of fire range)
//   root cause this catches: AI movement direction inverted, missing Y tracking,
//     AI fire range letting AI kill stationary players

Every expected result traces to a known failure mode. When a test fails, fix the game code — not the test.

3. Read game state with page.evaluate()

async function getState(page) {
  return await page.evaluate(() => {
    const s = window.__scene;
    if (!s || !s.p1) return null;
    return {
      p1x: Math.round(s.p1.x), p1y: Math.round(s.p1.y),
      p2x: Math.round(s.p2.x), p2y: Math.round(s.p2.y),
      p1a: Math.round(s.p1.alpha * 100), p2a: Math.round(s.p2.alpha * 100),
      s1: s.s1, s2: s.s2, go: s.gameOver, mode: s.mode
    };
  });
}

4. Simple pass/fail harness

No test framework needed:

function test(name, expected, actual, compareFn) {
  const pass = typeof compareFn === 'function' ? compareFn(actual) : actual === expected;
  console.log(pass ? `  ✓ ${name}` : `  ✗ ${name}: expected ...`);
  return pass;
}

5. SwiftShader WebGL flags for headless Chromium

const browser = await chromium.launch({
  headless: true,
  args: [
    '--headless=new', '--use-gl=angle', '--use-angle=swiftshader',
    '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist',
    '--enable-webgl', '--no-sandbox',
  ]
});

Seven Test Categories

CategoryWhat it verifiesExample assertion
LoadGame starts without errorscanvas exists, score 0-0
Fire → hitCollision detection workss1=1, gameOver=false
Score progressionMultiple hits accumulates1 >= 2, gameOver=false
Game overWin condition triggerss1=5, gameOver=true
AI behaviorAI tracks X and YP2 tracked X, P2 tracked Y
RespawnPlayers re-enabled after deathbody.enabled=true, alpha=100%
Bot modeBoth AIs playscore > 0 after 8s
Edge casesInfinite-loop guards, center hitsvx != 0 after center bounce

AI Testing Pitfall: Stay Out of Fire Range

When testing AI tracking, position the target outside the AI's engagement range. If too close, the AI kills it before tracking can be verified:

// WRONG — P1 at (300, 360) is inside AI fire range
await page.evaluate(() => scene.p1.setPosition(300, 360));
await page.waitForTimeout(2000);
// Game is already over (s2=5) — P2 y still at spawn

// RIGHT — P1 at (30, 300) is OUTSIDE fire range (550 > 450)
await page.evaluate(() => scene.p1.setPosition(30, 300)); // IMMEDIATELY
await page.waitForTimeout(2500);
// AI tracked X and Y, game still active

The AI's movement speed (99px/s) and fire range (450px) create a tight window. A stationary target means the AI closes to fire range in ~2s and kills in ~1.5s more.

Layer 2: Structural Verification

The verify-game-step.py script runs 15 structural checks before deploy:

CheckGuards against
Script tag balanceDouble <script> or missing </script>
Phaser.Game presentMissing game instantiation
Scale.FIT presentRaw 680x480 on large screens
No __DEFAULT textureInvisible sprites
No line-number prefixes12| corruption from read_file
No [truncated] artifactTruncated content in JS
JustDown() for fireOne-shot vs held-key bug
Bot mode checkMissing ?bot URL parsing
gameOver guardsCallbacks firing after win
</style> balanceAstro build failure
body color setInvisible text on dark bg

Industry Context: 11 Testing Types

Testing TypeIndustry DefinitionOur Implementation
FunctionalFeatures work per specPlaywright tests verify collision, scoring, win
PerformanceBehavior under loadMax bullet limits, off-screen cleanup, FPS
Ad-hocNo docs, tester instinctManual play-through before deploy
ExploratoryCharter-based testinggun-fight-debug.html with live console
RegressionNo new bugs from changesRe-run full test suite after every change
SmokeNew build stabilitynpm run build + verify before suite
CompatibilityWorks across platformsPhaser.Scale.FIT with CENTER_BOTH
UsabilityPlayer experienceControls text, restart instructions, clear states
AccessibilityInclusive designPlayer labels (BLUE/RED/YOU/AI)
LocalizationMulti-languageN/A (English-only games)
CompliancePlatform requirementsN/A (web, no console submission)

Testing Workflow

1. WRITE expected results in test file
   ↓
2. BUILD game code to match expectations
   ↓
3. RUN structural verifier (verify-game-step.py)
   ↓
4. RUN Playwright headless tests (node scripts/<game>-test.cjs)
   ↓
5. FIX game code if tests fail (never adjust tests to match buggy code)
   ↓
6. RUN npm run build
   ↓
7. DEPLOY

Critical rule: Tests must pass before deploy. A failing test means the game has a known bug.

Current State

GamePlaywright TestsStructural ChecksStatus
Breakout (step 1)✓ 48 assertions✓ verify-game-step.pyReference impl
Gun Fight (step 1)✓ 42 assertions✓ verify-game-step.pyReference impl
Pong (5 steps)None✓ verify-game-step.pyGAP — needs tests

Next Steps

  1. Backfill Pong tests — paddle movement, ball bounce, AI tracking, scoring, win
  2. CI integration — auto-run tests on every game build (pre-commit or cron)
  3. Per-step regression — each new step re-runs all previous step tests
  4. FPS and performance — verify 60fps under stress (max bullets, max enemies)

Phaser Gotchas Found During Testing

These are Phaser-specific quirks discovered while writing Playwright tests. Each one caused a test failure that looked like a game bug but was actually a Phaser framework behavior edge case.

1. StaticGroup bricks don't leave the group when destroyed

StaticGroup.getChildren() keeps destroyed children in the array. They're marked inactive (active = false) but not removed. Iterating getChildren() while calling destroy() on each child causes the array to shift mid-iteration — the for loop skips entries because the array mutates as children are removed from the display list.

Fix: Always snapshot the array with .slice() before iterating, and filter by .active when querying remaining bricks:

const children = sc.bricks.getChildren().slice(); // snapshot
for (let i = 0; i < children.length - 1; i++) {
  children[i].destroy();
}
const remaining = sc.bricks.getChildren().filter(c => c.active);

2. Headless keyboard input doesn't reach Phaser

Playwright's page.keyboard.press() doesn't reliably trigger Phaser's JustDown() detection in headless Chrome. Even canvas.focus() doesn't guarantee Phaser's keyboard handler receives the events.

Fix: Test game logic directly by calling methods via page.evaluate(). For example, instead of simulating SPACE key to test ball launch, call launchBall() directly and verify state. This still tests the launch logic — just bypasses the keyboard input layer, which is a Phaser internals concern, not game logic:

// Instead of: await page.keyboard.press('Space');
// Do:
await page.evaluate(() => {
  const sc = window.__scene;
  if (sc) sc.launchBall();
});
await page.waitForTimeout(500);
// Then verify ball velocity, gameStarted, etc.

3. Ball position tolerance after reset

After ball.setPosition(x, y) and ball.body.setVelocity(0, 0), the physics body position can lag behind the sprite position by up to 20px on the next frame read. This is because Phaser's physics step runs after update(), and the body's position sync isn't immediate.

Fix: Use a tolerance window (±20px) for ball position assertions after reset events, or wait one frame (100ms) before reading:

// Allow physics settling
check('Ball y near 420', true,
  Math.abs((state?.by || 0) - 420) <= 20, a => a);

4. Legacy games need retrofitting

Games built before the testing infrastructure existed (Pong, Breakout) need two retrofits before tests can run:

  1. window.__scene = this in create() — enables Playwright to read game state
  2. Scale.FIT in Phaser config — required for responsive rendering
  3. JustDown() for one-shot keys (R restart, SPACE launch) — prevents multi-fire

These are one-time fixes per game. The testing reference page documents what to check before writing tests for an existing game.

5. Respawn must undo ALL three disabled states

When a player is hit in a Phaser overlap callback, the common pattern is to deactivate the bullet and disable the player temporarily. But the pendingReset block in update() often only restores position and alpha — not visibility, active state, or physics body.

The bug: onHitP1/2 calls setActive(false).setVisible(false) and body.enable = false on the hit player. Then pendingReset only does setPosition() + setAlpha(1) — never calls setVisible(true), setActive(true), or body.enable = true. setAlpha(1) does NOT undo setVisible(false).

Consequences:

Fix — re-enable all three:

if (this.pendingReset) {
  this.pendingReset = false;
  // Re-enable, restore visibility, and reset both players to start positions
  this.p1.setActive(true).setVisible(true);
  this.p1.body.enable = true;
  this.p1.setPosition(100, 420).setAlpha(1);
  this.p1.body.setVelocity(0, 0);
  this.p2.setActive(true).setVisible(true);
  this.p2.body.enable = true;
  this.p2.setPosition(580, 420).setAlpha(1);
  this.p2.body.setVelocity(0, 0);
}

Always re-enable BOTH players regardless of game mode. The old pattern of gating P2 reset on if (this.mode === 'pvp') caused identical respawn failures in AI and bot modes. Tests must assert body.enable === true AND alpha === 100% after every hit cycle.

6. Infinite vertical-loop from center paddle bounce

When the ball hits the dead center of the paddle in Breakout, the bounce angle can compute 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 forever — no bricks hit, no game progress. Both diff * 1.8 angle mapping and zone-based angle arrays (middle zone = 0°) have this bug.

Fix: enforce a minimum horizontal velocity (60px/s) in hitPaddle(). After computing the bounce velocity, check if |vx| < 60 and boost it to 60 while recalculating vy to preserve total speed:

let vx = Math.cos(rad) * speed;
let vy = Math.sin(rad) * speed;
if (Math.abs(vx) < 60) {
  vx = vx >= 0 ? 60 : -60;
  vy = -(Math.sqrt(Math.max(0, speed * speed - vx * vx)));
}
ball.body.setVelocity(vx, vy);

Testing for infinite loops

To detect vertical-loop bugs, place the ball above the paddle center with zero horizontal velocity and run 300 accelerated simulation frames. If the fix is missing, vx stays 0 and the ball never hits bricks. Test pattern:

// 1. Position ball above paddle center, velocity straight down
await page.evaluate(() => {
  sc.ball.setPosition(paddle.x, paddle.y - 80);
  sc.ball.body.setVelocity(0, 400);
  sc.gameStarted = true;
});
await page.waitForTimeout(500);  // ball falls, hits, bounces up

// 2. Verify horizontal velocity after bounce
check('vx != 0 after center hit', true, Math.abs(s.bvx) > 0);

// 3. Simulate 300 frames of gameplay
// (manual position stepping with wall/collision detection)
// 4. Verify score > 0 or bricks hit — proves no infinite loop
check('Game progressed', true, s.score > 0 || s.bricks < 50);

This pattern also works for any physics game where a projectile can get stuck in a dead-zone bounce (Pong, Arkanoid clones).

Running Tests

# Start dev server (required for Playwright tests)
cd aigamingdev.com && npm run dev

# Run all game tests
node scripts/gun-fight-test.cjs

# Run structural verifier on a specific game
python3 scripts/verify-game-step.py public/games/gun-fight/gun-fight-01.html

# Run structural verifier on all games
python3 scripts/verify-game-step.py public/games/

The dev server must be running locally for Playwright tests (they load localhost:4321). Structural checks work on file content directly and don't need a server.

References