Raw stdin to world state, in two layers:
src/io/input.phel: side effects.stty, ANSI escapes, signal handlers, non-blockingfread.src/glue/controls.phel: pure. Parses the drained byte string into hold counters, rising edges and mouselook. Fully unit-tested.
For player-facing controls, see gameplay.md. This table maps keys to parser output:
| Keys | Parsed into |
|---|---|
W S A D |
:fwd :back :strafe-left :strafe-right hold slots |
← → / ↑ ↓ |
:turn-left :turn-right / :pitch-up :pitch-down hold slots |
Shift+WASD, X |
:sprint hold slot |
| Mouse motion, left click, wheel | mouse-aim yaw / pitch / fire, wheel-weapon-step |
Space |
:fire edge and :fire-held level |
1-7, [ ] |
:select-weapon1..7, :prev-weapon :next-weapon |
R F E |
:reload, :action (secret wall or switch ahead), :about-face |
M N P F3 |
:toggle-map, :toggle-sound, :toggle-pause, :toggle-debug |
H or Esc |
:toggle-help |
F5 F9 |
:save :load (slot 1 only) |
Q |
:quit-request (asks first, see game-loop.md) |
Enter or Space |
:confirm (menus) |
Doors need no key: walk into them. TAB is not bound.
init-input! runs stty -icanon -echo min 0 time 0 (raw mode, reads return at once), sets STDIN non-blocking, then prints the pure init-escapes string:
| Escape | Purpose |
|---|---|
\e[?1049h |
Alternate screen |
\e[?25l |
Hide the text caret |
\e[?7l |
Disable autowrap |
\e[2J\e[H |
Clear and home |
\e[>3u |
Kitty keyboard opt-in: flags disambiguate (1) + report-event-types (2) |
\e[?1004h |
Focus reports, always on |
\e[?1003h\e[?1006h\e[>3p |
mouse-enable, only when the Mouse setting is on |
init-input! takes a mouse? flag (default true). init-escapes is pure, so the gating is unit-tested without a terminal.
restore! undoes it in three steps:
restore-prelude:\e[?2026l(end synchronized output),\e[<u(pop kitty flags),mouse-disable(\e[?1003l\e[?1006l\e[>1p),\e[?1004l. The mouse disable always goes out; it is harmless when the mouse was never on.- Flush, then
drain-stdin!for 120 ms. This swallows the kitty release report of the quit key, which would otherwise echo onto the shell prompt. \e[?25h\e[?7h\e[?1049l, thenstty sane.
The session runs inside a try whose finally calls teardown!: stop music, stop the sfx sidecar, demo/reset-off!, restore!. A throw still hands the terminal back cooked, then propagates.
finally does not run on signal death, so install-signal-handlers! traps SIGINT and SIGTERM:
- The handler only sets a flag.
interrupted?dispatches pending signals and reads it. Every interruptible loop polls it once per frame and returns:quitthrough the normal teardown. - The start-menu settings page closes itself and leaves the flag set, so the menu behind it quits next frame.
- A second signal restores the default handler and re-raises. A wedged process still dies without
kill -9. - Delivery is synchronous. symfony/console turns
pcntl_async_signalson in itsApplicationconstructor, which would let a signal land mid-render, where writes are not retried. The installer turns it back off. - Handlers are armed before
init-input!, so raw mode never runs without a teardown guarantee. - Without ext-pcntl nothing is installed.
SIGWINCH works the same way: see game-loop.md.
drain-keys reads up to drain-bytes (512) once per frame and returns "" when nothing is queued. The limit accommodates fast mouse drags, which generate many ~12-byte any-motion reports. The parser drops a partially read trailing report. Held keys can produce several auto-repeat bytes per frame; each byte counts.
refresh-from-keys folds the string into :moves in four stages:
- Strip SGR mouse reports and focus reports (
\e[I,\e[O). - Kitty CSI-u key events.
- Kitty-enhanced arrows.
- Legacy: normalise each arrow to one byte (
^_<>), then walk the rest throughrefresh-move.
Legacy arrows come as CSI (\e[A) or SS3 (\eOA). tmux and the alternate screen often switch to SS3, so both forms are normalised.
key->slot maps the single bytes to slots. shift-key->slot maps capital W A S D to the same direction plus :sprint. The :moves shape is in state.md.
Each byte sets its slot to a hold time in seconds. Physics subtracts dt every frame and stops the direction at 0, so a hold lasts the same wall-clock time at any frame rate.
| Constant | Value | Why |
|---|---|---|
move-hold-secs |
0.30 | First byte of a press. Bridges the OS initial repeat delay (250-500 ms). |
move-hold-repeat-secs |
0.15 | A byte on a still-warm slot is an auto-repeat and only has to reach the next one (#461). |
turn-hold-secs |
0.05 | Turning halts within ~50 ms of release. |
pitch-hold-secs |
0.05 | Looking up/down halts the same way. |
sprint-hold-secs |
0.05 | No Shift-release signal exists; a longer hold shows a phantom stamina drain. |
Before #461, every byte re-armed 0.30 s, so the last repeat kept the player walking ~300 ms after release, sometimes past doorways or into melee range. The 0.15 s repeat hold covers rates down to ~7/s, below the defaults (macOS ~15/s at its slowest, X11 ~25/s). A slower rate such as xset r rate 400 5 stutters.
A kitty release event (type 3) clears the slot at once, so kitty terminals have no glide.
↑ looks up, ↓ looks down. The arrows form a camera cluster (← → turn, ↑ ↓ look) and WASD moves. They refresh :pitch-up / :pitch-down, and physics shears the player's :pitch fraction, clamped to [-1, 1] with no wrap.
Every arrow encoding reaches pitch: CSI, SS3 and kitty \e[1;..A. The shear is a pure render offset (see raycaster.md); :pitch 0 renders the same as no pitch. Pitch also drives aim: hitscan is vertical-aware (#243), so a shot must land on the drawn sprite. See the vertical aim gate in combat.md.
Translation speed x1.6; turning is not boosted. Sprint drains :stamina (max 100) at 30/s and regenerates at 20/s after a 0.5 s cooldown. At empty it stays locked until stamina recovers to 20. The rules live in core/physics.phel (tick-stamina, sprinting?).
Three paths arm :sprint:
- Kitty: a press or repeat on a movement key with the Shift bit set.
- Legacy Shift: without kitty, Shift uppercases the byte, and
WASDrefresh direction and sprint together. x: the dedicated fallback.
With \e[>3u accepted, the terminal sends:
\e[<code>;<mods>:<event>ufor keys. The code is the lowercase codepoint (w= 119). Left/right arrows may come as 57351 / 57349.\e[1;<mods>:<event><A|B|C|D>for arrows, keeping the legacy suffix.
Events: 1 press, 2 repeat, 3 release; no field means press. Mods are bits + 1, bit 0 is Shift.
| Tier | Terminals | Stop on release |
|---|---|---|
| Kitty events | kitty, WezTerm, Ghostty, Alacritty >= 0.13, iTerm2 >= 3.5 | Instant |
| Legacy bytes | Terminal.app, GNOME Terminal, xterm | Hold-time bridge |
In tmux: set -g extended-keys on, setw -g xterm-keys on, and set -g focus-events on.
key-states records every tracked key each frame, plus the focus-out report. rising-edges compares it with the previous snapshot. Exceptions:
:fire-heldis true whenever space is in the input. Auto-fire weapons (pistol, chaingun) spray on it.:confirmis space or Enter as a rising edge, so a held fire key cannot auto-select a menu row.:focus-outis presence, not an edge: the report arrives once per focus loss.
Parsing rules:
- Strip CSI before the plain-byte check.
\e[50;1:3u(release of2) contains the bytes1and3.key-pressed?removes every CSI sequence first. - Kitty presses only. The match accepts no event field or
:1, so a held key fires once. - Esc is ambiguous. Arrows and F-keys start with ESC.
esc-pressed?strips CSI and SS3 sequences, then looks for a bare\e, or matches kitty's\e[27u. - Menu arrows skip releases.
arrow-countcounts press and repeat, not release. Counting both moved the settings cursor twice per tap. - F-keys vary. F3 is
\eORor\e[13~(Linux console). F5 is\e[15~, F9\e[20~.
F5 / F9 run in the game loop, not tick-world, because they do file IO (savegame.md). Menus skip edges: nav-deltas sums arrow and WASD presses into signed {:cursor :value} steps, so a held key ramps a slider (settings.md).
\e[?1004h makes the terminal report focus loss as \e[O and regain as \e[I. Focus loss pauses a live run and zeroes the hold counters, so alt-tabbing cannot leave the player gliding into an enemy. An already-paused world is left alone, so the settings sub-page stays open.
Both reports are scrubbed before the byte walk. They embed an ESC byte, and a bare ESC opens the help panel.
FPS mouselook: the + crosshair stays at screen centre, the gun fires dead ahead, and mouse motion turns the camera and looks up/down. The mouse is an extra path, on by default. With the Mouse setting off, no mouse escape is sent and play is keyboard-only.
\e[?1003h asks for any-motion tracking (reports with no button held). \e[?1006h selects SGR encoding, compact and safe past column 223.
A report is \e[<b;Cx;Cy then M (press or motion) or m (release). Cx Cy are 1-based absolute cells.
Bits of b |
Meaning |
|---|---|
| 0-1 | Button: 0 left, 1 middle, 2 right |
| 2 / 3 / 4 | Shift / Meta / Ctrl |
5 (& 32) |
Motion: a drag, or a hover under 1003 |
6 (& 64) |
Wheel: 64 up, 65 down |
b=0 + M is a left press, b=32 a left drag, b=35 a bare hover.
Terminals cannot lock, warp or confine the pointer (#313). A turn based only on pointer movement freezes when the pointer leaves the window and reporting stops. mouse-aim adds an edge term:
yaw = ( mouse-look-gain * dx / fov-proj-dist(cols) + edge-pan-rate(x, cols, mouse-yaw-rate-max) * dt ) * sensitivity
pitch = ( -mouse-look-gain * dy / (pitch-cap * rows) - edge-pan-rate(y, rows, mouse-pitch-rate-max) * dt ) * sensitivity
- Delta turn.
dxdyare cells moved since last frame.1 / fov-proj-dist(cols)is one rendered column per pointer column, which reads slow: a full swipe is only ~80-180 columns.mouse-look-gain(5.0) makes one swipe cover most of a turn. Nodt: one report already is one frame's motion. - Edge pan. 0 in the centre region. In the outer
mouse-edge-band-frac(30%) of each half-axis it ramps ease-in (fraction squared) tomouse-yaw-rate-max9.6 rad/s (a full turn in ~0.65 s) ormouse-pitch-rate-max3.6/s. Per second, so scaled bydt. - Ungated. The edge pan runs every frame from the cached position. A pointer parked at or past a border keeps turning after it leaves the window.
- No mid-screen drift. A pointer resting in the centre adds nothing, so an unhideable OS pointer never spins the camera.
- Signs. Right of centre is +yaw. Rows grow downward, so pointer up is +pitch.
mouse-aim returns {:aim-col nil :aim-row nil :yaw :pitch :fire? :fire-held? :pos}.
:posis the last pointer cell.game-loopthreads it forward, seeds itniland resets it tonilon a resize. A nil previous position gives no delta, so those frames hold steady.- Without
dims([rows cols]), yaw and pitch are 0 and fire still parses.
Each frame the loop calls state/reset-reticle and then apply-mouse-look, which folds yaw and pitch into the player before tick-world. It is skipped while paused, so a menu cannot drift the view. The hitscan fires along :angle: combat/aim-angle-offset returns 0 because :aim-col is always nil.
core/settings.mouse-sensitivity maps the 0-100 setting to 3 ^ ((pct - 50) / 50) (#275): 1/3x, 1.0x (default 50%), 3.0x. It scales both terms, so far-edge yaw runs 3.2 / 9.6 / 28.8 rad/s. There is no acceleration curve; the ease-in band gives fine control near centre.
A left press sets :fire?, ORed into :fire. A left press or drag sets :fire-held?, ORed into :fire-held, so auto-fire weapons fire while the button is down. Terminals report a held button only while it moves.
The wheel and [ / ] step through owned weapons, wrapping. wheel-weapon-step reads wheel reports from the same drain string; the last tick in a frame wins. Brackets are edges, so holding one steps once. Both go through weapons/cycle-weapon to switch-weapon, which keeps mag bookkeeping and the reload lockout. A number key in the same frame wins. Unowned weapons are skipped.
A focus change or a resize can make a terminal drop mouse tracking. rearm-mouse! re-sends \e[?25l and mouse-enable. The play loop calls it, gated on the Mouse setting, on a resize and every rearm-mouse-frames (120) frames, about once a second.
- Text caret.
\e[?25lhides it. A mode flip, a resize or mouse tracking can bring it back, sorender!re-sends it every frame. - OS pointer.
\e[?25ldoes not touch it, and xterm's default (XTSMPOINTER 1) shows it once tracking is on.mouse-enablesends\e[>3p(always hide);mouse-disablesends\e[>1p.
| Terminal | OS pointer during mouselook |
|---|---|
| xterm | Hidden: honours \e[>3p |
| iTerm2, Terminal.app | Visible: no XTSMPOINTER, no setting |
| kitty, WezTerm, Ghostty, Alacritty | Visible: hidden only when idle or typing |
Terminal support limits pointer hiding. The centre crosshair remains the aim point, and edge pan keeps turning. pointer-limit-notice checks $TERM_PROGRAM; for iTerm2, Apple Terminal, VS Code and Ghostty the start menu says the arrow is cosmetic.
paint-crosshair draws at row svh/2, col cols/2. With the mouse on, Crosshair style off still draws ·. With it off, off hides the idle crosshair. The hit marker (✗ kill, × wound) always shows.
An SGR report shares the \e[ prefix with kitty keys and arrows, and <32;15;10M contains < (turn left) and digits. refresh-from-keys strips reports before the byte walk, so none reads as a key. mouse-aim only matches \e[<..., so a key never moves the pointer or fires. Both directions are regression-tested.