Repository navigation
Description
The Breakout Task paddle can move past the canvas walls. The keyboard clamp checks the paddle position before moving it 7px, so the last step goes over the edge. In the 480px canvas (scale 1) the paddle stops at x = 405.5 against a limit of 405 on the right, and at x = -0.5 against 0 on the left. The overshoot depends on the scale: at scale 1.25 it is about 6px. The mouse handler has no clamp at all. It centers the paddle on any pointer inside the canvas, so the paddle can hang up to half its width (37.5px at scale 1) off either side. The patient sees part of the paddle cut off at the edge of the game.
Where
packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/index.ts:203-207 (keyboard):
if (rightPressed && paddleX < width - paddleWidth) {
paddleX += 7;
} else if (leftPressed && paddleX > 0) {
paddleX -= 7;
}packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/index.ts:94-99 (mouse): paddleX = relativeX - paddleWidth / 2 for any 0 < relativeX < width.
Reproduce
- Open the Breakout Task in a viewport that gives the 480x320 canvas, and press Start.
- Hold the right arrow until the paddle stops.
Actual: the paddle comes to rest at x = 405.5, so its right edge is at 480.5, past the 480px canvas. Holding the left arrow leaves it at x = -0.5.
Expected: the paddle stops flush with the wall, at x = 405 on the right and x = 0 on the left.
You can also move the mouse to the far right edge of the canvas. Actual: half the paddle is drawn off the canvas. Expected: the paddle stops at the wall.
Tests
packages/instrument-library/src/__tests__/DNP_BREAKOUT_TASK.test.ts. The tests should stop the paddle at the right wall and should stop the paddle at the left wall were written loosely on purpose: they only assert that the paddle stops within one 7px step of the wall, so they pass with or without the fix. When fixing, tighten them to expect(stoppedX).toBe(CANVAS_WIDTH - PADDLE_WIDTH) and expect(stoppedX).toBe(0). If the mouse path is clamped too, add a case that moves the mouse near each edge.
Suggested fix
Clamp after moving, e.g. paddleX = Math.min(paddleX + 7, width - paddleWidth) and paddleX = Math.max(paddleX - 7, 0). Apply the same [0, width - paddleWidth] clamp in mouseMoveHandler.
Found while raising unit test coverage in #1632.
Activity
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). NeitherDNP_BREAKOUT_TASK/index.tsnor its test has changed since this was filed, and no open PR touches them.Fix
runTask,const clampPaddleX = (value: number) => Math.min(Math.max(value, 0), width - paddleWidth), and use it for the keyboard step and inmouseMoveHandler. Breakout Task paddle does not follow a finger dragged across a touch screen #1672's pointer handlers should call it too.:203and:205instead of keeping them next to the clamp. Today, holding both arrows at the right wall flickers the paddle between 405.5 and 398.5 every frame: the right check fails, so theelse ifmoves it left. With the checks kept, it flickers between 405 and 398. Without them the right arrow wins, as it already does away from the wall.relativeX > 0 && relativeX < widthguard at:96.mousemoveevents are discrete, so a fast flick can go from mid-canvas to past the edge between two events, and the guard then leaves the paddle short of the wall.#rootfills the frame and centres the canvas (styles.css:12-18), so the pointer can leave the canvas without leaving the frame.#1641, #1669 and #1672 also change
runTaskandDNP_BREAKOUT_TASK.test.ts, so one PR with a commit per issue is simplest.Tests
Unit, in
DNP_BREAKOUT_TASK.test.ts, on top of the issue's twotoBechanges:setViewport(1000, 500)), where the overshoot is largest:mainstops at 512.125 against 506.25 on the right, and at -5.875 on the left.lastPaddleX()(:79-81) finds the paddle byy === 310, the scale 1 row, so it needs the row as a parameter (387.5 at 1.25).should ignore the mouse at x = %i, outside the canvas(:239-244) becomes a wall case:moveMouse(0)gives0andmoveMouse(480)gives405.E2E, in
testing/src/specs/instrument-completion.spec.ts. The suite's setup creates a demo instance (testing/src/pages/setup.page.ts:21), which seeds the task (apps/api/src/demo/demo.service.ts:72).iframe[name="interactive-instrument"].boundingBox().getImageDatainlocator.evaluate. The canvas draws only shapes and text, so it is not tainted. The scale iscanvas.clientWidth / 480and the backing ratio iscanvas.width / canvas.clientWidth.width - paddleWidthis#0095DD.On
mainthat pixel stays empty, because the paddle is centred on the pointer atwidth - 1. The keyboard overshoot is half a pixel at scale 1, so leave it to the unit tests.Playground copy
Interactive-With-Vanilla/index.tshas the same mouse handler (:69-74) and keyboard block (:174-178) on a fixed 480px canvas, plus the other three Breakout bugs. File it as oneArea: Playgroundissue. It cannot get a unit test the way this task does: the playground's vitest project runs in node with no/runtime/v1alias, and the glob atapps/playground/src/instruments/index.ts:8would pull a__tests__/folder beside the example into the example's files.playground.spec.tsalready opens examples by name (:221), so the e2e check above can run there.Triage note written with Claude Code. Code references point at
main@d86740e.