Use when writing tests for packages/game. Three levels are required depending on what is being tested.
Deterministic Test Primitives
Use the current test harness vocabulary:
advanceGameState(state, inputs) is the pure logical step. Empty inputs returns the same state; queued input advances exactly one logical tick, applies player input first, then the tick action.
frameIndex counts logical ticks, not rendered frames. Render-only stepFrames(n) calls with no queued input must not move enemies or increment frameIndex.
Gameplay movement is grid-based. State tests should assert actor cell and board occupancy invariants; actors must not carry xPos / yPos.
createFrameStepDriver / stepFrames(n) exercise the effects-layer driver with a manualClock; they may render and advance animation time even when state does not tick.
projectRenderCommands(state) is the pure foreground draw plan. It should be tested with literal GameState values and exact ReadonlyArray<RenderCommand> expectations.
executeRenderCommand / executeRenderCommands are effects-layer Canvas executors. They are tested with the browser provider and a real CanvasRenderingContext2D spy, not from pure render tests.
window.__bruffTestApi is browser-facing only and is tested through effects tests or Playwright, never from pure state tests.
Level 1 — Unit Tests (always required for pure functions)
Co-locate as *.test.ts next to the source file. Run via Vitest.
Rules:
Test pure functions only — no DOM, no Canvas, no network. Pure render tests assert RenderCommand and RenderStats data rather than Canvas calls.
One test per logical branch / state transition.
Assert the complete return value in one snapshot-style assertion where possible (per T-6).
import { describe, expect, it } from "vitest";
import type { GameState } from "../core/types.ts";
import updatePlayer from "./update-player.js";
describe("updatePlayer", () => {
it("moves north when input is arrowup", () => {
const state: GameState = /* … */;
expect(updatePlayer(state, { type: "move-up" })).toStrictEqual({
...state,
player: { ...state.player, cell: { column: 3, row: 2 } },
playerMoved: true,
});
});
});
Level 2 — Property-Based Tests (required for PRNG, replay runners, and state transitions)
Use Vitest + @fast-check/vitest.
Properties to test:
PRNG: same seed → same sequence of values.
Reducers: applying inverse actions returns to original state (where applicable).
State transitions: frameIndex never decreases, increments only for logical ticks with input, actors stay inside board, and no two actors occupy the same cell after valid transitions.
Replay runners: output is deterministic given the same seed and fixture; replay frames without input are render-only and do not increment frameIndex.
import { test, fc } from "@fast-check/vitest";
import { expect } from "vitest";
test.prop([fc.integer()])(
"PRNG produces same sequence for same seed",
(seed) => {
const seq1 = runPrng(seed, 10);
const seq2 = runPrng(seed, 10);
expect(seq1).toStrictEqual(seq2);
},
);
Level 3 — Snapshot / Replay Tests (required for full run determinism)
Capture a full deterministic run and assert the final state (or a hash of it) matches a stored snapshot.
Pattern:
Fix a seed in a replay fixture.
Feed scripted frame/input pairs through runReplay(fixture).
Assert the resulting GameState matches a committed JSON snapshot.
Fixtures live in packages/game/tests/fixtures/; snapshots live in packages/game/tests/snapshots/.
import { expect, it } from "vitest";
import fixtureJson from "../../tests/fixtures/canonical-replay.json";
import snapshotJson from "../../tests/snapshots/canonical-replay.json";
import { parseReplayFixture } from "./replay-fixture.js";
import { runReplay } from "./run-replay.js";
it("produces deterministic output for fixed seed and input sequence", () => {
const fixture = parseReplayFixture(fixtureJson);
expect(fixture.type).toBe("ok");
if (fixture.type === "error") {
return;
}
expect(runReplay(fixture.value)).toStrictEqual({
type: "ok",
value: snapshotJson,
});
});
Checklist
Every new pure function has a Level 1 unit test.
Every PRNG consumer, replay runner, or deterministic step path has a Level 2 property test.
Any new full-run integration path has a Level 3 replay snapshot.
Render projection tests cover command shape, command order, zero-entity cases, and deterministic output for the same state.
Render executor tests cover every RenderCommand branch and command ordering.
Frame-driver tests cover both render-only frames and input-driven logical ticks.
Test API tests prove getState() / getRenderStats() return clones, dispatchInput() normalises raw input, and attachment is gated by __BRUFF_TEST_MODE__.
Arcade visual checks use await expect(locator).toHaveScreenshot("name.png") with an @snapshot test title tag; never leave raw locator.screenshot() captures unasserted. Update Arcade E2E screenshot baselines with pnpm run --filter @bruff/arcade test:e2e:update-snapshots.
No DOM or Canvas access inside pure core/, state/, input/, or render/ tests; effects tests may use browser APIs deliberately.
No Math.random(), Date.now(), or raw performance.now() inside any test (seed and clock everything).
1---2name: write-game-tests3description: Write the three required test levels for game logic — unit, property-based, and deterministic replay/snapshot4---56# Write Game Tests78Use when writing tests for `packages/game`. Three levels are required depending on what is being tested.910---1112## Deterministic Test Primitives1314Use the current test harness vocabulary:1516- `advanceGameState(state, inputs)` is the pure logical step. Empty `inputs` returns the same state; queued input advances exactly one logical tick, applies player input first, then the tick action.17- `frameIndex` counts logical ticks, not rendered frames. Render-only `stepFrames(n)` calls with no queued input must not move enemies or increment `frameIndex`.18- Gameplay movement is grid-based. State tests should assert actor `cell` and board occupancy invariants; actors must not carry `xPos` / `yPos`.19- `createFrameStepDriver` / `stepFrames(n)` exercise the effects-layer driver with a `manualClock`; they may render and advance animation time even when state does not tick.20- `projectRenderCommands(state)` is the pure foreground draw plan. It should be tested with literal `GameState` values and exact `ReadonlyArray<RenderCommand>` expectations.21- `executeRenderCommand` / `executeRenderCommands` are effects-layer Canvas executors. They are tested with the browser provider and a real `CanvasRenderingContext2D` spy, not from pure render tests.22- `window.__bruffTestApi` is browser-facing only and is tested through effects tests or Playwright, never from pure state tests.2324---2526## Level 1 — Unit Tests (always required for pure functions)2728Co-locate as `*.test.ts` next to the source file. Run via Vitest.2930Rules:3132- Test pure functions only — no DOM, no Canvas, no network. Pure render tests assert `RenderCommand` and `RenderStats` data rather than Canvas calls.33- One test per logical branch / state transition.34- Assert the complete return value in one snapshot-style assertion where possible (per T-6).3536```ts37import { describe, expect, it } from "vitest";38import type { GameState } from "../core/types.ts";39import updatePlayer from "./update-player.js";4041describe("updatePlayer", () => {42 it("moves north when input is arrowup", () => {43 const state: GameState = /* … */;44 expect(updatePlayer(state, { type: "move-up" })).toStrictEqual({45 ...state,46 player: { ...state.player, cell: { column: 3, row: 2 } },47 playerMoved: true,48 });49 });50});51```5253---5455## Level 2 — Property-Based Tests (required for PRNG, replay runners, and state transitions)5657Use Vitest + @fast-check/vitest.5859Properties to test:6061- **PRNG**: same seed → same sequence of values.62- **Reducers**: applying inverse actions returns to original state (where applicable).63- **State transitions**: `frameIndex` never decreases, increments only for logical ticks with input, actors stay inside `board`, and no two actors occupy the same cell after valid transitions.64- **Replay runners**: output is deterministic given the same seed and fixture; replay frames without input are render-only and do not increment `frameIndex`.6566```ts67import { test, fc } from "@fast-check/vitest";68import { expect } from "vitest";6970test.prop([fc.integer()])(71 "PRNG produces same sequence for same seed",72 (seed) => {73 const seq1 = runPrng(seed, 10);74 const seq2 = runPrng(seed, 10);7576 expect(seq1).toStrictEqual(seq2);77 },78);79```8081---8283## Level 3 — Snapshot / Replay Tests (required for full run determinism)8485Capture a full deterministic run and assert the final state (or a hash of it) matches a stored snapshot.8687Pattern:88891. Fix a `seed` in a replay fixture.902. Feed scripted frame/input pairs through `runReplay(fixture)`.913. Assert the resulting `GameState` matches a committed JSON snapshot.924. Fixtures live in `packages/game/tests/fixtures/`; snapshots live in `packages/game/tests/snapshots/`.9394```ts95import { expect, it } from "vitest";96import fixtureJson from "../../tests/fixtures/canonical-replay.json";97import snapshotJson from "../../tests/snapshots/canonical-replay.json";98import { parseReplayFixture } from "./replay-fixture.js";99import { runReplay } from "./run-replay.js";100101it("produces deterministic output for fixed seed and input sequence", () => {102 const fixture = parseReplayFixture(fixtureJson);103 expect(fixture.type).toBe("ok");104 if (fixture.type === "error") {105 return;106 }107108 expect(runReplay(fixture.value)).toStrictEqual({109 type: "ok",110 value: snapshotJson,111 });112});113```114115---116117## Checklist118119- [ ] Every new pure function has a Level 1 unit test.120- [ ] Every PRNG consumer, replay runner, or deterministic step path has a Level 2 property test.121- [ ] Any new full-run integration path has a Level 3 replay snapshot.122- [ ] Render projection tests cover command shape, command order, zero-entity cases, and deterministic output for the same state.123- [ ] Render executor tests cover every `RenderCommand` branch and command ordering.124- [ ] Frame-driver tests cover both render-only frames and input-driven logical ticks.125- [ ] Test API tests prove `getState()` / `getRenderStats()` return clones, `dispatchInput()` normalises raw input, and attachment is gated by `__BRUFF_TEST_MODE__`.126- [ ] Arcade visual checks use `await expect(locator).toHaveScreenshot("name.png")` with an `@snapshot` test title tag; never leave raw `locator.screenshot()` captures unasserted. Update Arcade E2E screenshot baselines with `pnpm run --filter @bruff/arcade test:e2e:update-snapshots`.127- [ ] No DOM or Canvas access inside pure `core/`, `state/`, `input/`, or `render/` tests; effects tests may use browser APIs deliberately.128- [ ] No `Math.random()`, `Date.now()`, or raw `performance.now()` inside any test (seed and clock everything).
Run npx skillmds@latest add dkolba/write-game-tests in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Write the three required test levels for game logic — unit, property-based, and deterministic replay/snapshot It is listed under Coding & Dev Tools on SkillMD.
This skill has not completed SkillMD's automated safety review yet. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free, and the skill stays under its author's original license.
dkolba (@dkolba) published this skill. Their other Agent Skills are listed on their SkillMD profile.