Skip to content

Breakout Task runs twice as fast on a 120 Hz display, so its recorded time depends on the subject's screen #1669

Open

Description

@joshunrau

The Breakout Task moves its objects a fixed distance per animation frame, not per unit of time, so the game runs faster on faster screens. draw ignores the timestamp that requestAnimationFrame passes it. On every call it moves the ball by dx/dy (2 px × scale) and the paddle by 7 px, and requestAnimationFrame fires once per display refresh. At scale 1, the ball moves 120 px/s on a 60 Hz screen, 240 px/s on a 120 Hz screen (ProMotion iPads and MacBooks, most recent phones) and 288 px/s at 144 Hz. The task records two measures, and both depend on the subject's hardware. timeElapsed is wall-clock time (Date.now() - startTime), so the same play finishes in about half the time on a 120 Hz display. win is affected because the ball moves twice as fast, which makes the game harder. When the main thread drops frames, the game slows down instead. The instructions ask the subject to "win the game as quickly as possible", so elapsed time is the task's outcome, and results from different devices cannot be compared. The pattern also spreads: this package's AGENTS.md tells authors to read this task before they write an interactive instrument.

Where

packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/index.ts:169-212:

const draw = () => {
  // ...
  if (rightPressed && paddleX < width - paddleWidth) {
    paddleX += 7;
  } else if (leftPressed && paddleX > 0) {
    paddleX -= 7;
  }

  x += dx;
  y += dy;
  requestAnimationFrame(draw);
};

The per-frame velocities are set at :61-62 (let dx = 2 * scale; let dy = -2 * scale;). The elapsed time comes from Date.now() at :29, :114 and :191.

Reproduce

  1. Open the Breakout Task in Chrome on a 60 Hz display and press Start. Time one wall-to-wall crossing of the ball.
  2. Repeat on a 120 Hz display, or on the same display switched to 120 Hz.

Actual: a wall-to-wall crossing is 460 px at 2 px per frame, or 230 frames. That takes 3.8 s at 60 Hz and 1.9 s at 120 Hz. A whole game's timeElapsed scales the same way.
Expected: the ball moves at the same speed in pixels per second on every display, so timeElapsed and win depend only on how the subject plays.

Tests

In packages/instrument-library/src/__tests__/DNP_BREAKOUT_TASK.test.ts:

  • it('should move the ball by elapsed time rather than per frame, so a 120 Hz display does not play twice as fast'). Drive the frame callback with timestamps 1000/60 ms apart in one run and 1000/120 ms apart in another. Assert that the ball has moved the same distance after one simulated second.
  • it('should cap the time step after a long gap between frames, so returning to a backgrounded tab does not carry the ball through the paddle').

The step helper currently passes performance.now(). It will need to pass a synthetic timestamp that advances 1000/60 ms per call, so the existing per-frame assertions keep holding.

There is no e2e test for this. Playwright cannot set the display refresh rate, so the unit test drives the frame timestamps directly.

Suggested fix

