Intermediate / Game state & scoring

Write Repeatable Engine Tests with Seeded Randomness

Screenshot of a source excerpt explained in this article.
Screenshot of a source excerpt explained in this article.

A failing collision test is difficult to debug if the next run creates different objects. The HoloViral factories accept an optional random function so tests can control randomness without replacing Math.random across the whole browser.

What you'll build

We will design repeatable deck and spawn tests using the real factory interfaces. A small seeded generator is an optional test utility; the production demos continue using their default random source.

Working example · Open Memory Match and source

Prerequisites

Understand functions as arguments and simple assertions. Read makeDeck, createCollector, and createRacer. Memory shuffling consumes random values for swaps; the falling-object engines consume them when creating positions and object types.

Inject a source at the boundary

The memory deck builder defaults its random parameter to Math.random. A test can supply a function that returns known values. Collector and Racer follow the same pattern, so a test does not need to intercept global browser APIs or change unrelated code.

assets/js/engines/memory.js
export function makeDeck(pairCount, random = Math.random) {
  const symbols = ['★', '◆', '●', '▲', '☀', '♣', '☂', '♥'];
  const deck = symbols.slice(0, pairCount).flatMap(symbol => [
    { symbol, matched: false }, { symbol, matched: false }
  ]);
  // Fisher–Yates: each remaining position has the same chance of being picked.
  for (let i = deck.length - 1; i > 0; i--) {
    const j = Math.floor(random() * (i + 1));
    [deck[i], deck[j]] = [deck[j], deck[i]];
  }
  return deck;
}

A constant source is enough to exercise a predictable path, but a seeded sequence covers more arrangements while remaining repeatable. Keep the generator in test code unless predictable production runs are an intentional game feature.

Use a small repeatable sequence

The following generator produces values in the expected half-open range. It is a testing convenience, not a cryptographic source or a promise of ideal statistical quality.

javascript
function seededRandom(seed) {
  let state = seed >>> 0;
  return () => {
    state = (Math.imul(1664525, state) + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
const random = seededRandom(42);

Each newly created generator with seed 42 starts the same sequence. Reusing one generator across several tests advances its state, so later tests can become dependent on earlier execution. Construct a fresh generator for each independent scenario.

Assert invariants rather than one favorite board

A shuffled memory deck should have the correct length, two copies of every chosen symbol, and distinct card objects. Those invariants matter more than whether a particular star appears at index zero.

You can use the controlled deck to locate each pair and drive the actual choose method through completion. Also choose invalid or repeated indexes and verify they do not change moves. Deterministic setup should make rule tests reproducible, not reduce them to checking one output snapshot forever.

For Collector, insert specific objects directly when testing collision geometry. Randomly waiting for a hazard to appear mixes spawning, movement, and contact into one test. Test those responsibilities independently before combining them in a longer scenario.

Control time as well as randomness

A seed alone does not make a run repeatable if update intervals differ. Spawn timing determines when random values are consumed, and movement depends on elapsed seconds. Supply a known delta sequence when comparing complete engine runs.

Changing the spawn algorithm can alter how many random calls happen, causing later objects to differ even with the same seed. Treat a seeded replay as a version-specific scenario unless you explicitly stabilize the random-consumption protocol.

This is why test failures should report both seed and timing setup. “Seed 42 failed” is incomplete if the update schedule is unknown.

Keep test visibility separate from player-facing APIs

The engine factories expose state because the examples are designed to be readable and testable. That is useful for direct assertions. It does not imply that a production competitive game can trust client-side state as authoritative.

Here the test goal is local rule correctness: a consumed gem scores once, a completed board rejects input, and difficulty produces valid settings. Avoid expanding that goal into claims about anti-cheat or network synchronization that this static project does not implement.

Next steps

Run the existing tools/test-engines.mjs checks, then add one seeded scenario that reproduces a real bug you observed. Keep the setup small and explain the invariant it protects. Repeatability is most valuable when it turns an intermittent failure into a short, understandable example rather than a large opaque replay file.