Playtesting games with AI before players do

By GamesByAI · Published · Updated · 13 sources

On this page

A coding agent can tell you a game works when it has never actually seen it run, just because the file saved. That’s why Anthropic’s Claude Code guide says to give the agent “a check it can run: tests, a build, a screenshot to compare”. The same page boils it down to “If you can’t verify it, don’t ship it” (best practices).

For a game, that check is a playtest the agent can run by 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.

1. Make the game testable

Three changes to the game make every later step possible, and you can ask for all of them in one prompt.

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 moves forward in steps of, say, 1/60 of a second, whatever the frame rate is. That way a test can step time exactly, and a round plays out the same way on a fast laptop and a slow phone. Glenn Fiedler’s Fix Your Timestep is the classic explanation if you want the details.

Seed the random numbers. With one generator and a seed in the URL, as in ?seed=42, enemies spawn the same way every run. That’s really handy, because a bug you saw once replays on demand.

Expose a test hook. It’s just 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,
  });
  // Whole frames only (game.step is in ms): summing 16.67 ms steps up to 1000 ms would run 61 frames, not 60.
  window.advanceFrames = (n) => {
    for (let i = 0; i < n; i++) game.update(game.step);
    game.render();
  };
  window.advance = (ms) => window.advanceFrames(Math.round(ms / game.step));
}

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 your tests will depend on it like any other interface.

2. Test the rules without a browser

Game rules are really just 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 for each rule a player would notice if it broke. Since they’re so fast, a Claude Code Stop hook can even run them before every turn ends and block the stop until they pass (best practices). Claude Code skills for game development has that hook.

3. Automate one full round

Next comes 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 is there for the black-screen bug. With a fixed seed and a paused loop, the menu frame doesn’t change from run to run. The first run reports a missing snapshot and writes the baseline, and later runs compare against it. When you change the look on purpose, npx playwright test --update-snapshots replaces it.

Make the baselines on the machine that runs the tests, though, 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, which is a pretty sneaky way for a file to go missing.

The phaser4-gamedev playtest kit checks for exactly that, next to black screens, dead scenes and frame-rate collapse (phaser4-gamedev). If yours does that, it’s worth also checking that images come back with an image content type.

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, and it doesn’t need any extra tools for that.

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).

Turning on --caps=vision adds coordinate clicks for canvas and WebGL games (vision mode). Even better, 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). And ask it for a report, not 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.

If you work in Godot, community MCP servers do pretty much 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).

5. Use bots to measure balance

Once the rules run without a browser, a simple bot can play hundreds of rounds per level. Give it a policy, something like “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}%`);
}

What comes out is basically a difficulty curve. If 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 does show you the moment a tuning change bends the curve.

Measure frame time and play on a real phone

Frame time. At 60 frames per second, each frame gets 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 the long frames (Chrome DevTools MCP).

Phones. Playwright’s device emulation sets a phone viewport, touch and user agent (emulation). It’s worth adding a second smoke test that taps instead of pressing keys, because 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 you two isolated players (browser contexts), and DevTools has been able to throttle WebSocket connections since Chrome 99 (DevTools network). Automate this part early, because it takes a lot of time by hand. 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).

What the agent can’t test

A test can’t tell you whether the game is fun. A bot can prove the jump works, but it has no way of knowing the jump feels floaty to a person.

So 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 after that.

Once you publish, players take over. On GamesByAI, the top rated list starts from jam placements, and player votes move games from there.

Make the checks run themselves

Claude Code skills for game development adds a /playtest skill and a test hook that runs on stop. MCP servers for game development gets Playwright and Chrome DevTools MCP installed, and make a multiplayer game with AI covers netcode and how to test it with two players.

Games to look at

Questions

Can AI playtest a game?

It can play it and tell you what breaks. With a browser automation tool like Playwright, a coding agent can load the game, press keys, read console errors and take screenshots. What it can't tell you is whether the game is fun, and that still takes human players.

How does an AI agent see what happens inside a canvas game?

There are 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, for the agent to read directly. The second is faster, since 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. Between them, 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. Then join the same room from both and check that each one sees the other's moves.