本文へスキップ

Keiko でロジックテスト ​

Keiko(稽古)は、あなたの本物のゲームを Node で動かします。ブラウザは使わず、何も描画しません。時間、入力、乱数はテストが指示したときにしか進まないので、毎回まったく同じ展開になり、典型的なテストは数十ミリ秒で終わります。

ts
// tests/movement.test.ts
import { expect, play, test } from "keiko";

test("buff reaches top speed within a quarter second", async () => {
  const game = await play();
  game.press("ArrowRight");
  expect(game.until((s) => s.player.vx >= 140)).toBeAtMost(15);
});

ゲームが どう振る舞うか(動き、タイミング、ルール、シーン、パラメータ)にはロジックテストを使いましょう。どう見えるか にはシナリオを使います。こちらは本物のブラウザでスクリーンショットを比較します。新しいプロジェクトには両方が入っていて、npm test はまずロジックテスト、次にシナリオを実行します。

Keiko は独立したパッケージで、debuff に限らずどんな Web ゲームでも使えます。このページでは debuff のプロジェクトでの使い方を説明します。完全なガイドとリファレンスは Keiko のドキュメントにあります。

最初のテスト ​

npm create debuff で作った新しいプロジェクトには、最初から例の入った tests/ があります。古いプロジェクトでは、次を実行します。

bash
npm install -D keiko

続けて tsconfig.json の include に "tests" を追加し、tests/game.test.ts を作ります。

ts
import { expect, play, test } from "keiko";

test("the game starts in the play scene", async () => {
  const game = await play();
  expect(game.scene).toBe("play");
});

play() は、index.html が読み込むコードを npm run dev と同じ Vite の設定で、新しいヘッドレスページに起動します。ゲームはテストがプレイを始めるまで tick 0 で一時停止しています。game.state はシーンの inspect() が返すものそのものなので、テストしたいものをそこに入れておきましょう。

ts
inspect: () => ({ player: { x: Math.round(player.x), vx: Math.round(player.vx) }, score }),

補完を効かせたいなら、play に型を渡します:play<{ player: { x: number; vx: number }; score: number }>()。

ゲームをプレイする ​

プレイする呼び出しはすべて game を返すので、短いスクリプトは 1 行で書けます。

ts
game.press("ArrowRight").wait("0.5s").release("ArrowRight");
game.hold("ArrowLeft", 30);      // 押して、30 ステッププレイして、離す
game.tap("Space");               // 1 ステップだけ押す
game.tap(160, 90);               // 論理座標でポインターを押す
game.set("player.speed", 300);   // パラメータ。範囲に対して検証されます
game.command("warp", "hollow");  // devTools({ commands }) のコマンド

長さはステップ数(30)か時間("0.5s"、"250ms")で指定します。デフォルトの 1 秒 60 ステップなら、"0.5s" は 30 ステップです。

ゲームらしい問いを立てる ​

スナップショットからわかるのは、物がどこにあるかです。でもゲームでは、物がどう 動くか を知りたいことがほとんどです。

どのくらいで? until は条件が満たされるまでプレイし、かかったステップ数を返します。いつまでも満たされなければ、最後の状態とともに失敗します。

ts
game.press("ArrowRight");
const steps = game.until((s) => s.player.vx >= 140, { within: "1s" });
expect(steps * game.stepSeconds).toBeBelow(0.25);

絶対に起きない? during は毎ステップ後に不変条件をチェックし、破れたステップで失敗します。

ts
game.press("ArrowLeft");
game.during("3s", (s) => s.player.x >= 8, "buff stays on screen");

どのくらい高く? どのくらい速く? trace は値を毎ステップ記録します。

ts
game.press("Space");
const y = game.trace("player.y", "1.5s");
expect(y.start - y.min()).toBeAbove(30); // 少なくとも 30 px 上がる(y は下向きに増える)
expect(y.last).toBe(y.start);            // そして踏み切った位置に着地する

trace には start、first、last、at(i)、min()、max()、range()、stepOf()、every()、some()、changes() があります。

スイープ:出荷しうるすべての値で ​

