Game Testing Guide
How we test browser-based Phaser games — from Playwright headless tests to structural verification. A reference for the testing infrastructure across all AI Gaming Dev builds.
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:
| Layer | Tool | Catches |
|---|---|---|
| Headless Playwright | scripts/<game>-test.cjs | Game logic bugs: collision, scoring, AI, reset, game-over |
| Structural verifier | scripts/verify-game-step.py | Code 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
| Category | What it verifies | Example assertion |
|---|---|---|
| Load | Game starts without errors | canvas exists, score 0-0 |
| Fire → hit | Collision detection works | s1=1, gameOver=false |
| Score progression | Multiple hits accumulate | s1 >= 2, gameOver=false |
| Game over | Win condition triggers | s1=5, gameOver=true |
| AI behavior | AI tracks X and Y | P2 tracked X, P2 tracked Y |
| Respawn | Players re-enabled after death | body.enabled=true, alpha=100% |
| Bot mode | Both AIs play | score > 0 after 8s |
| Edge cases | Infinite-loop guards, center hits | vx != 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:
| Check | Guards against |
|---|---|
| Script tag balance | Double <script> or missing </script> |
Phaser.Game present | Missing game instantiation |
Scale.FIT present | Raw 680x480 on large screens |
No __DEFAULT texture | Invisible sprites |
| No line-number prefixes | 12| corruption from read_file |
No [truncated] artifact | Truncated content in JS |
JustDown() for fire | One-shot vs held-key bug |
| Bot mode check | Missing ?bot URL parsing |
gameOver guards | Callbacks firing after win |
</style> balance | Astro build failure |
body color set | Invisible text on dark bg |
Industry Context: 11 Testing Types
| Testing Type | Industry Definition | Our Implementation |
|---|---|---|
| Functional | Features work per spec | Playwright tests verify collision, scoring, win |
| Performance | Behavior under load | Max bullet limits, off-screen cleanup, FPS |
| Ad-hoc | No docs, tester instinct | Manual play-through before deploy |
| Exploratory | Charter-based testing | gun-fight-debug.html with live console |
| Regression | No new bugs from changes | Re-run full test suite after every change |
| Smoke | New build stability | npm run build + verify before suite |
| Compatibility | Works across platforms | Phaser.Scale.FIT with CENTER_BOTH |
| Usability | Player experience | Controls text, restart instructions, clear states |
| Accessibility | Inclusive design | Player labels (BLUE/RED/YOU/AI) |
| Localization | Multi-language | N/A (English-only games) |
| Compliance | Platform requirements | N/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
| Game | Playwright Tests | Structural Checks | Status |
|---|---|---|---|
| Breakout (step 1) | ✓ 48 assertions | ✓ verify-game-step.py | Reference impl |
| Gun Fight (step 1) | ✓ 42 assertions | ✓ verify-game-step.py | Reference impl |
| Pong (5 steps) | None | ✓ verify-game-step.py | GAP — needs tests |
Next Steps
- Backfill Pong tests — paddle movement, ball bounce, AI tracking, scoring, win
- CI integration — auto-run tests on every game build (pre-commit or cron)
- Per-step regression — each new step re-runs all previous step tests
- 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:
window.__scene = thisincreate()— enables Playwright to read game stateScale.FITin Phaser config — required for responsive renderingJustDown()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:
- PvP mode: Player disappears after "respawn" (invisible) and can't be hit again (body disabled). Game stalls.
- AI mode: AI player never respawns. Score stuck at 0-1. No more collisions.
- Bot mode: Both AIs invisible and body-disabled. Score stops at any value, game doesn't progress.
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
- Basics of Game Testing — TestDevLab — ISTQB-based game testing framework
- Game Testing Guide — SnoopGame — Development stage mapping
- Gun Fight Test Script — 42-assertion Playwright reference