Real CONTRABAND development case

Pointer lock vs clickable dialogue

A Martinez AI Studios education product

Flight wants the mouse captured. Choice buttons 1/2 need the mouse free. Those states collided in Last Run.

During Last Run, a hail/choice UI with clickable 1/2 buttons could appear while pointer lock was still active. Keyboard/mouse players could not click. Gamepad players did not need the same hint. The documented fix is a “Press ESC to take the mouse” prompt, with two follow-on rules: do not recapture while the hail is open, and after ESC do not ask for ESC again.

Category
Input / UX
Project
CONTRABAND
Recorded
2026-08-19
Status
Verified · published

1. Symptom

Clickable story choices were on screen while document.pointerLockElement was set. The cursor was captured for flight. ESC also opened other tutorial skip UI if the recapture hint was wrong.

2. Player impact

A timed or story-critical choice (including the Varek hail) could not be taken with the mouse. Players mashed ESC and either recaptured lock immediately or opened Skip. Gamepad users were unaffected by the click path, so the bug looked “random” depending on device.

3. Reproduction

Enter Last Run on keyboard/mouse, keep pointer lock, reach a hail with visible 1/2 buttons. Attempt to click. Press ESC. Confirm whether lock returns immediately and whether Skip opens. lastRunShouldShowReleaseMouseHint is true only when pointerLocked && clickableChoiceVisible && dominantDevice !== "gamepad".

4. System ownership

Pointer-lock policy for story vs live combat is pointer-lock-policy.js. Last Run hint rules are last-run-map-lesson.js / last-run-from-verge.js. Many other systems call exitPointerLock when opening stations, maps, or story — the incident is the missing handoff when a clickable overlay opens without exiting lock.

5. Incorrect assumption

If a UI is visible, the mouse is available. If the player presses ESC, they want lock again. One recapture string (“press ESC”) works before and after the browser has already dropped lock.

6. Root cause

Pointer lock and clickable DOM are mutually exclusive on desktop Chromium. Last Run showed choices without exiting lock. After the browser released lock on ESC, the same “press ESC” copy was still shown, so ESC was overloaded (recapture vs Skip). While the hail stayed open, recapture stole the cursor again.

7. Minimal fix

Show the release-mouse hint only for locked pointer + visible clickable choices + not gamepad. While that hail is open, ESC must not recapture. After ESC, switch the hint to click-to-recapture and retry lock only after the browser cooldown. Do not change progression or rewards (a related Survival toast was later made non-modal so it would not drop lock at all).

8. Regression test

last-run-from-verge.test.mjs covers the ESC hint and recapture copy. lastRunShouldShowReleaseMouseHint is unit-tested with the three predicates (lock, choices, device).

9. Release validation

Development log 19 Aug 2026, v0.4.148–v0.4.153: ESC hint, no recapture during hail, post-ESC click hint. Later Last Run Electron QA (keyboard vs gamepad cells) is the release-style check for this input split.

10. Generalized lesson

Capture modes are exclusive with clickable overlays. Own the transition: exit lock, tell the player how, and do not bind ESC to two verbs. Device-specific UI (hide the hint on gamepad) is part of correctness, not polish.

Simplified excerpt

Simplified / pseudocode for teaching. Not a dump of production files.

The hint predicate, simplified.

// Simplified teaching excerpt — not CONTRABAND production source.
function shouldShowReleaseMouseHint({ pointerLocked, clickableChoiceVisible, dominantDevice }) {
  return pointerLocked && clickableChoiceVisible && dominantDevice !== 'gamepad';
}
// While hail open: ESC must not recapture.
// After ESC: browser already dropped lock — ask for a click, not another ESC.

Public evidence

  • CONTRABAND development log, 19 Aug 2026, v0.4.148–v0.4.153 (ESC hint / recapture).
  • lastRunShouldShowReleaseMouseHint; pointer-lock-policy.js story vs live combat.
  • last-run-from-verge.test.mjs

Related flagship modules

Related notes