Appearance
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 simulations —
step: 1makesadvance(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.