Skip to content

Headless testing

The payoff of the headless core: your actual game — systems, mechanics, saves — runs deterministically in Vitest with no DOM, no mocks, no timers. This is how the toolkit's own 300+ tests work.

Simulation tests with advance

For pure simulation, you don't even need a scheduler — build the game, drive it with advance(seconds), assert:

ts
import { describe, it, expect } from "vitest";
import { createEngine } from "@idlekitjs/core";
import { producers, type ProducerDef } from "@idlekitjs/mechanics/producers";

const TIERS: ProducerDef[] = [
  { id: "farm", cycleTime: 2, yieldPerUnit: 1, baseCost: 10, costGrowth: 1.15 },
];

it("one farm produces 30 potatoes in a minute", () => {
  const engine = createEngine<State>({
    initialState: { potatoes: 0, owned: [0], total: [1], progress: [0] },
  });
  engine.use(farmExtension(engine)); // your wiring, same as production code

  engine.advance(60); // one simulated minute, in exact fixed steps

  expect(engine.state.potatoes).toBe(30); // 60 s / 2 s cycles × 1 yield
});

advance replays fixed steps identical to real time, and the loop is deterministic — this assertion is exact, not approximate. Balance questions ("how long to afford tier 3?") become plain unit tests:

ts
it("reaches 1000 potatoes within two hours", () => {
  const engine = buildEngine();
  engine.advance(2 * 3600);
  expect(engine.state.potatoes).toBeGreaterThanOrEqual(1000);
});

Frame-level tests with manualScheduler

When the loop behavior matters (render cadence, pause semantics, frame deltas), inject manualScheduler and push frames by hand:

ts
import { createEngine, manualScheduler } from "@idlekitjs/core";

it("pausing produces no time jump on resume", () => {
  const scheduler = manualScheduler();
  const engine = createEngine<State>({
    initialState: { coins: 0, rate: 1 },
    scheduler,
    step: 0.1,
  });
  engine.addSystem((state, dt) => {
    state.coins += state.rate * dt;
  });

  engine.start();
  scheduler.frame(0); // first frame: delta 0
  scheduler.frame(1000); // +1 s simulated
  expect(engine.state.coins).toBeCloseTo(1, 6);

  engine.pause();
  engine.resume();
  scheduler.frame(99_000); // wall clock jumped while paused…
  expect(engine.state.coins).toBeCloseTo(1, 6); // …but the resume delta is 0
});

scheduler.frame(now) is a real frame: fixed steps run for the accumulated delta, then the render pass. Timestamps are yours to script.

Save/load flows with MemoryAdapter

Round-trip persistence — including migrations and offline catch-up — with the in-memory backend:

ts
import { createEngine, SaveManager } from "@idlekitjs/core";
import { MemoryAdapter } from "@idlekitjs/storage/memory";
import { offlineProgress } from "@idlekitjs/plugins/offline-progress";

it("credits capped offline time on load", async () => {
  const adapter = new MemoryAdapter();
  adapter.write(
    "t",
    JSON.stringify({
      version: 1,
      savedAt: Date.now() - 3_600_000, // saved an hour ago
      state: { coins: 0, rate: 2 },
    }),
  );

  const engine = createEngine<State>({ initialState: { coins: 0, rate: 0 }, step: 1 });
  engine.addSystem((state, dt) => {
    state.coins += state.rate * dt;
  });
  engine.use(offlineProgress<State>({ maxMs: 10_000 })); // cap at 10 s

  await engine.load(new SaveManager<State>({ key: "t", version: 1, adapter }));

  expect(engine.state.coins).toBeCloseTo(20, 6); // 2/s × capped 10 s
});

Guidelines

  • Coarsen the step for long simulationsstep: 1 makes advance(86_400) cheap; use the default 1/20 s only when sub-second precision matters to the test.
  • Seed your randomness — mechanics that roll (collections) take a seed; a seeded run is exactly reproducible.
  • Never mock the engine — it runs fine headless; mocking it only tests your mocks.
  • Test through public APIs — the same imports your game uses; if a test needs a mechanic's internals, that's an API smell worth reporting.