BLIT386BLIT386

Game Loop

Fixed-timestep simulation timing, tick counters, and the Timer helper.

Fixed-timestep simulation timing, tick counters, and the Timer helper.

BLIT386 runs two independent cadences:

ConceptWhereMeaning
Simulation ratetargetFPS, BT.targetFPS, BT.deltaSeconds, BT.ticksFixed update() step; game logic and Timer use ticks
Render rateOverlay Present: N FPS, BT.renderAlphaMeasured requestAnimationFrame cadence; render() runs here

render() may run more or fewer times per second than update() (for example 120 Hz display with targetFPS: 60). Use tick-based timing for gameplay; use overlay present FPS only to spot GPU or draw-call bottlenecks. Because the two cadences drift apart, BT.renderAlpha gives render() a normalized [0, 1) interpolation factor toward the next fixed step, for smoothing motion across that mismatch (see Interpolating render state with renderAlpha below).

.; // seconds per fixed tick (1 / BT.targetFPS)
.; // elapsed seconds since init (ticks × deltaSeconds)
.; // current tick counter (increments each update)
.; // fractional progress [0, 1) toward the next update, for interpolation
.(); // reset tick counter to 0
.('Round start'); // timing chart event tag at current tick (requires isOverlayTimingChartEnabled)

Palette effects advance on the same tick clock, once per fixed update. To stop the loop and render exactly the frame at a given time (a screenshot at second 3.2, a headless capture), use BT.renderAt(seconds) and restart with BT.resume() - see Seeking a frame with renderAt.

BT.deltaSeconds Since 1.0.4 BT.timeSeconds Since 1.0.4 BT.ticks Since 1.0.4 BT.renderAlpha Since 1.3.0 BT.ticksReset Since 0.1.0 BT.assignTag Since 1.1.0

Multiple update() steps per render frame

The fixed step runs through an accumulator: each render frame adds the elapsed wall-clock time to a running total, then drains as many updateInterval-sized (1000 / targetFPS) chunks as fit. Before draining, the accumulator is clamped to 8 × updateInterval (MAX_STEPS), so at most 8 update() calls run per frame - a guard against a spiral-of-death catch-up burst after a long pause (a backgrounded tab, a breakpoint, a slow asset load). When that cap is hit, only the executed steps are subtracted from the accumulator and the excess wall-clock time is discarded: BT.ticks still advances once per executed update(), but a long pause skips the missed simulation time rather than fully catching up on it. When render and update cadences are close - a 60 Hz display with targetFPS: 60 - the accumulator typically drains exactly one chunk per render frame, so update() and render() alternate 1:1; when render() runs faster than targetFPS, some frames drain zero chunks (see Render frames with zero update() steps below).

When render() falls behind targetFPS - a throttled background tab, a slow device, or a browser power-saving cap on requestAnimationFrame - the accumulator has more than one updateInterval worth of time to drain before the next render, so multiple update() calls run in a row ahead of that single render() call. Game logic still advances at the correct rate (BT.ticks still increments once per fixed step, BT.deltaSeconds is unchanged); only the render cadence drops. The engine overlay's frame-metrics row surfaces this as an xN suffix on update() - see Top row 3 (left) in the Overlay API docs.

A concrete real-world trigger: macOS Low Power Mode makes Safari/WebKit halve its requestAnimationFrame dispatch rate to about 30 Hz, unrelated to script performance or display refresh rate. See Safari render throttling in Browser Support for the WebKit reference and why other engines are unaffected.

Render frames with zero update() steps

The opposite direction happens too: on a high refresh-rate display (120 Hz or higher) with targetFPS: 60, requestAnimationFrame calls render() more often than the accumulator drains a full updateInterval chunk, so a sizeable fraction of render frames have zero preceding update() calls that frame. This is normal, not a dropped frame - BT.ticks and any state written during the last update() (including the camera offset from BT.cameraSet(), see API: Camera) still reflect the last completed tick, so render() draws the same game state twice in a row rather than resetting to defaults. A pointer press that lands in such a frame is carried to the next frame that runs update(), so BT.isPressed(BT.BTN_POINTER_A) still sees it - see Frame-timing semantics.

Interpolating render state with renderAlpha

render() frequently runs at a different cadence than update() (see the two sections above), so game state read during render() can be up to one fixed step stale. The accumulator introduced above always holds less than one updateInterval of leftover time once its whole-step chunks are removed for the frame - that remainder is exactly what the whole-step loop couldn't consume. Dividing that leftover by updateInterval gives, from first principles, how far the simulation has already progressed toward its next step - a fraction in [0, 1). BT.renderAlpha exposes that fraction: 0 means a fixed update just completed, values approaching 1 mean the next update is imminent.

const  = .;
const  = . + (. - .) * ;
const  = . + (. - .) * ;

Interpolating between the previous and current tick's positions smooths motion on displays whose refresh rate does not line up cleanly with targetFPS, without changing simulation timing. BT.renderAlpha only reflects the most recently completed render frame's accumulator state; read it from render(), not update(). For the full worked pattern, including a zero-allocation version and Vector2i.lerp, see Guide: Game Loop.

Timer

Timer Since 1.0.3

Timer counts fixed update ticks, not render frames. Intervals are in ticks; convert to seconds with intervalTicks / BT.targetFPS. Use it in update() for periodic events: particle spawns, score ticks, palette swaps.

const  = new (180); // every 180 ticks (3 s when BT.targetFPS === 60)

// Inside update():
if (.()) {
  ();
}

// Additional API:
.(); // restart interval from now
.(); // ticks since last fire/reset
.(); // ticks until next fire
.; // readonly interval size

Timer.fireIfElapsed() advances the internal baseline on each true return. Pass BT.ticks explicitly only when you need a specific snapshot; the default is the engine tick counter.

API history

SymbolSinceLast changedStatus
BT.assignTag1.1.0–stable
BT.deltaSeconds1.0.4–stable
BT.renderAlpha1.3.0–stable
BT.ticks1.0.4–stable
BT.ticksReset0.1.0–stable
BT.timeSeconds1.0.4–stable
Timer1.0.3–stable

1.3.0 - Mon Jul 13 2026

  • AddedBT.renderAlpha

1.1.0 - Sat May 30 2026

  • AddedBT.assignTag

1.0.4 - Sun May 17 2026

  • AddedBT.deltaSeconds
  • AddedBT.ticks
  • AddedBT.timeSeconds

1.0.3 - Sat May 16 2026

  • AddedTimer

0.1.0 - Sat Dec 13 2025

  • AddedBT.ticksReset

See also

Last updated on October 18, 2026

On this page