Game Loop
Smoothing motion between fixed update() steps using BT.renderAlpha and Vector2i.lerp.
Fixed-step timing, tick counters, and BT.renderAlpha are documented in API: Game Loop. This guide
walks through the standard pattern for smoothing motion when render() doesn't land exactly on an update() step.
Why motion can look stale or jittery
update() runs at a fixed rate (targetFPS); render() runs at whatever rate requestAnimationFrame delivers, which
rarely divides evenly into targetFPS. See
Multiple update() steps per render frame and
Render frames with zero update() steps for the two ways that
mismatch shows up. A sprite that only ever draws at its last-computed update() position appears to hitch: it sits
still for a render frame with no update, then jumps its full per-tick distance on the next frame that has one.
Smoothing motion between updates
The fix is to interpolate: keep both the previous and current tick's position, and blend them in render() using
BT.renderAlpha as the blend factor. Store the previous position at the start of update(), before moving:
class {
= new (0, 0);
= new (0, 0);
= new (2, 0);
(): void {
this. = this..();
this. = this..(this.);
}
(): void {
const = .(this., this., .);
.(, , );
}
}Vector2i.lerp truncates its result to integer pixels (see API: Core Types), so this only
smooths motion that covers more than a pixel or two per tick - it doesn't add subpixel precision to a pixel-perfect
renderer. For a sprite moving 2 px/tick at targetFPS: 60 on a 144 Hz display, it turns a visible 1-then-2-then-0
px stutter into a steadier progression across render frames.
Zero-allocation version
update()/render() run every frame, so the allocating version above (clone(), add(), Vector2i.lerp()) is fine
for prototyping but worth tightening in a shipping demo. Reuse fixed vectors with the in-place and *To() variants (see
Performance Best Practices):
class {
= new (0, 0);
= new (0, 0);
= new (2, 0);
private readonly = new (0, 0);
(): void {
this..(this.);
this..(this.);
}
(): void {
.(this., this., ., this.);
.(, , this.);
}
}Color transitions (palette flashes, tinted hit-feedback) can be interpolated the same way with Color32.lerp /
Color32#lerp - see API: Core Types.
Seeking and stepping
The engine's clock is ticks, not wall time: BT.timeSeconds is BT.ticks * BT.deltaSeconds, palette effects advance
once per fixed update (right after your update()), and custom post-process effects receive tick-clock milliseconds in
writeUniforms(deltaMs, ...). That is what lets BT.renderAt jump to any
time and draw the same frame the live loop draws on that tick (a seek renders with BT.renderAlpha at 0, so it
matches a live frame that lands exactly on the tick, not one interpolated between ticks). Drive animation from
BT.timeSeconds or BT.ticks, and change state only in init() and update(). A value read from performance.now()
cannot be seeked, and a seek calls render() once, at the end. BT.ticksReset() moves the clock back to zero without
running anything backwards: palette effects skip that one update instead of reversing. An external driver (a screenshot
tool, a play-testing harness) waits for BT.isReady before its first seek or
BT.testState() read.
BT.renderAt(t) BT.resume()
running ------------------> stopped ------------------> running
| ^
BT.renderAt(t) or | |
BT.captureFrame() +---+
(re-renders the seeked tick)Seeking from the start costs one update() per tick - 10,800 calls for three minutes at 60 FPS - so a demo that
computes its frame from BT.timeSeconds seeks instantly, while a game with heavy per-tick simulation seeks slowly by
design.
API history
See also
API: Game Loop
API: Core Types
Performance Best Practices
Last updated on October 18, 2026