Intermediate / Game loop

Preserve the Remainder in a Game Spawn Timer

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

The collector creates an object when its spawn timer crosses an interval, then resets the timer to zero. This is simple, but it discards the extra time beyond the threshold. Over many updates, that small remainder can affect the actual spawn rate.

What you'll build

We will inspect the current collector cadence and design a remainder-preserving alternative. The shipped demo retains its original rule until you deliberately change it. Altering cadence changes difficulty, so this is a gameplay change as well as a timer refactor.

Working example · Open Gem Collector and source

Prerequisites

Know how state.spawn accumulates dt. Difficulty reduces the collector interval from a base of 0.52 seconds. The shared scheduler caps individual deltas, but normal frame intervals still rarely divide the spawn interval exactly.

Find the discarded remainder

Imagine an interval of 0.52 seconds and updates of 0.05 seconds. After ten updates, the accumulator is 0.50. The next reaches 0.55, creates one object, and resets to zero. The extra 0.03 seconds vanish.

assets/js/engines/collector.js
function update(dt, direction = 0) {
    if (state.finished) return;
    state.time += dt;
    state.spawn += dt;
    state.x = clamp(state.x + direction * 500 * dt, 45, WIDTH - 45);
    if (state.spawn >= 0.52 - difficulty * 0.035) {
      state.spawn = 0;
      state.objects.push({ x: 40 + random() * (WIDTH - 80), y: -25,
        bad: random() < 0.23 + difficulty * 0.015 });
    }
    for (const object of state.objects) {
      object.y += (150 + difficulty * 25 + state.time * 1.2) * dt;
      if (object.y > HEIGHT - 78 && object.y < HEIGHT - 25 &&
          Math.abs(object.x - state.x) < 52) {
        object.y = HEIGHT + 60; // Consume once, then remove below.
        if (object.bad) state.lives--;
        else state.score++;
      }
    }
    state.objects = state.objects.filter(object => object.y < HEIGHT + 30);
    state.won = state.score >= goal;
    state.finished = state.won || state.lives <= 0 || state.time >= 75;
  }

The exact difference depends on frame timing. This is why two runs with different update patterns can produce slightly different counts even when both accumulate approximately the same active duration. For a casual example that may be acceptable; for a carefully balanced challenge, you may want a more precise cadence.

Subtract the interval instead of clearing it

For a delta small enough to cross only one threshold, subtracting the interval preserves the remainder. In the example, 0.55 becomes 0.03, so the next spawn begins with that leftover credit.

javascript
state.spawn += dt;
if (state.spawn >= interval) {
  state.spawn -= interval;
  spawnOneObject();
}

This sketch assumes the caller cannot supply enough time to require several spawns. The actual engine's capped delta and interval currently make multiple crossings within a single step unlikely by construction. If you generalize the interval or bypass the scheduler in tests, state the assumption rather than relying on it silently.

Handle multiple crossings deliberately

A while loop can consume several intervals, but an unbounded loop can create a burst after a stall. Limit how many objects one update can create and decide whether remaining backlog is retained or discarded.

javascript
let spawned = 0;
while (state.spawn >= interval && spawned < 3) {
  state.spawn -= interval;
  spawnOneObject();
  spawned++;
}

The bound of three is an example policy, not a recommendation for every game. If retained backlog causes repeated bursts over following frames, you may also cap the accumulator. If you discard it, document that you are prioritizing responsiveness over exact historical cadence.

Separate when to spawn from what to spawn

The collector chooses an x position and hazard flag when creating each object. Changing the timer changes how many random values are consumed. Even with the same seed, later objects may no longer match the old run because the random sequence is sampled at different moments.

This matters when comparing difficulty. A remainder-preserving timer may produce more objects during a fixed active-time round. More opportunities for gems and more hazards can both affect outcomes. Do not describe the change as behavior-preserving without measuring those effects.

The racer has the same timer pattern with a different interval. Apply the decision separately to each engine rather than assuming both should use identical values or backlog limits.

Test cadence with controlled updates

Run the spawn logic for the same total simulation duration using different step sizes. Compare object counts before offscreen cleanup removes them, or use a dedicated creation counter in the test. Counting only the final array length mixes cadence with object lifetime.

Include an exact-threshold case, a just-over-threshold case, and a delta large enough to exceed the burst bound. Verify both the count and the remaining accumulator. A test that checks only the count can miss a retained backlog that surprises the next update.

Next steps

Inspect Gem Collector's source and calculate the interval at each difficulty. If you implement the alternative, compare completion rates and hazard exposure over repeatable runs. Timer accuracy is useful only when the resulting gameplay still matches the challenge you intend to publish.