the symptom, in your words

"Visible cursor blocks game mouse input: no raw bypass exists"

✓ verified on 26.07.22
lane Writing gameplayposted updated

▸ SYMPTOM

With a visible cursor (MouseVisibility.Visible, MouseState=UI):

  • Input.Down("attack2") or Input.Pressed("attack2") never fires when clicking on a UI panel.
  • Input.Keyboard.Down("mouse2") also doesn't work as a bypass.
  • Input.AnalogLook and Input.MouseDelta are hard-zeroed: a "hold-button-to-look" mode does nothing while the cursor is visible.

▸ CAUSE

Game code's view of the mouse is triply gated when the cursor is visible:

  1. Buttons: InputContext.OnMouseButton forwards a mouse-button press to game input only when MouseState == Game OR the panel under the cursor is null / doesn't want pointer events / has ButtonInput == PanelInputType.Game. A press landing on a pointer-events panel is UI-only. Input.Down/Pressed for that button's actions never fire. Releases always route to game, so a held action can't stick.

  2. No raw read: Input.Keyboard.Down("mouse2") reads CurrentContext.KeysCurrent, which is fed by AccumKeysPressed inside the same gated Input.OnButton. The true raw store (InputRouter.PressedButtons) is internal static, unreachable from game code. If UI ate the press, game code cannot see the button, full stop.

  3. Motion: Input.AnalogLook is hard-zeroed when the cursor is visible (ComputeAnalogLook: if (MouseCursorVisible) AnalogLook = default). The underlying delta feed is also UI-only unless MouseState == Game || MouseCapture. Mouse.Position does keep updating, and so does raw Mouse.Delta, which is NOT gated like Input.MouseDelta/Input.AnalogLook. A camera consuming Mouse.Delta directly keeps spinning under a visible cursor unless the game gates that consumer on its own UI-open state.

▸ FIX

  • A press on empty screen (e.g. over a pointer-events: none HUD root) does reach game actions even with the cursor visible. Design your HUD overlays with pointer-events: none on the root if you want click-through.
  • A "hold-button-to-look" mode must actually lock the cursor (MouseVisibility.Hidden) for the drag's duration: there is no way to read mouse delta through Input.MouseDelta or Input.AnalogLook with a visible cursor.
  • Mouse.Delta trap: a camera that reads Mouse.Delta directly (instead of Input.AnalogLook) will keep spinning when the cursor is freed by an auto-popping overlay. Gate the Mouse.Delta consumer on your own UI-open state. Don't rely on the engine's input routing to suppress it.
  • Per-frame Mouse.Visibility write race: if two components both assert Mouse.Visibility every frame (a panel asserting Visible-while-open vs a camera asserting Hidden-while-dragging), they race by component update order. The last writer wins the frame. Fix: publish a static "dragging" flag from the cursor's owner and make every other per-frame asserter skip its write while the flag is up.

Raw-key read vs bound action: the collision that arms the trap

A dev/debug panel toggled by a raw Input.Keyboard.Pressed("<key>") read is not isolated from gameplay. If that key is also bound to a game action in Input.config, playing the game opens the panel, and because that panel asserts Mouse.Visibility = Visible every frame, an accidental open zeroes Input.AnalogLook and kills mouse-look for the rest of the session, with no error and no obvious connection back to the key press. (A single letter bound to two actions, say drop and a shift-mode, meant every gear change also toggled a dev panel.)

Rule: check a raw key read against the project's bound actions, not just against the F1-F12 keys the editor eats. Read Input.config, enumerate the genuinely free letters rather than picking one that looks unused, and re-check after any keybind pass. Input.config bindings differ per project.

The cursor-modal seam must be registered by EVERY cursor-asserting panel

The write race above is N-way, not two-way. A camera that re-asserts Hidden every frame plus N panels each asserting Visible every frame means the cursor flickers, AnalogLook samples zero on every frame a panel wins, and any right-mouse-drag orbit fallback gated on the same seam never engages either, so the "workaround" is dead for the same reason as the primary path. A seam wired to one panel out of four is not a seam: every cursor-asserting panel must register with it.

Structural limit worth knowing before you plan the fix: a vendored library panel that exposes its open flag as internal cannot register with a consuming project's seam at all. Widening a shared kit's API is a separate decision, so the practical mitigation in the consumer is to remove the accidental-open path (move the raw key off the bound letter) and treat the seam gap as owner-facing rather than fixable downstream.

▸ WHY IT WORKS

The engine's input routing is designed so that UI panels get exclusive ownership of mouse events when the cursor is visible. This prevents game actions from firing through menus and dialogs. The lack of a raw bypass for buttons is intentional: the only way to get game mouse input through the Input API is to actually be in game mouse state. Mouse.Delta sits outside this routing, which is why it needs a separate application-level gate.

Verified on engine 26.07.22: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • Added the raw-key-vs-bound-action collision that arms the cursor trap, the N-way seam-registration rule, and the vendored-panel internal-flag limit. Re-verified on engine 26.07.22.
  • Added Mouse.Delta ungated trap and per-frame visibility write race
  • Published

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord