Test a three.js game with Claude Code

How Claude Code tests a three.js game with the paprika3d plugin. It opens the game in headless Chrome on a paused clock, looks at it, presses keys on exact frames and reads the state back as JSON through window.__game.

The problem

Claude Code can write a three.js game in one go, but the game is a single <canvas>. From the outside it is a picture: Claude Code has no way to see it, press its keys or read its score unless something runs the page for it. Animation frames also follow the display's refresh rate and the machine's load, so a key held for "half a second" moves the player a different distance on each run, and a test written that way gives a different answer each time.

paprika3d is a Claude Code plugin and MCP server that runs the game in its own headless Chrome on a paused clock, where each frame is 1/60 s of game time. With it installed, Claude Code tests a game in a short loop of tool calls: game_open loads the page, game_look pictures it and lists the objects on screen, game_act presses keys on exact frames, and game_observe reads the game's state as JSON.

Install

paprika3d needs Google Chrome and Node 20.11 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

Chrome is found at its usual macOS path; on Linux or Windows, set PAPRIKA3D_CHROME to the Chrome executable. Then ask Claude Code for the game, or to test a game you already have, in your own words. The plugin's skill has Claude Code open and play the page as it builds. The rest of this guide shows the calls it makes and what comes back.

The game

Keys work on any page, since paprika3d sends them as real browser events. To get the state back as JSON, the game exposes window.__game with describe(), observe(), act(input) and setState(partial), as described on the convention page. Three habits make a game testable:

This page moves a ball with the arrow keys and jumps on Space:

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Jumper</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

// Game state lives in plain variables, apart from the meshes that draw it.
const SPEED = 3, JUMP = 6, GRAVITY = 18;   // units per second, units per second, units per second squared
const held = new Set();
let x = 0, y = 0, vy = 0, jumps = 0;

function step(dt) {
  x += ((held.has('ArrowRight') ? 1 : 0) - (held.has('ArrowLeft') ? 1 : 0)) * SPEED * dt;
  vy -= GRAVITY * dt;
  y = Math.max(0, y + vy * dt);
  if (y === 0) vy = Math.max(0, vy);
}

// One input path for real keys and for the harness.
function act({ key, down = true }) {
  if (key === ' ' && down && y === 0) { vy = JUMP; jumps++; }   // one-shot: acts on keydown, and only on the floor
  if (down) held.add(key); else held.delete(key);
}
addEventListener('keydown', e => act({ key: e.key }));
addEventListener('keyup', e => act({ key: e.key, down: false }));

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x182030);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 100);
camera.position.set(0, 2, 9);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2));
const floor = new THREE.Mesh(new THREE.BoxGeometry(12, 0.2, 2), new THREE.MeshStandardMaterial({ color: 0x4a6a4a }));
floor.name = 'floor';
floor.position.y = -0.6;
scene.add(floor);
const ball = new THREE.Mesh(new THREE.SphereGeometry(0.5, 32, 16), new THREE.MeshStandardMaterial({ color: 0xff8030 }));
ball.name = 'ball';
scene.add(ball);
addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});

let last = null;
renderer.setAnimationLoop((now) => {
  step(last === null ? 1 / 60 : Math.min((now - last) / 1000, 0.1));   // never more than 0.1 s a frame
  last = now;
  ball.position.set(x, y, 0);
  renderer.render(scene, camera);
});

const round = (n) => +n.toFixed(3);
window.__game = {
  describe: () => ({ name: 'jumper', units: 'world units, y up', keys: { ArrowRight: 'hold to move right', ArrowLeft: 'hold to move left', ' ': 'jump' } }),
  observe: () => ({ x: round(x), y: round(y), vy: round(vy), jumps }),
  act,
  setState: (s) => { ({ x = x, y = y, vy = vy, jumps = jumps } = s); },
};
</script>
</body></html>

Save it as index.html in the project folder.

The tool calls, in order

Each call is followed by the reply paprika3d gave for this page on 2026-10-16. Run folder paths are shortened to <project>.

1. Open the game. game_open serves the folder, loads the page and pauses its clock at frame 0. With window.__game present, the reply carries the game's own describe().

game_open {"url": "index.html", "cwd": "<project>"}

