Intermediate / Game loop

Introduce Fixed Simulation Steps with Bounded Catch-Up

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

The shared HoloViral loop advances each engine once per animation callback using a capped variable delta. That is easy to read and works for these simple rules. A fixed-step loop makes a different tradeoff: simulation updates use a constant interval, while rendering follows the browser's repaint schedule.

What you'll build

We will design an optional fixed-step scheduler around the collector's existing update(dt) interface. This is an extension, not the loop currently used by the demo. Its most important feature is a bounded catch-up policy rather than an unlimited attempt to replay every delayed update.

Working example · Open Gem Collector and source

Prerequisites

Understand the current createLoop function and why movement multiplies speed by elapsed seconds. Keep the collector's rules unchanged while replacing the scheduler in a local copy. That lets you compare timing policies without also changing collision geometry.

Accumulate elapsed time

An accumulator stores time not yet simulated. Each animation callback adds an elapsed interval. While enough time remains for one fixed step, update consumes that step. Rendering happens after the update work for that callback.

javascript
const step = 1 / 60;
const maxSteps = 5;
let accumulator = 0;

function advance(elapsed) {
  accumulator += Math.min(elapsed, step * maxSteps);
  let updates = 0;
  while (accumulator >= step && updates < maxSteps) {
    update(step);
    accumulator -= step;
    updates++;
  }
  render();
}

This sketch omits the scheduling and lifecycle methods so the accumulator is visible. A complete replacement still needs the single-chain guard, cancellation, initial timestamp handling, and pause behavior found in the existing loop.

Bound catch-up work

Without a bound, a long stall can trigger a large number of updates in one callback. Those updates delay the next frame, which creates more backlog. A small game should not lock the page trying to catch up with several seconds of invisible simulation.

The sketch caps incoming time and limits work to five updates. It intentionally drops excess wall time. That is a documented policy, not a mathematically exact replay of everything that happened while the browser was busy. The displayed timer must still be described as simulation time if it advances only through those steps.

A different implementation could retain some backlog, pause automatically, or use a separate deadline clock. Pick a policy appropriate to the project rather than copying an accumulator and assuming its behavior is neutral.

Preserve the engine interface

The collector already accepts a delta, so a fixed scheduler can call the same update method with a constant value. Movement, spawning, and collision remain inside the engine. Avoid moving those rules into the scheduler simply because you are editing timing code.

The renderer can initially draw the latest completed state. That may appear slightly uneven when display and simulation rates differ. Interpolation is a later extension: store previous and current positions and render between them using the accumulator fraction. It requires extra model snapshots and should not be introduced without understanding what is being interpolated.

Discrete states such as lives or matched cards should not be visually averaged like positions. A half-matched card is not a meaningful simulation state. Interpolation belongs to selected rendering properties, not to every field in the model.

Handle pause and completion

On resume, reset the previous callback timestamp so paused wall time does not enter the accumulator. Decide whether to preserve a partial accumulated step or clear it; either choice should be consistent and tested. Clearing it is simple and loses at most a small fraction of simulation time.

If update finishes the game during a catch-up loop, stop further steps immediately. Checking only before the callback begins can allow several post-completion updates. Engine guards help, but the scheduler should also honor its stopped state before doing more work.

Compare behavior, not just frame rate

Run the same seeded object sequence through variable and fixed stepping. Compare collision outcomes, spawn counts, and active time under normal intervals and a long gap. A smoother graph alone does not prove the rules remained equivalent.

Also test whether the page remains responsive during a deliberately delayed callback. The purpose of the bound is to protect interaction, not merely to produce a particular count in a unit test.

Next steps

Keep the existing loop source as a baseline. Add the fixed-step version in a separate module, document its backlog policy, and switch schedulers without changing the engine factory. Only consider interpolation after the lifecycle and simulation results are clear.