調整すれば数値は変わります。スイープは同じテストをパラメータの組み合わせごとに 1 つずつ実行するので、今日のデフォルト値だけでなく、範囲全体でメカニクスが成り立つことを確かめられます。

ts
test.sweep("jump height follows jump speed", { "player.jumpSpeed": [200, 290, 400] }, (game, values) => {
  game.press("Space");
  const y = game.trace("player.y", "1s");
  const v = Number(values["player.jumpSpeed"]);
  const g = Number(game.param("player.gravity"));
  expect(y.start - y.min()).toBeCloseTo((v * v) / (2 * g), 6);
});

失敗すると、壊れた組み合わせが名前に出ます:jump height follows jump speed [player.jumpSpeed=400]。

決定性 ​

リプレイ、シナリオ、バグレポートが役に立つのは、同じ入力から必ず同じゲームが生まれるときだけです。test.deterministic はスクリプトを 2 回プレイし、毎ステップ後の状態を比較します。2 回目は Math.random() のシードと日付が変わるので、game.random や固定ステップをこっそり迂回しているロジックを捕まえられます。

ts
test.deterministic("ten seconds of play are reproducible", (game) => {
  game.hold("ArrowRight", "2s").tap("Space").wait("8s");
});
text
✖ the two runs diverged at tick 37: state.enemies.0.x was 112 in the first run and 118 in the second.

バグはテストになる ​

dev オーバーレイから保存したリプレイ、エージェントが game_replay で再現した瞬間、debuff fuzz が見つけたクラッシュ。これらはすべてシナリオファイルで、replay を使えば数ミリ秒で再生できます。

ts
test("the wall-clip bug stays fixed", async () => {
  const game = await play();
  game.replay(".debuff/fuzz/stuck-run-2.json");
  expect(game.state.player.x).toBeAtLeast(8);
});

何も描画しないので、capture ステップはスキップされます。

テストが失敗したら ​

失敗するたびに、壊れた瞬間のゲームの様子が表示されます。

text
fail tests/movement.test.ts › stops in speed ÷ acceleration [player.speed=300] 40 ms
  ExpectationError: expected 60 to be 0
  at tests/movement.test.ts:33:21
  game tick 42 (0.70s) · scene play
  state {"player":{"x":309,"vx":60},"pops":0,"particles":0}

ゲーム自体が例外を投げた場合は、どのステップとシーンだったかが、ゲームのソースの該当行とともにエラーに表示されます。テストとゲームのコンソール出力は、失敗したテストのぶんだけ表示されます。

テストを実行する ​

bash
npx debuff test                    # tests/ と src/ のすべてのテスト
npx debuff test tests/jump.test.ts # 一部のファイル
npx debuff test -t pause           # 名前に "pause" を含むテスト
npx debuff test --json             # ツールやエージェント向けのレポート

エージェントは MCP サーバーの run_tests ツールでテストを実行し、add-mechanic、fix-errors、tune-feel のワークフローは変更ごとにテストを求めます。AI エージェントと一緒に作るを見てください。

ほかのエンジン ​

Keiko はエンジンの API ではなく Web プラットフォームを通してゲームを動かすので、特定のエンジンに縛られません。Phaser、PixiJS、KAPLAY、Excalibur、three.js で動作を確認しています。エンジン別のレシピを見てください。

  • requestAnimationFrame、performance.now()、Date.now() は仮想時計の上で動き、Math.random() にはシードが入ります。1 ステップが 1 フレームです。
  • 入力は本物のキーボードとポインターのイベントとして届きます。
  • 状態を読むには、開発ビルドでスナップショットを window.__KEIKO__ として公開します。
ts
if (import.meta.env.DEV) {
  window.__KEIKO__ = {
    state: () => ({ player: { x: player.x, y: player.y }, score }),
    scene: () => game.scene.getScenes(true)[0]?.scene.key,
  };
}

あとは npx keiko を実行するだけです。フックはどれも省略できます。ゲームの状態を Keiko に渡すを見てください。

カバーしていないこと ​

何も描画されず、音も鳴りません。レンダリングとオーディオのコンテキストは、どんな呼び出しも受け付けて何もしません。見た目に関わることにはシナリオを使ってください。詳しくは Keiko の仕組みにあります。