Engine recipes
Each recipe is a minimal game and test that keiko's own engine checks run: a box that moves right while ArrowRight is held and counts clicks. Copy the hooks into your game and adapt the state.
Tested versions: Phaser 4.2 and 3.x, PixiJS 8.22, KAPLAY 3001.0, Excalibur 0.32, three.js r186, debuff 0.3.
A shared test works for all of them:
// tests/engine.test.ts
import { expect, play, test } from "keiko";
const ready = (g) => g.state !== undefined;
test("moves right while ArrowRight is held", async () => {
const game = await play({ ready });
const start = game.state.x;
game.hold("ArrowRight", "1s");
expect(game.state.x - start).toBeBetween(100, 130);
});
test("a click reaches the game", async () => {
const game = await play({ ready });
game.tap(160, 90).wait(2);
expect(game.state.clicks).toBe(1);
});
test.deterministic("plays the same twice", (game) => game.hold("ArrowRight", 20).wait(10), { play: { ready } });ready waits until the hooks report state, since engines often build their scenes a frame or two after start.
Real games
Keiko was also run against open-source games it had never seen, adding only a window.__KEIKO__ hook to each:
| Game | Engine | What the tests check | Deterministic |
|---|---|---|---|
| Runner | Phaser 3 | score, jump, click to jump, game over | ✔ |
| Starshake | Phaser 3 | start, ship speed, shooting | ✔ |
| Puzzling Potions | PixiJS 8 | Play opens a full board | ✔ |
| Bubbo Bubbo | PixiJS 8 | Play reaches the game | ✘ (why) |
Games with async UI flows (press Play, a tween finishes, the next screen loads its assets) use run() and eventually(); see Async flows.
Hooking up an existing game
The engine recipes above are fresh games. With a game you didn't write, the hook is the only code you add. These four rules covered every real game above.
- Start from the object that knows the current screen. In Phaser that's
game.scene. In PixiJS games it's usually a navigation or screen manager the game already has. Expose that object's current screen asscene. - Return
undefineduntil the screen is built. Thenplay({ ready: (g) => g.state !== undefined })waits for it, and you don't need to guess at frame counts. - Expose only what tests assert. Score, position, a flag or two. Round positions, so small float differences don't show up as diffs.
- Expose button positions in page pixels. Games scale the canvas to fit the window, so
tap(x, y)uses page (CSS) pixels. Put the button's bounds in the state and tap its middle.
Phaser: read the active scene
const game = new Phaser.Game(config);
if (import.meta.env.DEV) {
window.__KEIKO__ = {
scene: () => game.scene.getScenes(true)[0]?.scene.key,
state: () => {
const s = game.scene.getScene("game");
if (!s?.player?.body) return undefined; // not built yet
return { score: s.score, y: Math.round(s.player.y), onGround: s.player.body.blocked.down };
},
};
}PixiJS: read the screen manager
if (import.meta.env.DEV) {
window.__KEIKO__ = {
scene: () => navigation.currentScreen?.constructor.name,
state: () => {
const screen = navigation.currentScreen;
const toPage = app.canvas.clientWidth / app.renderer.width; // stage pixels → page pixels
const b = screen?.playButton?.getBounds();
return {
screen: screen?.constructor.name,
playButton: b && { x: (b.x + b.width / 2) * toPage, y: (b.y + b.height / 2) * toPage },
score: screen?.game?.score ?? null,
};
},
};
}Then a test presses Play the way a player would:
const game = await play({ ready: (g) => g.state?.playButton != null });
const { x, y } = game.state.playButton;
game.tap(x, y);
await game.eventually((s) => s.screen === "GameScreen", { within: "5s" });Use eventually() rather than until() here: screens often await a transition tween or load their assets before they appear.
Phaser
import Phaser from "phaser";
class Play extends Phaser.Scene {
constructor() { super("play"); }
create() {
this.player = this.add.rectangle(160, 90, 16, 16, 0xcffb2d);
this.cursors = this.input.keyboard.createCursorKeys();
this.clicks = 0;
this.input.on("pointerdown", () => this.clicks++);
}
update(_time, delta) {
if (this.cursors.right.isDown) this.player.x += (120 * delta) / 1000;
}
}
const game = new Phaser.Game({ type: Phaser.AUTO, width: 320, height: 180, scene: [Play] });
if (import.meta.env.DEV) {
window.__KEIKO__ = {
state: () => {
const s = game.scene.getScene("play");
return s?.player ? { x: Math.round(s.player.x), clicks: s.clicks } : undefined;
},
scene: () => game.scene.getScenes(true)[0]?.scene.key,
};
}Phaser reads pointer positions from pageX, which keiko fills in, so game.tap(x, y) lands at (x, y) in the scene. For deterministic randomness, seed Phaser's generator: Phaser.Math.RND.sow(["seed"]).
PixiJS
import { Application, Graphics } from "pixi.js";
const app = new Application();
await app.init({ width: 320, height: 180 });
document.querySelector("#app").appendChild(app.canvas);
const box = new Graphics().rect(0, 0, 16, 16).fill(0xcffb2d);
box.x = 160;
app.stage.addChild(box);
app.stage.eventMode = "static";
app.stage.hitArea = app.screen;
let clicks = 0;
app.stage.on("pointerdown", () => clicks++);
const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.code));
addEventListener("keyup", (e) => keys.delete(e.code));
app.ticker.add((t) => {
if (keys.has("ArrowRight")) box.x += (120 * t.deltaMS) / 1000;
});
if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(box.x), clicks }) };PixiJS hit-tests against what it last rendered; keiko plays the first frame before your test starts, so taps work straight away. The first PixiJS test in a run takes a few seconds while its modules load.
KAPLAY
import kaplay from "kaplay";
const k = kaplay({ width: 320, height: 180, global: false });
const box = k.add([k.rect(16, 16), k.pos(160, 90)]);
let clicks = 0;
k.onMousePress(() => clicks++);
k.onUpdate(() => {
if (k.isKeyDown("right")) box.pos.x += 120 * k.dt();
});
if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(box.pos.x), clicks }) };KAPLAY listens for keys on its canvas; keiko sends keys there when nothing else has focus. It loads built-in assets at start, which play() waits for.
Excalibur
import * as ex from "excalibur";
const game = new ex.Engine({ width: 320, height: 180, suppressPlayButton: true, displayMode: ex.DisplayMode.Fixed });
const box = new ex.Actor({ x: 160, y: 90, width: 16, height: 16, color: ex.Color.fromHex("#cffb2d") });
game.add(box);
let clicks = 0;
game.input.pointers.primary.on("down", () => clicks++);
box.on("preupdate", (e) => {
if (game.input.keyboard.isHeld(ex.Keys.Right)) box.pos.x += (120 * e.elapsed) / 1000;
});
await game.start();
if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(box.pos.x), clicks }) };suppressPlayButton skips Excalibur's "click to start" screen, which a test would otherwise have to click.
three.js
import * as THREE from "three";
const renderer = new THREE.WebGLRenderer();
renderer.setSize(320, 180);
document.querySelector("#app").appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(0, 320, 180, 0, -1, 1);
const box = new THREE.Mesh(new THREE.PlaneGeometry(16, 16), new THREE.MeshBasicMaterial({ color: 0xcffb2d }));
box.position.x = 160;
scene.add(box);
let clicks = 0;
renderer.domElement.addEventListener("pointerdown", () => clicks++);
const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.code));
addEventListener("keyup", (e) => keys.delete(e.code));
let last;
renderer.setAnimationLoop((t) => {
const dt = last === undefined ? 0 : (t - last) / 1000;
last = t;
if (keys.has("ArrowRight")) box.position.x += 120 * dt;
renderer.render(scene, camera);
});
if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(box.position.x), clicks }) };three.js needs WebGL; keiko's WebGL context accepts every call, so scenes, materials and render loops run, with nothing drawn.
A hand-written loop
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const keys = new Set();
addEventListener("keydown", (e) => keys.add(e.key));
addEventListener("keyup", (e) => keys.delete(e.key));
let x = 160;
let last;
function frame(now) {
const dt = last === undefined ? 0 : (now - last) / 1000;
last = now;
if (keys.has("ArrowRight")) x += 120 * dt;
ctx.fillRect(x, 90, 16, 16);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
if (import.meta.env.DEV) window.__KEIKO__ = { state: () => ({ x: Math.round(x) }) };debuff
debuff games need nothing extra. Keiko detects them from package.json and drives them through debuff's dev tools, which every template installs:
if (import.meta.env.DEV) game.use(devTools({ input }));game.state is the active scene's inspect(), steps are debuff's fixed steps, and params and commands are debuff's (game.params.define, devTools({ commands })). Run tests with npx debuff test; agents run them through the MCP server's run_tests tool. New projects include example tests. See Logic tests in debuff.