Files
nfsmw-online/docs/LOBBY_UI_DESIGN.md
megboyzzandClaude e7c76fc2dd docs: bring 1.8 MB of project documentation under version control
These files had never been tracked anywhere - they lived in a plain directory
with no git at all, which is also where the whole reverse-engineering record
sat. Code already committed refers to them by name (opponent_substitution.h
cites "ANALYSIS.md section 6hh", DebugMenuOverlay.kt cites "DEBUG_MENU.md
section 3"), so until now a fresh clone carried references to documents it did
not contain.

  ANALYSIS.md                       the RE record, and the reason the rest works
  ARCHITECTURE.md                   how the mod's pieces fit together
  ARM64_TRANSLATION_LAYER.md        the translation layer's running log
  PROGRESS.md                       chronological progress across both chats
  BETA_TELEMETRY_PLAN.md            how crash/telemetry reporting is meant to work
  LOBBY_UI_DESIGN.md + .html        lobby design and its clickable prototype
  DEBUG_MENU.md                     debug panel design
  STATIC_RECOMPILATION_FALLBACK.md  the plan if translation had not panned out
  evidence/                         font atlas capture from the glyph-corruption bug
  save_backups/                     saves at known milestones, for reproducing state

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-22 23:29:12 +03:00

22 KiB
Raw Permalink Blame History

LOBBY_UI_DESIGN.md — Multiplayer Lobby UI & Flow

Living document for the lobby overlay's UI/UX — screens, flow, data shape, and the product decisions behind them. Split out of ARCHITECTURE.md on 2026-08-18 (that file now only links here) so the native/engine-integration doc doesn't keep growing with UI-only decisions. Update this file whenever a screen or flow decision changes; don't let it drift from the prototype.

Clickable prototype: LOBBY_PROTOTYPE.html — self-contained, open directly in any browser, no server needed. Landscape orientation (matches the game). Every screen is tagged OVERLAY (our Compose UI) / NATIVE (real, unmodified game screen) / TRANSITION, with inline notes on what's already proven on-device vs. still open.


1. Lobby UI mechanism — overlay View, markers via BitmapGraphics

Recommendation: hybrid. Full interactive lobby screens (player list, ready-up, countdown) go through an Android overlay View/Compose layer on top of the game's GLSurfaceView — the native UI pipeline has no concept of touch-driven widgets, so it can't host these itself. Simple in-world/in-menu indicators (e.g. a future green/red map marker for an active lobby) should instead go through the BitmapGraphics native text/atlas bridge (launcher/app/src/main/java/com/ea/ironmonkey/BitmapGraphics.kt, ANALYSIS.md §3.3) — verified, already trusted by the game's own renderer, no new Flow/SB authoring needed.

Concern Map markers → BitmapGraphics extension Full lobby screens → Android overlay View
Mechanism Add a method (e.g. drawMarker(x, y, color) via canvas.drawCircle) to BitmapGraphics.kt; call it from a hook alongside the existing drawString JNI call site (0x5625a0) Standard Android View/Compose overlay added to the activity's view hierarchy above/around the GLSurfaceView
Implementation cost Low — one new Kotlin method, one new hook call from mpcore at the point we know a lobby is active Low — standard Android UI, fast iteration
Visual integration Genuinely native — rendered through the exact same atlas-blit path as the game's own UI text, in the game's own coordinate space Good but technically a separate layer composited over/under the GL surface
Touch/interaction support None — this pipeline only produces pixels in a texture; no input handling Full Android input handling, exactly what's needed for player list + ready buttons
Risk to singleplayer Low — purely additive draw call, no-ops when no lobby is active None — overlay is purely additive, toggled only when a multiplayer session exists
Car/upgrade display integration N/A We don't render the car ourselves either way — invoke the game's existing native car-select screen (same one singleplayer uses) as a sub-flow, our overlay only wraps around it

Both are additive and don't touch Flow/SB authoring, so neither destabilizes the shared menu system. Preliminary — revisit once a first marker hook is actually implemented.

Still open: whether EAMText/GlyphMeshGLES/FreeType (also present in the binary, ANALYSIS.md §3.3) plays any role that would interfere with a BitmapGraphics-based marker — low priority until that hook is attempted.


2. Flow decision & data schema

Flow: Compose/View overlay for lobby creation and management, native car-select (filtered by the lobby's class setting) as a sub-flow, ready-checkmark to confirm. "Lobbies as cards on real street pins" is shelved — depends on hooking visible-street MapTrack event population, which visible-streets-investigation found no viable path for. The overlay approach needs no further RE — arbitrary track loading (ARCHITECTURE §3a) and the native car-select screen already work.

Lobby {
  id: string
  hostPlayerId: string
  address: { ip: string, port: int }   // see §4 — shown for both LAN and WAN join
  trackId: string              // arbitrary track, see ARCHITECTURE §3a BuildTrackScenePath hook
  classRestriction: int | null // car class filter applied to native car-select; null = any
  maxPlayers: int              // real cap not decided yet (see §6a) — UI works for whatever it ends up being
  rewards: {                   // reuses the CashReward shape found on RaceEvent, ANALYSIS.md §6aa
    bronze: int
    silver: int
    gold: int
  }                             // see §5 — explicit fields, validated at creation, not free-floating pills
  players: [LobbyPlayer]
}

LobbyPlayer {
  playerId: string
  displayName: string             // from the local PlayerProfile, see §3 — sent to peers on join
  avatarId: string                 // preset id ("color+icon"), see §3 — not a photo/upload
  isLeader: bool                 // explicit flag, not inferred from hostPlayerId match —
                                  // keeps leader-ness a first-class, UI-visible fact instead of
                                  // something every screen has to re-derive by comparison
  ready: ReadyState
  carId: string
  colourIndex: int
  mods: [int | null, int | null] // exactly 2 upgrade slots; each is a CarMod ModType id
                                  // (or null = "НЕТ"/empty). Purely for display — other players
                                  // resolve name/icon/description client-side from the car's own
                                  // CarDescription.Mods catalog, already loaded for the garage
                                  // screen. No numeric value/balance computation server-side.
}

enum ReadyState {
  SetsUpLobby   // leader only — track/class/rewards configuration
  ChoosingCar
  ChoosingRace
  Loading       // race-start fired, client is loading its own RaceLoaderTask
  NotReady
  Ready
}

Confirmed against the live in-game upgrade screen (2 slots, ВЫБОР УЛУЧШЕНИЯ list) and the CarMod struct in alfaromeo_4c_2012_desc.prefabs.sb — 7/7 checked entries matched by price (ModType 1/4/5/6/7/8 confirmed, TYRES_REINFLATING/CHASSIS_REINFORCED/BODY_IMPACT/POWERTRAIN_POWERPACK/NITROUS_BURN/NITROUS_EARN). ModType is a unique per-option id, not a shared category grouping. The separate im::app::car::CarPart/UpgradeParts catalog (skipCost/orderTimeMinutes timer-gating fields) has no live instance data anywhere checked — not part of this schema.

Resolved decisions:

  • Host-leaves-lobby: auto-promote the longest-connected remaining LobbyPlayer to leader; dissolve only when the lobby becomes empty.
  • classRestriction: enforced explicitly by our own overlay (revised 2026-08-19, was "soft/advisory only") — car_select has no known native class-filter hook (RACEEVENT_CATEGORYTAG_OFFSET is a confirmed false lead, PROGRESS.md cont.58; 5 independent dead ends, cont.58-60), so enforcement can't happen inside the native carousel — but that's not a reason to skip enforcing it at all. The overlay checks the chosen carId's class against classRestriction the moment onCarSelected fires; a mismatch blocks readying up (a visible warning replaces the ready button, "Сменить" is the only way forward) rather than silently letting a mismatched car through. Still a client-side check, not a true hard filter on the carousel itself — revisit if the real class-filter mechanism is ever found.
  • Straggler/timeout policy: hard timeout, auto-start, default 45s from when the first client finishes loading — see §6.

3. Player identity — profile chip, not a form

Every player row needs something to display before anyone's picked a car, and it can't come from an EA account — ARCHITECTURE §8 already rules out the Synergy backend for multiplayer, and this project has no login system.

Where the button lives: a small persistent chip (avatar + name) pinned in a fixed corner, present on every OVERLAY screen (map's entry point, lobby browser, create, waiting room) — not buried inside one specific screen's header. It's global identity, so it shouldn't only be reachable from one place. It deliberately does not appear on the two screens where our footprint is intentionally minimal (the native car_select hand-off, the start countdown) — same principle as the minimized lobby badge there.

Tapping it opens a popover, not a full-screen navigation — name field + an 8-preset avatar grid, right there over whatever screen you were on. Editing your name mid-lobby-browsing shouldn't cost you your place in the flow.

First run — not a forced form. Auto-generate a usable default (ROOKIE_####, random 4-digit suffix, random avatar preset) the moment the profile is first read, so multiplayer works immediately with zero typing. Edit anytime via the chip. This mirrors how most lobby-based games (Fortnite, Fall Guys, etc.) handle it — a name you can live with immediately, not a gate.

PlayerProfile {
  displayName: string   // local default "ROOKIE_####" (random), freely editable
  avatarId: string       // one of a small fixed set of {color, glyph} presets
}

Stored locally on-device only (Android SharedPreferences/DataStore — no server round-trip, no RE work, plain new Kotlin). Avatar is a preset, not a photo — a small fixed palette of colour+glyph combinations (🏎️//🔥/🏁/★/🌙/⚙/💀 over a themed accent colour), picked from a grid. Deliberately not a camera/gallery upload: no permissions prompt, no image transport concerns once RakNet carries lobby state, and it matches the HUD-badge visual language already established by CarSelectionBadge. Revisit only if the game ever needs a real player photo — nothing today requires it.

PlayerProfile is copied into LobbyPlayer.displayName/avatarId at join/create time and travels with the rest of the lobby state once RakNet exists.


4. Addressing — IP and port, LAN and WAN alike

Every lobby reference carries an explicit address: { ip, port } (schema above), surfaced consistently rather than hidden behind "LAN auto-discovers, WAN doesn't need it":

  • LAN browser cards show the host's LAN address (192.168.1.42:7777) alongside name/track/players — not just a bare "● LAN" ping indicator.
  • Manual "по IP" join tab gets real address + port input fields (not a stub), validated before the connect action enables: IPv4 dotted-quad or hostname pattern, port an integer in 165535.
  • Waiting room shows the host's own address in the side panel ("Адрес: 192.168.1.42:7777", with a copy affordance — a plain icon button, not a text label, to stay out of the way of the address itself) — for sharing with WAN friends who need to type it in manually, since there's no master-server/matchmaking list yet (ARCHITECTURE §5 — direct-connect only for the first working version).

Default placeholder port used throughout mocks/design: 7777 — not a final decision, just a consistent stand-in until RakNet setup actually picks one.


5. Rewards — explicit fields, validated

Bronze/Silver/Gold are real numeric inputs in Create Lobby, not fixed display pills. Validation, checked live as the leader types:

  • Each value is a positive integer.
  • Non-decreasing: bronze ≤ silver ≤ gold.
  • "Создать" stays disabled with an inline error message until all three pass.

No other reward shape changes — still the same CashReward-derived structure (ANALYSIS.md §6aa) on the Lobby schema above.

The waiting room shows all three tiers too, not just gold — three small cells (🥇/🥈/🥉 + amount), not one pill. This mirrors the real game's own EventDetails screen (see §9 — its "1-Й/2-Й/3-Й НА ФИНИШЕ" reward list is exactly this shape), so the convention isn't invented, it's matched to what the real UI already does one screen over.


6. Waiting room — track hero, ready as a button

Revised 2026-08-18 after direct feedback that the first landscape pass was really just a portrait layout squeezed sideways, not a real redesign:

  • Player rows show the car name and paint colour next to the player name — no car-shaped icon. An earlier pass added a small CSS-drawn car silhouette per row; cut it after direct feedback that it read as visual noise ("некрасиво") rather than useful information. The colour swatch (below) already carries the "this player picked a car" signal on its own.
  • Track preview: the leader's chosen track shows as a placeholder hero block (a wide gradient/road-motif rectangle standing in for a real screenshot, which doesn't exist yet) with the track's friendly name and internal id caption — more prominent than a small text pill, since "what track is this" is the single most important piece of context in the room.
  • Readiness is a button, not a toggle switch — deliberately different from the small iOS-style switch most lobby UIs default to. A full-width button reading "Я ГОТОВ" that becomes a solid, distinctly-coloured "✓ ГОТОВ" on press (press again to un-ready) is a clearer tap target and a clearer at-a-glance state, especially at the smaller touch scale of a landscape phone overlay.
  • Car choice stays changeable after readying up. A persistent "Машина: <car> · Сменить" row is always tappable — even when ready == Ready — and always reopens native car_select. Changing car always resets ready → NotReady automatically, so no peer ever sees a stale "ready" attached to a car that just changed; the player has to explicitly re-press ready afterward.
  • Paint colour is its own swatch, a small bordered round dot next to the car name (matching the visual weight of the game's own PAINT1PAINT6 respray swatches) — never baked into an icon shape. An intermediate version tinted the (since-removed) car icon with the colour directly; that read poorly at small size, especially light/white paints against the dark panel. Same underlying colorHex data (from onCarSelected, proven), just its own unambiguous element.
  • Upgrades are shown too, using the exact mods: [int|null, int|null] shape already in the Lobby/LobbyPlayer schema (§2) and already flowing live off onUpgradesAccepted (proven, cont.39-41): the compact player list shows a 2-dot filled/empty indicator per row (room space is tight — a name is more useful there than two upgrade names), while your own car card in the side panel shows the full resolved names for both slots (or "НЕТ" for an empty slot, matching the schema comment's own convention). Slot→name resolution (ModType id → display string) is a placeholder mapping in the prototype — the real display strings live in the game's own localized upgrade-screen text, not extracted here.
  • Fixed 2026-08-19: the demo car assigned when confirming through the normal flow (Chevrolet Corvette ZR1) now matches the lobby's default class (СПОРТКАР), so the ready button is visible on the main click-through path instead of being silently replaced by the class-mismatch warning every time. The mismatch state (§2, still a real, useful thing to demo) is now reached deliberately, via a small "[демо] показать несовпадение класса" toggle link next to the ready button — not something the primary flow trips over by accident.

6a. Max players per lobby — real number TBD, UI works either way

Added 2026-08-19: the actual cap isn't decided yet, so the UI is built to not assume one.

  • Create Lobby gets a /+ stepper next to the track selector (range 212, arbitrary bounds — just enough to make the control meaningfully bounded, not a real design decision).
  • Waiting room shows a live 👤 N/max pill next to the class tag, turning the same warning colour used elsewhere (--danger) once the room is full.
  • Server browser cards show current/max instead of a static player-count string, and a full server (current == max) is visually locked — dimmed, tagged "ЗАПОЛНЕНО", not tappable — rather than silently allowing a join that wouldn't actually work.

None of this enforces anything server-side (there's no server yet) — it's UI that reflects whatever Lobby.maxPlayers ends up being once that number is actually decided.


7. Loading & waiting for stragglers — engine-level, not a custom screen

Revised 2026-08-18 per direct correction — the earlier draft had this as our own Compose "Загрузка заезда" screen with a list of progress bars. That's the wrong layer for it to live on. The actual requirement: one phone loads fast, another loads slow, and whoever's ready first needs to visibly still be waiting on people, not staring at our UI — the intro/starting-line scene should keep playing, on loop, until everyone's in.

  • Once a client's own race load reaches the point where the game would normally show its native pre-race starting-grid scene (cars lined up, camera settled, about to cut to the 3-2-1-GO countdown), that's what stays on screen — not a custom loading UI panel. Consistent with this project's standing principle (ARCHITECTURE §2): hook the game's own flow, don't replace it with ours.
  • Our overlay's only contribution here is a thin status strip, not a full panel — something like "Ожидаем игроков (2/4) · SLOWPOKE_88 загружается", pinned to one edge, with a subtle looping pulse so it visibly reads as "still working," not "frozen."
  • The native 3-2-1-GO countdown itself is held until the host signals everyone's in, then plays exactly as it would in a normal single-player race start — no substitute countdown of our own.
  • Engineering reality check: the hold/release mechanism doesn't exist yet. Candidate hook anchor is InRaceState construction (RTTI-confirmed, ANALYSIS.md §3.1) or the final event in RaceLoaderTask_DispatchInitialFSMEvents (ANALYSIS.md §6b stage 8) — either is a real, already-located point, just not yet wired for this purpose. RaceLoaderTask_ExecuteLoadSequence's own SetLoadProgress(this, N) calls (ANALYSIS.md §6a slot 4, fractions 0.10.8) are the natural source for the real per-client progress number to report to peers, instead of inventing a synthetic one.
  • Straggler timeout still applies on top of this: hard 45s timeout (resolved 2026-08-18) from when the first client is ready — if it expires before everyone's in, the held countdown releases anyway and whoever isn't in yet is left behind. Left-behind representation (spectator vs. rubber-band-in-when-ready) is still an open, lower-priority call.
  • Also still open, and upstream of all of this (see ARCHITECTURE §4b step 5.1): which race event actually carries the load. The synthetic "LAN" event currently has its loadout-confirm deliberately redirected back to the map because it has no real track/scene reference (crashes otherwise, PROGRESS.md cont.36) — the recommended fix is having the lobby's "start" re-target a real, existing race event as the vessel and let its loadout-confirm proceed normally (real events already sail through with zero crashes, cont.41), rather than trying to give the synthetic event a scene reference of its own.

8. Screen inventory (prototype)

Matches LOBBY_PROTOTYPE.html's screen rail. Profile is not in this numbered flow — it's the global chip/popover from §3, demonstrable from any screen.

# Screen Tag What it is
0 Карта — точка входа OVERLAY Entry button on the map, same FrameLayout pattern as CarSelectionBadge
1 Обзор лобби OVERLAY LAN list (with address) + "по IP" tab (validated) + create
2 Создание лобби OVERLAY Name, track (with placeholder preview), class, validated rewards
3 Комната ожидания OVERLAY Player rows (ready-button, reward trio, mod dots), track hero, address
4 event_details (перед car_select) NATIVE Real on-device screenshot — any ordinary event, not ours; see §9
5 car_select (после event_details) NATIVE Real on-device screenshot — our overlay shrinks to a corner badge
6 Снова в лобби: авто выбрано OVERLAY Car/colour captured; "Сменить" stays available; ready button
7 Все готовы OVERLAY Host's "Начать гонку" enabled once everyone's Ready
8 Ожидание в гонке NATIVE + thin overlay Native starting-grid scene loops; thin status strip shows stragglers
9 Старт заезда TRANSITION Native 3-2-1-GO, released once everyone's in (or timeout)

9. event_details before car_select — real screenshots, real reason

Added 2026-08-19, direct request: show the actual native hand-off as two steps, not one, using real captures instead of a drawn placeholder.

Why event_details comes first, not just car_select alone: this isn't a UX preference, it's the same technical constraint already on record in ARCHITECTURE §4b step 5.2 / PROGRESS.md cont.55/57 — TriggerTrueDirectCarSelectJump() needs at least one real event_detail → car_select transition to have happened this session (g_realEventDetailsVisitHappened) before a cold direct jump is reliable. Showing event_details first in the prototype makes that real engine requirement visible in the flow itself, instead of leaving it as an invisible precondition documented only in prose.

The screenshots are real, captured live via adb screencap on the project's own Galaxy A9 test device (com.ea.games.nfs13_mod), not drawn: an ordinary street (МАККЛЕЙН) → an ordinary event (ПОБУДКА, everyday street race) → its EventDetails screen → continue → car_select showing whatever car happened to be equipped (Subaru Cosworth Impreza STI CS400). Deliberately not the project's own synthetic "LAN" event — per the request, any real street/event/car works, and using a genuinely ordinary one keeps the mockup honest about what the native screens actually look like today, unmodified.

Both screenshots are embedded directly in LOBBY_PROTOTYPE.html (inline data: JPEGs, cropped to drop the on-screen nav bar) — the prototype has zero external image dependencies.

On-device viewing: LOBBY_PROTOTYPE.html?kiosk=1 hides the review-tool chrome (masthead, screen rail, notes panel) and lets the phone-frame content fill the real viewport — this is the mode to use when checking the mockup on an actual phone via adb, not the full review page.