BLIT386BLIT386

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

Last updated on October 18, 2026

On this page