Make draw take the requestAnimationFrame timestamp. From it, compute const frames = Math.min(timestamp - previousTimestamp, 50) / (1000 / 60), then multiply the ball step and the 7 px paddle step by frames. This keeps today's speeds, defined per 1/60 s. Use frames = 1 on the first frame.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: MediumStraightforward change, about a day or less
    Area: InstrumentsInstrument authoring API, bundling, runtime, rendering, measures and the built-in library
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Nothing has touched DNP_BREAKOUT_TASK/ since the issue was filed.

    The suggested fix gets the ball stuck in walls

    If only the moves (:203-210) are multiplied by frames, the look-ahead checks at :178, :181 and :183 still test one 60 Hz step (x + dx, y + dy). At 120 Hz, a 50 ms frame moves the ball 6 px after a 2 px look-ahead, which can leave it up to 4 px past the bounce line. Once it is more than 3 px past, each 1 px frame after that flips dx again, and the ball slides along the wall. In a port of the loop with occasional dropped frames, this lasted thousands of frames at a time at 120, 144 and 240 Hz, and never happened at 60 Hz. On the bottom edge it costs a life once the ball slides off the paddle.

    The synthetic clock does not keep the per-frame assertions under that fix either. (t + 1000 / 60 - t) / (1000 / 60) is not exactly 1 on almost every frame, so stepUntil(() => lastBall().y === 310) (DNP_BREAKOUT_TASK.test.ts:273) never matches, and the re-serve test (:283) gets an x of 241.99999999999997 instead of 242.

    Use a fixed timestep instead

    Keep today's physics as a 1/60 s tick, and on each frame run as many ticks as the elapsed time covers:

    const TICK_MS = 1000 / 60;
    const MAX_BACKLOG_MS = 3 * TICK_MS;
    
    let lastTimestamp: number | undefined;
    let backlogMs = 0;
    let ticksPlayed = 0;
    
    const draw = (timestamp?: number) => {
      render(); // :170-175
      const elapsedMs = lastTimestamp === undefined || timestamp === undefined ? TICK_MS : timestamp - lastTimestamp;
      lastTimestamp = timestamp;
      backlogMs = Math.min(backlogMs + elapsedMs, MAX_BACKLOG_MS);
      while (backlogMs >= TICK_MS / 2) {
        ticksPlayed++;
        tick(); // :176-210, unchanged
        backlogMs -= TICK_MS;
      }
      requestAnimationFrame(draw);
    };

    The physics never sees the frame rate, so every device plays the same trajectories, not only the same speed, and the collision code stays as it is. Keep render() first: collisionDetection reads the brick positions that drawBricks writes (:145-146).

    The threshold is half a tick because rAF timestamps are coarsened (MDN: "a minimal precision of 1 millisecond"). A steady 60 Hz display reports deltas such as 16.7, 16.7 and 16.6 ms. With a threshold just under a full tick, the loop then runs 0, 1 and 2 ticks in rotation, so the ball freezes on one frame in three and jumps on the next. With half a tick, every 60 Hz frame runs exactly one tick, as today, and game time stays within half a tick of the clock at any rate. In a port, a synthetic clock advancing 1000/60 ms per call ran one tick on each of 100,000 frames, so the existing assertions hold.

    timeElapsed

    Record Math.round(ticksPlayed * TICK_MS) rather than Date.now() - startTime. Browsers pause requestAnimationFrame in a hidden tab, and below 20 fps the cap drops time. In both cases the game stops or slows while Date.now() keeps counting.

    This changes what timeElapsed means, so bump internal.edition to 2, as docs/en/5-reference/5.0-form-instrument.md:21 describes for an internal revision. Without the bump, the fix cannot reach an instance that already has the task. The id is a hash of name-edition (apps/api/src/instruments/instruments.service.ts:413-415), so create rejects the new bundle as a conflict (:111-113). As edition 2, it is also added to every group that had edition 1 (:121-135).

    Same class of bug

    Tests

    Unit, in DNP_BREAKOUT_TASK.test.ts:

    • The issue's 60 vs 120 Hz case. With a fixed step, the positions are equal, not just the distance travelled.
    • A 60 Hz clock rounded to 0.1 ms runs one tick on every frame.
    • A 10 s gap runs three ticks, then one on the next frame, and adds at most three ticks to timeElapsed.
    • The two completion tests (:286-302) assert the game-time timeElapsed instead of faking Date.

    e2e: game time makes a no-input game deterministic. In a port, it loses its third life after about 1,064 ticks (17.7 s), give or take a tick or two depending on the scale, so read the canvas width inside the frame to choose the expected value. In testing/src/specs/instrument-completion.spec.ts, open the demo-seeded Breakout Task, click Start inside iframe[name="interactive-instrument"] and let it lose without input (test.slow()). Then assert subject-table-cell-timeElapsed exactly. On main that cell holds wall-clock time, which differs on every run. #1825 appends to the same spec, so expect a rebase.

    Landing

    Land this before #1640, #1641 and #1672, or in one PR with them, since it changes the step() clock that every Breakout test uses. #1641's finished flag must also break out of the while loop, not only skip the next requestAnimationFrame.

    Triage note written with Claude Code. Code references point at main @ d86740e.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: InstrumentsInstrument authoring API, bundling, runtime, rendering, measures and the built-in libraryBugType: existing behavior is wrongDifficulty: MediumStraightforward change, about a day or lessPriority: MediumReal value; schedule normally

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions