DRIFTLANDS co-op survival on drifting tides — studio progress

UX Spec: Title Screen

Status: Reviewed — APPROVED (/ux-review re-pass 2026-09-01: 0 blocking, 4 advisory — all applied; pass 1 3 B / 8 A applied same day) · v0.3
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: none at the screen level — the third-party differences live in the Host / Join sub-flows
Requirements input: design/ux/settings.md (Settings entry; Back returns here) · design/ux/pause.md (this screen is the session exit target and owns the session-ended notice) · ADR-0007 §4 (the reset floor: hold default Esc / Start 3 s wipes binding overrides) · design/accessibility-requirements.md (the pre-launch photosensitivity notice lives here; reduced-motion keep-row added for the tide loop 2026-09-01) · art bible §2 row 6 (Menus/Title mood: a frozen mid-transition gradient, held static, low energy — the ≈ 40 s loop is the near-still reading of it; art-director to confirm, OQ 8) · netcode quick-spec Rules 1/3/4 (host-and-play, whoever owns the save hosts, capacity 2 at MVP, transport undecided until ADR-0008) · save quick-spec Rules 1/4 (one world = one host-owned file; loading an older copy forks permanently — a fact, not an error; version gate refuses loudly)
Patterns used: #1 Button · #2 List Picker (no-wrap deviation, declared) · #3 Panel/Modal · #4 Focus & Nav · #6 Reason String · #27 Option Cycler (stretched — the join-code steppers run well outside #27's 2–6-named-options envelope and wrap; declared as a deviation + a Gaps row, not a clean reuse)
Related specs: design/ux/settings.md, design/ux/pause.md (both APPROVED; both name this spec as their missing dependency), design/ux/hud.md (not present here — no HUD at title)


Purpose & Player Need

The title screen is the front porch of the shared world: pick it up and host, walk over to a friend's (join), make the game fit (Settings), or leave. The player arrives wanting to be in the world with their friends in as few presses as possible — for the host that's one press on the world they always play; for a friend, one press on Join. It also carries the three things no other screen can: the session-ended notice (the world behind it just closed — this is where that fact lands, tone-safe), the binding reset floor (the hold that works even when every binding is wrecked), and the one-time photosensitivity notice. Nothing here may manufacture ceremony: no splash chain, no "press any key" gate, no news panel.


Player Context on Arrival


Navigation Position

