UX Spec: Settings — Controls, Display, Audio, Accessibility
Status: Reviewed — APPROVED (
/ux-reviewpass 3 2026-08-31: 0 blocking, 4 advisory — applied; pass 1 3 B / 8 A and pass 2 2 B / 4 A applied the same day) · v0.4
Author: Jeroen + ux-designer
Last Updated: 2026-08-31
Journey Phase(s): unknown — no player journey map yet (see Open Questions)
Template: UX Spec
Variants: reached from the title screen and from pause (same screen, same state; the in-game variant returns to pause)
Requirements input:design/accessibility-requirements.md— the ten committed controls (UI scale, reduced-motion, flash-reduction, HUD opacity, mono, Music / SFX / UI volume, gamma + calibration image, toggle-hold revive) + full rebinding; ADR-0007 §4 (rebind flow, swap-on-confirm, protected actions, reset floor,bindings.json); pattern #20 Rebind Row; ui-hud quick-spec Rule 4 (Movement / Camera row) + queue item ⑤; art bible §7; Tier 1 stubs reserved: Help panel, deadzone / aim-sensitivity sliders
Patterns used: #1 Button · #3 Panel/Modal · #4 Focus & Nav · #6 Reason String · #20 Rebind Row · #25 Slider, #26 Toggle, #27 Option Cycler (new — defined by this spec; the pattern-library gap that blocks the accessibility commitments)
Related specs:design/ux/hud.md(opacity scope; UI-scale mechanics OQ 1),design/ux/build-mode.md(#31 single-press alternative),design/ux/inventory.md(panel conventions); title-screen and pause specs do not exist yet — named as dependencies in Navigation
Purpose & Player Need
Settings is where a friend makes the game fit their hands, eyes, ears and screen — and then never thinks about it again. The player arrives wanting to rebind a key, fix the volume balance, or turn on the option that lets them play at all (reduced motion, flash reduction, toggle-hold revive, UI scale). It must be reachable from anywhere (title and pause), operable entirely on either scheme, honest about conflicts (swap-on-confirm, never silent), impossible to lock yourself out of (protected Menu, the reset floor), and free of ceremony: every change applies the moment it is made and persists without an Apply button. It is the only screen in the game with sliders and toggles, so it also defines those controls for the project (patterns #25–27).
Player Context on Arrival
- First encounter: the title screen, first launch — usually a friend checking volume or whether the game "has controller support". Calm, exploratory.
- Typical: (a) title screen before joining — rebinding to taste, setting scale for a TV; (b) pause mid-session — a quick volume tweak, or turning on reduced motion after a friend says the flyouts bother them; the world keeps running (host-and-play never pauses the tide), so the in-game variant must be quick to enter and leave; (c) an accessibility need discovered mid-play (flash sensitivity) — must be fixable now, hence live-apply.
- Emotional assumption: calm at the title; slightly time-aware in pause (the tide is moving); never pressured by the screen itself.
- Voluntary: always.
Navigation Position
Title → Settings; Gameplay → Menu (Esc / Start) → Pause → Settings. One screen, one state, two return targets. A captured panel (#3; UI map live) with a tab bar — the project's only tabbed screen. Title-screen and pause specs do not exist yet; this spec fixes only that both offer a "Settings" entry and that Back returns to whichever opened it. Sub-flows: Rebind (inline listening state on a row, #20); the title-screen reset floor (hold the default Menu control 3 s — lives on the title screen, ADR-0007 §4) is mirrored by this screen's Reset button.
Entry & Exit Points
| Entry Source | Trigger | Player carries this context |
|---|---|---|
| Title screen | "Settings" entry (UI.Submit / click) | current settings.json + bindings.json; active scheme selects the Controls sub-tab |
| Pause (in-game) | "Settings" entry | same; plus the live world behind it (HUD visible per #3 layering; panels never pause the tide) |
| Last-tab memory | — | the last-visited tab is remembered for the session (default: Controls); not saved |
| Exit Destination | Trigger | Notes |
|---|---|---|
| Whichever opened it (title / pause) | Back button, UI.Cancel (Esc / East), Menu (Esc / Start via the panel stack) | all changes already applied + written; nothing to confirm |
| Same screen | tab switch | no state loss; a rebind in listening state is cancelled by a tab switch |
| Gameplay (force-close) | host quit / disconnect while in pause-settings | settings already persisted; bindings.json untouched mid-listen (a cancelled listen writes nothing) |
No one-way exits. The only persistent effects are per-change writes to settings.json (new — this spec; persistentDataPath/Driftlands/settings/) and bindings.json (ADR-0007 §4) — both per machine via ADR-0004's atomic replace (temp + File.Replace) without the save's rolling .1/.2/.3 backups or F_FULLFSYNC — the same trim ADR-0007 §4 applies to bindings.json: a lost settings write is not data loss, and a corrupt file recovers through the settings.bad.json path (States), not a backup — never in the world save (ADR-0007 §4; ADR-0004's save defines no settings table).
Layout Specification
Information Hierarchy
- Which tab am I on — the tab bar (Controls · Display · Audio · Accessibility).
- What is each thing set to — row label + current value, readable without touching anything (numeric readouts on sliders, the word on toggles, the value on cyclers, the binding glyph on rebind rows).
- The control itself — the means to change it, right-aligned in one column so the eye scans down.
- Consequence text where it matters — a Small line under the row: rebind conflict prompt, the brightness calibration strip, the HUD-opacity scope note, the flash cap, the toggle-hold instruction.
- Footer —
Back;Reset to defaultson the Controls tab only.
Absent by design: an Apply / Save button (live-apply, ruled), profiles, per-player settings (settings are per machine), search, a "restore previous" undo (every change is one step to reverse by hand).
Layout Zones
Arrangement: one centred captured panel (#3), 720 × 520 px @100 % scale (1080 × 780 at 150 % — fits 1080p with margins; ruled 2026-08-31). Height = 48 header + 40 tabs + 360 rows + 56 footer + 16 vertical padding (8 top, 8 bottom). Settings is the project's only centred panel: it is reached from the title or from pause, never from a world verb, so the bag's right-of-centre anchor (which keeps the player's own character visible) buys nothing here and 720 px at that anchor would clip at 150 % on 1080p. Width = 16 pad + 400 px label column + 24 gutter + 260 px control column + 20 pad.
- Z1 — Header (48 px): "Settings", Large 24 px, left-aligned. No close glyph (Back lives in the footer;
UI.Cancel/Menualways work). - Z2 — Tab bar (40 px, full-bleed — ignores the panel's side padding, so four cells of 180 px span the 720 px width): Controls · Display · Audio · Accessibility. The active cell is filled and underlined (weight + fill, never colour alone). The bar is one focus stop: ←/→ on it switches tabs immediately (commit on focus — the #2 default). No shoulder-button tab switching exists: the
UImap is Unity's reference set and carries no such action (ADR-0007 §1). - Z3 — Row list (360 px): rows of 44 px (label Medium 16–18 px left; control right-aligned in the 260 px column); a row with a consequence line is 64 px (Small 13–14 px line beneath, spanning both columns). ≈ 8 plain rows visible. Scrolls only when the tab needs it (Controls); scrolling follows focus so the focused row is always fully visible; a thin, non-focusable slate scroll indicator on the right edge (mouse-draggable, ignored by gamepad).
- Z4 — Footer (56 px):
Back(Secondary #1) left · reason-string slot (#6) centre ·Reset to defaults(Secondary #1 — ruled 2026-08-31: the screen has no Primary, because its default action is leaving, and the one bulk-discard must not carry the heaviest weight) right — Controls tab only; the other tabs show an empty right slot (no per-tab reset at MVP). - Background: the title art (title variant) or the live world with the closed-list HUD (pause variant) — the HUD is never occluded by this panel (#3 layering). The world keeps running; nothing on this screen says otherwise.
All px at 100 % UI scale (pattern-library scale convention); the panel, its columns and its rows scale together.
Component Inventory
Every row is label · control · optional consequence line. Three control patterns are new — defined by this spec (#25 Slider, #26 Toggle, #27 Option Cycler); #20 Rebind Row and #1 Button already exist.
Control anatomy (new patterns — lifted to interaction-patterns.md as #25–27 when this spec is drafted; ruled 2026-08-31):
- #25 Slider — track + knob + numeric readout always shown (the number is the non-visual channel; never a bare track). Keyboard / gamepad: ←/→ moves one step (key-repeat per #4: 300 ms then 100 ms); mouse: drag the knob or click the track. Live-apply on every step. Ends: the knob stops; the readout states the bound.
- #26 Toggle — a two-cell switch
[ OFF | ON ]; the active cell is filled and carries the word (state by word + fill, never colour or position alone).UI.Submit/ click / ←/→ flips it. Live-apply. - #27 Option Cycler —
◂ value ▸; ←/→ steps through a fixed ordered list, no wrap: the arrow at an end turns dashed (the disabled grammar, #1 / #8);UI.Submitsteps forward when possible, otherwise no-op (the dashed arrow already says why — no cue). Mouse: click either arrow; clicking the value does nothing. Live-apply.
| Tab | Row (player-facing label) | Control | Range · default | Consequence line | Source |
|---|---|---|---|---|---|
| Controls | Scheme | #27 | Keyboard / Mouse · Gamepad — defaults to the active scheme on open; session-only, never saved | — | ADR-0007 §3 (scheme is presentation) |
| Controls | Move — KB/M: four part rows Move · up / down / left / right (the 2D composite's parts); Gamepad: one row (stick glyph) | #20 | — | — | ADR-0007 §1; ui-hud Rule 4 |
| Controls | Aim stick — Gamepad tab only | #20 | — | — (the KB/M tab simply has no aim row — an unrebindable pointer needs no explanation, the #27 "no cue" principle; OQ 10) | ADR-0007 §1 (aim sources); OQ 10 |
| Controls | Strike · Dash · Interact / Revive (hold) · Demolish · Inventory · Build mode · Menu 🔒 | #20 | — | conflict prompt inline under the row: "also bound to Dash — swap?" + Confirm / Cancel (#1 Secondary ×2); Menu shows the lock glyph and no button | ADR-0007 §1 / §4; #20 |
| Controls | Place · Cancel · Previous structure · Next structure (Build map, under a "Build mode" divider) | #20 | each row edits the action's primary binding for the scheme (KB/M: Place = LMB, Cancel = RMB, Previous = Q, Next = R — ADR-0007 §1 row split 2026-08-31); the fixed secondaries — Esc on Cancel, scroll wheel on Previous / Next — are not shown and not rebindable | — | ADR-0007 §1 / §4 amendment (fixed secondaries, 2026-08-31) |
| Controls | (footer) Reset to defaults | #1 Secondary | — | — | ADR-0007 §4 reset floor mirror; #20 (amended: Secondary) |
| Display | Window mode (provisional) | #27 | Fullscreen · Borderless · Windowed · default Borderless | — | none — OQ (ADR-0014) |
| Display | Resolution (provisional) | #27 | the current monitor's mode list · default native | — | none — OQ (ADR-0014) |
| Display | VSync (provisional) | #26 | OFF · ON · default ON | — | none — OQ (ADR-0014) |
| Display | Brightness | #25 | −50…+50 %, step 5 · default 0 | calibration strip, shown while the row is focused (non-focusable, no controls): three patches at 5 / 50 / 95 % luminance + a silhouette figure on the darkest — "set until the left figure is just visible" | a11y doc (gamma + calibration image); art bible §1 strip-to-black |
| Display | HUD opacity | #25 | 50–100 %, step 5 · default 100 | "never dims your own health or a downed friend" | hud.md (element-scoped; SHOULD-KEEP tier only) |
| Audio | Music · SFX · UI | #25 ×3 | 0–100 %, step 5 · default 80 | — (the bus is audible while you move it; UI plays its click per step) | a11y doc (three buses, no voice bus) |
| Audio | Mono audio | #26 | OFF · ON · default OFF | — | a11y doc |
| Accessibility | UI scale | #27 | 100 % · 125 % · 150 % · default 100 % | "HUD and panels scale together" | a11y doc; hud.md; mechanism OQ 1 (ADR-0014) |
| Accessibility | Reduced motion | #26 | OFF · ON · default OFF | "removes shakes, pulses and slides; telegraphs and dashes are unchanged" | a11y doc keep-list |
| Accessibility | Flash reduction | #26 | OFF · ON · default OFF | "caps hit flashes at 3 per second" | a11y doc; OQ 5 there |
| Accessibility | Revive: toggle instead of hold | #26 | OFF · ON · default OFF | "press once to start reviving, again to stop" | a11y doc hold inventory (1); #10 |
| all | (footer) Back | #1 Secondary | — | — | #3 |
| all | (footer) reason-string slot | #6 | — | refused rebinds, save failures, recovery notices (States) | #6 |
Rulings folded into the table (2026-08-31): Interact and Revive are one row — they share E / West by design (ADR-0007 §1) and rebinding the row applies one override to both actions; §4's same-map conflict rule must exempt this pair (Open Questions → ADR-0007 / ADR-0014). Shared controls survive rebinding by control identity (ruled 2026-08-31, ADR-0007 §3 amendment): Strike / Place share LMB and Demolish / Cancel share RMB by design; if a rebind moves one half away, build mode's shared-control routing follows whatever Player action now holds the control a Build action shares — nothing on this screen pairs those rows. Fixed secondary bindings (ruled 2026-08-31, ADR-0007 §4 amendment): ADR-0007 §1's rule (b) is a minimum, and two KB/M actions ship with a second binding — Build.Cancel RMB / Esc and PickPrev / PickNext Q · R / scroll (§1's single pair row named only "Q / scroll" and left PickNext without a key; split 2026-08-31 into Q = Previous, R = Next, scroll as the pair's secondary). Those secondaries (Esc, scroll) are fixed: never displayed, never rebindable, never overridden — Esc because it is Menu's protected control (so Cancel can never leave Esc and build mode's "Esc exits then opens pause" in build-mode.md always holds), scroll because it is a pointer affordance like AimPointer. Every rebind row therefore edits exactly one binding — the scheme's primary — and "one rebindable binding per action per scheme" is true of the default set. Window mode / Resolution / VSync are provisional: no GDD or ADR commits them; a PC build cannot ship without them; ADR-0014 owns confirming or removing them. AimPointer (mouse position) is not rebindable — there is nothing to rebind a pointer to; AimStick is (left-handed stick swap).
ASCII Wireframe
Controls tab (Keyboard / Mouse scheme, mid-rebind) and Display tab. * = the one focused element (3 px outline + inset tick ─▸); ▒ = scroll indicator.
┌─ Settings ───────────────────────────────────────────────────────────┐
│ ▌Controls▐ Display Audio Accessibility │
├──────────────────────────────────────────────────────────────────────┤
│ Scheme ◂ Keyboard / Mouse ▸ │
│ Move · up [ W ] Rebind │
│ Move · down [ S ] Rebind │
│ Strike [ LMB ] Rebind │
│ Dash ─▸* [ press a key… 5 ] │ ← listening
│ Interact / Revive (hold) [ E ] Rebind │
│ Demolish [ RMB ] Rebind │
│ Menu [ Esc ] 🔒 │▒
├──────────────────────────────────────────────────────────────────────┤
│ [ Back ] [ Reset to defaults ]│
└──────────────────────────────────────────────────────────────────────┘
┌─ Settings ───────────────────────────────────────────────────────────┐
│ Controls ▌Display▐ Audio Accessibility │
├──────────────────────────────────────────────────────────────────────┤
│ Window mode ◂ Borderless ▸ │
│ Resolution ◂ 1920 × 1080 ▸ │
│ VSync [ OFF |▐ON▌ ] │
│ Brightness ─▸* ──────●────── +10 % │
│ ▓▓▓ ▒▒▒ ░░░ ♟ set until the left figure is just visible │
│ HUD opacity ────────●──── 80 % │
│ never dims your own health or a downed friend │
├──────────────────────────────────────────────────────────────────────┤
│ [ Back ] │
└──────────────────────────────────────────────────────────────────────┘
The Controls list continues below the fold (Inventory, Build mode, then the Build-map divider with Place / Cancel / Previous structure / Next structure); the Gamepad scheme replaces the four Move part rows with one stick row and adds Aim stick. Checked at 150 %: 1080 × 780 on 1080p leaves 420 px of margin on each side and 150 px above and below; the row count per screen is unchanged because rows scale with the panel.
States & Variants
| State / Variant | Trigger | What Changes |
|---|---|---|
| Default — title | opened from the title screen | title art behind; Back → title; first focus = the first row of the remembered tab (default Controls → the Scheme cycler) |
| Default — pause | opened from pause | the live world + closed-list HUD behind, still running; Back → pause; otherwise identical to the title variant |
| Controls · scheme sub-state | the Scheme cycler | KB/M: four Move part rows, no aim row; Gamepad: one Move row + Aim stick. Opens on the active scheme (InputFrame.Scheme); a device switch mid-panel does not flip the cycler (the player may be deliberately editing the other scheme); prompt glyphs elsewhere follow the scheme per #4 |
| Gamepad tab, no gamepad connected (empty) | Scheme = Gamepad while no gamepad is present | rows show the stored bindings; every Rebind is dashed (#1 disabled) with the reason "no gamepad connected" in the footer slot on focus; listening is impossible |
| Rebind — listening | Rebind pressed (row Submit / click) | the row's glyph cell shows "press a key… 5" counting down once per second (#20: 5 s → cancel); the interactive rebind captures the next input on that scheme's devices; UI.Cancel (Esc / East) cancels the listen, not the panel — on either device: the scheme's device exclusion (WithControlsExcluding, ADR-0007 §4) applies to capture only, so Esc aborts a gamepad listen and East aborts a keyboard listen without being captured; a pointer click outside the row cancels the listen, and if the click landed on a tab or Back that action then applies (the Entry & Exit "tab switch cancels a listen" rule) |
| Rebind — conflict | the captured input is already bound in the same map + scheme | both rows highlighted (3 px outline — weight, no colour); inline prompt under the listening row: "also bound to Dash — swap?" with Confirm / Cancel; focus lands on Confirm (the player chose that key); Confirm exchanges: the rebound action takes the new control and the colliding action takes the control just vacated (swap-on-confirm = exchange — ruled 2026-08-31, ADR-0007 §4 amendment); Cancel keeps both unchanged. The Interact / Revive pair never conflicts with itself (one row, one override for both); when the pair is the rebound row and collides with a third action, the exchange gives that action the pair's single vacated control (two ids move out, one id moves in — one atomic result). A collision with a protected action (Menu) never reaches this prompt — see Refused |
| Rebind — refused | the captured control belongs to a protected action (Menu) — refused before any prompt, no Confirm / Cancel offered, Menu never moves and the reset floor's default control stays true. Protection is keyed on Menu's control regardless of who else shares it: Esc is also Build.Cancel's fixed secondary binding (below), and a capture of Esc is still refused. (An empty-binding refusal cannot arise from the default set — every action has exactly one rebindable binding per scheme; it remains only as the rebind service's safety net.) | nothing changes; the row leaves listening with its old glyph; footer reason string (#6), glyph-templated from the Menu entry of IRebindService.Display(scheme) (the shape of BindingDisplay is ADR-0014's to pin — OQ 4) so it names the current scheme's control and follows keyboard layout: "Menu always stays on {glyph}" (KB/M: "…on Esc"; Gamepad: "…on Start") · "that would leave Dash with no key on this scheme"; persists while the row is focused |
| Reset confirmed | Reset to defaults | every Controls row snaps to defaults in one frame; bindings.json rewritten; no dialog (no confirmation dialogs exist at MVP, #3). Honest cost: a reset discards every rebind at once and reversing it means redoing them by hand — which is why the button is Secondary weight, sits alone at the far end of the footer, and is reached only by ↓ from the last row or → from Back; it cannot be hit while scrolling or stepping a control |
| Settings recovered (error) | settings.json was malformed at boot → defaults applied, file renamed settings.bad.json (the same recovery ADR-0007 §4 gives bindings.json) | the first open after recovery shows one footer notice: "settings were reset — the old file is kept" (41 chars; the file name is settings.bad.json, stated in the log); cleared on the next change or on close |
| Save failed (error) | the atomic write of settings.json / bindings.json fails | the value stays applied for the session; footer: "couldn't save settings — changes last until you quit"; the write is retried on the next change |
| Reduced motion ON | the toggle itself | this panel's own motion drops out immediately: tab swap and close become cuts; the slider knob snaps instead of easing |
| UI scale change | the cycler | the panel re-lays out in place at the new scale on the next frame; focus stays on the UI-scale row |
| Resolution list unreadable | the platform returns no display modes | Resolution shows the current mode alone with both arrows dashed |
| Downed / Dead while in pause-settings | combat state | nothing — Downed = ordinary settings (the standing precedent: UI never invents a gate) |
| Force-close | host quit / disconnect while in pause-settings | the panel closes with the session; nothing left to persist (every change was written when made; a live listen writes nothing) |
| Loading | — | none: settings.json is read at boot and bindings.json at adapter start (ADR-0007 §4); the screen never fetches |
No tutorial, locked, premium or platform-variant states exist (PC only; nothing on this screen is progression-gated).
Interaction Map
Input scope (technical-preferences): Keyboard / Mouse primary, Gamepad partial — every action below is reachable on both. The panel captures the UI map (ADR-0007 §3); Menu and Inventory are the only Player edges that survive capture, and Inventory does nothing here (it only closes the bag it opened, #3).
| Component | Action | KB/M | Gamepad | Feedback | Outcome |
|---|---|---|---|---|---|
| any element | Focus | hover (hover == focus, #4) / ↑ ↓ | UI.Navigate (d-pad / left stick; key-repeat 300 → 100 ms) | 3 px outline + inset tick; the list scrolls to keep the focused row fully visible | — |
| Tab bar | Switch tab | ← / → on the bar, or click a cell | Navigate ← / → on the bar | active cell fills + underline moves; Z3 swaps | tab changes on focus; a live listen is cancelled first |
| #25 Slider | Step | ← / → ; drag the knob ; click the track | Navigate ← / → | knob moves one step; readout updates; the bus / effect applies at once (the UI click plays on the UI bus, so the UI slider is audible per step) | SettingChanged + write |
| #26 Toggle | Flip | Enter / click / ← / → | Submit / Navigate ← / → | the cell fill swaps to the other word | SettingChanged + write |
| #27 Cycler | Step | ← / → ; click an arrow ; Enter = forward | Navigate ← / → ; Submit = forward | value replaces; the arrow at an end goes dashed; at the end Submit is a silent no-op | SettingChanged + write (Scheme: session-only, no write) |
| #20 Rebind row | Start listening | Enter on the row / click Rebind | Submit on the row | "press a key… 5" countdown | listen state |
| #20 listening | Capture | the next key / mouse button | the next gamepad control | glyph replaces; the row exits listening | BindingChanged + bindings.json write; or the conflict prompt |
| #20 listening | Cancel | Esc / a click outside the row / 5 s | Cancel (East) / 5 s | glyph restored | no write |
| conflict prompt | Confirm / Cancel | ← / → between the two, Enter ; click ; Esc = Cancel | Navigate ← / →, Submit ; East = Cancel | rows un-highlight | swap + write, or nothing — the prompt is modal within the row: focus cannot leave it until Confirm or Cancel |
Reset to defaults | Press | Enter / click | Submit | rows snap; one click sound | BindingsReset + write |
Back | Close | Enter / click / Esc from anywhere | Submit / East from anywhere | 100–150 ms fade | returns to the opener (title / pause) |
Menu | Close | Esc | Start | same | closes via the panel stack — protected; never opens pause from here (ADR-0007 §3) |
| scroll indicator | Scroll | wheel over Z3 ; drag the indicator | — (focus-driven scrolling only) | the list moves | — (the wheel never adjusts a slider — accidental-change guard, ruled 2026-08-31) |
Nothing is pointer-only: every control has a Navigate / Submit route, and Rebind is the row's own Submit (unlike crafting's pointer-only Craft button, which mirrors a focused row). No hold exists on this screen.
Focus adjacency — first focus = the first row of the remembered tab (default Controls → Scheme):
| From | ↑ | ↓ | ← | → |
|---|---|---|---|---|
| Tab bar | nothing | first row | previous tab (stops at Controls) | next tab (stops at Accessibility) |
| Row n | row n−1 (row 1 → tab bar) | row n+1 (last row → Back) | the control's own ← (rebind rows: nothing) | the control's own → |
Conflict Confirm / Cancel (modal within the row while shown) | nothing | nothing | Confirm | Cancel |
Back | last row | nothing | nothing | Reset (Controls only) |
Reset | last row | nothing | Back | nothing |
No traps: every element reaches the tab bar in ≤ (rows + 1) presses of ↑ and Back in ≤ (rows + 1) presses of ↓; Esc / East closes from anywhere except two row-local states: mid-listen (the first press cancels the listen, the second closes) and a conflict prompt (Esc / East is the prompt's Cancel; the prompt is dismissed only by Confirm or Cancel, so a stray Esc never closes the panel over an undecided swap). The calibration strip is non-focusable (no controls; it shows and hides with the Brightness row's focus).
Events Fired
No analytics system exists in the project; every event here is local, and nothing crosses the wire (settings are per machine; ADR-0007: input never leaves the machine).
| Player Action | Event | Payload | Persistent? |
|---|---|---|---|
| open | PanelOpened(Settings) → panel manager SetUiCaptured(true) (ADR-0007 §3; ADR-0014 owns the manager) | opener (title / pause) | no |
| slider / toggle / cycler change | SettingChanged | key, oldValue, newValue | yes — settings.json, atomic write per change (ADR-0004 writer) |
— consumers of SettingChanged | audio mixer (Music / SFX / UI buses, mono fold); HUD presenter (hudOpacity, hud.md); UI Toolkit PanelSettings scale (UI scale — mechanism a11y OQ 1 / ADR-0014); reduced-motion + flash-cap flags (presentation); revive toggle-hold → ADR-0015's local revive input handling — changes how this client interprets the Revive Held read (press starts, press cancels); the 3 s channel and its four cancel causes stay host-timed and untouched (#10) | — | — |
| Scheme cycler | none | — | no (session-only) |
| rebind captured / swap confirmed | BindingChanged via IRebindService.RebindAsync (ADR-0007 §4) | actionIds, scheme, newPath, exchangedActionId?, exchangedPath? — the Interact / Revive row carries two ids; nothing is ever cleared under exchange | yes — bindings.json |
| rebind cancelled / timed out / refused | RebindEnded(reason) — log only | reason (cancelled · timeout · protected · would-unbind) | no |
Reset to defaults | BindingsReset via IRebindService.ResetToDefaults() | all overrides, both schemes | yes — bindings.json |
| tab switch, focus move, scroll | none (deliberate — no telemetry, no state) | — | no |
close (Back / Esc / Menu) | PanelClosed(Settings) → SetUiCaptured(false) | — | no |
Flagged for architecture: both persistent files live in persistentDataPath/Driftlands/settings/, per machine, written by ADR-0004's atomic replace without its rolling backups or F_FULLFSYNC (deliberate — see Entry & Exit), outside the world save (asserted by ADR-0007 §4; ADR-0004's save schema defines no settings table — the enforcing Schema_* test is ADR-0014's, OQ 4). settings.json is a new file: ADR-0014 owns its schema and the write path; whether it appears in ADR-0006's data manifest at all (it is runtime-written like bindings.json, not shipped data) is an Open Question. The UI owns none of this state — it reads the applied values and asks the owning systems to change them.
Transitions & Animations
Art bible §7.5 binds: 100–150 ms ease-out only; nothing animates that gates an input.
| Moment | Motion | Reduced motion ON |
|---|---|---|
| Enter | instant (≤ 1 frame, #3); the focus tick appears on the first element the same frame | same |
| Exit | 100–150 ms ease-out fade; input is released on the close edge, not at animation end (#3) | cut |
| Tab swap | Z3 content crossfade 100 ms; the underline slides to the new cell in 100 ms | cut + snap |
| Slider step | knob eases 100 ms to the new step; the readout updates instantly | knob snaps |
| Toggle flip | cell fill swaps 100 ms | cut |
| Cycler step | value replaces instantly (no slide — nothing to imply direction) | same |
| Listening countdown | the number ticks 5 → 1 once per second; no pulse, no glow | same (nothing to remove) |
| Conflict highlight | outline weight 2 → 3 px on both rows, instant | same |
| Calibration strip | appears on focus, disappears on focus leave, instant | same |
| UI scale change | re-layout in one frame, no tween (a scale tween is motion for its own sake and would re-flow text mid-animation) | same |
| Reset | rows snap in one frame | same |
No screen shake, no bounce, no open delay, no confirm-hold. The only timed element on the screen is the 5 s listen timeout, and it is a safety cancel (#20), not an input window — the a11y doc's "no timed input that cannot be extended or toggled" clause is untouched.
Data Requirements
| Data | Source System | Read / Write | Notes (update · null) |
|---|---|---|---|
the 13 setting values — windowMode, resolution, vsync (provisional); gamma, hudOpacity, musicVolume, sfxVolume, uiVolume, monoAudio, uiScale, reducedMotion, flashReduction, reviveToggleHold | Settings service — new, ADR-0014; file settings.json (persistentDataPath/Driftlands/settings/) | Read / Write | read on open; written per change with ADR-0004's atomic writer. Missing file → defaults; malformed → defaults + settings.bad.json + one notice (States) |
| current bindings per scheme | IRebindService.Display(scheme) (ADR-0007 §4) | Read | refreshed on BindingChanged / BindingsReset; never empty (asset test: every action bound in both schemes) |
| rebind result · conflict · refusal | IRebindService.RebindAsync(actionId, scheme, ct) | Write | Awaitable; the conflict is surfaced for the prompt; a refusal carries its reason |
| reset | IRebindService.ResetToDefaults() | Write | all overrides, both schemes |
| active scheme (the Scheme cycler's default) | InputFrame.Scheme — presentation-only (Driftlands.Presentation is the only permitted referencer, ADR-0007 §2) | Read | once on open; never re-read to flip the cycler |
| gamepad present | Input System device list, via the adapter | Read | on device-change events; drives the empty state |
| display modes · current mode | platform (Screen) via the settings service | Read | on open; empty list → single entry, arrows dashed |
| calibration strip | static art asset | Read | — |
| labels, consequence lines, reason strings, notices | string table (L10n) | Read | — |
| last-visited tab | this panel, session memory | — | the one UI-owned datum; no game meaning; not saved |
Nothing updates per frame. The UI owns no game state: it displays applied values and asks the owning system (settings service, rebind service, mixer, HUD presenter, PanelSettings, combat's local revive handling) to change them.
Accessibility
Tier: Standard (design/accessibility-requirements.md). This screen delivers ten of that document's commitments; the document stays the source of truth for each one's scope.
- Keyboard-only and gamepad-only paths: complete via the adjacency table (Interaction Map); nothing pointer-only; no hold; Esc / East closes from any non-listening state.
- Focus: 3 px outline + inset tick (#4); exactly one focused element at all times; first focus named (first row of the remembered tab).
- Text: labels Medium 16–18 px; consequence lines, reason strings and notices Small 13–14 px (the floor); header Large 24 px; slate
#3A3D42/ text#E8E6DE≥ 4.5:1 — verified at theme lock (AC-17). - Colour never alone: toggle = word + fill; cycler end = dashed arrow; conflict = outline weight; slider = numeric readout; focus = weight + tick; active tab = fill + underline; disabled
Rebind= dashed + reason on focus. - Hit targets: rows 44 px tall; footer buttons ≥ 44 × 160 px; tab cells ≥ 40 × 120 px.
- Lockout-proof:
Menu/UI.Cancelprotected (lock glyph, no button); refuse-unbind;Reset to defaultson this screen and the title-screen reset floor (hold the default Esc / Start 3 s — ADR-0007 §4). The floor works with no usable Player bindings at all; the on-screen button is always reachable because theUImap is not rebindable. - Commitment map: UI scale · reduced motion · flash reduction · HUD opacity (element-scoped,
hud.md) · mono · Music / SFX / UI · brightness + calibration strip · toggle-hold revive · full remapping — each is a row in the Component Inventory. - Reduced motion: the Transitions table; the toggle takes effect on this very screen the frame it flips.
- Timed element: the 5 s listen timeout is a safety cancel (#20), restartable without limit — a player with slow reach may still find 5 s short → Open Question 8 (raise to 10 s).
- Cognitive: one column, one control per row, plain-verb labels ("Place", not "Confirm"); consequence lines state what changes in the world, never a system term; at most one prompt on screen at a time; no confirmation dialogs.
- Screen reader: out of scope (a11y doc, Known Intentional Limitations). 150 %: 1080 × 780 verified in Layout; row count per screen unchanged.
Localization Considerations
| Element | Limit (EN source) | Layout constraint | Expansion risk |
|---|---|---|---|
| Tab labels | ≤ 14 chars | 180 px cells at 100 % (full-bleed bar, 4 × 180 = 720); cells may size to content (min 120 px) — "Barrierefreiheit" (16) forces it | HIGH |
| Row labels | ≤ 32 chars, 2 lines allowed (row → 64 px) | 400 px column, Medium | HIGH — "Revive: toggle instead of hold" is already 30 |
| Consequence lines | ≤ 64 chars, 2 lines allowed | 684 px, Small | MEDIUM |
| Cycler values | ≤ 16 chars | 260 px minus two 24 px arrows | HIGH — "Keyboard / Mouse" is 16 |
| Toggle words | ≤ 5 chars each | two 60 px cells | HIGH — a language whose words exceed 5 uses its short form (FR "OFF / ON" is acceptable) |
| Button labels | ≤ 18 chars | 160–200 px | HIGH — "Reset to defaults" is 17 |
| Reason strings / notices | ≤ 56 chars, 2 lines allowed — a deliberate relaxation of #6's ≤ 32 because the footer slot is 360 px, not a row's trailing slot | footer centre ≈ 360 px, Small | MEDIUM |
| Binding glyphs | not localised — Input System display names, which are keyboard-layout-aware (AZERTY shows Z where QWERTY shows W) — verify (OQ 9) | 80 px glyph cell | verify |
| Numbers | ASCII digits, %, ×; no thousands separators | — | LOW |
Every layout-critical string above is flagged for 40 % expansion; the two-line allowances are the accommodation. No RTL commitment exists at MVP (no RTL language is planned for a friend-group release).
Acceptance Criteria
- [PLAYMODE] Opens within 1 frame of the entry press from the title and from pause; the focus tick is on the first row of the remembered tab (Controls → Scheme on first open).
- [MANUAL] Every non-provisional row exists with the stated range, step and default (Component Inventory); each change applies within 1 frame (audio the same frame; HUD opacity and UI scale the next frame).
- [PLAYMODE] Change any value, quit without pressing Back, relaunch → the value is restored; exactly one
settings.jsonwrite per committed change. - [PLAYMODE] Rebind Dash on both schemes, restart → restored (ADR-0007's own criterion); rebind the Interact / Revive row to F →
bindings.jsoncarries the override on both action ids. - [PLAYMODE] Rebind Dash to E → the prompt names "Interact / Revive" with focus on Confirm; Confirm → Dash = E and Interact / Revive = Space (exchange); no action is left unbound; Cancel changes nothing.
5b. [PLAYMODE] On the Keyboard / Mouse scheme, rebind Strike to Esc → no prompt appears; footer reads "Menu always stays on Esc"; Strike stays LMB; Esc still opens pause from gameplay and still exits build mode. 5c. [PLAYMODE] Rebind Inventory to LMB, Confirm → Strike = Tab and Inventory = LMB; enter build mode, press LMB → a structure is placed and build mode stays active; the Cancel row shows RMB only and rebinding it never affects Esc.
- [UNIT / MANUAL] The
Menurow shows a lock and no Rebind; no row exists for Navigate, Submit, Cancel, Point, Click, RightClick, MiddleClick, ScrollWheel, TrackedDevicePosition or TrackedDeviceOrientation. - [MANUAL] Gamepad-only: full traversal per the adjacency table; ↓ from any element reaches
Back; East closes from any non-listening state; mid-listen East cancels the listen only. - [MANUAL] Listening: 5 s without input restores the glyph; a click on another tab cancels the listen and switches tabs; on the Gamepad scheme, Esc cancels the listen without being captured as a binding.
- [MANUAL] Unplug the gamepad on the Gamepad tab →
Rebindrows dashed with "no gamepad connected" on focus; plug it back in → re-enabled without reopening. - [PLAYMODE] Corrupt
settings.json→ defaults,settings.bad.jsonbeside it, one footer notice on the first open, none on the second. - [MANUAL] Read-only settings folder → the change applies for the session; the footer says so; no crash, no retry storm (one retry per subsequent change).
- [MANUAL] 1080p and 1440p at 100 / 125 / 150 %: panel 720 × 520 → 1080 × 780; no clipping, no overlap, focus order unchanged; EN consequence lines on one line.
- [MANUAL] Reduced motion ON: tab swap and close are cuts; the slider knob snaps (frame capture shows no intermediate position).
- [MANUAL] Toggle-hold ON: one press starts the revive channel, a second press cancels it; taking damage, dashing, and walking out of reach of the downed friend each still cancel the revive, exactly as with the hold.
- [MANUAL] The mouse wheel over a focused slider scrolls the list and leaves the value unchanged.
- [MANUAL] Open pause-settings while Downed → every control operable (no invented gate).
- [AUTOMATED] All text on the screen ≥ 4.5:1 against its background, measured on the locked theme.
- [MANUAL] While a conflict prompt is showing, ↑ / ↓ do nothing, Esc / East dismisses the prompt without closing the panel, and both rows keep their old bindings.
Open Questions
- Player journey map not yet created. Template available at
.claude/docs/templates/player-journey.md. Run/ux-designPhase 2b or create it manually to establish player context for this screen. - ADR-0007 §4 swap semantics — ruled 2026-08-31: exchange. "Confirm clears the other binding, subject to the empty-binding refusal" was dead on the default set (one binding per action per scheme → every conflict refused). Amendment: Confirm exchanges — the colliding action receives the control the rebound action vacated. ADR-0007 §4 + pattern #20 amended in this change;
IRebindService.RebindAsyncmust return the exchange as one atomic result. Two edge rulings added after/ux-review(2026-08-31): a collision with protectedMenuis refused before any prompt; a shared LMB / RMB control follows control identity in build-mode routing after a rebind (ADR-0007 §3 amendment). Second re-review (same day): the "one binding per scheme" premise was false forBuild.Cancel(RMB / Esc) andPickPrev/PickNext(Q / scroll) — ruled fixed secondaries (hidden, unrebindable; rows edit the primary only), which also settles Esc's sharing withMenu. Pass 3:PickNexthad no named KB/M primary → ADR-0007 §1 row split, Q = Previous, R = Next (R was unbound on both KB/M maps). - Interact / Revive pair — one row, one override applied to both action ids; ADR-0007 §4's same-map conflict rule must exempt the pair (they share E / West by design, §1). ADR-0007 amendment (with 2); ADR-0014 implements.
- ~~
settings.jsonschema and write path~~ — CLOSED 2026-09-01 by ADR-0014 §2: 14-key schema atpersistentDataPath/Driftlands/settings/settings.json, written with ADR-0004's atomic writer (no backups, noF_FULLFSYNC), per-change commit debounced 250 ms, flushed on close and quit; malformed → defaults + loud log +settings.bad.json. Confirmed out of ADR-0006's shipped-data manifest. Original note: → ADR-0014. It is runtime-written likebindings.json, so it does not belong in ADR-0006's shipped-data manifest — confirm when ADR-0014 is written. ADR-0014 also owns theSchema_*test that keeps settings out of the world save (ADR-0007 §4 asserts it; ADR-0004 does not test it). - ~~Window mode / Resolution / VSync provisional~~ — CLOSED 2026-09-01 by ADR-0014 §2 (user ruling): KEPT, with the proposed defaults Borderless / native / ON. Note
Application.targetFrameRate = 60is pinned regardless of the vsync setting (ADR-0014 §9), so vsync selects tearing behaviour only and never changes the 16.6 ms budget. - ~~UI-scale mechanism (a11y OQ 1)~~ — CLOSED 2026-09-01 by ADR-0014 §3: the knob is
PanelSettings.scale(uniform), separate fromscaleMode/referenceResolutionwhich stayScale with Screen Size@ 1920×1080. World-space text does NOT scale — recorded as a Known Intentional Limitation in the a11y doc. Original note: UI ToolkitPanelSettingsscale mode + reference resolution, and whether world-space text (flyouts, tags, ring prompt) scales with it. - Title-screen and pause specs do not exist — both must offer a "Settings" entry,
Backmust return to the opener, and the reset floor lives on the title screen (/ux-design title-screen,/ux-design pause). - Listen timeout 5 → 10 s for slower motor response (#20 amendment) — ask the friend group (a11y OQ 3).
- Binding display names:
InputControl.displayNameis layout-aware,InputControlPath.ToHumanReadableStringis not — pick one at the first settings story and verify on an AZERTY layout. - ~~
AimStickrebindable /AimPointernot~~ — CONFIRMED 2026-09-01 by ADR-0014 §7:AimStickis rebindable (left-handed stick swap),AimPointeris not. - Ultrawide / letterbox policy (ui-hud Rule 4) — camera quick-spec owns it; no control on this screen at MVP.
- Tier 1 reserved rows: Help panel; deadzone / aim-sensitivity sliders (bottom of the Controls tab, below the Build divider).
- "caps hit flashes at 3 per second" assumes the per-screen cap (a11y OQ 5) — retext if combat rules per-enemy.