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:
| Concept | Where | Meaning |
|---|---|---|
| Simulation rate | targetFPS, BT.targetFPS, BT.deltaSeconds, BT.ticks | Fixed update() step; game logic and Timer use ticks |
| Render rate | Overlay Present: N FPS, BT.renderAlpha | Measured 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 sizeTimer.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
| Symbol | Since | Last changed | Status |
|---|---|---|---|
BT.assignTag | 1.1.0 | – | stable |
BT.deltaSeconds | 1.0.4 | – | stable |
BT.renderAlpha | 1.3.0 | – | stable |
BT.ticks | 1.0.4 | – | stable |
BT.ticksReset | 0.1.0 | – | stable |
BT.timeSeconds | 1.0.4 | – | stable |
Timer | 1.0.3 | – | stable |
1.3.0 - Mon Jul 13 2026
- Added
BT.renderAlpha
1.1.0 - Sat May 30 2026
- Added
BT.assignTag
1.0.4 - Sun May 17 2026
- Added
BT.deltaSeconds - Added
BT.ticks - Added
BT.timeSeconds
1.0.3 - Sat May 16 2026
- Added
Timer
0.1.0 - Sat Dec 13 2025
- Added
BT.ticksReset
See also
API: Core
API: Overlay
API: Camera
Browser Support
Guide: Game Loop
Last updated on October 18, 2026