Opened http://127.0.0.1:32969/index.html in 0.3 s, paused at frame 0.
Page text outside the canvas: none
{"name":"jumper","units":"world units, y up","keys":{"ArrowRight":"hold to move right","ArrowLeft":"hold to move left"," ":"jump"},"hasConvention":true}
Procedural geometry: import { geo, scatter } from "./geo.js" (see the skill's geometry reference).
Effects: import { stage, tex, place, sfx, burst, shake, uv } from "./fx.js" (lights and camera, canvas textures, placement, sound, particles; seeded, no files).
Next: game_look to see it, then game_act for each interaction the request names.

Run folder: <project>/paprika3d/runs/2026-10-16T23-20-52-829Z

Open once. After Claude Code edits the game, the next tool call reloads the page by itself.

2. Look at it. game_look saves a screenshot and lists every named object on screen with its share of the picture and its box in pixels.

game_look {}

Picture: <project>/paprika3d/runs/2026-10-16T23-20-52-829Z/look-1.png
Pixels: brightness 13%, colour saturation 50%, 0% near black, 0% blown out.
Hues: blue 91%, green 9%.
Canvas: 1280x800 buffer for 1280x800 CSS at DPR 1 (renderer pixel ratio 1, no cap); resize handler ran.
On screen (2):
floor 8.7% [0, 614, 1279, 689]
ball 0.71% [592, 542, 687, 639]
Never visible: none

3. Read the starting state. game_observe returns observe() plus a harness block: the frame, renderer counts, console errors since the last observe, and what changed.

game_observe {}

{"x":0,"y":0,"vy":0,"jumps":0,"harness":{"frame":1,"renderer":{"calls":2,"triangles":972,"textures":1,"geometries":2,"programs":2},"scene":{"objects":3,"meshes":2,"lights":1,"cameras":0},"errors":[],"changed":null}}

4. Hold a key for a set number of frames. Each step advances frames frames (default 1). Holding ArrowRight for 30 frames is half a second of game time, at 3 units per second:

game_act {"steps": [{"key": "ArrowRight", "frames": 30}, {"key": "ArrowRight", "down": false}]}

{"changed":{"x":1.5}}

The reply holds what changed and its new value. The ball moved 1.5 units, as the speed says it should.

5. Jump, stop at the top, and look in the same call. A key goes down on one frame and up on the next; wait steps frames with no input. With a jump speed of 6 and gravity of 18, the ball stops rising a third of a second after the jump, which is where these steps end. look: true adds the picture after the steps to the reply:

game_act {"steps": [{"key": " "}, {"key": " ", "down": false}, {"wait": 18}], "look": true}

{"changed":{"y":0.95,"jumps":1}}
Picture: <project>/paprika3d/runs/2026-10-16T23-20-52-829Z/look-2.png
Pixels: brightness 14%, colour saturation 50%, 0% near black, 0% blown out; 2% of the picture changed since the last look.
Hues: blue 90%, green 9%.
On screen (2):
floor 8.8% [0, 614, 1279, 689]
ball 0.71% [735, 452, 831, 547]
Never visible: none

The state says the ball is up and to the right, and the ball's box moved up and to the right in the picture, so the game draws what its state says.

6. Test a rule by hand: no jump in mid-air. Press Space again at the top of the jump and let the ball fall:

game_act {"steps": [{"key": " "}, {"key": " ", "down": false}, {"wait": 40}]}

{"changed":{"y":0}}

jumps is not in the reply, so it is still 1: the second press was ignored, and the ball landed. Sending the same steps after a reload gives the same values on the same frames, since the clock and Math.random are under the harness's control.

What to read in the reply

Pitfalls

Without paprika3d

A browser test can drive the same game. With Playwright or Puppeteer, page.keyboard.down('ArrowRight') and page.keyboard.up('ArrowRight') send real key events, and page.evaluate(() => window.__game.observe()) reads the state. To make the result repeatable, the test also needs control of time: Playwright's page.clock API can install a fake clock and advance it, or the game can expose a step(dt) function the test calls directly with a fixed 1/60 s. For logic alone, keeping the rules in a module with no three.js imports lets a unit test runner such as Vitest call step and act with no browser at all.