Aug 25, 2026
Pointer lock's Esc cooldown and an 8-byte patch
Question: Day Hike is a first-person game that runs in Chromium. Every time a player presses Esc to open the pause menu, the next click to resume does nothing for over a second. Where does that dead zone come from, can a page get rid of it, and if not, what is the cheapest way to ship a runtime without it?
Short answer: Chromium's browser process refuses to re-grant pointer lock for 1.25 s
after the user escapes one. The duration is a compile-time constant with no flag or switch.
A page can avoid it only when the page itself released the lock, or when the tab is
fullscreen. The fullscreen route worked until a macOS window-manager exit left
document.fullscreenElement reporting fullscreen in a windowed tab, so it was reverted. The
browser build keeps the cooldown and softens it with a pause menu. The desktop build runs on
Electron, which compiles the same controller, and there the fix is a binary patch: Electron
publishes Breakpad symbols for every release, which give the exact file offset of
PointerLockController::HandleUserPressedEscape(), and replacing its first two instructions
with mov w0, #0; ret (8 bytes on macOS arm64; xor eax, eax; ret, 3 bytes, on Windows x64)
hands Esc to the page. Measured relock after Esc drops from 1268–1526 ms to 2–29 ms. No
Chromium checkout and no compile. The patched builds are published as
electron-gamepatch.
Versions: the first experiments ran on Electron 44.0.0 (Chromium 152.0.7977.54); the build
that ships is Electron 44.1.1 (Chromium 152.0.7977.65). The Chromium source below is quoted
from the current main branch; its constant and gate agree with the disassembly in section 5.
The constant and the gate live in
chrome/browser/ui/exclusive_access/pointer_lock_controller.cc:
constexpr base::TimeDelta kEffectiveUserEscapeDuration =
base::Milliseconds(1250);
// RequestToLockPointer(web_contents, user_gesture, last_unlocked_by_target)
if (!last_unlocked_by_target && !web_contents->IsFullscreen()) {
if (!user_gesture) { /* kRequiresUserGesture */ }
if (base::TimeTicks::Now() <
last_user_escape_time_ + kEffectiveUserEscapeDuration) {
/* kUserEscapeCooldown */
}
}
Only HandleUserPressedEscape() sets last_user_escape_time_, and it does so in the same
call that ejects the lock. Two exemptions follow from the gate:
- A page-initiated release never arms the timer. If the page called
document.exitPointerLock(),last_unlocked_by_targetis true and the next request is granted at once. - A fullscreen tab skips the gate entirely, cooldown and user-gesture check included.
The comment above the gate gives the reason: it stops a misbehaving site from constantly re-locking the pointer after the user has tried to get out. That is a sound default for the web. For a first-person game it turns every Esc into a dead zone: click, nothing, click again.
- Release the lock ourselves wherever we can. When the game unlocks for its own reasons
(the command bar, for example), it calls
exitPointerLock()explicitly, so those relocks are exempt. This works and is kept. It cannot help with Esc itself, because Chromium ejects the lock before the page sees the key. - Fullscreen on play, plus Keyboard Lock. Entering fullscreen when play starts and
calling
navigator.keyboard.lock(["Escape"])voids the cooldown (fullscreen exemption) and makes a tap of Esc reach the page instead of ejecting the lock; the player holds Esc to leave fullscreen. It worked perfectly until a player left fullscreen with the macOS green window button. Instrumentation showed Chrome fires nofullscreenchangeon that window-manager exit:document.fullscreenElementstayed set while the window was plainly windowed (innerHeightwent from 923 to 836 with the element still reported). Every re-entry strategy built on top of that state lost to it in practice: retrying after the fullscreen promise, resyncing the flag when it contradicted the geometry, and detecting the exit from resize events. The fullscreen approach was reverted in full; the shipped client never requests HTML fullscreen. - A pause menu. Losing the lock opens a Resume / Exit overlay, driven off
pointerlockchange, with game input suppressed while it is open. The time it takes to move the mouse to Resume absorbs most of the 1.25 s. A Resume click that lands too early is refused and the player clicks again; Esc pressed quickly a second time leaves the menu up for the same reason. This is what the browser build ships today (pauseMenu.ts).
The client's Esc handler is written for both environments
(input.ts):
on keydown for Escape while locked, it calls exitPointerLock(). In a browser the lock is
already gone by then, so it does nothing. In a runtime where Esc does not eject the lock, the
same handler is the whole Esc flow, and because the release is page-initiated it arms no
cooldown.
A desktop shell was the next option, because it owns its window: no green-button problem and no need for HTML fullscreen. The question was whether Electron behaves differently.
The cooldown and the eject are stock. Electron compiles the same
pointer_lock_controller.cc. Its own Chromium patch for exclusive access touches the fullscreen controller, not pointer lock.The eject happens before the main process can see the key. In Electron 44.1.1,
WebContents::PreHandleKeyboardEventasks the exclusive-access manager first and emitsbefore-input-eventonly if it did not handle the key:if (exclusive_access_manager_.HandleUserKeyEvent(event)) return content::KeyboardEventProcessingResult::HANDLED; ... bool prevent_default = Emit("before-input-event", tweaked_event);A physical Esc while locked confirmed it: the lock ejected, the keydown was consumed, and only the harmless keyup reached
before-input-event. There is no main-process hook that can intercept it.Injected keys do not test this path. Keys sent through the DevTools protocol or
webContents.sendInputEventbypassPreHandleKeyboardEvent: an injected Esc neither ejects the lock nor firesbefore-input-event, so an automated test built on them passes on stock Electron and proves nothing. Only a key press delivered through the operating system exercises the path. On macOS that isosascript -e 'tell application "System Events" to key code 53'; on Windows, PowerShell's[System.Windows.Forms.SendKeys]::SendWait('{ESC}').A second, renderer-side rate limit. Polling for the relock every 100 ms on stock Electron produced
SecurityError: Pointer lock cannot be acquired immediately after the user has exited the lockfor roughly the first 300 ms, thenNotAllowedError: Too many pointer lock requests in a short window of time. That one comes from Blink's own pointer lock controller, which keeps a sliding window of recent request timestamps and rejects requests while the window is full. It is why the measured stock gap overshoots 1250 ms, and it means a "retry until granted" Resume button must poll slower than that window or it makes the wait longer. Such a retry loop was scoped as a stopgap for stock Electron; the shipped client has none.The only other refusal seen in testing was
WrongDocumentErroron a window hidden behind others.
Either of two one-function changes removes the dead zone:
| Shape | Change | Effect |
|---|---|---|
zero |
kEffectiveUserEscapeDuration becomes 0 |
Esc still ejects the lock in the browser process; relock is immediate |
noeject |
HandleUserPressedEscape() returns false without ejecting |
Esc reaches the page like any other key; the page releases the lock, which arms no cooldown |
noeject is the one the game is written for: the page owns Esc outright, and the existing
handler in input.ts opens the menu. Its cost is that a page which never releases the lock
can only be escaped by switching away from the window. That is acceptable for an app that
loads one game and nothing else; it would not be for a general browser.
The obvious delivery is an Electron source fork with one extra patch in Electron's patch series. It was costed and not built: a Chromium checkout of tens of gigabytes, a first clean build estimated at several hours on Apple Silicon (an estimate, never measured), and a re-sync and rebuild for every Chromium security release. A compile cache that Electron's build tooling can use might cut the rebuilds; that was scoped but never tried, so it is unverified.
What made a binary patch possible is that both changes are tiny at the instruction level.
Because the duration is constexpr, it is inlined into the comparison as an immediate
(1,250,000 microseconds, 0x1312D0). And HandleUserPressedEscape() is 17 instructions on
arm64, so overwriting its prologue with "return false" is a legal replacement for the whole
function.
No pattern scanning and no guessing. Electron publishes Breakpad symbols with every release
(electron-v44.0.0-darwin-arm64-symbols.zip, 128 MB). The Electron Framework.sym file lists
every function's address and size, plus a per-instruction source-line table, and its MODULE
line carries the binary's UUID, which is checked against the framework before trusting any
address. The 1.4 GB dSYM is not needed. In the framework, the __TEXT segment has virtual
address 0 at file offset 0, so a symbol address is also a file offset.
| Function (Electron 44.0.0, darwin-arm64) | Address | Size |
|---|---|---|
PointerLockController::RequestToLockPointer(WebContents*, bool, bool) |
0x903c5cc |
0xfc |
PointerLockController::HandleUserPressedEscape() |
0x903ca3c |
0x44 |
llvm-objdump -d with start and stop addresses shows the gate exactly as the source reads
(the line table maps it to the comparison in section 1):
903c61c: 52825a09 mov w9, #0x12d0
903c620: 72a00269 movk w9, #0x13, lsl #16 ; w9 = 0x1312D0 = 1,250,000 us
903c624: ab090108 adds x8, x8, x9 ; last_user_escape_time_ + duration
903c630: eb08001f cmp x0, x8 ; Now() < ...
903c634: 5400024b b.lt ; -> kUserEscapeCooldown
Each shape is one 8-byte write:
| Shape | Offset | Before | After |
|---|---|---|---|
zero |
0x903c61c |
095a8252 6902a072 |
09008052 1f2003d5 (mov w9, #0; nop) |
noeject |
0x903ca3c |
f44fbea9 fd7b01a9 |
00008052 c0035fd6 (mov w0, #0; ret) |
Changing the framework invalidates its code signature, so it is re-signed ad hoc. Stock
Electron is already only ad hoc and linker signed, so nothing is lost, and a packager signs
the final app with a real identity anyway. One detail matters for a byte-exact result: signing
the framework binary in place makes codesign seal the whole bundle even without --deep,
adding files the upstream zip does not have. Signing a copy outside the bundle and copying it
back avoids that (the
design notes
cover it).
The test app is small: synthetic clicks lock the pointer (clicks are fine; only keys take the
bypass in section 3), a real Esc goes through the operating system, then the main process
clicks every 100 ms until pointerlockchange reports locked, and records the gap. Three
rounds per build on Electron 44.0.0, macOS arm64:
| Build | Relock gap after Esc | Attempts | Page saw keydown Escape while locked |
|---|---|---|---|
| stock | 1322 / 1526 / 1526 ms | 14–16 | no |
zero |
12 / 12 / 3 ms | 1 | no |
noeject |
10 / 24 / 20 ms | 1 | yes; the page called exitPointerLock() itself |
Neither patched build ever saw a refusal. Then the real game ran in the noeject build,
driving the actual flow: click the canvas, real Esc, click the pause menu's Resume. Three
instrumented runs:
| Step | Run 1 | Run 2 | Run 3 |
|---|---|---|---|
| Click to locked | 127 ms | 96 ms | 80 ms |
Esc keydown reached the page while locked |
yes | yes | yes |
| Page released, menu open | 27 ms | 34 ms | under 10 ms |
| Resume click to relocked | 12 ms | 13 ms | 16 ms |
pointerlockerror or rejected requests |
0 | 0 | 0 |
No client change was needed. An earlier, uninstrumented run reported a 1286 ms relock; it was a race in the test driver (it read the Resume button's position before the menu existed, so the click missed) and it never reproduced once the page was instrumented.
The same method carries over with two format changes and a different probe.
- Symbol address to file offset. Windows symbols give a relative virtual address, not a
file offset, so the PE section table is walked: find the section containing the address,
then
offset = rva - VirtualAddress + PointerToRawData. In Electron 44.1.1'selectron.exe,HandleUserPressedEscape()is at RVA0x7c53420,.textstarts at RVA0x1000and raw offset0x600, so the file offset is0x7c52a20. - Binary identity. Instead of a Mach-O UUID, the executable's CodeView debug record (the
RSDS GUID and age), formatted the way Breakpad formats it, must equal the
.symmodule id. - The bytes. The prologue starts with three single-byte pushes,
push rsi; push rdi; push rbx(56 57 53), replaced byxor eax, eax; ret(31 c0 c3). Same length, same effect as the arm64 write.electron.exeships unsigned, so there is no re-signing, and a byte compare of patched against upstream prints exactly three differing bytes. - The probe. Esc is sent with PowerShell
SendKeyson a hosted Windows CI runner. The stock build runs as a mandatory control: if stock passes, the runner is not delivering real keystrokes and the result is thrown out. Without that control, a runner that silently dropped the key would make every build look patched.
Measured on the CI runners for Electron 44.1.1, three rounds each:
| Runner | Build | Relock gap after Esc | Attempts |
|---|---|---|---|
| Windows x64 | stock | 1268 / 1317 / 1327 ms | 13–14 |
| Windows x64 | noeject |
11 / 2 / 9 ms | 1 |
| macOS arm64 | noeject |
3 / 29 / 18 ms | 1 |
The patch README summarises the arm64 numbers and the page-side contract (pointerlock-noeject).
08
Delivery
Route A, shipped: republish patched Electron releases. electron-gamepatch republishes
official Electron releases with the noeject patch on darwin-arm64 and win32-x64 and
everything else byte-identical. A project adds two lines to .npmrc and installs Electron as
usual; packagers see a normal Electron. The
README has the setup,
the platform table and how to verify a release with cmp, and the patch itself is a small
declarative file,
patch.json,
naming the symbol, the expected bytes and the replacement.
No offsets are stored, because they move. The same HandleUserPressedEscape() prologue sat
at 0x903ca3c in Electron 44.0.0's arm64 framework and at 0x908e3b0 in 44.1.1's, with
identical bytes. The tool resolves the symbol per release, asserts the expected bytes before
writing, disassembles the result, and checks that the binary differs from upstream only at
the declared sites (plus the signature on macOS). If a new Electron's compiler output changes
the function, the byte assertion fails and nothing is published until someone re-derives the
bytes. Adding another patch follows the
runbook.
The game's desktop build also proves the patch survived packaging, and there was a trap
there. On macOS the packaged framework is compared with the mirror's outside the regions code
signing owns. On Windows an exact compare is never possible: the packager (electron-builder
26.15.3, as measured) writes an integrity resource into the executable's .rsrc section
before its after-pack hook runs, and edits the icon and version strings after it, so the
executable never matches the mirror's byte for byte at any moment. The rule became "equal
outside .rsrc": the same section list, and identical raw bytes for every section except the
resources.
Route B, documented, never built: a source fork. A byte rewrite can express scalar and branch changes and early returns. A change that needs new state, a new function or a runtime switch needs a real Electron fork and the costs in section 4. That is the escalation path if a future patch cannot be written as bytes; the pointer-lock fix did not need it.
The first desktop builds bundled the game client, for offline start and real versioning.
Within a day that decision cost more than it bought: an installed copy carried a client that
the live server no longer spoke to, and nothing short of a new desktop release could fix
it. The shell is now a thin launcher
(main.cjs): one
window that loads the live site, sandboxed and context-isolated, with no preload and no IPC,
a permission handler that grants pointer lock and native fullscreen and nothing else, and a
static offline page with a Retry button when the site cannot be reached. It appends its own
version to the user agent so the page can offer a newer shell. Every web deploy is now the
desktop update, and a desktop release is cut only when the patched Electron underneath
changes. Offline start was given up knowingly.
| Source | Covers |
|---|---|
pointer_lock_controller.cc (Chromium) |
The 1.25 s constant, the gate and its exemptions, HandleUserPressedEscape() |
Blink pointer_lock_controller.cc (Chromium) |
The renderer-side sliding-window rate limit and both error messages |
electron_api_web_contents.cc at v44.1.1 (Electron) |
PreHandleKeyboardEvent handling exclusive access before before-input-event |
webContents API (Electron) |
before-input-event and sendInputEvent |
| Keyboard: lock() (MDN) | Keyboard Lock and press-and-hold Esc in fullscreen |
| Pointer Lock 2.0 (W3C) | requestPointerLock, exitPointerLock, pointerlockchange |
| Breakpad symbol files (Breakpad) | MODULE and FUNC records used to locate the functions |
| electron-gamepatch and its probe | The shipped patch, its verification and the relock probe |