Skip to content

Breakout Task paddle does not follow a finger dragged across a touch screen #1672

Open

Description

@joshunrau

On a touch screen, dragging a finger does not move the Breakout Task paddle. The paddle responds only to mousemove and the arrow keys. A finger dragged across a touch screen produces touch and pointer events but no mousemove: browsers send compatibility mouse events only for a tap, after the finger lifts. Tablets and phones have no arrow keys either. So the paddle stays put while the ball falls, and the only way to move it is to tap where it should go, one jump per tap. The canvas also has no touch-action: none, so the browser treats a drag over it as a pan gesture. A subject who does a remote assignment on a tablet or phone through the gateway will usually lose all three lives and be recorded as win: false. That outcome measures the input device, not the subject.

Where

packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/index.ts:94-99 and :214-216:

const mouseMoveHandler = (e: MouseEvent) => {
  const relativeX = e.clientX - canvas.offsetLeft;
  if (relativeX > 0 && relativeX < width) {
    paddleX = relativeX - paddleWidth / 2;
  }
};
// ...
document.addEventListener('keydown', keyDownHandler, false);
document.addEventListener('keyup', keyUpHandler, false);
document.addEventListener('mousemove', mouseMoveHandler, false);

The canvas rule in packages/instrument-library/src/interactive/DNP_BREAKOUT_TASK/styles.css:6-10 sets no touch-action.

Reproduce

  1. Open the Breakout Task on a tablet or phone and press Start.
  2. Put a finger on the paddle and drag it left or right.

Actual: the paddle does not move during the drag. The ball falls past it and a life is lost.
Expected: the paddle follows the finger, the same way it follows the mouse.

Tests

packages/instrument-library/src/__tests__/DNP_BREAKOUT_TASK.test.ts: it('should center the paddle on a touch pointer inside the canvas, so the task is playable on a tablet'). Dispatch new PointerEvent('pointermove', { clientX: 100, pointerType: 'touch' }), step one frame, and expect lastPaddleX() to be 62.5, matching the existing mouse case.

There is no e2e test for this. The paddle exists only as pixels on a canvas, so Playwright has no element whose position it can assert. The unit test covers the input handling.

Suggested fix

Listen for pointermove instead of mousemove. Pointer events cover mouse, pen and touch, with the same clientX. Add touch-action: none to the canvas rule in styles.css, so the browser hands the drag to the page instead of treating it as a pan.

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 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). Nothing sets touch-action, neither the task's styles.css nor packages/runtime-internal/src/interactive/iframe.html.

    Changes to the suggested fix

    • Put touch-action: none on #root, not on canvas. The browser intersects the touch-action of the touched element and its ancestors (MDN), so a rule on #root covers the canvas as well. A rule on canvas alone is not enough. The paddle is drawn flush with the bottom edge of the canvas (index.ts:133), so a finger aimed at it can land below the canvas, on #root. A subject who keeps the finger under the paddle so it stays visible is always there. That drag is still taken as a pan, the browser sends pointercancel, and pointermove stops. The listener is on document and reads only clientX, so it would otherwise steer fine from below the canvas.
    • Listen for pointerdown as well. Today a tap places the paddle only through the compatibility mousemove. A finger that does not move fires no pointermove, so with pointermove alone a tap stops working.
    • Ignore pointers whose isPrimary is false. Only the primary pointer produces compatibility mouse events (Pointer Events), so today a second finger cannot move the paddle. A pointermove listener on document would let it. The mouse pointer is primary, so mouse play does not change.

    Tests

    • moveMouse (DNP_BREAKOUT_TASK.test.ts:87-89) dispatches MouseEvent('mousemove'), which the new listener never sees. Two game tests also use it through followBallWithMouse. Change it to dispatch new PointerEvent('pointermove', { clientX, isPrimary: true, pointerType: 'mouse' }).
    • isPrimary defaults to false in the spec and in happy-dom 20.11.15, so the touch case in the issue needs isPrimary: true.
    • Add a pointerdown case. Add a non-primary pointermove case that leaves the paddle at 202.5; the new branch needs it for 100% coverage.

    An e2e test is possible, and it is the only test that can see the CSS: the unit tests load styles.css as an empty module. The demo seed creates the Breakout Task (apps/api/src/demo/demo.service.ts:72). In testing/src/specs/instrument-completion.spec.ts, under test.use({ hasTouch: true }):

    1. Start a session, open the task and click Start inside iframe[name="interactive-instrument"].
    2. Assert that #root in that frame has touch-action: none (toHaveCSS).
    3. Drag a finger with page.context().newCDPSession(page) and Input.dispatchTouchEvent: touchStart on #root just below the canvas, a few touchMoves, then touchEnd. Take the coordinates from the canvas's boundingBox(). End away from the center but well inside the canvas, so that Breakout Task paddle can move past the canvas walls #1640's clamp does not apply. In Playwright 1.63 Touchscreen has only tap(), and its Chromium implementation sends this same CDP call. CDP is Chromium-only, so do not tag the test @smoke.
    4. Poll the bottom row of the canvas with getImageData, scaled by canvas.width / canvas.clientWidth. Assert that the run of #0095DD pixels is centered under the finger. The ball is first served from y = height - 30 moving up, so it is nowhere near that row in the first second.

    Elsewhere

    • The playground's Interactive-With-Vanilla example has the same handler (index.ts:53, :69-74) and the same canvas rule. It appends the canvas directly to body, so its touch-action: none goes on html, body.
    • After this fix nothing in the frame can be panned, so a canvas larger than the frame stays clipped. getScale (index.ts:16-26) never returns less than 1, so the canvas is at least 480×320 CSS px. On a phone in portrait it is wider than the frame, and justify-content: center on #root puts its left edge out of reach. That needs its own issue.
    • Breakout Task paddle can move past the canvas walls #1640 clamps inside this handler and Breakout Task keeps running and can report completion twice after it finishes #1641 removes the listeners when the game ends. Whichever lands after this one has to work on pointermove and pointerdown instead of mousemove.

    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