Root. Everything starts here and every session ends here. Children: Host (world list sub-panel, #2), Join (mechanism stub — ADR-0008 owns the flow; provisional direct-connect field for the playtest build), Settings (design/ux/settings.md — its Back returns here). Hidden sub-flow: the reset floor — holding the default Menu control (Esc / Start) for 3 s anywhere on this screen resets all binding overrides (ADR-0007 §4; deliberately not a visible button — settings' Reset is the visible one; this is the recovery of last resort). Exits: into a session (host / join), or to the desktop (Quit).


Entry & Exit Points

Entry Source Trigger Player carries this context
App launch—settings.json + bindings.json applied; first launch adds the photosensitivity notice
Session ended (the host pressed End, or you left)automaticnotice per cause; everything already saved / persisted host-side
Connection lost / host quit (client)automatictone-safe notice; "rejoin" = just Join again (a rejoin is a late join — netcode Rule 4)
Settings (child)its Back / UI.Cancelfocus restored to the Settings button
Version-gate refusalattempted Host on an incompatible saveyou stay here; the world row carries the refusal reason (#6)
Exit Destination Trigger Notes
Session (as host)world row → loadstarts hosting; loading an older copy of a save forks the world permanently — a fact the row states via its timestamp, never a warning dialog
Session (as host, fresh)New world rowseed rolled; one file created host-side
Session (as client)Joinflow owned by ADR-0008 (Steam expected); provisional direct-connect field in the playtest build
DesktopQuitnothing to flush — settings and bindings were written per change; the save belongs to sessions
This screenthe reset floor completesoverrides wiped + one confirmation line (a world fact); no navigation

No one-way exits from the screen itself; the only permanent act reachable from here is the fork-by-older-copy, which is a filesystem fact the screen reports honestly rather than gates.


Layout Specification

Information Hierarchy

Absent by design: news, splash chain, a press-any-key gate, profiles / accounts, save thumbnails, music-credit ticker — the porch has nothing to sell.

Layout Zones

The only screen with fixed world-art behind it, so px values are anchors, not a panel box (1080p @100 %; anchors scale with the UI-scale option).

Component Inventory

Zone Component Pattern Interactive Notes
Z2Continue — [world]#1 Primaryyesthe one-press path: loads the most-recent world and hosts. Hidden when no world exists. This screen has a Primary — Continue is a commit into a session, exactly #1's class; the no-Primary-for-dismissal rule (settings Z4 / pause) does not apply
Z2Host…#1 Secondaryyesopens the world list (Z5)
Z2Join#1 Secondaryyesflow owned by ADR-0008; playtest build: opens the provisional direct-connect panel
Z2Settings#1 Secondaryyessettings.md; its Back restores focus here
Z2Quit#1 Secondaryyesto the desktop
Z3notice line#6-tone textnoper-cause strings (States); cleared on the next input. Carries the slate fill behind it (like the button column) so contrast never depends on the tide-loop phase
Z5World list#3 + #2yesrows: auto-name ("World of {0}" with the locale short date — "World of 8/29/2026" in en-US; no text input / no rename at MVP, ruled 2026-08-31) · last-played timestamp; New world as the last row; a version-refused save = dashed row (#1 disabled grammar) with its refusal reason on focus (#6) — refused worlds stay listed, honestly; Back (Secondary)
Z5Join panel (provisional — playtest build only)#3 + #27yesa 6-character join code entered on six character steppers (◂ A ▸ per character; Navigate ←/→ steps a character, ↑/↓ moves between them) — ruled 2026-09-01: fully gamepad-operable, so the project keeps zero parity exceptions and needs no text-input pattern; on KB/M the six steppers also accept direct typing and paste as a convenience, never as the only route · Connect (Primary) · Back.

Deviation from #27, declared (2026-09-01): #27 is specified for "2–6 discrete named options … no wrap". A character stepper carries ~32 glyphs (A–Z 0–9 minus the ambiguity-free exclusions) and wraps — a character ring has no meaningful end, and without wrap reaching Z from A costs 31 presses. Submit on a focused character does not step it (that is ←/→ only) — Submit belongs to Connect, so the panel's commit affordance is never ambiguous. Tracked as a "character stepper" Gaps row in the pattern library; the widget geometry (six steppers in a 420 px panel) is likewise title-screen-local. What is provisional here, and what is not (2026-09-01): the six-stepper mechanism is what delivers gamepad parity and it survives any ADR-0008 outcome — only the alphabet and payload are provisional. A join code presupposes a code-issuing lobby / relay layer (Steam lobby ids, a relay service); an address does not. ADR-0008 owns whether such a layer exists (netcode Rule 7); if it lands on a bare IP transport, the same six steppers become numeric-address entry and the parity guarantee is untouched (Open Question 2) |

| Z5 | Photosensitivity notice | #3 | yes | one-time on first launch; short body + OK (Primary); photosensitivityNoticeShown stored in settings.json (a11y doc: pre-launch notice) |

| — | reset floor | ADR-0007 §4 | hold | invisible by design; holding the default Menu control (Esc / Start) 3 s anywhere on this screen wipes all binding overrides → Z3: "controls were reset to defaults" |

| Z4 | version string | text | no | build id for playtest bug reports. Dimmed, but on the slate fill and verified ≥ 4.5:1 like every other text on this screen (dimming reduces contrast against the art, so the fill is required — Accessibility) |

ASCII Wireframe

  DRIFTLANDS░                                                            
    (wordmark)                 ~  tide loop: a land silhouette drifting  
                                  in on amber, out on violet  ~          
                                                                         
                                                                         
   the session has ended — everything is saved                           
  ┌──────────────────────────────┐                                       
  │ Continue — World of 8/29/2026 ─▸*                                       
  ├──────────────────────────────┤                                       
  │ Host…                        │                                       
  │ Join                         │                                       
  │ Settings                     │                                       
  │ Quit                         │                                       
  └──────────────────────────────┘                                       
                                            driftlands 0.1.0+2e8cfe3    

* = focused (3 px outline + inset tick). The world list, join panel and photosensitivity notice are centred 420 px #3 panels over this scene.

Z-clearance (measured, not asserted — 1920 × 1080; the art's focal band is defined as the centre 40 % of width, x = 576–1344, where the drifting land silhouette sits — art-director to confirm, OQ 8):

Scale Column x-range Z3 x-range Centred panel x-range Column → focal band Column → panel
100 %96 – 41696 – 656750 – 1170160 px clear334 px
125 %120 – 520120 – 820697 – 122256 px clear177 px
150 %144 – 624144 – 984645 – 1275overlaps by 48 px21 px

At 150 % the column's right edge crosses into the focal band by 48 px and sits 21 px from the centred panels — both stated as accepted, not clearance: the column carries its own slate fill so legibility never depends on the art (Accessibility), the art's subject is a silhouette whose read survives partial occlusion (art bible §1 strip-to-black), and the panels are modal — nothing needs to be read beside them. Z3 at 125 % / 150 % extends across the focal band and is the one element that must never be occluded by the art: it carries the slate fill too (Component Inventory). If art-director sets a narrower focal band, only the 150 % row changes.


States & Variants

State / Variant Trigger What Changes
Defaultlaunch, ≥ 1 world existsfull column; first focus Continue
Empty / first runno save files on this machineContinue hidden; first focus Host…, whose list holds only the New world row; nothing else changes (a brand-new friend most often presses Join anyway)
First-launch noticephotosensitivityNoticeShown falsethe one-time photosensitivity panel (#3) over everything; OK dismisses and writes the flag; shown before any other interaction
Return notice — host endedarrived from a session the host endedZ3: "the session has ended — everything is saved"
Return notice — you leftarrived via pause → LeaveZ3: "you left the session — rejoin any time"
Return notice — connection lostdisconnect / host vanishedZ3: "the connection was lost — you can rejoin when it's back"
Controls resetthe reset floor completedZ3: "controls were reset to defaults"
all Z3 notices—one line, #6 tone, cleared on the next input; only one shows at a time (latest wins)
World list openHost…centred #2-in-#3 panel; focus on the most recent world; rows show auto-name + last-played; a version-refused save = dashed row, its refusal reason (#6) shown on focus in the panel footer — refused worlds stay listed
Opening worldContinue / a world row / New worldcolumn and panel inert (dashed); Z3: "opening the world…"; no spinner (seconds-scale, and a bar would imply a promise); then into the session as host
Recovered saveADR-0004's load fell back to the previous good file / a backupZ3 on arrival in the list: "an older good copy of this world was opened — recent moments may be missing" (a filesystem fact, stated once)
Join panel (provisional)Join, playtest buildcentred #3 panel: six join-code steppers (#27), Connect, Back; first focus = the first character
ConnectingConnectpanel inert; "connecting…"; failure → tone-safe reason ("no session answered that code"), back to the panel; success → into the session (late-join sync, netcode Rule 4)
Reduced motion ONthe settingthe tide loop holds a still frame; only the slow tint crossfade remains; all panel motion per #3 (cuts)

No tutorial, locked or premium states; no platform variants (PC only). Loading exists only as Opening world / Connecting above — the screen itself reads local files and shows instantly.


Interaction Map

Input scope (technical-preferences): Keyboard / Mouse primary, Gamepad partial. There is no world behind this screen, so the UI map is live throughout and no capture handoff occurs. This is a map state ADR-0007 §3 does not enumerate (its rule (c) says UI is enabled only while a panel is open, and Player is enabled at boot): the title screen needs UI enabled standing, and Player present but with only Menu consumed — the reset floor reads Menu, which is a Player-map action (§1). Flagged as an ADR-0007 §3 clarification for ADR-0014's panel manager (Open Question 10) — not a redesign, but it must be written down before the first UI story.

Component Action KB/M Gamepad Feedback Outcome
any buttonFocushover (hover == focus, #4) / ↑ ↓UI.Navigate3 px outline + inset tickclears any Z3 notice
ContinuePressEnter / clickSubmit (South)column inert; Z3 "opening the world…"hosts the most-recent world
Host…PressEnter / clickSubmitthe world list opens instantly (#3)Z5 panel, focus on the most recent world
JoinPressEnter / clickSubmitthe join panel opens (playtest build)flow owned by ADR-0008
SettingsPressEnter / clickSubmitthe settings panel opens on the stacksettings.md; its Back restores focus here
QuitPressEnter / clickSubmitinstantdesktop
world rowFocus / Presshover, ↑ ↓ / Enter, clickNavigate, Submitdashed rows show their refusal reason on focus (#6); Submit on a dashed row does nothing but re-assert the reasonopens that world
New world rowPressEnter / clickSubmitsame "opening the world…"rolls a seed; the save system creates one file
any Z5 panelCloseEsc / click BackEast / Submit on Back100–150 ms fadeback to the column, focus restored to the button that opened it
join-code steppers (provisional)Step a character← / → ; type / paste (convenience)Navigate ← / →the character replaces; wraps (no dashed ends — the #27 deviation)—
join-code steppersMove between characters↑ / ↓ / Tab-equivalent via focusNavigate ↑ / ↓focus tick moves—
reset floorHoldEsc held 3 sStart held 3 sno visual until it completes — it must work when the screen is unreadable or every binding is wreckedoverrides wiped + the Z3 line

Focus adjacency — first focus = Continue (or Host… in the empty state):

From ↑ ↓ ← / →
ContinuenothingHost…nothing
Host… / Join / Settingsthe previous buttonthe next buttonnothing
QuitSettingsnothingnothing
world row n (in panel)row n−1 (row 1 → nothing)row n+1 (last → New world → Back)nothing

No wrap — a deliberate deviation from #2's "wrap at both ends" (stated per #21's precedent of declaring every edge): wrapping from New world back to row 1 would put world creation one keypress from the top of the list.

| Back (in panel) | the last row | nothing | nothing |

| Join panel | character 1 … 6 → Connect → Back (↑ / ↓) | the reverse | ← / → step the focused character (#27) |

Five stops in the column, no traps. Esc / East closes any open panel and does nothing on the bare column — there is nothing above the root to go back to (ruled 2026-08-31: a "back" at the root would have no destination, and Quit is a deliberate press, never an Esc away).

Esc short-press vs the 3 s hold (ruled 2026-09-01): the two never race. A panel close fires on the release edge, and only if the hold did not complete; if the 3 s threshold passes, the floor resolves (overrides wiped, Z3 line) and the release is consumed — the panel stays open, so the player sees the reset land in context. A short press with a panel open closes it as usual; a short press on the bare column does nothing. Same press-vs-release grammar as Demolish (ADR-0007's 2026-08-31 amendment).


Events Fired

No analytics system exists; every event is local. Nothing crosses the wire until a session starts.

Player Action Event Payload Persistent?
launchTitleShownfirst-run?no
photosensitivity OKNoticeAcknowledged—yes — settings.json (photosensitivityNoticeShown)
Continue / a world rowHostSessionRequestedsaveIdthe save system opens the file (ADR-0004 load pipeline); this screen writes nothing
New worldNewWorldRequestedrolled worldSeedyes — needs a CreateWorld(seed) → worldFolder operation on the save system; ADR-0004 §1 mints worldId "at world creation" but names no owner and exposes no such API (Open Question 9, amendment (d))
Join → ConnectJoinSessionRequestedjoin code (provisional — the payload is ADR-0008's to fix; code assumes a resolving layer, an address does not: OQ 2)no — ADR-0008 owns the flow
SettingsPanelOpened(Settings)opener = titleno
QuitQuitRequested → Application.Quit()—ADR-0004's wantsToQuit latch runs, but no session is open so the flush queue is empty and it returns immediately
reset floor completesBindingsReset via IRebindService.ResetToDefaults() (ADR-0007 §4)—yes — bindings.json
focus moves; panel open / closenone (deliberate)—no

Flagged for architecture: NewWorldRequested is the one action on this screen that creates persistent world state. It must belong to the save system (save Rule 1: one world = one host-owned file, never merged; no merge path may ever exist) — but no document assigns seed rolling or world creation to anyone, and SavePersistence exposes no CreateWorld. Amendment (d), Open Question 9. The UI never rolls a seed.


Transitions & Animations

Art bible §7.5 binds the UI: 100–150 ms ease-out only; nothing animates that gates an input. The tide loop is world-art, not UI, and follows §4.3's curve.

Moment Motion Reduced motion ON
App → titlefade from black 150 ms; the tide loop starts mid-cycle (never from a "beginning" — the tide has no start)cut; still frame
Panel open (list / join / notice)instant (#3)same
Panel close100–150 ms ease-out fadecut
Title → sessionfade to black 150 ms, then the world — the loop's current tint carries into the first frame (the one deliberate continuity flourish: you fall into the tide you were watching)cut
Z3 notice appear / clearinstant, no slidesame
Tide loopslow continuous amber ↔ violet cycle, ≈ 40 s, no parallax driftstill frame; tint crossfade only — luminance-stable
Reset floor completingno progress indicator during the hold (by design — it must work when nothing renders); the Z3 line is the only feedbacksame

No spinner anywhere (Opening world / Connecting are text states), no bounce, no splash chain, no press-any-key gate.


Data Requirements

Data Source System Read / Write Notes
world list — id, auto-name (creation date), last-played, saveFormatVersion / configVersion compatibilitysave system — requires three ADR-0004 amendments (Open Question 9): (a) a SaveHeader ReadHeader(worldFolder) operation that parses the header keys without running the load pipeline; (b) createdUtc and (c) lastPlayedUtc added to §1's document and to save Rule 2's "what persists" list. None of these exists today — Load() is currently whole-pipeline-only, and the save format carries no timestamps at allReadon entering the list; must never run the load pipeline (that is what pressing the row does). Filesystem mtime was rejected: it does not survive the copy that save Rule 1's fork case is about
most-recent world (for Continue)same — sorted by lastPlayedUtc (amendment (c) above)Readon launch; absent → the empty state. Until the amendment lands there is no source for "most recent" — the list order and Continue's target both depend on it
version-gate verdict + refusal reasonsave system (save Rule 4 — refuse loudly, never partial-load, never silently migrate)Readper row; drives the dashed row + its #6 reason
recovered-load fact (a backup / the previous good file won)save system (ADR-0004)Readdrives the "older good copy" notice
photosensitivityNoticeShownsettings service (settings.json)Read / Writeone bit — the only write this screen owns directly
session-end cause (host ended / you left / connection lost)netcode session, handed over on the return transitionReadtransient; drives the Z3 notice; never persisted
binding-override resetIRebindService.ResetToDefaults() (ADR-0007 §4)Write (trigger)bindings.json; the operation belongs to the rebind service
build version + commitbuild metadataReadthe Z4 string
labels, notices, reason stringsstring table (L10n)Read—

Nothing updates per frame. The UI owns no game state; the single bit it writes is a UI preference, and world creation belongs to the save system.


Accessibility

Tier: Standard (design/accessibility-requirements.md).


Localization Considerations

Element Limit (EN source) Layout constraint Expansion risk
Join code6 characters, not localised (A–Z 0–9, ambiguity-free alphabet — no O/0, I/1); the payload is provisional (code vs address — ADR-0008, OQ 2)six steppers in the 420 px panelLOW
Continue label incl. world name≤ 300 px rendered, one line, ellipsised ("Continue — World of 8/29/2026" ≈ 250 px at Medium)320 px buttonHIGH
Other button labels≤ 18 chars ("Settings", "Quit", "Host…", "Join")320 px, one lineLOW
Z3 notices≤ 56 chars, 2 lines allowed (EN max = 52 ≈ 470 px)560 px slot (Layout Zones), left-anchored with the columnMEDIUM
World-row auto-namea localised template "World of {0}" (never concatenation — some languages do not compose noun + date that way), where {0} is the "d" short-date specifier in the current culture (createdUtc.ToLocalTime().ToString("d", CultureInfo.CurrentCulture) — "29-08-2026" in nl-NL, "8/29/2026" in en-US, "2026/8/29" in ja-JP). Budget ≤ 260 px rendered, not a character count — CJK glyphs are ≈ double-width, so a char budget misleads420 px panel rowHIGH (template + locale date)
Refusal reason (#6)≤ 56 chars, 2 linespanel footerMEDIUM
Photosensitivity body≤ 240 chars, wraps freely420 px panel (grows to content)LOW
Version stringnot localised (build id)Z4LOW

Dates and times are the one locale-formatting requirement in the project — world names and last-played stamps use the "d" standard specifier with CultureInfo.CurrentCulture, never a hardcoded or custom pattern. (The earlier EN example "World of 29 Aug" was itself a custom pattern and contradicted this rule; corrected 2026-09-01 — the EN rendering is "World of 8/29/2026".) No currencies, no RTL commitment at MVP.


Acceptance Criteria


Open Questions