Skip to Content
canvasgame-enginesperformancefrontendarchitecture

Fifteen Canvas Games, One Loop: Fixed Timesteps, Object Pooling, and Server-Validated High Scores in a Static Site

AUTHENTICATED_ACCESS_GRANTED

The Context: "The Frame Drop on Tab Five"

The Arcade hub hosts fifteen distinct games, all written as React components and all shipped as static files to Cloudflare Pages. No backend process, no game server — just a CDN and a browser.

In September I opened a laptop, visited three games in quick succession, and switched browser tabs while a fourth was running. When I came back, the fourth game was rendering at single-digit frames per second and the character was teleporting through walls.

The cause was not a bug in any one game. It was a design assumption repeated fifteen times, none of which held up in the aggregate.

The Problem Statement

Build a portfolio of browser games that stays responsive under realistic session behaviour, with no server-side runtime:

  • Frame budget: Hold 60fps with hundreds of active entities on a 5-year-old laptop.
  • Lifecycle: Fifteen games are mounted and unmounted as the user navigates. Not one may keep running, holding a GPU context, after the user leaves.
  • Input correctness: Fixed-timestep simulation with interpolated rendering.
  • Integrity: High scores are local-first but server-validated.
  • Weight: Every game is an island loaded on demand; none may drag the whole page.

Visual Architecture

The Core Logic / Process

1. The Loop Belongs to One Module

The single most consequential refactor across all fifteen games: the frame loop lives in exactly one place, and games declare what happens on a step, never how time passes.

// One loop, shared by every game.
export function createLoop(
  step: (dt: number) => void,
  draw: (alpha: number) => void,
) {
  const TICK_MS = 1000 / 60;
  let last = performance.now(),
    acc = 0,
    raf = 0,
    running = false;

  const frame = (now: number) => {
    if (!running) return;
    // Clamp: a 5s stall must not trigger 300 catch-up steps.
    acc = Math.min(acc + (now - last), TICK_MS * 5);
    last = now;
    while (acc >= TICK_MS) {
      step(1 / 60);
      acc -= TICK_MS;
    }
    draw(acc / TICK_MS);
    raf = requestAnimationFrame(frame);
  };

  return {
    start() {
      if (running) return;
      running = true;
      last = performance.now();
      raf = requestAnimationFrame(frame);
    },
    stop() {
      running = false;
      cancelAnimationFrame(raf);
    },
  };
}

The accumulator clamp is not a performance optimisation, it is a correctness requirement. Unclamped, a tab restored from the background after 30 seconds accumulates 30 seconds of debt and runs ~1,800 simulation steps in one frame — which locks the tab solid and teleports every entity. I reproduced the exact "teleporting through walls" symptom I was originally chasing, and the cause was an unclamped accumulator rather than any lifecycle leak.

2. Pool or Perish

Every game creates and destroys hundreds of bullets, particles, and enemies per second. In a browser, that allocation pressure lands directly in the GC, and a major collection mid-frame is a visible hitch.

The pool is a free-list — release appends, acquire pops:

class Pool<T> {
  private free: T[] = [];
  constructor(
    private make: () => T,
    private reset: (o: T) => void,
    pre = 256,
  ) {
    for (let i = 0; i < pre; i++) this.free.push(make());
  }
  acquire(): T {
    const o = this.free.pop() ?? this.make();
    return o;
  }
  release(o: T) {
    this.reset(o);
    this.free.push(o);
  }
}

The reset on release matters more than the reuse. A pool that hands back dirty state produces "bullets that spawn already aimed at something" bugs that are miserable to track, because the object is correct — it just carries stale fields.

3. Unmount Is the Whole Game

With fifteen islands, lifecycle discipline is the difference between a smooth site and a browser that eats 2GB of RAM after a browse session:

