Skip to content

Sweeps and determinism ​

Sweeps: every value you might ship ​

Tuning changes numbers, and a mechanic that works at today's jump speed can break at tomorrow's. test.sweep runs the same test across params, one test per combination, each on a fresh game with those params set:

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); // v² / 2g
  },
);
text
pass jump height follows jump speed [player.jumpSpeed=200]
pass jump height follows jump speed [player.jumpSpeed=290]
pass jump height follows jump speed [player.jumpSpeed=400]

With several params, every combination runs:

ts
test.sweep("clears the first gap", { "player.jumpSpeed": [260, 290, 320], "player.gravity": [800, 900] }, (game) => {
  game.hold("ArrowRight", "0.4s").tap("Space");
  game.until((s) => s.player.x > 120, { within: "2s" });
});

Sweeps need the game to expose params; see Exposing your game. Pass { play: { … } } as a fourth argument for other play() options.

Determinism: the same every time ​

Replays, recorded bug reports, multiplayer lockstep and keiko tests themselves rely on one property: the same input gives the same game. It breaks quietly, through a Math.random() in an enemy's AI or a Date.now() in a cooldown.

test.deterministic plays a script twice on fresh games and compares the state after every step. The second run gets a different Math.random() seed and a different date, so anything that depends on unseeded randomness or the wall clock shows up:

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.
  Look for Math.random(), Date.now() or real-time timers in game logic: use a seeded generator
  and the time step your loop passes in.

Fixing what it finds ​

  • Randomness: use a seeded generator (debuff's game.random, Phaser's Phaser.Math.RND.sow([seed]), or a small one like mulberry32) and seed it once.
  • Time: use the dt your loop receives, not Date.now() differences.
  • Iteration order: iterate arrays rather than objects whose keys change shape.

If the game takes all its randomness from Math.random() (engine helpers like Phaser.Math.Between do), keiko already seeds it, so the game replays exactly from a seed. Check everything else (the clock, real-time timers) with { sameSeed: true }. When runs with different seeds diverge, the error suggests this:

ts
test.deterministic("physics is reproducible", (game) => game.hold("ArrowRight", "3s"), { sameSeed: true });