Repository navigation
Description
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
- Open the Breakout Task in Chrome on a 60 Hz display and press Start. Time one wall-to-wall crossing of the ball.
- 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
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). Nothing has touchedDNP_BREAKOUT_TASK/since the issue was filed.The suggested fix gets the ball stuck in walls
If only the moves (
:203-210) are multiplied byframes, the look-ahead checks at:178,:181and:183still 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 flipsdxagain, 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, sostepUntil(() => lastBall().y === 310)(DNP_BREAKOUT_TASK.test.ts:273) never matches, and the re-serve test (:283) gets an x of241.99999999999997instead of242.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:
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:collisionDetectionreads the brick positions thatdrawBrickswrites (: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.
timeElapsedRecord
Math.round(ticksPlayed * TICK_MS)rather thanDate.now() - startTime. Browsers pauserequestAnimationFramein a hidden tab, and below 20 fps the cap drops time. In both cases the game stops or slows whileDate.now()keeps counting.This changes what
timeElapsedmeans, so bumpinternal.editionto 2, asdocs/en/5-reference/5.0-form-instrument.md:21describes for an internal revision. Without the bump, the fix cannot reach an instance that already has the task. The id is a hash ofname-edition(apps/api/src/instruments/instruments.service.ts:413-415), socreaterejects 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
paddleX += 7/-= 7(:204,:206) is the only step not multiplied byscale. At scale 2 (an iframe window of at least 1080×720), the ball still crosses in 230 frames, but the keyboard paddle needs about 116 frames instead of 58. Use7 * scale. The existing paddle tests run at scale 1.apps/playground/src/instruments/examples/interactive/Interactive-With-Vanilla/index.ts:140-183is the same game, with the same per-frame loop andDate.now()timing (:13,:89,:162). It also has the bugs from Breakout Task paddle can move past the canvas walls #1640, Breakout Task keeps running and can report completion twice after it finishes #1641 and Breakout Task paddle does not follow a finger dragged across a touch screen #1672. File it separately, since the playground's vitest project cannot load/runtime/v1imports (apps/playground/AGENTS.md).Tests
Unit, in
DNP_BREAKOUT_TASK.test.ts:timeElapsed.:286-302) assert the game-timetimeElapsedinstead of fakingDate.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 insideiframe[name="interactive-instrument"]and let it lose without input (test.slow()). Then assertsubject-table-cell-timeElapsedexactly. Onmainthat 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 thewhileloop, not only skip the nextrequestAnimationFrame.Triage note written with Claude Code. Code references point at
main@d86740e.