Quick answer: Cursor capture is a state machine: Default, Confined, Relative. Transitions happen on UI state changes and on OS focus events. Most cursor bugs come from skipping a transition — usually forgetting to restore the cursor on focus-lost. Track the expected mode explicitly and log every transition; the log will show you where the state machine skipped a step.

Players hate nothing more than fighting their own cursor. You alt-tab out of the game to check Discord, switch back, and your cursor is invisible in the middle of the screen. Or the cursor escapes onto the second monitor every time you swing the camera. Or the pause menu opens but clicks pass through to the gameplay behind it. Cursor bugs are small to the code and huge to the player.

Three Modes of Cursor State

Every game cursor is in one of three states:

Every cursor bug is a case where the mode was wrong for the UI state, or where a transition didn’t fire. Enumerate the modes explicitly in your engine; don’t leave them as booleans like cursorVisible and cursorLocked — that combination has four possible states, three of which are meaningful and one of which is a bug.

OS-Level Differences

Windows uses ClipCursor(RECT*) to confine and ShowCursor(FALSE) to hide. Relative mouse input comes through raw input (WM_INPUT) or the now-standard RIDEV_INPUTSINK. On multi-monitor, ClipCursor confines correctly only if the rect is in screen coordinates; engine wrappers sometimes pass client coordinates and the cursor escapes.

macOS uses CGAssociateMouseAndMouseCursorPosition(false) for relative input and CGWarpMouseCursorPosition to reposition. Confined mode is not a first-class concept — you implement it by warping the cursor back to the window edge each time it moves past. Do this in the input callback, not on a timer.

Linux/X11 uses XGrabPointer and XIGrabDevice. Wayland uses the pointer-constraints protocol. Wayland’s model is stricter: you can only constrain when your surface has focus, and the user can always break out. Your code must handle the break-out gracefully.

Linux/Steam Deck (gamescope) has its own quirks — relative mouse mode is mostly solid, but confined mode interacts badly with the overlay. Test on a real Deck, not just desktop Linux.

The Focus Event Trap

Alt-tab, clicking another window, the OS showing a system notification, a USB controller connecting — any of these can fire a focus-lost event. When it does, you must release the cursor. Every platform hates an application that confines the cursor while not focused; Windows will override your ClipCursor but not your ShowCursor(FALSE), leaving the user in the “invisible cursor” state.

// Pseudo-code for cursor state on focus events
void OnFocusLost() {
    savedMode = currentMode;
    ApplyMode(CursorMode.Default);
}

void OnFocusGained() {
    ApplyMode(savedMode);
}

The pattern is: save the intent when focus is lost, restore the intent when focus is regained. Don’t try to keep confinement active in the background — the OS will fight you and players will lose.

Mode Transitions on UI State

Your UI layer owns cursor mode transitions. When the pause menu opens, it requests Confined. When the player returns to gameplay, it requests Relative. Loading screens typically request Default so players can move the cursor out of the way if they want.

Buggy transitions happen when two systems both think they own the cursor. The pause menu requests Confined; a tutorial popup on top of it also sets the cursor, overwrites to Default, and when the tutorial closes, the cursor stays Default. Fix this with a stack: each UI layer pushes its desired mode, pops it when closed, and the current mode is whatever’s on top of the stack.

Common Bugs and Fixes

Cursor invisible after alt-tab. You forgot to re-show the OS cursor on focus-lost. Windows hides the cursor globally while your hide is active; when you lose focus, the cursor state isn’t automatically restored. Explicit ShowCursor(TRUE) on focus-lost fixes it.

Cursor escapes on multi-monitor. Your confinement rect is in window coordinates, not screen coordinates. On a second monitor with different DPI, the rect is even more wrong. Convert to screen coords before calling ClipCursor.

First mouse delta after unlock is huge. When you switch from Confined to Relative, the stored cursor position and the current position can differ by hundreds of pixels. The next raw input reports the real delta, which snaps the camera. On mode switch, flush one frame of mouse input before applying it to the camera.

Clicks pass through menus. You’re in Relative mode while the menu is open. In Relative mode, the OS cursor is off-screen and can’t hit UI elements. The menu must switch to Confined on open.

Cursor drifts on high-refresh-rate monitors. Some engines scale raw mouse deltas by monitor refresh rate. On a 240 Hz monitor, that’s 4x the expected sensitivity. Lock sensitivity to an absolute dots-per-degree value and decouple from frame rate.

Instrument the State Machine

Log every cursor-mode transition with the trigger. A log like:

14:02:11.100  cursor Default -> Relative  (reason: gameplay_start)
14:02:44.220  cursor Relative -> Confined (reason: pause_menu_open)
14:03:01.850  cursor Confined -> Default  (reason: focus_lost)
14:03:08.440  cursor Default -> Confined  (reason: focus_gained)
14:03:12.990  cursor Confined -> Relative (reason: pause_menu_close)

Ship this log as part of bug-report attachments. When a player says “my cursor is broken,” the log almost always has the answer — a missing transition, a mode that fired twice, or a focus event that didn’t land.

“Cursor bugs are all state-machine bugs. Write the state machine down, log its transitions, and the bugs fix themselves.”

Related Issues

For broader input issues, see how to debug input lag and stuck keys. For focus-loss bugs that affect more than the cursor, see how to debug Windows alt-tab fullscreen bugs.

The cursor is a state machine with three states and six events. Write it like one, and every “cursor weirdness” becomes a log line you can point at.