Skip to content

Breakout Task paddle can move past the canvas walls #1640

Open

Description

@joshunrau

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

  1. Open the Breakout Task in a viewport that gives the 480x320 canvas, and press Start.
  2. 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

  1. added
    BugType: existing behavior is wrong
    Priority: LowNice to have, cosmetic or speculative
    Difficulty: LowIsolated change, about 2 hours or less
    Area: InstrumentsInstrument authoring API, bundling, runtime, rendering, measures and the built-in library
    Good First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributor
    on Oct 5, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Neither DNP_BREAKOUT_TASK/index.ts nor its test has changed since this was filed, and no open PR touches them.

    Fix

    • Add one helper in runTask, const clampPaddleX = (value: number) => Math.min(Math.max(value, 0), width - paddleWidth), and use it for the keyboard step and in mouseMoveHandler. Breakout Task paddle does not follow a finger dragged across a touch screen #1672's pointer handlers should call it too.
    • Drop the position checks at :203 and :205 instead 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 the else if moves 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.
    • Drop the relativeX > 0 && relativeX < width guard at :96. mousemove events 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. #root fills 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 runTask and DNP_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 two toBe changes:

    • A scale 1.25 case (setViewport(1000, 500)), where the overshoot is largest: main stops at 512.125 against 506.25 on the right, and at -5.875 on the left. lastPaddleX() (:79-81) finds the paddle by y === 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) gives 0 and moveMouse(480) gives 405.
    • Hold both arrows past the wall and assert the paddle is at 405 on consecutive frames.

    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).

    1. Start a session, open the Breakout Task and click Start inside iframe[name="interactive-instrument"].
    2. Move the mouse to 1px inside the canvas's right edge, using its boundingBox().
    3. Read the bottom row with getImageData in locator.evaluate. The canvas draws only shapes and text, so it is not tainted. The scale is canvas.clientWidth / 480 and the backing ratio is canvas.width / canvas.clientWidth.
    4. Poll until the pixel 2 CSS px right of width - paddleWidth is #0095DD.

    On main that pixel stays empty, because the paddle is centred on the pointer at width - 1. The keyboard overshoot is half a pixel at scale 1, so leave it to the unit tests.

    Playground copy

    Interactive-With-Vanilla/index.ts has 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 one Area: Playground issue. It cannot get a unit test the way this task does: the playground's vitest project runs in node with no /runtime/v1 alias, and the glob at apps/playground/src/instruments/index.ts:8 would pull a __tests__/ folder beside the example into the example's files. playground.spec.ts already 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.

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: LowIsolated change, about 2 hours or lessGood First IssueWell-scoped, low-risk and self-contained; a good entry point for a new contributorPriority: LowNice to have, cosmetic or speculative

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions