Playtesting games with AI: let the agent play
A coding agent can report success on a game it has never seen run, because the file saved. Anthropic’s Claude Code guide says to give the agent “a check it can run: tests, a build, a screenshot to compare.” Its short version: “If you can’t verify it, don’t ship it” (best practices).
For a game, that check is a playtest the agent can run itself. This guide builds one for a browser game in five steps, in plain JavaScript that works the same with Three.js, Phaser, PixiJS or a bare canvas.
Step 1: make the game testable
Three changes to the game make every later step possible, and one prompt can ask for all of them:
Make the game testable. Run the simulation in fixed steps of 1/60 of a second, separate from rendering. Use one seeded random generator for everything, seeded from ?seed= in the URL. When the URL has ?test=1, pause the loop and expose window.gameState() and window.advance(ms).
Separate update from render, on a fixed timestep. The simulation advances in steps of, say, 1/60 of a second, whatever the frame rate. Glenn Fiedler’s Fix Your Timestep is the classic explanation. A test can then step time exactly, and a round plays out the same way on a fast laptop and a slow phone.
Seed the random numbers. With one generator and a seed in the URL, as in ?seed=42, enemies spawn the same way every run, so a bug you saw once replays on demand.
Expose a test hook. A few functions on window, only in development builds:
// src/test-hooks.js: loaded only when the URL has ?test=1
export function installTestHooks(game) {
game.pauseLoop(); // time moves only when a test says so
window.gameState = () => ({
scene: game.scene, // 'menu', 'playing', 'gameover'
score: game.score,
lives: game.lives,
player: { x: Math.round(game.player.x), y: Math.round(game.player.y) },
enemies: game.enemies.length,
});
window.advance = (ms) => {
for (let t = 0; t < ms; t += game.step) game.update(game.step);
game.render();
};
}
gameState() hands the agent a few hundred bytes of JSON instead of a screenshot to interpret, and advance() gives it a clock. Keep the state small and stable, because tests will depend on it like any other interface.
Step 2: test the rules without a browser
Game rules are code like any other. Collision, scoring, damage and level-ups are pure functions of state, so they test in milliseconds with Vitest or Jest:
import { test, expect } from 'vitest';
import { createGame } from '../src/game.js';
test('touching a coin adds 10 points and removes the coin', () => {
const game = createGame({ seed: 42 });
game.player.x = game.coins[0].x;
game.player.y = game.coins[0].y;
game.update(1 / 60);
expect(game.score).toBe(10);
expect(game.coins).toHaveLength(9);
});
Write one test per rule the player would notice if it broke. Because they run fast, a Claude Code Stop hook can run them before every turn ends and block the stop until they pass (best practices). Claude Code skills for game development has that hook.
Step 3: a smoke test that plays one round
Now the whole game, in a real browser. This Playwright test loads the game, checks the first frame, plays a round and fails on anything suspicious:
import { test, expect } from '@playwright/test';
test('a round starts, scores and ends cleanly', async ({ page }) => {
const problems = [];
page.on('pageerror', (e) => problems.push(e.message));
page.on('console', (m) => m.type() === 'error' && problems.push(m.text()));
page.on('response', (r) => r.status() >= 400 && problems.push(`${r.status()} ${r.url()}`));
await page.goto('http://localhost:5173/?test=1&seed=42');
await page.waitForFunction(() => window.gameState?.().scene === 'menu');
await page.evaluate(() => window.advance(0)); // draw one frame
await expect(page).toHaveScreenshot('menu.png', { maxDiffPixels: 100 });
await page.keyboard.press('Space');
await page.evaluate(() => window.advance(500));
expect(await page.evaluate(() => window.gameState().scene)).toBe('playing');
const before = await page.evaluate(() => window.gameState().player.x);
await page.keyboard.down('ArrowRight');
await page.evaluate(() => window.advance(1000));
await page.keyboard.up('ArrowRight');
expect(await page.evaluate(() => window.gameState().player.x)).toBeGreaterThan(before);
await page.evaluate(() => window.advance(120_000)); // idle until the round ends
expect(await page.evaluate(() => window.gameState().scene)).toBe('gameover');
await page.screenshot({ path: 'test-results/gameover.png' });
expect(problems).toEqual([]);
});
The screenshot assertion catches the black-screen bug. With a fixed seed and a paused loop the menu frame is the same every run. The first run reports a missing snapshot and writes the baseline, and later runs compare against it. After a deliberate visual change, npx playwright test --update-snapshots replaces it. Make baselines on the machine that runs the tests, because rendering varies with the OS, browser version and hardware (visual comparisons).
The response listener catches missing files, with one blind spot. Some dev servers answer a missing asset with index.html and a 200 status, and the phaser4-gamedev playtest kit checks for exactly that, next to black screens, dead scenes and frame-rate collapse (phaser4-gamedev). If your server does it, also check that images come back with an image content type.
Step 4: let the agent play
With the hook and the test in place, the agent has two ways to play.
Run the test. After a change, the agent runs npx playwright test, reads the failure and the screenshot, and fixes the code. It needs no extra tools.
Drive the browser itself. For exploratory play, connect Playwright MCP. Its normal mode reads the page’s accessibility tree, which a canvas doesn’t have, so Playwright’s docs suggest combining snapshots with screenshots for canvas apps (snapshots).
The --caps=vision option adds coordinate clicks for canvas and WebGL games (vision mode). Better still, have the agent call browser_evaluate with window.gameState() after each burst of input, so it reads the state instead of squinting at pixels (README). Ask for a report and no fixes:
Open the game with ?test=1&seed=7 and play three rounds. After each burst of input, read window.gameState(). Don’t change any code. Report what you did, what the state said and anything that looked wrong, with screenshots.
Fix what it finds in a separate message, so the report stays a record of the build it played.
For Godot, community MCP servers do the same job. satelliteoflove/godot-mcp (MIT, Godot 4.5+) can freeze the game clock, step exact slices of game time and inject input (README).
Step 5: bots that measure balance
Once the rules run without a browser, a simple bot can play hundreds of rounds per level. Give it a policy, such as “move toward a coin, jump when an enemy is close”, and run it across a hundred seeds:
// balance.mjs: a bot plays 100 seeds per level, no browser needed
import { createGame } from './src/game.js';
const bot = (game) => {
const coin = game.coins[0] ?? game.player; // no coins left: stand still
game.input.left = coin.x < game.player.x;
game.input.right = coin.x > game.player.x;
game.input.jump = game.enemies.some((e) => Math.abs(e.x - game.player.x) < 40);
};
for (const level of [1, 2, 3]) {
let score = 0;
let survived = 0;
for (let seed = 1; seed <= 100; seed++) {
const game = createGame({ seed, level });
for (let t = 0; t < 120 && game.scene === 'playing'; t += 1 / 60) {
bot(game);
game.update(1 / 60);
}
score += game.score;
if (game.lives > 0) survived++;
}
console.log(`level ${level}: average score ${score / 100}, survived ${survived}%`);
}
The output is a difficulty curve. When level 3 comes out easier than level 2 for the bot, check that level with a person before you ship. The bot can’t tell you what’s fun, but it shows the moment a tuning change bends the curve.
Performance and phones
Frame time. At 60 frames per second, a frame has 16.7 milliseconds. Chrome DevTools MCP records a performance trace and can throttle the CPU, so the agent can play ten seconds on a simulated slow phone and report long frames (Chrome DevTools MCP).
Phones. Playwright’s device emulation sets a phone viewport, touch and user agent (emulation). Add a second smoke test that taps instead of pressing keys. It catches a game whose keyboard controls work while a phone gets a start button and nothing else.
Multiplayer. Two browser contexts in one test give two isolated players (browser contexts), and DevTools has throttled WebSocket connections since Chrome 99 (DevTools network). One creator who built a multiplayer shooter with AI called multiplayer playtesting and feeding problems back to the model “the most time consuming part of the process” (Kulkarni). Automate it early.
What the agent can’t test
Fun. A bot can prove the jump works; it can’t tell you the jump feels floaty. Watch one real person play for five minutes without helping, and write down where they hesitate, die or quit. Game feel with AI covers what to change next.
After you publish, players take over. On GamesByAI the top rated list is ordered by player votes.
Next: make the checks automatic
- Claude Code skills for game development: a
/playtestskill and a test hook that runs on stop. - MCP servers for game development: Playwright and Chrome DevTools MCP, installed.
- Make a multiplayer game with AI: netcode, and how to test it with two players.
Games to look at
Null Range
Fly a first-person spacecraft and dogfight other pilots online
WenWare
Guess the place and year of a 360-degree historical scene.
HALDANE-4
Descend alone beneath the Antarctic ice and uncover what waits below in ASCII.
Fanto's Mega-Mart
Race through a haunted grocery store at rocket speed, grabbing items as you go.
Questions
Can AI playtest a game?
It can play it and report what breaks. With a browser automation tool such as Playwright, a coding agent can load the game, press keys, read console errors and take screenshots. It can't tell you whether the game is fun; that still takes human players.
How does an AI agent see what happens inside a canvas game?
Two ways. It can take screenshots and click by coordinates, which Playwright MCP's vision capability supports. Or the game can expose a small function that returns its state as JSON, such as score, lives and player position, which the agent reads directly. The second is faster, and the agent reads numbers instead of interpreting pixels.
What should an automated playtest check first?
That the game loads without console errors or failed file requests, that the first frame isn't blank, that input changes the game state, and that a round can end. Together those checks catch a game that no longer loads, draws, responds or finishes.
How do I test a multiplayer browser game automatically?
Open two isolated browser contexts in one Playwright test, one per player, so each has its own storage. Join the same room from both and check that each sees the other's moves.