useEffect(() => {
  const loop = createLoop(step, draw);
  loop.start();

  const onKey = (e: KeyboardEvent) => keys.add(e.code);
  const onVisibility = () => (document.hidden ? loop.stop() : loop.start());
  window.addEventListener("keydown", onKey);
  document.addEventListener("visibilitychange", onVisibility);

  return () => {
    loop.stop(); // cancel RAF
    window.removeEventListener("keydown", onKey); // remove listeners
    document.removeEventListener("visibilitychange", onVisibility);
    ctx = null; // drop canvas reference
  };
}, []);

React 19 Strict Mode mounts, unmounts, and remounts every effect in development. An effect that allocates without cleaning up leaks on every one of those cycles, and it looks fine in production where the double-invoke does not happen. Every game was failing this test and only one of them visibly.

Pausing on visibilitychange is a free frame-rate win for the same reason: a backgrounded requestAnimationFrame is throttled to roughly 1fps, and stepping a simulation once per second produces spectacular nonsense the moment the user returns.

4. Local-First Scores, Validated Remotely

Every game is playable offline, so scores are written to local storage immediately and reconciled with the server when it is reachable. The server is the tiebreaker, never a gate:

async function submitScore(gameKey: string, score: number, player: string) {
  const local = loadLocal(gameKey);
  // Optimistic: show it now, reconcile later.
  saveLocal(gameKey, score, player);

  try {
    const remote = await fetch(`${apiUrl()}/api/validate-highscore`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ gameKey, score, player }),
    });
    if (remote.ok) mergeVerified(gameKey, await remote.json());
  } catch {
    // Offline is a normal state, not an error. Keep the local score.
  }
}

player is constrained server-side to exactly three uppercase letters, validated at the boundary with a schema rather than trusted from the client. The rule that matters: a submission is a claim, and the server validates its shape and its plausibility, while the player's own device remains the source of truth for their experience. No game may block on a network call to show a score.

The "Baigan" Take (Expert Insights)

The lesson I keep relearning: browser games fail at the lifecycle, not the gameplay.

baigan ka game loop is the belief that "the game logic is the hard part." The logic is a day. Keeping fifteen games alive in one tab session, with no leaks, correct under Strict Mode double-mounting, and stable across tab switches, took three weeks.

Four things I would tell my past self:

  1. Clamp the accumulator. Always. Without exception. Every unbounded accumulator I have ever shipped has eventually produced a bug report that looked like a physics defect and was actually a five-second stall.

  2. Test with unmount, not just with mount. A component that renders correctly on first load can leak without ever throwing an error. The reliable test is: mount, play 10 seconds, unmount, repeat 20 times, watch memory. Anything that grows monotonically is a missing cleanup.

  3. Pause on hidden, not just on unmount. requestAnimationFrame throttling in background tabs is one of the few genuinely free wins in the entire platform, and it also protects users' laptops from fans spinning up.

  4. Never let a score wait on a network. I briefly made high scores server-first "for integrity." It made the game feel broken on a train. Local-first with background reconciliation is both more honest about the network and much better to play.

The pattern generalises well beyond games: optimistic local state with background reconciliation is the correct default for any feature that can degrade — scores, drafts, settings, telemetry. Blocking on authority for something the user already experienced is a design mistake, not a safety feature.

Performance Checklist

  • One shared loop module; games implement step and draw, never their own RAF.
  • Clamp the accumulator to a few ticks to survive long stalls and background tabs.
  • Pool entities with an explicit reset on release; never hand back dirty state.
  • Cancel RAF and remove every listener in the effect cleanup.
  • Pass Strict Mode double-mount by allocating nothing outside the effect.
  • Stop the loop on visibilitychange; resume on return.
  • Validate player and score shape server-side; never trust the client payload.
  • Write scores locally first and reconcile in the background; offline is a normal state.
  • Keep each game a lazily-mounted island so none of the fifteen block the shell.
  • Profile with a repeated mount/unmount loop; memory that climbs monotonically is a leak.
© 2026 PRANABESH_VAULT_ENCRYPTION_V4CONFIDENTIAL_REPRODUCTION_PROHIBITED