{"deployment":"lobby","scripts":[{"label":"lobby","source":"// The first-party lobby script — the entry shard of the deployment.\n// See docs/lobby.md for the design and docs/online-service.md for how\n// it fits into the connection flow.\n//\n// What's here today:\n//   - Server side parses the harness-supplied handoff payload (rom\n//     hash + nickname), tracks per-slot state, and broadcasts member\n//     joins/leaves to every connected client.\n//   - Client side pauses the ROM on first frame, renders an overlay\n//     with an album-style grid of mode tiles on the left and a\n//     right-edge player sidebar, and `hop()`s to the gameplay shard\n//     on A/Start. Tiles are solid-color placeholders for now — real\n//     per-mode art will replace them once we have any.\n//   - All inputs are `consume_input()`d so the paused ROM doesn't see\n//     them. D-pad navigates the mode grid; RIGHT at the rightmost\n//     populated cell jumps to the sidebar, LEFT from the sidebar\n//     jumps back. A/Start confirms the selected mode.\n//\n// What's deferred to a follow-up:\n//   - Filtering the player list to the current ROM's cohort. Every\n//     connected player is shown for now; mixing ROMs is fine while\n//     there is only one gameplay shard wired up.\n//   - The multi-mode table keyed by ROM hash that docs/lobby.md\n//     describes. One hard-coded mode keeps v1 small.\n//   - Lowercase glyph coverage in the bundled font. Names render in\n//     uppercase via fold_upper below.\n//\n// Handoff wire format from the harness: empty. The harness is a\n// generic player — it knows nothing about this script. The lobby\n// reads the player's ROM identity via the `local_rom_hash()` builtin\n// (client side). Players land in the lobby with a server-assigned\n// default nickname (PLAYER1, PLAYER2, ... keyed off slot index) and\n// can rename themselves through the in-script keyboard via the\n// SETTINGS button.\n//\n// Server→client broadcast format (rebroadcast on every change; the slot\n// count is low enough that bandwidth is irrelevant):\n//\n//     [count:u8] then `count` occupied-slot ids, one u8 each,\n//     then a trailing block of per-mode live player counts:\n//     [mode_count_0_lo:u8] [mode_count_0_hi:u8] ... (MODE_COUNT entries,\n//     each a little-endian u16 — caps at 65535, plenty for v1),\n//     then availability flags (MODE_COUNT mode bytes, then SP_MOD_COUNT sp\n//     bytes; 1 = the entry's script is in the deployed library).\n//\n// Occupancy only — there are no player names. The roster shows a generic\n// \"PLAYER N\" per occupied slot. The mode counts come from\n// `player_count(label)`, which the server walks recursively across the spawn\n// tree so an orchestrator's shards roll up to their mode's count.\n//\n// Client→server wire format (each payload begins with a MSG_* type byte):\n//\n//     [MSG_PICK_MODE=2] [mode_index:u8]                 — please send me to mode N\n//\n// The pick-mode flow is asymmetric: client sends the type+index; server finds\n// (or spawns) the gameplay instance for that mode and `redirect`s the client to\n// it via a runtime control frame. The browser turns the redirect control frame\n// into a hop without any further script-side involvement. The redirect handoff\n// is empty filler — gameplay carries no nickname and learns what it needs from\n// the player's own state packets + local_rom_hash().\n\nconst MAX_PLAYERS: i32       = 64\n// Per-mode live count tacked onto the end of the broadcast: one u16 LE\n// per mode. Capacity-wise the broadcast tops out at the slot table\n// (64 records) plus the mode block; the buffer below is sized to that\n// max so we never re-allocate.\nconst MODE_COUNT_BYTES: i32  = 2\n// Availability flags tacked onto the broadcast after the per-mode counts:\n// one byte per multiplayer mode (8) then one per singleplayer mod (7),\n// 1 = the script is in the server's deployed library, 0 = absent. The\n// lobby's gloss is the basis; these flags are the per-entry stock-check\n// (docs/lobby-script-catalog.md). Literals here because MODE_COUNT /\n// SP_MOD_COUNT are declared further down.\nconst AVAIL_BYTES: i32       = 8 + 7\nconst BROADCAST_MAX: i32     = 1 + 64 * 34 + 10 * 2 + 10 + 8\n\n// Shared-bus channel for the lobby's control protocol — both halves `listen`\n// on it (the host routes by channel, docs/multi-script-client.md). The lobby is\n// its own deployment endpoint, so the id only needs to be unique among its own\n// members; 1 suffices.\nconst CHANNEL_LOBBY: i32     = 1\n\n// Client→server message-type prefix. Every payload starts with one of\n// these bytes so a single `event receive` can route to the right\n// handler.\nconst MSG_PICK_MODE: i32     = 2     // [2][mode_index:u8]\n\n// Screen geometry. The lobby is sized for the NES's 256x240 visible\n// area with the runtime's 8px overscan mask in mind: usable region is\n// roughly x in [8, 248), y in [8, 232).\n//\n// Layout (v4 — \"launcher\" redesign):\n//   - a dark title band across the top with the MULTINOSTALGIA mark and\n//     a live \"N ONLINE\" readout,\n//   - a segmented tab control (FEATURED / CUSTOM) rendered as\n//     two filled buttons so it reads as interactive,\n//   - one uniform card column shared by both sections (same thumbnail +\n//     title + blurb footprint in each), and\n//   - a one-line, section-aware control-hint footer.\nconst SAFE_X0: i32           = 8\nconst SAFE_X1: i32           = 248\nconst ROW_HEIGHT: i32        = 10        // sidebar line height (parked roster)\n\n// Title band: a full-width coloured bar behind the brand mark.\nconst BAND_X: i32            = 8\nconst BAND_Y: i32            = 8\nconst BAND_W: i32            = 240       // 8..248\nconst BAND_H: i32            = 20        // 8..28\nconst BRAND_X: i32           = 16\nconst BRAND_Y: i32           = 14        // vertically centred in the band\n\n// Uniform card column shared by both sections. One card per row; the\n// same geometry drives multiplayer modes and singleplayer scripts so a\n// future thumbnail / blurb lands in the same place in both.\nconst CONTENT_X: i32         = 16\nconst CONTENT_Y: i32         = 50\nconst CARD_W: i32            = 216       // 16..232\nconst CARD_H: i32            = 30\nconst CARD_STRIDE: i32       = 34        // card + 4px gap\nconst CARD_THUMB: i32        = 26        // square thumb, inset 2px\n\n// Game Boy compact layout (~160x144). The NES card column doesn't fit a\n// 160px-wide / 144px-tall screen, so on a narrow console the lobby drops\n// to single-line rows (no thumbnail, no blurb). With ROM-gating only one\n// MUTED MUSIC row is ever visible per cartridge, so START + the visible\n// rows fit easily. \"Cramped is fine\" (docs/lobby.md). Widths derive from\n// screen_width() at draw time; these are the fixed bits. See lo_compact().\nconst GB_X0: i32             = 8         // left/right margin\nconst GB_BAND_Y: i32         = 4\nconst GB_BAND_H: i32         = 12\nconst GB_TAB_Y: i32          = 18\nconst GB_TAB_H: i32          = 12\nconst GB_CONTENT_Y: i32      = 32\nconst GB_ROW_H: i32          = 16\nconst GB_ROW_STRIDE: i32     = 18\n\n// Vertical scrolling. A list longer than its visible window scrolls a fixed\n// window: the cursor stays inside it, and ^/v indicators in the right margin\n// (NES) show there is more above/below (docs/lobby.md §\"List behavior\"). LIST_V\n// card rows fit the NES column; GB_LIST_V single-line rows fit the Game Boy.\nconst LIST_V: i32            = 5\nconst GB_LIST_V: i32         = 6\n\n// Inter-glyph spacing for the proportional big font (px added after each\n// glyph's ink width). 1px reads as natural letter-spacing at this size.\nconst GLYPH_GAP: i32         = 1\n\n// Selection-gleam animation: GLEAM_SPEED frames per colour step, over a\n// cycle of GLEAM_STEPS warm shades (see gleam_cycle in the client block).\nconst GLEAM_SPEED: i32       = 6\nconst GLEAM_STEPS: i32       = 10\n\n// Right-edge sidebar (player list). 88px wide column from x=160..248\n// with a 2px separator at x=160 and text starting at x=168. The\n// \"PLAYERS\" header sits at HEADER_Y aligned with the brand mark; rows\n// start one line below.\nconst SIDEBAR_DIV_X: i32     = 160\nconst SIDEBAR_X: i32         = 168\nconst SIDEBAR_ROW_Y0: i32    = 28            // HEADER_Y + 12px gap\nconst SIDEBAR_VISIBLE: i32   = 18\n\nconst COL_MODES: i32         = 1\nconst COL_SETTINGS: i32      = 2\n\nconst MODE_COUNT: i32        = 9\n\n// Settings button — bottom-left of the lobby panel. v1 has exactly\n// one setting (re-open the naming view to change your nickname); the\n// button is its own focus column so D-pad navigation stays predictable\n// instead of overloading \"DOWN past the last mode\" with no visible\n// target.\nconst SETTINGS_X: i32        = 16\nconst SETTINGS_Y: i32        = 208\nconst SETTINGS_W: i32        = 80\nconst SETTINGS_H: i32        = 16\n\n// Top-level views. NAMING shows the virtual keyboard until the player\n// confirms a nickname; LOBBY is the FEATURED/CUSTOM browser; DRILL is the\n// per-bundle member sub-list opened from a multi-member CUSTOM bundle\n// (docs/lobby.md §\"The drill-in\").\nconst VIEW_NAMING: i32       = 0\nconst VIEW_LOBBY: i32        = 1\nconst VIEW_DRILL: i32        = 2\n\n// Two top-level tabs inside the lobby view (docs/lobby.md). LEFT focuses\n// FEATURED — pre-hosted networked experiences you JOIN, single-select →\n// server redirect. RIGHT focuses CUSTOM — the catalogue of local bundles\n// you toggle on and START together, becoming the host of the assembled set.\n// The axis is join-a-running-experience vs assemble-and-host, NOT\n// single- vs multi-player.\nconst SECTION_FEATURED: i32        = 0\nconst SECTION_CUSTOM: i32          = 1\n\n// Singleplayer mod catalogue. Each entry is a local .munos mod the\n// supervisor runs as a side-by-side tenant over the bare ROM\n// (docs/multi-script-client.md). Unlike multiplayer modes, SEVERAL can be\n// toggled on at once and START launches the whole set. MUTE MUSIC ships\n// one script per game — each gates on local_rom_hash() and drives that\n// game's own sound engine to silence the music while leaving SFX; the\n// list shows only the entry that matches the local cartridge. Today:\n// Legend of Zelda (sp_muted_music_legend_of_zelda.munos), Metroid\n// (sp_muted_music_metroid.munos), Mega Man 2 (sp_muted_music_mega_man_2.munos),\n// Super Mario Bros. 3 (sp_muted_music_smb3.munos), Mega Man\n// (sp_muted_music_mega_man.munos), Super Mario Bros. 2\n// (sp_muted_music_super_mario_bros_2.munos), Super Mario Bros.\n// (sp_muted_music_super_mario_bros.munos) and Wario Land\n// (sp_muted_music_wario_land.munos) — different engines, same idea.\n//\n// Plus the SMB ghost record/replay mods (indices 8-11) that ride the\n// in-browser LOCAL bus (their paths carry a `local:` scheme, so the supervisor\n// stands up a LocalServer and hosts their server halves on the player's own\n// machine — docs/multi-script-client.md §\"The lobby's role\"):\n//   - RUN RECORDER (smb_recorder.munos) — a server-side tap that saves runs;\n//   - GHOST SENDER (smb_sender.munos) — the uplink that produces the stream;\n//   - GHOST RENDERER (smb_renderer.munos) — draws other runs as ghosts;\n//   - GHOST REPLAY (smb_replay.munos) — replays your saved runs as ghosts.\n// To CAPTURE, enable RUN RECORDER + GHOST SENDER. To PLAY BACK, enable GHOST\n// SENDER (so replay knows your level/position) + GHOST RENDERER (to draw) +\n// GHOST REPLAY (the source). All four can run at once — record a fresh run\n// while past runs replay around you (docs/recording-and-replay.md).\nconst SP_MOD_COUNT: i32      = 12\n\n// CUSTOM bundles. The catalogue is presented as bundles, not raw scripts\n// (docs/lobby.md §\"The bundle is the atom\"): the 8 MUTED MUSIC entries are\n// one-member bundles (indices 0..7, ROM-gated so exactly one shows), and the\n// four SMB ghost record/replay scripts (sp-mod indices 8..11) collapse into a\n// single multi-member \"SMB GHOSTS\" bundle (index 8) you drill into to toggle\n// its members. Member ranges are contiguous, so bundle_first/bundle_len map a\n// bundle to its sp-mod indices with no side table. 8 singletons + 1 group = 9.\nconst BUNDLE_COUNT: i32      = 9\n\n// Segmented tab control under the band. Two buttons with a small gap; the\n// active one is filled with the accent colour, the other dim, so the pair\n// reads as a selectable control rather than two headings. FEATURED and\n// CUSTOM are both short enough to centre comfortably in the widths inherited\n// from the old (longer MULTIPLAYER/SINGLEPLAYER) labels — no geometry change.\nconst TAB_Y: i32             = 30\nconst TAB_H: i32             = 16        // 30..46\nconst TAB_MP_X: i32          = 16        // 16..118\nconst TAB_MP_W: i32          = 102\nconst TAB_SP_X: i32          = 122       // 122..232\nconst TAB_SP_W: i32          = 110\n\n// Virtual keyboard geometry. 6x6 grid of 16px cells fits the 26\n// letters + 10 digits cleanly, leaves room for the sidebar on the\n// right, and stays well above the bottom hint line.\nconst KB_COLS: i32           = 6\nconst KB_ROWS: i32           = 6\nconst KB_CELL: i32           = 16\nconst KB_GAP: i32            = 2\nconst KB_X: i32              = 76       // centered in the full safe region\nconst KB_Y: i32              = 88\nconst NAME_INPUT_MAX: i32    = 10       // visible cap on the keyboard name entry\n\n// END button — a single virtual key below the letter grid that\n// confirms the typed name. Reachable as kb_cursor == KB_END, so the\n// same DPAD/A scheme drives the whole keyboard.\nconst KB_END: i32            = 36       // = KB_COLS * KB_ROWS\nconst END_X: i32             = 104      // centered on screen: 8 + (240 - 48) / 2\nconst END_Y: i32             = 204\nconst END_W: i32             = 48\nconst END_H: i32             = 16\n\nserver {\n    listen CHANNEL_LOBBY\n\n    var clients: client[64]\n    var occupied: bool[64]\n\n    // Scratch buffer for outgoing broadcasts. Re-zeroed inside\n    // build_broadcast each time. Sized for 1 (count) + 64 records *\n    // 34 bytes + MODE_COUNT * 2 trailing per-mode count bytes.\n    var bcast: u8[2220]             // 1 + 64*34 + MODE_COUNT*2 + MODE_COUNT + SP_MOD_COUNT avail + slack\n    var bcast_len: i32 = 0\n\n    // Refresh ticks since the last periodic fan_out. Bumped each\n    // `event tick`; once it reaches one full tick_rate() worth (≈1s\n    // at the default 30 Hz) we fan_out so connected lobby clients\n    // see live per-mode player counts update in the UI without having\n    // to do any client-side polling.\n    var refresh_ticks: i32 = 0\n\n\n    function build_broadcast() {\n        for (var i: i32 = 0; i < BROADCAST_MAX; i = i + 1) { bcast[i] = u8(0) }\n        var count: i32 = 0\n        for (var i: i32 = 0; i < MAX_PLAYERS; i = i + 1) {\n            if occupied[i] { count = count + 1 }\n        }\n        bcast[0] = u8(count)\n        var off: i32 = 1\n        // Occupancy only — one slot id per connected player (no names; the\n        // roster renders a generic PLAYER N label).\n        for (var i: i32 = 0; i < MAX_PLAYERS; i = i + 1) {\n            if !occupied[i] { continue }\n            bcast[off] = u8(i)\n            off = off + 1\n        }\n        // Per-mode live player counts. `player_count(label)` walks the\n        // server's spawn tree, so an orchestrator-style mode (e.g.\n        // smb-orchestrator) correctly returns the sum across all its\n        // spawned shards even though the orchestrator itself has no\n        // direct clients. Stored as little-endian u16 — counts above\n        // 65535 are clamped, which can't happen in v1 anyway.\n        for (var i: i32 = 0; i < MODE_COUNT; i = i + 1) {\n            // A deployment is its own instance keyed by the deployment id, not\n            // by any member's script label, so count it by that id.\n            var clbl: string = mode_script_label(i)\n            if mode_is_deployment(i) { clbl = mode_deployment_id(i) }\n            var pc: i32 = player_count(clbl)\n            if pc < 0 { pc = 0 }\n            if pc > 65535 { pc = 65535 }\n            bcast[off + i * MODE_COUNT_BYTES + 0] = u8(pc)\n            bcast[off + i * MODE_COUNT_BYTES + 1] = u8(pc / 256)\n        }\n        off = off + MODE_COUNT * MODE_COUNT_BYTES\n        // Per-entry availability: 1 if the entry's script is in the\n        // server's deployed library, 0 if it's glossed but absent. The\n        // client renders an absent entry as UNAVAILABLE rather than\n        // letting the catalogue drift silently from the deployment\n        // (docs/lobby-script-catalog.md). list_scripts() is the deployed\n        // truth; fetch it once and intersect each catalogue label.\n        var deployed: string[] = list_scripts()\n        for (var i: i32 = 0; i < MODE_COUNT; i = i + 1) {\n            var a: i32 = 0\n            if set_contains(deployed, mode_script_label(i)) { a = 1 }\n            bcast[off + i] = u8(a)\n        }\n        off = off + MODE_COUNT\n        for (var i: i32 = 0; i < SP_MOD_COUNT; i = i + 1) {\n            var a: i32 = 0\n            if set_contains(deployed, sp_mod_script_label(i)) { a = 1 }\n            bcast[off + i] = u8(a)\n        }\n        off = off + SP_MOD_COUNT\n        bcast_len = off\n    }\n\n    function fan_out() {\n        build_broadcast()\n        var msg: u8[2220]\n        for (var i: i32 = 0; i < bcast_len; i = i + 1) { msg[i] = bcast[i] }\n        for (var i: i32 = 0; i < MAX_PLAYERS; i = i + 1) {\n            if !occupied[i] { continue }\n            send_to(clients[i], CHANNEL_LOBBY, msg, reliable = true)\n        }\n    }\n\n    event connect(c: client, handoff: u8[]) {\n        var s = slot(c)\n        clients[s] = c\n        occupied[s] = true\n        // Handoff from the harness is empty and there are no names — a\n        // connection just adds the slot to the roster.\n        fan_out()\n    }\n\n    event disconnect(c: client) {\n        var s = slot(c)\n        occupied[s] = false\n        fan_out()\n    }\n\n    event tick(tick_num: i32) {\n        // Connect/disconnect/rename drive immediate broadcasts; the\n        // per-mode player counts (which the lobby renders under each\n        // tile) need their own heartbeat since they can change without\n        // any lobby-side event firing — a player joining smb-ghost\n        // doesn't notify the lobby. Re-fan once a second so the\n        // counters stay live.\n        refresh_ticks = refresh_ticks + 1\n        var period: i32 = tick_rate()\n        if period <= 0 { period = 30 }\n        if refresh_ticks >= period {\n            refresh_ticks = 0\n            fan_out()\n        }\n    }\n\n    // Mode-index → script label / redirect URL. Both functions are keyed\n    // on the same index so the client and server agree on what mode 0\n    // means. Adding a mode means updating these alongside MODE_COUNT\n    // and the client-side mode_label / mode_pal_for_index helpers.\n    function mode_script_label(i: i32): string {\n        // NORMAL is handled entirely client-side via disconnect() —\n        // the lobby never sends MSG_PICK_MODE for it, so the server\n        // has nothing to spawn. Returning \"\" keeps handle_pick_mode's\n        // empty-label guard as a defence in depth if the client ever\n        // sends mode 0 by mistake.\n        if i == 0 { return \"\" }\n        // SMB and Zelda are DEPLOYMENTS (sender + renderer + backend over one\n        // shared bus). Availability keys on a member label (the backend is in the\n        // deployed library); the redirect + player count use the deployment id\n        // instead (see mode_is_deployment / mode_deployment_id).\n        if i == 1 { return \"smb_backend\" }\n        if i == 2 { return \"loz_backend\" }\n        // MM2 is the mega_man_2 DEPLOYMENT (mm2_sender + mm2_renderer +\n        // mm2_backend over one shared bus), like SMB. Availability keys on a\n        // member label (mm2_backend is in the deployed library); the redirect\n        // + player count use the deployment id (see mode_is_deployment).\n        if i == 3 { return \"mm2_backend\" }\n        // SMB3 is the super_mario_bros_3 DEPLOYMENT (smb3_sender + smb3_renderer\n        // + smb3_backend over one shared bus), like SMB. Availability keys on a\n        // member label (smb3_backend is in the deployed library); the redirect +\n        // player count use the deployment id (see mode_is_deployment /\n        // mode_deployment_id). smb3_backend carries the per-area sharding orchestrator.\n        if i == 4 { return \"smb3_backend\" }\n        // MM is the mega_man DEPLOYMENT (mega_man_sender + mega_man_renderer +\n        // mega_man_backend over one shared bus). Availability keys on a member\n        // label (mega_man_backend is in the deployed library); the redirect +\n        // player count use the deployment id (see mode_is_deployment / mode_deployment_id).\n        if i == 5 { return \"mega_man_backend\" }\n        // SMB2 is the super_mario_bros_2 DEPLOYMENT (smb2_sender + smb2_renderer\n        // + smb2_backend). Availability keys on a member label (smb2_backend is\n        // in the deployed library); the redirect + player count use the\n        // deployment id (see mode_is_deployment / mode_deployment_id).\n        if i == 6 { return \"smb2_backend\" }\n        // Metroid is the `metroid` DEPLOYMENT (metroid_sender + metroid_renderer\n        // + metroid_backend over one shared bus). Availability keys on a member\n        // label (metroid_backend is in the deployed library); the redirect +\n        // player count use the deployment id (see mode_is_deployment /\n        // mode_deployment_id).\n        if i == 7 { return \"metroid_backend\" }\n        // Wario Land is the wario_land DEPLOYMENT (wario_sender + wario_renderer +\n        // wario_backend over one shared bus). Availability keys on a member label\n        // (wario_backend is in the deployed library); the redirect + player count\n        // use the deployment id instead (see mode_is_deployment / mode_deployment_id).\n        if i == 8 { return \"wario_backend\" }\n        return \"\"\n    }\n\n    // True for a mode served as a multi-script DEPLOYMENT (one shared bus)\n    // rather than a single spawned script. A deployment is mounted from server\n    // config on first connect, so handle_pick_mode redirects to it directly\n    // (no find_instance/spawn) and build_broadcast counts it by deployment id.\n    function mode_is_deployment(i: i32): bool {\n        if i == 1 { return true }   // super_mario_bros\n        if i == 2 { return true }   // legend_of_zelda\n        if i == 3 { return true }   // mega_man_2\n        if i == 4 { return true }   // super_mario_bros_3\n        if i == 5 { return true }   // mega_man\n        if i == 6 { return true }   // super_mario_bros_2\n        if i == 7 { return true }   // metroid\n        if i == 8 { return true }   // wario_land\n        return false\n    }\n\n    // The deployment id for a deployment-backed mode (the instance key the bus\n    // mounts under, distinct from any one member's script label). Only meaningful\n    // when mode_is_deployment(i) is true. build_broadcast counts a deployment's\n    // live players by this id (player_count walks its spawned worker shards), and\n    // it's kept in lock-step with mode_redirect_url's \"/\" + id.\n    function mode_deployment_id(i: i32): string {\n        if i == 1 { return \"super_mario_bros\" }\n        if i == 2 { return \"legend_of_zelda\" }\n        if i == 3 { return \"mega_man_2\" }\n        if i == 4 { return \"super_mario_bros_3\" }\n        if i == 5 { return \"mega_man\" }\n        if i == 6 { return \"super_mario_bros_2\" }\n        if i == 7 { return \"metroid\" }\n        if i == 8 { return \"wario_land\" }\n        return \"\"\n    }\n\n    // Path component the redirect frame names. We keep the instance ID\n    // equal to the script label for the v1 \"one shared room per game\"\n    // policy — private rooms (future work) will use unique IDs. The\n    // SMB RACE mode targets the super_mario_bros DEPLOYMENT, whose primary\n    // is a pure transit orchestrator that hosts no level and redirects every\n    // arriving player (world 1-1 included) onto the per-level worker shards\n    // it spawns on demand.\n    function mode_redirect_url(i: i32): string {\n        // See mode_script_label — NORMAL doesn't redirect, it\n        // disconnects locally. Empty string for symmetry.\n        if i == 0 { return \"\" }\n        if i == 1 { return \"/super_mario_bros\" }\n        if i == 2 { return \"/legend_of_zelda\" }\n        // MM2 RACE targets the mega_man_2 DEPLOYMENT (single shared room; the\n        // backend has no sharding orchestrator — it just relays).\n        if i == 3 { return \"/mega_man_2\" }\n        if i == 4 { return \"/super_mario_bros_3\" }\n        // MM targets the mega_man DEPLOYMENT, whose primary is a pure-transit\n        // orchestrator that hosts no strip and redirects every arriving player\n        // onto the per-strip worker shards it spawns on demand (like SMB RACE).\n        if i == 5 { return \"/mega_man\" }\n        if i == 6 { return \"/super_mario_bros_2\" }\n        // Metroid RACE targets the `metroid` DEPLOYMENT, whose primary is a pure\n        // transit orchestrator that hosts no region and redirects every arriving\n        // player onto the per-region worker shards it spawns on demand.\n        if i == 7 { return \"/metroid\" }\n        // Wario Land is the first GAME BOY multiplayer mode — the wario_land\n        // DEPLOYMENT (wario_sender + wario_renderer + wario_backend over one bus).\n        // /wario_land is a pure-orchestrator primary (like SMB RACE, but it hosts\n        // no players of its own): it routes each arriving player to a per-shard\n        // worker — one per level, plus one for the world map. See\n        // deployment-scripts/wario_land/.\n        if i == 8 { return \"/wario_land\" }\n        return \"\"\n    }\n\n    // Singleplayer-mod script label per catalogue index, mirroring the\n    // client's sp_mod_path() (same order, without the leading \"/\"). The\n    // server needs the bare label to cross-check each mod against\n    // list_scripts() when it stamps availability into the broadcast.\n    // Keep in lockstep with the client sp_mod_path / sp_mod_label tables.\n    function sp_mod_script_label(i: i32): string {\n        if i == 0 { return \"sp_muted_music_legend_of_zelda\" }\n        if i == 1 { return \"sp_muted_music_metroid\" }\n        if i == 2 { return \"sp_muted_music_mega_man_2\" }\n        if i == 3 { return \"sp_muted_music_smb3\" }\n        if i == 4 { return \"sp_muted_music_mega_man\" }\n        if i == 5 { return \"sp_muted_music_super_mario_bros_2\" }\n        if i == 6 { return \"sp_muted_music_super_mario_bros\" }\n        if i == 7 { return \"sp_muted_music_wario_land\" }\n        // SMB ghost record/replay set (rides the local bus). The bare labels\n        // mirror the client sp_mod_path entries with the `local:` scheme + \"/\"\n        // removed.\n        if i == 8 { return \"smb_recorder\" }\n        if i == 9 { return \"smb_sender\" }\n        if i == 10 { return \"smb_renderer\" }\n        if i == 11 { return \"smb_replay\" }\n        return \"\"\n    }\n\n    // Byte-wise string equality (server-side mirror of the client's\n    // streq). Used to scan list_scripts() for a catalogue label.\n    function srv_streq(a: string, b: string): bool {\n        if len(a) != len(b) { return false }\n        for (var i: i32 = 0; i < len(a); i = i + 1) {\n            if a[i] != b[i] { return false }\n        }\n        return true\n    }\n\n    // True if `label` is present in the deployed-library set. An empty\n    // label (e.g. NORMAL, which has no script) counts as available — it\n    // is never gated on a deployed script.\n    function set_contains(set: string[], label: string): bool {\n        if len(label) == 0 { return true }\n        for (var i: i32 = 0; i < len(set); i = i + 1) {\n            if srv_streq(set[i], label) { return true }\n        }\n        return false\n    }\n\n\n    function handle_pick_mode(c: client, data: u8[]) {\n        if len(data) < 2 { return }\n        var mode: i32 = i32(data[1])\n        var label: string = mode_script_label(mode)\n        if len(label) == 0 { return }\n        var url: string = mode_redirect_url(mode)\n        if len(url) == 0 { return }\n        // Find an existing instance for this mode; spawn one if missing.\n        // Persistent lifecycle — public rooms outlive any individual\n        // player so newcomers always land in the same shared world. A\n        // DEPLOYMENT skips this: it has no single spawn label and is mounted\n        // from server config on first connect, so we redirect to it directly\n        // and the runtime turns the redirect into a whole-bus group hop.\n        if !mode_is_deployment(mode) {\n            var existing: string = find_instance(label)\n            if len(existing) == 0 {\n                var empty: u8[1]\n                empty[0] = u8(0)\n                var ok: bool = spawn(label, label, empty, true)\n                if !ok { return }\n            }\n        }\n        // Gameplay carries no nickname, so the handoff is empty filler — the\n        // destination shard learns everything it needs from the player's own\n        // state packets (and its ROM via local_rom_hash()).\n        var ho: u8[1]\n        ho[0] = u8(0)\n        redirect(c, url, ho)\n    }\n\n    // Client→server router. Every payload begins with a MSG_* type byte\n    // (see constants near the top of the file).\n    event receive(from: client, data: u8[]) {\n        if len(data) < 1 { return }\n        var s: i32 = slot(from)\n        if !occupied[s] { return }\n        var msg_type: i32 = i32(data[0])\n        if msg_type == MSG_PICK_MODE {\n            handle_pick_mode(from, data)\n        }\n    }\n}\n\nclient {\n    listen CHANNEL_LOBBY\n\n    var other_occupied: bool[64]\n    // Live player counts per mode, parsed from the trailing block of\n    // every server broadcast. Sized generously (16) so adding modes\n    // later is just bumping MODE_COUNT plus the per-mode tables; this\n    // array stays. Zero-initialised — a mode whose count the server\n    // hasn't reported yet just renders as \"0\".\n    var mode_counts: i32[16]\n\n    // Per-entry availability, parsed from the broadcast's trailing flag\n    // block: mode_avail[i] / sp_avail[i] is true when that catalogue\n    // entry's script is in the server's deployed library. `avail_known`\n    // stays false until a broadcast carrying the flags lands; while it is\n    // false every entry is treated as available, so a first frame (or an\n    // older/shorter packet) never falsely greys the catalogue. See\n    // docs/lobby-script-catalog.md.\n    var mode_avail: bool[16]\n    var sp_avail: bool[16]\n    var avail_known: bool   = false\n\n    var did_init: bool      = false\n    var view: i32           = 0     // VIEW_NAMING (set definitively in init)\n    var focused_col: i32    = 1     // COL_MODES\n    // cursor_mode is an index into visible_modes, not the global mode\n    // index. The grid only renders modes playable on the local ROM, so\n    // visible[cursor_mode] is the global mode used by pick_mode / the\n    // server's MSG_PICK_MODE.\n    var cursor_mode: i32    = 0\n    var visible_modes: i32[16]\n    var visible_count: i32  = 0\n\n    // Active top-level section (SECTION_FEATURED / SECTION_CUSTOM) and the\n    // singleplayer cursor / selection. sp_cursor runs 0..bundle_visible_count,\n    // where 0 is the START row (now at the TOP of the list) and\n    // 1..bundle_visible_count are the script rows. visible_bundles[k] maps a row to\n    // its catalogue index, filtered by ROM (built in init_view, mirroring\n    // visible_modes). sp_selected[i] is keyed by catalogue index and tracks\n    // which scripts are toggled on; sp_handles keeps the spawn_local handles\n    // for the session (unused for now — room for an in-game toggle later).\n    var section: i32        = 0     // SECTION_FEATURED (set definitively in init_view)\n    var sp_cursor: i32      = 0\n    var sp_selected: bool[16]\n    var sp_handles: u32[16]\n    var visible_bundles: i32[16]\n    var bundle_visible_count: i32 = 0\n\n    // Drill-in state: the CUSTOM bundle currently opened to its member\n    // sub-list (docs/lobby.md §\"The drill-in\"), or -1 when no drill is open.\n    // drill_cursor indexes the members of that bundle. Selection stays in\n    // sp_selected[] (keyed by catalogue index), so START launches whatever\n    // the drill toggled without any extra plumbing.\n    var drill_bundle: i32   = -1\n    var drill_cursor: i32   = 0\n\n    // Scroll-window top row (first visible) per list. scroll_follow keeps the\n    // cursor inside the window each frame; persisting these means the window\n    // only moves at the edges instead of snapping. Shared by the NES/GB draws.\n    var featured_top: i32   = 0\n    var custom_top: i32     = 0\n    var drill_top: i32      = 0\n\n    // Set once the player launches a singleplayer set with >=1 mod: the\n    // lobby tenant goes dormant (stops drawing, releases its pause, stops\n    // consuming input) and lingers as the silent root of the spawned\n    // mods' subtree. It deliberately does NOT disconnect — that would tear\n    // down the mods it spawned as its children (docs/multi-script-client.md\n    // §\"The spawn door\").\n    var launched: bool      = false\n\n    // Virtual-keyboard state. chosen_name accumulates as the player types; the\n    // keyboard is kept as a UI widget but the name is no longer sent anywhere\n    // (nicknames were removed) — END just closes the view.\n    var kb_cursor: i32       = 0\n    var chosen_name: u8[32]\n    var chosen_name_len: i32 = 0\n\n    // Bundled font: the public-domain \"Torus Sans\" face, 9x8 cells,\n    // 96 glyphs (ASCII 0x20..0x7F) packed as an 864x8 horizontal strip.\n    // Inlined as base64 inside this source — single-file scripts per\n    // docs/script-distribution.md. Regenerate the strip from\n    // assets/torus-sans.png and re-embed with:\n    //   npx tsx packages/server/src/scripts/gen-font.ts\n    //   npm run munos-embed -w @multinostalgia/server -- \\\n    //     ../../assets/font-9x8.png --name FONT_PNG\n    var FONT_PNG: u8[] = base64_decode(\"iVBORw0KGgoAAAANSUhEUgAAA2AAAAAICAYAAACf3ZwOAAAFxElEQVR4Xu2UW64bRxQDs/9NJ2jANJgKz2NGj2tEqh+ZZHXPSDb814a/f8HeocOc+so5XN2Yhdy0V32ic7uNbL0/lfT++v6Cu9junbdxDp3jmzN50y4mj/th4zybR561PStv8ifv6n6gc5gc7oeNc7jqJK/aq7y5I5FcdsxVR6rdz1b3sJMnfEv7gc5hcrgf6Bwmh7tITpVTx8yu+l2nnDpm70jn+Caq3sE1rT/tE5tnbHjWPeTRe3X+7h3bs1vvULlV78iZvE/m03+f7vt3m9g4V3novs0L0WH2LjF5aa9y6pOTusTGmxx/D/9MdNtPkr4jO2b2aTuwT27K7Nzr9tQ7yWEm1RnvmFPHXHVk44grbsV0B7fKnzqeY04dc9cx37mH3HVSnt6n6yuSz4656khy1Klndo9nqpw65tQxp4656zyL5ApuzKljZuef3Tnm1DEnkpM6snHE5GrvnA1/yh2JR+595Kx4xh1bts/aep/KJ/8+03efdrH1Nuie2/dtX8adyve70r2p6+j8zbM8J9K5isnV3jmHaf8Jqvdmz+x0G9m4yfEu7d47lcO+ovLZM6eOuerIxjlMnnZC79BtJLnd99afU5/uueok6DBXHXmVwzz1Fcn3Lu1d78jhXVOnnvdUOXXMqWNOHXPVVXQuN+bUMbPzz+4cc+qYSbVXPXmG51vnPQs9o3oW9wOdwx3nMDlXd0HvcGWnw410LnvmqRfayeT4LiaH+4HOgc4h7XTd4Sa63TfBjTl1zOz47slJOXXM3hF3qrPOtIuNt3W6vGLzoC1+V3Wv+moX037YOB1Xzneuf+fqL0LnHTobeIegt6U7z43Z6TYhp/M6x/vKIclT5/jubPfOo3Og8wxecfd0p/bk+N9V1f0+DORXju/usHfoMKdn+SauOPSTQ9xxj31F8tWlTXSbqL4T/1w5v675z7OYU8ecOmbvHN/Fxjl0Ozfm1DGz88/KS5vYOIdu7zaycTuHPfMrSe/FjrnrmK/ewzz1gjtz6pinPlG53lfOodsOvIfdJqeOOXXMqWP2jr0z7WLyuDOnjpmdf3bnmFPHTKq96p2NIzrXt+67689O1Zfw4kfgi0/3Vk7VO+k50xmyeY7YuNN+6JzNM57J9DzuzE63HaadJF8dcYckhx2zc2VjTiQndWTjHCZPO6F36DbROer5G/GTO+9jx1x1hA5z1ZE7DnPqmKe+Ivnepb3rHb5vOpt6wb7K6ezB93SOOXXMic65sjGnjpmdf3bnmJ1uOzy6k8mf9neid3HSXuWqI3SYN+8iuu3AnTl1zFOfqFz11S6u7P7ZfQ/m1DGnjjl1zFWXkNf53Xbgzpw6Znb+WXlpS2eYnW47PLqLrSeSzyyq/uXoJROdY1f8y6ly6pivsDm7cbZ09zzrOZt77jjMzt1NcJ/OVDu75LFjdq5szOqYNx3ZOOKKW/GsO3ifYF/l1DF3HfOde8gdhzl1zFMvuCWfHXPVkWr3s3S0dc9nrjpChzl1zOqY2YkrG3PquNPhZ3JSdu5uYuOQ7ky3vRO+B3PqmLuO+c49iY0nx0l7lac+UbneV86h2w68h90mp445dcypY666iepM1QvuzKnjToefyUmZvVM57J3JmXax9Tqq81Vf8oyXEX5Xd++0MbPzfvO8ie5stz2bZz1n887TLnSXmHZx1UkedzF5037YOIft3nkbZ+LquUeftT07ud124Hlm7xzfxeRwP2ycQ3K8S9jx6LNn3twhJo89c7pD0PEs3E1OdZfjW3UmYVdEnz0z+2oX273ypv1A51A5VXY2G+kc3zro4pr1Pa+E73S4sos7zuERh31F8tkxp3fxTUx3pJ556oXv/tndzcx+ux/oHOgc0u4dsaO/oSO2u9Ptvgk6h8qpciI56og74u4mNs4G3ZOg++XLly8/xvc/qC/v4vtv7DOw/04i9D+Rd/4m73rOO7nzfe6c+b+j3+TVv82r77/KP/bLVEStJB0aAAAAAElFTkSuQmCC\")\n    var font: image = image_from_png_bytes(FONT_PNG, 0, 0, 864, 8)\n    // Per-glyph ink width (px) for the proportional big font, indexed by\n    // ASCII - 0x20. Each glyph in FONT_PNG is left-trimmed to its cell's\n    // edge (see gen-font.ts), so the renderer steps the cursor by\n    // glyph_w[c] + GLYPH_GAP instead of a flat cell pitch. Space and any\n    // ink-less slot carry a small fixed width. Regenerated together with\n    // FONT_PNG by gen-font.ts + the embed step — do not hand-edit.\n    var glyph_w: u8[] = [3, 2, 6, 7, 6, 6, 7, 2, 4, 4, 5, 5, 2, 6, 2, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 2, 2, 5, 6, 5, 6, 8, 6, 6, 6, 6, 6, 6, 6, 6, 4, 6, 6, 6, 8, 7, 6, 6, 6, 6, 6, 6, 6, 6, 7, 6, 6, 6, 4, 6, 4, 5, 8, 3, 6, 6, 6, 6, 6, 5, 6, 6, 4, 5, 6, 3, 7, 6, 6, 6, 6, 6, 6, 4, 6, 6, 7, 6, 6, 6, 4, 2, 4, 7, 7]\n    // Smaller secondary font (4x6 cells, 96 glyphs, ASCII 0x20..0x7F).\n    // Generated by server/src/scripts/gen-small-font.ts from the\n    // public-domain assets/small_font.png. Used for the\n    // description line under each mode tile where the 9x8 font would\n    // be too bulky. Same access pattern as `font` — only the cell\n    // width / strip width differ. Regenerate with:\n    //   npx tsx packages/server/src/scripts/gen-small-font.ts\n    //   npm run munos-embed -w @multinostalgia/server -- \\\n    //     ../../assets/small-font-4x6.png\n    var SMALL_FONT_PNG: u8[] = base64_decode(\"iVBORw0KGgoAAAANSUhEUgAAAYAAAAAGCAYAAADKWUnOAAACI0lEQVR4Ae3BQW7cABADwab//2emD3MYLCRB69hGjKiKMxWjQhU3VVyoWCqWim9WMSqWDtTB6GBUqEJdGB2oC6NiqXhTxYGKpQN1YakYHSwdqIPRBXVhdKAKVagL6oIqVKGKpUIV6mCpGB0sFaODpQN1QRWqUIUqVKEKVahCFapQTzAqRhfUhQNdePzzuqAuqAvqgrqgD75BFVXcUEUsUcUPqKKKETEyKpRRcSIDVRkog1FFFaqiilHxomJUEQeiipFRoYiliipGVKEqgxFVjAxGBqoyOBChCGXwhoolqlAVVYyoQlVUMaIKVRkoQhHfIIMRcSFiibgQ8fg1IpaIJWKJWCLGBzdEKOKmKuJExDfp4EIVcVMVMSouVCwVSxWhqIpQVLFEvIhQFXEhqhhVxE1VxE0djIoLFW+oIk5EvKGKuCGq+CJRFXGiijgQVTweb/rgRMRfiPhBFSODE1XETVXEErFUjIyKEVUcqKKKEbFUvKhQVHGhihgRFyqWqOKmDEbEi4oRcaLiRVTxRSJGVEWMKmJEFZ9UMaqoQlEVoSriQlTxeLzhgxMVnxR1cENUsVQRN1QRJyouRFXEiQ7UEaEMRkeEoipCVcSIqgh1RIyIpYoYUcWBKmKpGBUvIpYqQlEHo4oYHRyIKk5UqEJRFXGigxuiKuJEVDGiCnVEHIiqCEVVxIh4QwequFCxVCxdUMXj16hYKpaKpWKpeDwej8f/7Q/C+gKVBGrN/wAAAABJRU5ErkJggg==\")\n    var font_small: image = image_from_png_bytes(SMALL_FONT_PNG, 0, 0, 384, 6)\n    var font_pal_white: u32[]  = [0, NES_WHITE]\n    var font_pal_yellow: u32[] = [0, NES_LIGHT_YELLOW]\n    var font_pal_grey: u32[]   = [0, NES_LIGHT_GREY]\n    // Inverted glyph color for the highlighted keyboard cell — black\n    // on a yellow background.\n    var font_pal_black: u32[]  = [0, NES_BLACK]\n    // Background fill for the unselected END button — distinguishes it\n    // from the bare letter cells so it visibly reads as a \"button\".\n    var btn_pal: u32[]         = [0, NES_GREY]\n\n    // Black backdrop tile. 2x2 solid-index-1 image, drawn many times\n    // to cover the safe region. Coarse but it's a one-shot per frame.\n    // Also reused for solid-color mode tiles and the sidebar divider by\n    // swapping the palette at the call site.\n    var panel_pixels: image = image_literal(2, 2, \"AQEBAQ==\")\n    var panel_pal: u32[] = [0, NES_BLACK]\n    var sidebar_div_pal: u32[] = [0, NES_DARK_GREY]\n\n    // ---- palette: \"brass & slate\" ----\n    // One deliberate system instead of ad-hoc colours:\n    //   - NAVY is the chrome (the title band).\n    //   - SLATE GREY is every content surface (all cards, both sections).\n    //   - a warm GOLD/AMBER family is the ONLY accent, used for anything\n    //     interactive: the active tab, a toggled-on checkbox, the START\n    //     action, and the animated selection gleam. No competing greens.\n    //   - text is white (titles), light-grey (blurbs), gold (brand), and\n    //     cream (secondary readouts).\n    var pal_band: u32[]      = [0, NES_DARK_BLUE]    // navy chrome (title band)\n    var pal_card: u32[]      = [0, NES_DARK_GREY]    // slate card surface\n    var pal_accent: u32[]    = [0, NES_LIGHT_YELLOW] // static gold (active tab)\n    var pal_tab_off: u32[]   = [0, NES_GREY]         // inactive tab body\n    var pal_start: u32[]     = [0, NES_ORANGE]       // brass START (idle)\n    var pal_start_hi: u32[]  = [0, NES_LIGHT_ORANGE] // brass START (focused)\n    var pal_check: u32[]     = [0, NES_LIGHT_YELLOW] // ticked checkbox (gold)\n    var font_pal_cream: u32[] = [0, NES_PALE_YELLOW] // secondary text (count / subtitle)\n\n    // Animated \"gold gleam\" for the current selection's border — the\n    // retro shimmer Zelda/Mario use on highlighted items. gleam[1] is\n    // re-pointed each frame to the next entry of gleam_cycle (a smooth\n    // brown -> amber -> gold -> white -> back ramp). GLEAM_SPEED frames\n    // per step; GLEAM_STEPS entries in the cycle.\n    var gleam: u32[]         = [0, NES_LIGHT_YELLOW]\n    var gleam_cycle: u32[]   = [NES_DARK_ORANGE, NES_ORANGE, NES_LIGHT_ORANGE, NES_LIGHT_YELLOW, NES_PALE_YELLOW, NES_WHITE, NES_PALE_YELLOW, NES_LIGHT_YELLOW, NES_LIGHT_ORANGE, NES_ORANGE]\n\n    // Singleplayer-script thumbnail colour (mirrors mode_pal_* for\n    // multiplayer): a signature colour so the row stays legible before any\n    // real per-script art exists.\n    var sp_pal_0: u32[]      = [0, NES_CYAN]\n\n    // Thumbnail fill for an UNAVAILABLE entry — glossed in the catalogue\n    // but absent from the server's deployed library. Dark grey so the\n    // card visibly reads as inert next to its live neighbours.\n    var pal_unavail_thumb: u32[] = [0, NES_DARK_GREY]\n\n    // Mode tile palettes. Each entry is a solid-color placeholder; the\n    // rendering loop is keyed off MODE_COUNT so adding entries later is\n    // local to the mode_label / mode_required_rom_id / draw_mode_tile\n    // table below.\n    // NORMAL (index 0) uses a neutral grey — it's the \"no fancy\n    // stuff\" mode, so it visually reads quieter than the networked\n    // modes. The colored tiles follow each game's signature palette:\n    // orange for GHOST (the sharded SMB ghost-runner) and green for\n    // ZELDA. SMB3 and MM1 follow with red and cyan respectively, kept\n    // distinct from the four tiles above.\n    var mode_pal_0: u32[] = [0, NES_LIGHT_GREY]\n    var mode_pal_1: u32[] = [0, NES_ORANGE]\n    var mode_pal_2: u32[] = [0, NES_GREEN]\n    var mode_pal_3: u32[] = [0, NES_BLUE]\n    var mode_pal_4: u32[] = [0, NES_LIGHT_RED]\n    var mode_pal_5: u32[] = [0, NES_CYAN]\n    var mode_pal_6: u32[] = [0, NES_MAGENTA]\n    var mode_pal_7: u32[] = [0, NES_PURPLE]   // METROID — Samus's Varia accent\n    var mode_pal_8: u32[] = [0, NES_YELLOW]    // WARIO LAND — Wario's yellow\n    // PARKED: greyed-out fill for modes that aren't playable on the\n    // local ROM. Unreachable now that init_view filters those modes\n    // out of `visible_modes` entirely — see the matching parked branch\n    // in draw_mode_tile. Re-enable alongside that branch if we ever\n    // want to display disabled modes again.\n    // var mode_pal_disabled: u32[] = [0, NES_DARK_GREY]\n\n    // Error banner state. Set by pick_mode when the player picks a\n    // mode whose required ROM doesn't match what they're running;\n    // ticks down to zero on each frame so the banner auto-dismisses.\n    var error_ticks: i32 = 0\n    var error_text: u8[64]\n    var error_text_len: i32 = 0\n    // ~3 seconds at 60fps. Generous enough that a brief glance is\n    // enough; not so long that it feels stuck.\n    const ERROR_TICKS_INITIAL: i32 = 180\n\n    function fold_upper(c: i32): i32 {\n        if c >= 0x61 && c <= 0x7A { return c - 0x20 }\n        return c\n    }\n\n    function draw_char(c_in: i32, x: i32, y: i32, pal: u32[]) {\n        var c: i32 = fold_upper(c_in)\n        if c < 0x20 { return }\n        if c >= 0x80 { return }\n        // Clip exactly the glyph's ink width. Each cell is 9px on the\n        // strip but the glyph is left-trimmed, so drawing only glyph_w\n        // pixels avoids bleeding the next cell's pixels into this one.\n        draw_image(font, pal, x, y, clip_x = (c - 0x20) * 9, clip_w = i32(glyph_w[c - 0x20]))\n    }\n\n    // Cursor advance for one character: its ink width plus the tracking\n    // gap. Returns 0 for out-of-range so it never desyncs draw vs measure.\n    function char_adv(c_in: i32): i32 {\n        var c: i32 = fold_upper(c_in)\n        if c < 0x20 { return 0 }\n        if c >= 0x80 { return 0 }\n        return i32(glyph_w[c - 0x20]) + GLYPH_GAP\n    }\n\n    function draw_text(s: string, x: i32, y: i32, pal: u32[]) {\n        var cx: i32 = x\n        for (var i: i32 = 0; i < len(s); i = i + 1) {\n            draw_char(i32(s[i]), cx, y, pal)\n            cx = cx + char_adv(i32(s[i]))\n        }\n    }\n\n    // Pixel width of a string in the proportional big font, excluding the\n    // trailing gap — used to centre/right-align labels.\n    function measure_text(s: string): i32 {\n        var w: i32 = 0\n        for (var i: i32 = 0; i < len(s); i = i + 1) {\n            w = w + char_adv(i32(s[i]))\n        }\n        if w > 0 { w = w - GLYPH_GAP }\n        return w\n    }\n\n    // Small-font equivalents — 4-pixel-wide cells from the 384x6 strip.\n    // Same fold_upper convention as draw_char.\n    function draw_char_small(c_in: i32, x: i32, y: i32, pal: u32[]) {\n        var c: i32 = fold_upper(c_in)\n        if c < 0x20 { return }\n        if c >= 0x80 { return }\n        draw_image(font_small, pal, x, y, clip_x = (c - 0x20) * 4, clip_w = 4)\n    }\n\n    function draw_text_small(s: string, x: i32, y: i32, pal: u32[]) {\n        for (var i: i32 = 0; i < len(s); i = i + 1) {\n            draw_char_small(i32(s[i]), x + i * 4, y, pal)\n        }\n    }\n\n    // Draw a small-font description, wrapping to at most two lines within `w`\n    // pixels (the small font is a fixed 4px per glyph, so the column budget is\n    // just w/4) and vertically CENTERED in the band [ty, ty + bh]. Centring by\n    // line count is what earns the second line its room: a one-line blurb sits\n    // mid-band exactly as before, while a two-line blurb centres the whole\n    // 12/13px block — so line 1 rises a few px and line 2 lands in the space\n    // that opens below instead of spilling past the card edge. When the band is\n    // tall enough (>= 13px) the two lines get 1px of leading; on a cramped band\n    // they sit flush. Line 1 breaks at the last space that fits; a first word\n    // longer than the column hard-breaks at the edge; a line 2 that still\n    // overflows is clipped with a \"..\" tail so it never bleeds past the card /\n    // under the right-edge checkbox.\n    function draw_desc_wrapped(s: string, x: i32, ty: i32, bh: i32, w: i32, pal: u32[]) {\n        x = x - 1                       // nudge the blurb 1px left of the title\n        var maxc: i32 = w / 4\n        if maxc < 1 { maxc = 1 }\n        var n: i32 = len(s)\n        if n <= maxc {\n            draw_text_small(s, x, ty + (bh - 6) / 2, pal)\n            return\n        }\n        // Last space at or before column maxc marks the line-1 break; scanning\n        // index maxc too lets a space that lands exactly on the edge break\n        // cleanly. brk < 0 (no space in reach) => hard-break a long first word.\n        var brk: i32 = -1\n        for (var i: i32 = 0; i <= maxc && i < n; i = i + 1) {\n            if i32(s[i]) == 0x20 { brk = i }\n        }\n        var end1: i32 = maxc\n        var start2: i32 = maxc\n        if brk > 0 {\n            end1 = brk\n            start2 = brk + 1        // drop the break space from line 2\n        }\n        // Centre the two-line block in the band; give it 1px of leading only\n        // when the band can spare it (a card with a count row above the blurb\n        // hands us a 12px band, exactly two flush lines).\n        var lead: i32 = 0\n        if bh >= 13 { lead = 1 }\n        var top: i32 = ty + (bh - (12 + lead)) / 2\n        if top < ty { top = ty }\n        var y1: i32 = top\n        var y2: i32 = top + 6 + lead\n        for (var i: i32 = 0; i < end1; i = i + 1) {\n            draw_char_small(i32(s[i]), x + i * 4, y1, pal)\n        }\n        var rem: i32 = n - start2\n        if rem <= maxc {\n            for (var i: i32 = 0; i < rem; i = i + 1) {\n                draw_char_small(i32(s[start2 + i]), x + i * 4, y2, pal)\n            }\n        } else {\n            var keep: i32 = maxc - 2\n            if keep < 0 { keep = 0 }\n            for (var i: i32 = 0; i < keep; i = i + 1) {\n                draw_char_small(i32(s[start2 + i]), x + i * 4, y2, pal)\n            }\n            draw_char_small(0x2E, x + keep * 4, y2, pal)\n            draw_char_small(0x2E, x + (keep + 1) * 4, y2, pal)\n        }\n    }\n\n    // Small-font integer draw. Mirrors draw_int's left-anchored layout\n    // and returns the x just past the last digit so callers can chain.\n    function draw_int_small(n: i32, x: i32, y: i32, pal: u32[]): i32 {\n        if n <= 0 {\n            draw_char_small(0x30, x, y, pal)\n            return x + 4\n        }\n        var digits: i32[10]\n        var count: i32 = 0\n        var v: i32 = n\n        while v > 0 {\n            digits[count] = v % 10\n            v = v / 10\n            count = count + 1\n        }\n        for (var i: i32 = 0; i < count; i = i + 1) {\n            draw_char_small(0x30 + digits[count - 1 - i], x + i * 4, y, pal)\n        }\n        return x + count * 4\n    }\n\n    function draw_bytes(buf: u8[], offset: i32, n: i32, x: i32, y: i32, pal: u32[]) {\n        var cx: i32 = x\n        for (var i: i32 = 0; i < n; i = i + 1) {\n            draw_char(i32(buf[offset + i]), cx, y, pal)\n            cx = cx + char_adv(i32(buf[offset + i]))\n        }\n    }\n\n    // Pixel width of `n` bytes rendered as text (proportional, no trailing\n    // gap). Mirrors measure_text for the u8[] buffers (names, error text).\n    function measure_bytes(buf: u8[], offset: i32, n: i32): i32 {\n        var w: i32 = 0\n        for (var i: i32 = 0; i < n; i = i + 1) {\n            w = w + char_adv(i32(buf[offset + i]))\n        }\n        if w > 0 { w = w - GLYPH_GAP }\n        return w\n    }\n\n    // Render a small non-negative integer at (x, y). Returns the x\n    // coordinate just past the last digit drawn so callers can place\n    // text immediately after. Negative values are clamped to 0 — the\n    // lobby only ever passes a player count, which can't go negative.\n    function draw_int(n: i32, x: i32, y: i32, pal: u32[]): i32 {\n        if n <= 0 {\n            draw_char(0x30, x, y, pal)            // '0'\n            return x + char_adv(0x30)\n        }\n        // Decompose into digits backwards. Up to ten digits (the i32\n        // range fits in ten decimal digits).\n        var digits: i32[10]\n        var count: i32 = 0\n        var v: i32 = n\n        while v > 0 {\n            digits[count] = v % 10\n            v = v / 10\n            count = count + 1\n        }\n        // Draw most-significant-first, advancing by each digit's width.\n        var cx: i32 = x\n        for (var i: i32 = 0; i < count; i = i + 1) {\n            var ch: i32 = 0x30 + digits[count - 1 - i]\n            draw_char(ch, cx, y, pal)\n            cx = cx + char_adv(ch)\n        }\n        return cx\n    }\n\n    function draw_panel() {\n        // Fill the visible safe region (8px inset) of whatever screen we're\n        // on. On the NES this is the original [8,248) x [8,232); on a Game\n        // Boy it shrinks to the 160x144 panel instead of stamping tiles off\n        // the right/bottom edges.\n        var x1: i32 = screen_width() - 8\n        var y1: i32 = screen_height() - 8\n        for (var y: i32 = 8; y < y1; y = y + 2) {\n            for (var x: i32 = 8; x < x1; x = x + 2) {\n                draw_image(panel_pixels, panel_pal, x, y)\n            }\n        }\n    }\n\n    // Solid-color w x h fill at (x, y), stamping the 2x2 panel tile.\n    // w and h must be even (the only callers pass even constants).\n    function draw_fill(x: i32, y: i32, w: i32, h: i32, pal: u32[]) {\n        for (var py: i32 = 0; py < h; py = py + 2) {\n            for (var px: i32 = 0; px < w; px = px + 2) {\n                draw_image(panel_pixels, pal, x + px, y + py)\n            }\n        }\n    }\n\n    function draw_sidebar_divider() {\n        for (var y: i32 = 8; y < 232; y = y + 2) {\n            draw_image(panel_pixels, sidebar_div_pal, SIDEBAR_DIV_X, y)\n        }\n    }\n\n    // Per-mode label / url / palette lookups. Splitting these out keeps\n    // the rendering loop and pick_mode generic — adding a mode means\n    // touching exactly these three functions plus MODE_COUNT.\n    function mode_label(i: i32): string {\n        // Every networked mode is \"GHOST MULTIPLAYER\" — same\n        // game-agnostic ghost-runner feature, just per-ROM. The ROM\n        // filter in visible_modes ensures each player only ever sees one\n        // of them, so the duplicate label is never on screen at once.\n        if i == 0 { return \"NORMAL\" }\n        if i == 1 { return \"GHOST MULTIPLAYER\" }\n        if i == 2 { return \"GHOST MULTIPLAYER\" }\n        if i == 3 { return \"GHOST MULTIPLAYER\" }\n        if i == 4 { return \"GHOST MULTIPLAYER\" }\n        if i == 5 { return \"GHOST MULTIPLAYER\" }\n        if i == 6 { return \"GHOST MULTIPLAYER\" }\n        if i == 7 { return \"GHOST MULTIPLAYER\" }\n        if i == 8 { return \"GHOST MULTIPLAYER\" }\n        return \"\"\n    }\n\n    // Short one-line blurb under each mode label. The GHOST modes all\n    // share one line describing the feature itself (you see other\n    // players live in your own game), since they're the same\n    // ghost-runner per-ROM. Capped to fit the tile's small-font text\n    // column to the left of the \"N PLAYING\" count.\n    function mode_description(i: i32): string {\n        if i == 0 { return \"PLAY OFFLINE SOLO\" }\n        if i == 1 { return \"SEE OTHER PLAYERS RUN THE SAME LEVEL LIVE AS TRANSLUCENT GHOSTS\" }\n        if i == 2 { return \"SEE OTHER PLAYERS RUN THE SAME LEVEL LIVE AS TRANSLUCENT GHOSTS\" }\n        if i == 3 { return \"SEE OTHER PLAYERS RUN THE SAME LEVEL LIVE AS TRANSLUCENT GHOSTS\" }\n        if i == 4 { return \"SEE OTHER PLAYERS RUN THE SAME LEVEL LIVE AS TRANSLUCENT GHOSTS\" }\n        if i == 5 { return \"SEE OTHER PLAYERS RUN THE SAME LEVEL LIVE AS TRANSLUCENT GHOSTS\" }\n        if i == 6 { return \"SEE OTHER PLAYERS RUN THE SAME LEVEL LIVE AS TRANSLUCENT GHOSTS\" }\n        if i == 7 { return \"SEE OTHER PLAYERS RUN THE SAME LEVEL LIVE AS TRANSLUCENT GHOSTS\" }\n        if i == 8 { return \"SEE OTHER PLAYERS RUN THE SAME LEVEL LIVE AS TRANSLUCENT GHOSTS\" }\n        return \"\"\n    }\n\n    // ROM family ID each mode requires. The lobby maps the player's\n    // `local_rom_hash()` to one of these short identifiers via\n    // `rom_family_for_hash()` below; empty string means \"no ROM gate\"\n    // (useful for tooling-only modes, none today).\n    function mode_required_rom_id(i: i32): string {\n        // NORMAL is ROM-agnostic — the empty string means \"no ROM\n        // gate\", so mode_is_playable returns true for any cartridge.\n        if i == 0 { return \"\" }\n        if i == 1 { return \"super_mario_bros\" }\n        if i == 2 { return \"legend_of_zelda\" }\n        if i == 3 { return \"mega_man_2\" }\n        if i == 4 { return \"super_mario_bros_3\" }\n        if i == 5 { return \"mega_man\" }\n        if i == 6 { return \"super_mario_bros_2\" }\n        if i == 7 { return \"metroid\" }\n        if i == 8 { return \"wario_land\" }\n        return \"\"\n    }\n\n    // Hash → ROM family lookup. The harness gives us the SHA-1 of the\n    // ROM file via `local_rom_hash()`; this table is the lobby's\n    // opinion of which cartridge that hash corresponds to. Add an\n    // entry for every dump you want to recognise. Unknown hashes\n    // return \"\" — every gated mode will read as unplayable.\n    //\n    // Hashes are lowercase hex. The values below are the no-intro\n    // canonical SHA-1s for the most common vanilla dumps; if your ROM\n    // doesn't match, drop a `print(local_rom_hash())` into init_view\n    // to see what hash you actually have and add it to this table.\n    function rom_family_for_hash(h: string): string {\n        // Super Mario Bros (World) — no-intro.\n        if streq(h, \"ea343f4e445a9050d4b4fbac2c77d0693b1d0922\") {\n            return \"super_mario_bros\"\n        }\n        // The Legend of Zelda (USA) — no-intro. Rev 0 (original\n        // release) and Rev A (later production run) are both vanilla\n        // Zelda; they differ only in a tiny bug-fix patch and are\n        // gameplay-equivalent for our purposes. The Europe (PAL) dumps\n        // are deliberately *not* listed — NesJs has no PAL support, so\n        // running them would play ~20% too fast.\n        if streq(h, \"dab79c84934f9aa5db4e7dad390e5d0c12443fa2\") {\n            return \"legend_of_zelda\"\n        }\n        if streq(h, \"3701381a82fc7d52b2dd3e8892047b30a114ab43\") {\n            return \"legend_of_zelda\"\n        }\n        // Mega Man 2 (USA) — no-intro.\n        if streq(h, \"2290d8d839a303219e9327ea1451c5eea430f53d\") {\n            return \"mega_man_2\"\n        }\n        // Super Mario Bros. 3 (USA) — no-intro.\n        if streq(h, \"a03e7e526e79df222e048ae22214bca2bc49c449\") {\n            return \"super_mario_bros_3\"\n        }\n        // Mega Man (USA) — no-intro.\n        if streq(h, \"2f88381557339a14c20428455f6991c1eb902c99\") {\n            return \"mega_man\"\n        }\n        // Super Mario Bros. 2 (USA) — full-.nes SHA-1, matches the\n        // adapter's romHash and the rig ROM at\n        // workbench/super_mario_bros_2/ref/super_mario_bros_2.nes.\n        if streq(h, \"7df0f595b074f587c6a1d8f47e031f045d540dae\") {\n            return \"super_mario_bros_2\"\n        }\n        // Metroid (USA) — full-.nes SHA-1, matches the adapter's romHash\n        // and the rig ROM at workbench/metroid/ref/metroid.nes.\n        if streq(h, \"ecf39ec5a33e6a6f832f03e8ffc61c5d53f4f90b\") {\n            return \"metroid\"\n        }\n        // Wario Land: Super Mario Land 3 (Game Boy) — full-.gb SHA-1,\n        // matches the rig ROM at workbench/wario_land/ref/wario_land.gb.\n        if streq(h, \"ae65800302438e37a99e623a71d1c954d73c843e\") {\n            return \"wario_land\"\n        }\n        return \"\"\n    }\n\n    // Human-readable label for the mode's required game, used in the\n    // wrong-ROM error banner (\"NEEDS SMB\", \"NEEDS ZELDA\"). Kept short\n    // so the message fits on one line at 8px-per-glyph.\n    function mode_required_label(i: i32): string {\n        if i == 0 { return \"\" }            // NORMAL has no required ROM\n        if i == 1 { return \"SMB\" }\n        if i == 2 { return \"ZELDA\" }\n        if i == 3 { return \"MM2\" }\n        if i == 4 { return \"SMB3\" }\n        if i == 5 { return \"MM1\" }\n        if i == 6 { return \"SMB2\" }\n        if i == 7 { return \"METROID\" }\n        if i == 8 { return \"WARIO\" }\n        return \"\"\n    }\n\n    // ----- singleplayer mod catalogue -----\n    //\n    // Index → display label / one-line blurb / spawn path. The path is\n    // server-relative; spawn_local resolves it against the lobby's own\n    // origin, so the same source works in local dev and in production\n    // without hard-coding a host. Adding a mod is: bump SP_MOD_COUNT and\n    // add a branch to each of these three.\n    function sp_mod_label(i: i32): string {\n        if i == 0 { return \"MUTED MUSIC\" }\n        if i == 1 { return \"MUTED MUSIC\" }\n        if i == 2 { return \"MUTED MUSIC\" }\n        if i == 3 { return \"MUTED MUSIC\" }\n        if i == 4 { return \"MUTED MUSIC\" }\n        if i == 5 { return \"MUTED MUSIC\" }\n        if i == 6 { return \"MUTED MUSIC\" }\n        if i == 7 { return \"MUTED MUSIC\" }\n        if i == 8 { return \"RUN RECORDER\" }\n        if i == 9 { return \"GHOST SENDER\" }\n        if i == 10 { return \"GHOST RENDERER\" }\n        if i == 11 { return \"GHOST REPLAY\" }\n        return \"\"\n    }\n    function sp_mod_description(i: i32): string {\n        if i == 0 { return \"ZELDA: MUTE MUSIC, KEEP SFX\" }\n        if i == 1 { return \"METROID: MUTE MUSIC, KEEP SFX\" }\n        if i == 2 { return \"MEGA MAN 2: MUTE MUSIC, KEEP SFX\" }\n        if i == 3 { return \"SMB3: MUTE MUSIC, KEEP SFX\" }\n        if i == 4 { return \"MEGA MAN: MUTE MUSIC, KEEP SFX\" }\n        if i == 5 { return \"SMB2: MUTE MUSIC, KEEP SFX\" }\n        if i == 6 { return \"SMB: MUTE MUSIC, KEEP SFX\" }\n        if i == 7 { return \"WARIO LAND: MUTE MUSIC, KEEP SFX\" }\n        if i == 8 { return \"SMB: SAVE EACH RUN YOU FINISH SO THE SENDER CAN BROADCAST IT\" }\n        if i == 9 { return \"SMB: BROADCAST YOUR GHOST\" }\n        if i == 10 { return \"SMB: DRAW OTHER RUNS AS GHOSTS\" }\n        if i == 11 { return \"SMB: REPLAY YOUR SAVED RUNS\" }\n        return \"\"\n    }\n    function sp_mod_path(i: i32): string {\n        if i == 0 { return \"/sp_muted_music_legend_of_zelda\" }\n        if i == 1 { return \"/sp_muted_music_metroid\" }\n        if i == 2 { return \"/sp_muted_music_mega_man_2\" }\n        if i == 3 { return \"/sp_muted_music_smb3\" }\n        if i == 4 { return \"/sp_muted_music_mega_man\" }\n        if i == 5 { return \"/sp_muted_music_super_mario_bros_2\" }\n        if i == 6 { return \"/sp_muted_music_super_mario_bros\" }\n        if i == 7 { return \"/sp_muted_music_wario_land\" }\n        // The `local:` scheme routes these onto the in-browser LocalServer's\n        // shared bus (the supervisor strips it, fetches from the real origin,\n        // and hosts the server half locally) so the sender's stream and the\n        // recorder's tap meet on one local bus. See docs/multi-script-client.md.\n        if i == 8 { return \"local:/smb_recorder\" }\n        if i == 9 { return \"local:/smb_sender\" }\n        if i == 10 { return \"local:/smb_renderer\" }\n        if i == 11 { return \"local:/smb_replay\" }\n        return \"\"\n    }\n    // ROM family each mod requires; \"\" means ROM-agnostic. A mod whose\n    // required family doesn't match the local cartridge is hidden from the\n    // list (init_view builds visible_bundles the same way the multiplayer list\n    // builds visible_modes), so each MUTED MUSIC entry shows only under its\n    // own cartridge — one script per game, each gated to its own family.\n    function sp_mod_required_rom(i: i32): string {\n        if i == 0 { return \"legend_of_zelda\" }\n        if i == 1 { return \"metroid\" }\n        if i == 2 { return \"mega_man_2\" }\n        if i == 3 { return \"super_mario_bros_3\" }\n        if i == 4 { return \"mega_man\" }\n        if i == 5 { return \"super_mario_bros_2\" }\n        if i == 6 { return \"super_mario_bros\" }\n        if i == 7 { return \"wario_land\" }\n        if i == 8 { return \"super_mario_bros\" }\n        if i == 9 { return \"super_mario_bros\" }\n        if i == 10 { return \"super_mario_bros\" }\n        if i == 11 { return \"super_mario_bros\" }\n        return \"\"\n    }\n    // (ROM-gating now happens per bundle in bundle_is_playable — each MUTED\n    // MUSIC bundle wraps one ROM-gated sp-mod, and the SMB GHOSTS bundle is\n    // SMB-gated — so the per-sp-mod playability helper is no longer needed.)\n\n    function count_sp_selected(): i32 {\n        var n: i32 = 0\n        for (var i: i32 = 0; i < SP_MOD_COUNT; i = i + 1) {\n            if sp_selected[i] { n = n + 1 }\n        }\n        return n\n    }\n\n    // ----- CUSTOM bundle layer -----\n    //\n    // A bundle groups one or more sp-mod catalogue entries into a single card\n    // (docs/lobby.md §\"The bundle is the atom\"). Member ranges are contiguous:\n    // bundles 0..7 wrap MUTED-MUSIC sp-mods 0..7 one-to-one; bundle 8 wraps the\n    // four SMB ghost sp-mods 8..11. bundle_first + bundle_len are the whole map.\n    function bundle_first(b: i32): i32 {\n        if b < 8 { return b }\n        return 8\n    }\n    function bundle_len(b: i32): i32 {\n        if b < 8 { return 1 }\n        return 4\n    }\n    function bundle_label(b: i32): string {\n        if b < 8 { return sp_mod_label(b) }\n        if b == 8 { return \"SMB GHOSTS\" }\n        return \"\"\n    }\n    function bundle_desc(b: i32): string {\n        if b < 8 { return sp_mod_description(b) }\n        if b == 8 { return \"RECORD YOUR RUNS AND REPLAY THEM AS GHOSTS RACING ALONGSIDE YOU\" }\n        return \"\"\n    }\n    function bundle_required_rom(b: i32): string {\n        if b < 8 { return sp_mod_required_rom(b) }\n        if b == 8 { return \"super_mario_bros\" }\n        return \"\"\n    }\n    function bundle_is_playable(b: i32): bool {\n        var need: string = bundle_required_rom(b)\n        if len(need) == 0 { return true }\n        var have: string = rom_family_for_hash(local_rom_hash())\n        return streq(have, need)\n    }\n    // Members of bundle b currently toggled on.\n    function bundle_sel_count(b: i32): i32 {\n        var first: i32 = bundle_first(b)\n        var n: i32 = bundle_len(b)\n        var c: i32 = 0\n        for (var k: i32 = 0; k < n; k = k + 1) {\n            if sp_selected[first + k] { c = c + 1 }\n        }\n        return c\n    }\n    // Members of bundle b that are deployed (available to select).\n    function bundle_avail_count(b: i32): i32 {\n        var first: i32 = bundle_first(b)\n        var n: i32 = bundle_len(b)\n        var c: i32 = 0\n        for (var k: i32 = 0; k < n; k = k + 1) {\n            if sp_available(first + k) { c = c + 1 }\n        }\n        return c\n    }\n    // Toggle a whole bundle: if every available member is already on, turn the\n    // bundle off; otherwise turn all available members on. Glossed-but-absent\n    // members are never selected. A one-member bundle behaves exactly like the\n    // old single-row toggle. No required/optional distinction — you can drill\n    // in afterwards and break the set freely (docs/lobby.md §\"The drill-in\").\n    function toggle_bundle(b: i32) {\n        var first: i32 = bundle_first(b)\n        var n: i32 = bundle_len(b)\n        var want_on: bool = (bundle_sel_count(b) < bundle_avail_count(b))\n        for (var k: i32 = 0; k < n; k = k + 1) {\n            var m: i32 = first + k\n            if sp_available(m) { sp_selected[m] = want_on }\n        }\n    }\n\n    // Byte-wise string equality. Avoids relying on whatever the runtime\n    // does for `==` on strings (which might compare interned pointers\n    // rather than content) — explicit and obviously correct.\n    function streq(a: string, b: string): bool {\n        if len(a) != len(b) { return false }\n        for (var i: i32 = 0; i < len(a); i = i + 1) {\n            if a[i] != b[i] { return false }\n        }\n        return true\n    }\n\n    function mode_is_playable(i: i32): bool {\n        var need: string = mode_required_rom_id(i)\n        if len(need) == 0 { return true }\n        var have: string = rom_family_for_hash(local_rom_hash())\n        return streq(have, need)\n    }\n\n    // Availability is a separate axis from ROM-playability: a mode can be\n    // playable on this cartridge yet UNAVAILABLE because its script wasn't\n    // deployed. Until the server reports availability (avail_known), treat\n    // everything as available so nothing is falsely greyed on first frame.\n    function mode_available(i: i32): bool {\n        if !avail_known { return true }\n        if i < 0 || i >= 16 { return true }\n        return mode_avail[i]\n    }\n\n    function sp_available(i: i32): bool {\n        if !avail_known { return true }\n        if i < 0 || i >= 16 { return true }\n        return sp_avail[i]\n    }\n\n    // (Client side no longer needs a per-mode URL — the server picks\n    // the destination when it processes a MSG_PICK_MODE message and\n    // sends the runtime a `redirect` control frame. We keep mode_label\n    // / mode_pal_for_index here as the per-mode UI table.)\n\n    // Shared card chrome: an accent selection frame when the cursor is on\n    // this row, then the card body fill. Both sections call this so the\n    // two lists share an identical footprint pixel-for-pixel.\n    function draw_card_bg(x: i32, y: i32, selected: bool, base_pal: u32[]) {\n        if selected {\n            // Animated gold gleam (advanced once per frame in event frame).\n            draw_fill(x - 2, y - 2, CARD_W + 4, CARD_H + 4, gleam)\n        }\n        draw_fill(x, y, CARD_W, CARD_H, base_pal)\n    }\n\n    // One multiplayer mode card: a signature-colour thumbnail, the mode\n    // label, a one-line blurb, and the live player count right-aligned.\n    //\n    // PARKED: wrong-ROM dimmed rendering. `visible_modes` filters\n    // unplayable modes at init time, so the playable check that used to\n    // live here is redundant. Restore the dimmed thumb + grey label\n    // branches alongside the parked `mode_pal_disabled` palette to show\n    // disabled modes again.\n    function draw_mode_tile(i: i32, x: i32, y: i32, selected: bool) {\n        draw_card_bg(x, y, selected, pal_card)\n        var thumb_x: i32 = x + 2\n        var thumb_y: i32 = y + 2\n        var text_x: i32 = x + 2 + CARD_THUMB + 8        // 8px gap past the thumb\n        // Glossed but absent from the deployed library: inert UNAVAILABLE\n        // card — greyed thumb + title, no blurb, no player count.\n        if !mode_available(i) {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, pal_unavail_thumb)\n            draw_text(mode_label(i),       text_x, y + 4,  font_pal_grey)\n            draw_text_small(\"UNAVAILABLE\", text_x, y + 18, font_pal_grey)\n            return\n        }\n        if i == 0 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, mode_pal_0)\n        } else if i == 1 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, mode_pal_1)\n        } else if i == 2 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, mode_pal_2)\n        } else if i == 3 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, mode_pal_3)\n        } else if i == 4 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, mode_pal_4)\n        } else if i == 5 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, mode_pal_5)\n        } else if i == 6 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, mode_pal_6)\n        } else if i == 7 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, mode_pal_7)\n        } else if i == 8 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, mode_pal_8)\n        }\n        // Title (8x8) over a small-font blurb, to the right of the thumb.\n        draw_text(mode_label(i),             text_x, y + 4,  font_pal_white)\n        // The live \"N PLAYING\" count now rides the top-right corner (on the\n        // title's row), so the blurb owns the whole sub-title band (y+12..y+30,\n        // 18px) — same as a CUSTOM card — and sits a few px higher than it did\n        // when it had to clear a count row. Full width, 6px shy of the edge.\n        draw_desc_wrapped(mode_description(i), text_x, y + 12, 18,\n                          x + CARD_W - 6 - text_x, font_pal_grey)\n        // Live player count from the server broadcast's trailing\n        // mode-count block (refreshes ~1Hz). NORMAL (mode 0) is offline\n        // and never reaches this card, but guard anyway. The count is\n        // brightened when the room is non-empty so a populated mode pops.\n        if i == 0 { return }\n        var pc: i32 = mode_counts[i]\n        if pc < 0 { pc = 0 }\n        var suffix: string = \" PLAYING\"\n        var digits: i32 = 1\n        var v: i32 = pc\n        while v >= 10 {\n            v = v / 10\n            digits = digits + 1\n        }\n        var total_w: i32 = (digits + len(suffix)) * 4\n        var px: i32 = x + CARD_W - 2 - total_w           // top-right corner, tucked toward the edge\n        var py: i32 = y + 2\n        if pc > 0 {\n            var nx: i32 = draw_int_small(pc, px, py, font_pal_white)\n            draw_text_small(suffix, nx, py, font_pal_white)\n        } else {\n            var nx: i32 = draw_int_small(pc, px, py, font_pal_grey)\n            draw_text_small(suffix, nx, py, font_pal_grey)\n        }\n    }\n\n    function count_others(): i32 {\n        var n: i32 = 0\n        for (var i: i32 = 0; i < MAX_PLAYERS; i = i + 1) {\n            if other_occupied[i] { n = n + 1 }\n        }\n        return n\n    }\n\n    // Players currently inside the spawned gameplay instances. mode_counts\n    // comes from the server broadcast, where player_count() walks the spawn\n    // tree — so an orchestrator's per-level shards already roll up into its\n    // mode's count. Summed across every mode this gives the off-in-a-game\n    // population (the lobby's own occupants are counted separately).\n    function count_in_games(): i32 {\n        var n: i32 = 0\n        for (var i: i32 = 0; i < MODE_COUNT; i = i + 1) {\n            var c: i32 = mode_counts[i]\n            if c > 0 { n = n + c }\n        }\n        return n\n    }\n\n    // Grand total across the deployment: lobby occupants (which includes\n    // us) plus everyone off in a gameplay instance. A player is in exactly\n    // one place — the lobby or a game — so there's no double counting.\n    function count_total(): i32 {\n        return count_others() + count_in_games()\n    }\n\n    function clamp_cursors() {\n        // visible_count can be 0 before init_view runs (the server can\n        // fan_out before our first frame). In that case leave the\n        // cursor at 0 — there's nothing to point at yet anyway.\n        if visible_count <= 0 {\n            cursor_mode = 0\n            return\n        }\n        if cursor_mode >= visible_count { cursor_mode = visible_count - 1 }\n        if cursor_mode < 0 { cursor_mode = 0 }\n    }\n\n    // Build the wrong-ROM error banner text for mode `i`. Stored into\n    // the persistent error_text buffer so draw_error_banner can render\n    // it across frames without re-formatting each tick.\n    function set_wrong_rom_error(i: i32) {\n        var prefix: string = \"NEEDS \"\n        var name: string = mode_required_label(i)\n        var total: i32 = len(prefix) + len(name)\n        if total > 64 { total = 64 }\n        for (var j: i32 = 0; j < total; j = j + 1) {\n            if j < len(prefix) {\n                error_text[j] = u8(prefix[j])\n            } else {\n                error_text[j] = u8(name[j - len(prefix)])\n            }\n        }\n        error_text_len = total\n        error_ticks = ERROR_TICKS_INITIAL\n    }\n\n    function pick_mode() {\n        if cursor_mode < 0 || cursor_mode >= visible_count { return }\n        var global_mode: i32 = visible_modes[cursor_mode]\n        // Every entry in visible_modes was filtered through\n        // mode_is_playable at init time, so the wrong-ROM branch is\n        // unreachable in practice. The check + banner are left in place\n        // so the path still works if the filtering policy ever changes.\n        if !mode_is_playable(global_mode) {\n            set_wrong_rom_error(global_mode)\n            return\n        }\n        // Glossed but not in the deployed library: silently refuse to\n        // launch. The card already renders UNAVAILABLE, so no banner is\n        // needed. NORMAL (offline) has no script and is never gated this way.\n        if global_mode != 0 && !mode_available(global_mode) {\n            return\n        }\n        // NORMAL (global index 0) is the offline mode: no server-side\n        // instance, no hop. Unpause the ROM (the lobby paused it on\n        // first frame) and call `disconnect()` to close the WebSocket\n        // and stop this script entirely. From here on the player just\n        // sees the bare ROM running with no overlay, no networking,\n        // and no socket cost on the server.\n        if global_mode == 0 {\n            resume_rom()\n            disconnect()\n            return\n        }\n        // Networked modes go through the server. The server-side\n        // `handle_pick_mode` will spawn (or find) the right instance,\n        // build the gameplay handoff from our slot's stored nickname,\n        // and `redirect` us — the runtime's redirect-frame handler\n        // performs the actual hop.\n        var msg: u8[2]\n        msg[0] = u8(MSG_PICK_MODE)\n        msg[1] = u8(global_mode)\n        send(CHANNEL_LOBBY, msg, reliable = true)\n    }\n\n    // Launch the chosen singleplayer set. Two cases:\n    //\n    //   - Nothing selected → plain offline play (the former \"NORMAL\"\n    //     mode): unpause the ROM and disconnect entirely. With no children\n    //     to keep alive, the lobby can safely tear itself down.\n    //   - One or more selected → spawn each as a child tenant over the\n    //     bare ROM, release our pause, and go dormant. We must NOT\n    //     disconnect: the spawned mods are our subtree and would die with\n    //     us. The lobby lingers as the silent orchestrator root, exactly\n    //     the explicit-orchestrator shape in docs/multi-script-client.md.\n    function launch_singleplayer() {\n        var n: i32 = count_sp_selected()\n        if n == 0 {\n            resume_rom()\n            disconnect()\n            return\n        }\n        var empty: u8[1]\n        empty[0] = u8(0)\n        for (var i: i32 = 0; i < SP_MOD_COUNT; i = i + 1) {\n            // sp_available guards against launching a mod that's glossed\n            // but not deployed; the toggle path already blocks selecting\n            // one, so this is belt-and-suspenders.\n            if sp_selected[i] && sp_available(i) {\n                sp_handles[i] = spawn_local(sp_mod_path(i), empty)\n            }\n        }\n        resume_rom()\n        launched = true\n    }\n\n    // Centered error banner near the bottom of the lobby panel,\n    // rendered while error_ticks > 0. Yellow text on a black panel\n    // (we sit on top of the existing draw_panel fill, so no extra\n    // background fill is needed beyond what's already there).\n    function draw_error_banner() {\n        if error_ticks <= 0 { return }\n        var w_px: i32 = measure_bytes(error_text, 0, error_text_len)\n        // Centre on the actual screen (NES 256 → identical to the old\n        // 8 + (240 - w)/2; Game Boy 160 → centred for the narrow screen).\n        var x: i32 = (screen_width() - w_px) / 2\n        var y: i32 = 184\n        draw_bytes(error_text, 0, error_text_len, x, y, font_pal_yellow)\n    }\n\n\n    // Keyboard cell index → ASCII code. First 26 cells are 'A'..'Z',\n    // remaining 10 are '0'..'9'.\n    function kb_char(idx: i32): i32 {\n        if idx < 26 { return 0x41 + idx }\n        return 0x30 + (idx - 26)\n    }\n\n    function draw_keyboard() {\n        var total: i32 = KB_COLS * KB_ROWS\n        for (var i: i32 = 0; i < total; i = i + 1) {\n            var col: i32 = i % KB_COLS\n            var row: i32 = i / KB_COLS\n            var cx: i32 = KB_X + col * (KB_CELL + KB_GAP)\n            var cy: i32 = KB_Y + row * (KB_CELL + KB_GAP)\n            var ch: i32 = kb_char(i)\n            // Centre the (proportional) glyph within its 16px key cell.\n            var gx: i32 = cx + (KB_CELL - i32(glyph_w[fold_upper(ch) - 0x20])) / 2\n            // Selected cell: yellow background + black glyph. Others:\n            // bare glyph (the surrounding black panel reads as\n            // unselected by itself).\n            if i == kb_cursor {\n                draw_fill(cx, cy, KB_CELL, KB_CELL, font_pal_yellow)\n                draw_char(ch, gx, cy + 4, font_pal_black)\n            } else {\n                draw_char(ch, gx, cy + 4, font_pal_white)\n            }\n        }\n        draw_end_button()\n    }\n\n    function draw_end_button() {\n        // Always show a filled background so END reads as a button,\n        // not a letter cell. Selected = yellow + black glyph (matches\n        // the selected-letter treatment); unselected = mid-grey fill.\n        var ex: i32 = END_X + (END_W - measure_text(\"END\")) / 2\n        if kb_cursor == KB_END {\n            draw_fill(END_X, END_Y, END_W, END_H, font_pal_yellow)\n            draw_text(\"END\", ex, END_Y + 4, font_pal_black)\n        } else {\n            draw_fill(END_X, END_Y, END_W, END_H, btn_pal)\n            draw_text(\"END\", ex, END_Y + 4, font_pal_white)\n        }\n    }\n\n    function draw_name_preview() {\n        // \"NAME: <chars><caret>\" centered for the full-screen naming\n        // view. Layout reserves room for the maximum input width\n        // (\"NAME: \" + 10 chars = 16 cells = 144px) and stays put as\n        // the user types so the label doesn't jitter.\n        var x: i32 = 56\n        var y: i32 = 56\n        draw_text(\"NAME: \", x, y, font_pal_grey)\n        var bx: i32 = x + measure_text(\"NAME: \") + GLYPH_GAP\n        draw_bytes(chosen_name, 0, chosen_name_len, bx, y, font_pal_white)\n        if chosen_name_len < NAME_INPUT_MAX {\n            var caret_x: i32 = bx + measure_bytes(chosen_name, 0, chosen_name_len)\n            if chosen_name_len > 0 { caret_x = caret_x + GLYPH_GAP }\n            draw_char(0x5F, caret_x, y, font_pal_yellow)\n        }\n    }\n\n    function confirm_name() {\n        // Names are no longer sent anywhere — END just closes the keyboard.\n        view = VIEW_LOBBY\n    }\n\n    function draw_player_sidebar() {\n        var n_others: i32 = count_others()\n        if n_others == 0 {\n            draw_text(\"(NO ONE\", SIDEBAR_X, SIDEBAR_ROW_Y0,                  font_pal_grey)\n            draw_text(\" ELSE)\",  SIDEBAR_X, SIDEBAR_ROW_Y0 + ROW_HEIGHT,     font_pal_grey)\n            return\n        }\n        var sb_row: i32 = 0\n        for (var i: i32 = 0; i < MAX_PLAYERS; i = i + 1) {\n            if !other_occupied[i] { continue }\n            if sb_row >= SIDEBAR_VISIBLE { break }\n            var y: i32 = SIDEBAR_ROW_Y0 + sb_row * ROW_HEIGHT\n            // No names — a generic per-slot label.\n            draw_text(\"PLAYER \", SIDEBAR_X, y, font_pal_white)\n            draw_int(i + 1, SIDEBAR_X + measure_text(\"PLAYER \"), y, font_pal_white)\n            sb_row = sb_row + 1\n        }\n    }\n\n    // Title band: coloured bar + brand mark + live \"N ONLINE\" readout.\n    // Drawn first in the lobby view so the tabs and cards sit on top.\n    function draw_header() {\n        draw_fill(BAND_X, BAND_Y, BAND_W, BAND_H, pal_band)\n        draw_text(\"MULTINOSTALGIA\", BRAND_X, BRAND_Y, font_pal_yellow)\n        // Grand total across the deployment: lobby occupants plus everyone\n        // off in a spawned game. Right-aligned in the small font.\n        var n: i32 = count_total()\n        var suffix: string = \" ONLINE\"\n        var digits: i32 = 1\n        var v: i32 = n\n        while v >= 10 {\n            v = v / 10\n            digits = digits + 1\n        }\n        var total_w: i32 = (digits + len(suffix)) * 4\n        var px: i32 = SAFE_X1 - 8 - total_w\n        var nx: i32 = draw_int_small(n, px, BRAND_Y + 1, font_pal_cream)\n        draw_text_small(suffix, nx, BRAND_Y + 1, font_pal_cream)\n    }\n\n    // One tab button. Active = accent fill + black label; inactive = dim\n    // fill + white label. The filled-button treatment makes it obvious\n    // the tabs are selectable, not just coloured headings. The label is\n    // centred within the button.\n    function draw_tab(label: string, x: i32, w: i32, active: bool) {\n        if active {\n            draw_fill(x, TAB_Y, w, TAB_H, pal_accent)\n            draw_text(label, x + (w - measure_text(label)) / 2, TAB_Y + 4, font_pal_black)\n        } else {\n            draw_fill(x, TAB_Y, w, TAB_H, pal_tab_off)\n            draw_text(label, x + (w - measure_text(label)) / 2, TAB_Y + 4, font_pal_white)\n        }\n    }\n\n    // Section tab strip under the band: FEATURED | CUSTOM.\n    function draw_tab_strip() {\n        draw_tab(\"FEATURED\", TAB_MP_X, TAB_MP_W, section == SECTION_FEATURED)\n        draw_tab(\"CUSTOM\",   TAB_SP_X, TAB_SP_W, section == SECTION_CUSTOM)\n    }\n\n    // FEATURED section: networked GHOST experiences, single-select. We\n    // iterate the visible-modes filter (NORMAL excluded; see init_view),\n    // so modes that don't match the local ROM are simply absent.\n    // Keep `cur` inside a `view`-row window over `total` items, moving the\n    // stored `top` only when the cursor leaves the window, then clamping so the\n    // final page isn't scrolled past the end. Returns the new top.\n    function scroll_follow(cur: i32, top: i32, view: i32, total: i32): i32 {\n        var t: i32 = top\n        if cur < t { t = cur }\n        if cur >= t + view { t = cur - view + 1 }\n        var maxt: i32 = total - view\n        if maxt < 0 { maxt = 0 }\n        if t > maxt { t = maxt }\n        if t < 0 { t = 0 }\n        return t\n    }\n\n    function draw_featured_section() {\n        if visible_count == 0 {\n            // Styled empty-state card so the section never looks broken.\n            draw_fill(CONTENT_X, CONTENT_Y, CARD_W, CARD_H, pal_card)\n            draw_text_small(\"NO ONLINE MODES FOR THIS GAME\",\n                            CONTENT_X + 12, CONTENT_Y + CARD_H / 2 - 3, font_pal_grey)\n            return\n        }\n        featured_top = scroll_follow(cursor_mode, featured_top, LIST_V, visible_count)\n        // Draw LIST_V full rows plus one peek row (r == LIST_V) that spills off\n        // the bottom edge as a \"more below\" hint. scroll_follow keeps the cursor\n        // inside [top, top+LIST_V), so the peek row is never the selected one.\n        for (var r: i32 = 0; r < LIST_V + 1; r = r + 1) {\n            var i: i32 = featured_top + r\n            if i >= visible_count { break }\n            var ty: i32 = CONTENT_Y + r * CARD_STRIDE\n            draw_mode_tile(visible_modes[i], CONTENT_X, ty, cursor_mode == i)\n        }\n    }\n\n    // One singleplayer script card. Same slate footprint as a\n    // multiplayer card: a signature-colour thumbnail, title, and blurb.\n    // Toggled-on rows are signalled by a gold-filled checkbox; the cursor\n    // row gets the animated gold gleam frame. Every card body is the same\n    // slate so the section reads as one consistent surface.\n    function draw_sp_row(i: i32, y: i32, selected: bool) {\n        draw_card_bg(CONTENT_X, y, selected, pal_card)\n        var thumb_x: i32 = CONTENT_X + 2\n        var thumb_y: i32 = y + 2\n        var text_x: i32 = CONTENT_X + 2 + CARD_THUMB + 8\n        // Glossed but absent from the deployed library: inert UNAVAILABLE\n        // row — greyed thumb + title, no blurb, no checkbox (can't toggle).\n        if !sp_available(i) {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, pal_unavail_thumb)\n            draw_text(sp_mod_label(i),     text_x, y + 4,  font_pal_grey)\n            draw_text_small(\"UNAVAILABLE\", text_x, y + 18, font_pal_grey)\n            return\n        }\n        draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, sp_pal_0)\n        draw_text(sp_mod_label(i),             text_x, y + 4,  font_pal_white)\n        // No count row here, so the blurb's band is the whole sub-title area\n        // (y+12..y+30, 18px): a one-line blurb stays centred at y+18 and a\n        // two-line blurb rises to sit centred with room top and bottom. Stop\n        // 4px shy of the right-edge checkbox (bx = CARD_W-20).\n        draw_desc_wrapped(sp_mod_description(i), text_x, y + 12, 18,\n                          (CONTENT_X + CARD_W - 24) - text_x, font_pal_grey)\n        // Checkbox on the right edge: a 12x12 frame, hollow when off,\n        // filled gold when on.\n        var bx: i32 = CONTENT_X + CARD_W - 20\n        var by: i32 = y + CARD_H / 2 - 6\n        draw_fill(bx, by, 12, 12, btn_pal)\n        if sp_selected[i] {\n            draw_fill(bx + 2, by + 2, 8, 8, pal_check)\n        } else {\n            draw_fill(bx + 2, by + 2, 8, 8, pal_card)\n        }\n    }\n\n    // One CUSTOM bundle card. A one-member bundle renders exactly like the\n    // underlying sp-mod row (unchanged look). A multi-member bundle renders\n    // its own title/blurb, a right-edge aggregate checkbox (empty / partial /\n    // full for none / some / all members selected), and a \">\" chevron marking\n    // it as drillable (SELECT opens the member sub-list). See docs/lobby.md.\n    function draw_bundle_row(b: i32, y: i32, selected: bool) {\n        if bundle_len(b) == 1 {\n            draw_sp_row(bundle_first(b), y, selected)\n            return\n        }\n        draw_card_bg(CONTENT_X, y, selected, pal_card)\n        var thumb_x: i32 = CONTENT_X + 2\n        var thumb_y: i32 = y + 2\n        var text_x: i32 = CONTENT_X + 2 + CARD_THUMB + 8\n        // Entire bundle absent from the deployed library: inert UNAVAILABLE.\n        if bundle_avail_count(b) == 0 {\n            draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, pal_unavail_thumb)\n            draw_text(bundle_label(b),     text_x, y + 4,  font_pal_grey)\n            draw_text_small(\"UNAVAILABLE\", text_x, y + 18, font_pal_grey)\n            return\n        }\n        draw_fill(thumb_x, thumb_y, CARD_THUMB, CARD_THUMB, sp_pal_0)\n        draw_text(bundle_label(b),      text_x, y + 4,  font_pal_white)\n        // Same 18px band as a plain script row; a multi-member bundle also\n        // carries a chevron (bx-12) left of its checkbox, so keep the blurb\n        // clear of it.\n        draw_desc_wrapped(bundle_desc(b), text_x, y + 12, 18,\n                          (CONTENT_X + CARD_W - 34) - text_x, font_pal_grey)\n        // Aggregate checkbox on the right edge.\n        var bx: i32 = CONTENT_X + CARD_W - 20\n        var by: i32 = y + CARD_H / 2 - 6\n        draw_fill(bx, by, 12, 12, btn_pal)\n        var sel: i32 = bundle_sel_count(b)\n        var av: i32  = bundle_avail_count(b)\n        if sel <= 0 {\n            draw_fill(bx + 2, by + 2, 8, 8, pal_card)         // none\n        } else if sel >= av {\n            draw_fill(bx + 2, by + 2, 8, 8, pal_check)        // all\n        } else {\n            draw_fill(bx + 2, by + 2, 8, 8, pal_card)         // some: small inner pip\n            draw_fill(bx + 4, by + 4, 4, 4, pal_check)\n        }\n        // Drill chevron just left of the checkbox.\n        draw_text(\">\", bx - 12, y + CARD_H / 2 - 4, font_pal_cream)\n    }\n\n    // The START action. A brass button carrying the animated gold gleam when\n    // it's the cursor row. The gleam FRAME is the only highlight — the body\n    // fill stays pal_start whether or not it's selected, so the button doesn't\n    // flash a brighter fill on top of the surrounding selection box. Both lines\n    // are centred horizontally: \"START\" (vertically centred in the card) with\n    // the live selected-count (\"<n> SCRIPT(S)\") tucked just beneath it.\n    function draw_start_row(y: i32, selected: bool) {\n        draw_card_bg(CONTENT_X, y, selected, pal_start)\n        // Line 1: \"START\", horizontally centred, sitting a little above the\n        // card's vertical midline to leave the count room just below.\n        var label: string = \"START\"\n        draw_text(label, CONTENT_X + (CARD_W - measure_text(label)) / 2, y + 7, font_pal_white)\n        // Line 2: \"<n> MUNOS SCRIPT(S)\", centred. The small font is a fixed\n        // 4px/glyph, so the pixel width is just the digit count plus the\n        // suffix length, times four.\n        var n: i32 = count_sp_selected()\n        var suffix: string = \" SCRIPTS\"\n        if n == 1 { suffix = \" SCRIPT\" }\n        var digits: i32 = 1\n        var v: i32 = n\n        while v >= 10 {\n            v = v / 10\n            digits = digits + 1\n        }\n        var lw: i32 = (digits + len(suffix)) * 4\n        var ly: i32 = y + 18                     // just beneath the START label\n        var nx: i32 = draw_int_small(n, CONTENT_X + (CARD_W - lw) / 2, ly, font_pal_cream)\n        draw_text_small(suffix, nx, ly, font_pal_cream)\n    }\n\n    // CUSTOM section: the START action at the TOP of the list,\n    // then the toggleable script cards. sp_cursor == 0 is START;\n    // 1..bundle_visible_count are the ROM-playable script rows (visible_bundles maps\n    // each to its catalogue index). START launches every toggled script at\n    // once (or plain offline play when none are selected). See\n    // launch_singleplayer.\n    function draw_custom_section() {\n        // Row 0 is START; rows 1..bundle_visible_count are the bundles. Treat\n        // the whole thing as one scrollable list of (bundle_visible_count + 1).\n        var total: i32 = bundle_visible_count + 1\n        custom_top = scroll_follow(sp_cursor, custom_top, LIST_V, total)\n        // LIST_V full rows + one peek row past the window (see draw_featured_section).\n        for (var r: i32 = 0; r < LIST_V + 1; r = r + 1) {\n            var row: i32 = custom_top + r\n            if row >= total { break }\n            var y: i32 = CONTENT_Y + r * CARD_STRIDE\n            if row == 0 {\n                draw_start_row(y, sp_cursor == 0)\n            } else {\n                draw_bundle_row(visible_bundles[row - 1], y, sp_cursor == row)\n            }\n        }\n    }\n\n    // The drill-in sub-screen (NES): a back-chevron header carrying the\n    // bundle title, then one card per member reusing draw_sp_row so each\n    // shows its own label / blurb / checkbox from sp_selected[]. A toggles\n    // the member under the cursor, B backs out (docs/lobby.md §\"The drill-in\").\n    function draw_drill_view() {\n        draw_fill(BAND_X, BAND_Y, BAND_W, BAND_H, pal_band)\n        draw_text(\"< \", BRAND_X, BRAND_Y, font_pal_cream)\n        draw_text(bundle_label(drill_bundle),\n                  BRAND_X + measure_text(\"< \"), BRAND_Y, font_pal_yellow)\n        var first: i32 = bundle_first(drill_bundle)\n        var n: i32 = bundle_len(drill_bundle)\n        drill_top = scroll_follow(drill_cursor, drill_top, LIST_V, n)\n        // LIST_V full rows + one peek row past the window (see draw_featured_section).\n        for (var r: i32 = 0; r < LIST_V + 1; r = r + 1) {\n            var k: i32 = drill_top + r\n            if k >= n { break }\n            var y: i32 = CONTENT_Y + r * CARD_STRIDE\n            draw_sp_row(first + k, y, drill_cursor == k)\n        }\n    }\n\n    // ----- Game Boy compact layout -----\n    //\n    // A narrow console (Game Boy, 160px) can't fit the NES card column, so\n    // the whole lobby view drops to single-line rows here. The logical\n    // state (sections, cursors, selection, the ROM-gated visible_modes /\n    // visible_bundles filters) is identical — only the rendering differs — so\n    // navigation is shared and unchanged. The NES path above is untouched.\n    function lo_compact(): bool {\n        return screen_width() < 200\n    }\n\n    // Compact title band: brand + live online count in the small font so\n    // both fit a 160px band.\n    function draw_header_gb() {\n        var w: i32 = screen_width() - 2 * GB_X0\n        draw_fill(GB_X0, GB_BAND_Y, w, GB_BAND_H, pal_band)\n        draw_text_small(\"MULTINOSTALGIA\", GB_X0 + 2, GB_BAND_Y + 4, font_pal_yellow)\n        var n: i32 = count_total()\n        var digits: i32 = 1\n        var v: i32 = n\n        while v >= 10 { v = v / 10; digits = digits + 1 }\n        draw_int_small(n, GB_X0 + w - (digits * 4) - 2, GB_BAND_Y + 4, font_pal_cream)\n    }\n\n    // One compact tab. Big font, centred — the labels are short enough.\n    function draw_tab_gb(label: string, x: i32, w: i32, active: bool) {\n        if active {\n            draw_fill(x, GB_TAB_Y, w, GB_TAB_H, pal_accent)\n            draw_text(label, x + (w - measure_text(label)) / 2, GB_TAB_Y + 3, font_pal_black)\n        } else {\n            draw_fill(x, GB_TAB_Y, w, GB_TAB_H, pal_tab_off)\n            draw_text(label, x + (w - measure_text(label)) / 2, GB_TAB_Y + 3, font_pal_white)\n        }\n    }\n\n    // Abbreviated tab labels — \"FEATURED\"/\"CUSTOM\" still don't fit ~70px.\n    function draw_tabs_gb() {\n        var total: i32 = screen_width() - 2 * GB_X0\n        var half: i32 = total / 2\n        draw_tab_gb(\"FEAT\", GB_X0, half - 2, section == SECTION_FEATURED)\n        draw_tab_gb(\"CUST\", GB_X0 + half, half - 2, section == SECTION_CUSTOM)\n    }\n\n    // Single-line row chrome: a gleam bar under the cursor, the body fill\n    // otherwise.\n    function draw_row_gb_bg(x: i32, y: i32, w: i32, selected: bool, base_pal: u32[]) {\n        if selected {\n            draw_fill(x, y, w, GB_ROW_H, gleam)\n        } else {\n            draw_fill(x, y, w, GB_ROW_H, base_pal)\n        }\n    }\n\n    // Compact CUSTOM list — START plus the ROM-playable rows\n    // (visible_bundles), mirroring draw_custom_section's logical structure.\n    function draw_custom_section_gb() {\n        var w: i32 = screen_width() - 2 * GB_X0\n        var total: i32 = bundle_visible_count + 1\n        custom_top = scroll_follow(sp_cursor, custom_top, GB_LIST_V, total)\n        for (var r: i32 = 0; r < GB_LIST_V + 1; r = r + 1) {\n            var row: i32 = custom_top + r\n            if row >= total { break }\n            var y: i32 = GB_CONTENT_Y + r * GB_ROW_STRIDE\n            var ry: i32 = y + (GB_ROW_H / 2) - 2\n            if row == 0 {\n                if sp_cursor == 0 {\n                    draw_fill(GB_X0, y, w, GB_ROW_H, pal_start_hi)\n                } else {\n                    draw_fill(GB_X0, y, w, GB_ROW_H, pal_start)\n                }\n                draw_text_small(\"> START\", GB_X0 + 4, ry, font_pal_white)\n                draw_int_small(count_sp_selected(), GB_X0 + w - 8, ry, font_pal_cream)\n                continue\n            }\n            var b: i32 = visible_bundles[row - 1]\n            draw_row_gb_bg(GB_X0, y, w, sp_cursor == row, pal_card)\n            var bx: i32 = GB_X0 + w - 12\n            var by: i32 = y + 3\n            draw_fill(bx, by, 10, 10, btn_pal)\n            var sel: i32 = bundle_sel_count(b)\n            var av: i32  = bundle_avail_count(b)\n            if sel <= 0 {\n                draw_fill(bx + 2, by + 2, 6, 6, pal_card)\n            } else if sel >= av {\n                draw_fill(bx + 2, by + 2, 6, 6, pal_check)\n            } else {\n                draw_fill(bx + 2, by + 2, 6, 6, pal_card)\n                draw_fill(bx + 3, by + 3, 4, 4, pal_check)\n            }\n            draw_text_small(bundle_label(b), GB_X0 + 4, ry, font_pal_white)\n            // Drill marker for a multi-member bundle, left of the checkbox.\n            if bundle_len(b) > 1 {\n                draw_text_small(\">\", bx - 6, ry, font_pal_cream)\n            }\n        }\n    }\n\n    // The drill-in sub-screen (Game Boy): a back-chevron band + one single-\n    // line row per member with its own checkbox. Mirrors draw_drill_view's\n    // logical structure at the narrow row pitch.\n    function draw_drill_view_gb() {\n        var w: i32 = screen_width() - 2 * GB_X0\n        draw_fill(GB_X0, GB_BAND_Y, w, GB_BAND_H, pal_band)\n        draw_text_small(\"<\", GB_X0 + 2, GB_BAND_Y + 4, font_pal_cream)\n        draw_text_small(bundle_label(drill_bundle), GB_X0 + 10, GB_BAND_Y + 4, font_pal_yellow)\n        var first: i32 = bundle_first(drill_bundle)\n        var n: i32 = bundle_len(drill_bundle)\n        drill_top = scroll_follow(drill_cursor, drill_top, GB_LIST_V, n)\n        for (var r: i32 = 0; r < GB_LIST_V + 1; r = r + 1) {\n            var k: i32 = drill_top + r\n            if k >= n { break }\n            var m: i32 = first + k\n            var y: i32 = GB_CONTENT_Y + r * GB_ROW_STRIDE\n            draw_row_gb_bg(GB_X0, y, w, drill_cursor == k, pal_card)\n            var ry: i32 = y + (GB_ROW_H / 2) - 2\n            var bx: i32 = GB_X0 + w - 12\n            var by: i32 = y + 3\n            draw_fill(bx, by, 10, 10, btn_pal)\n            if sp_selected[m] {\n                draw_fill(bx + 2, by + 2, 6, 6, pal_check)\n            } else {\n                draw_fill(bx + 2, by + 2, 6, 6, pal_card)\n            }\n            draw_text_small(sp_mod_label(m), GB_X0 + 4, ry, font_pal_white)\n        }\n    }\n\n    // Compact FEATURED list — the ROM-playable experiences (visible_modes).\n    function draw_featured_section_gb() {\n        var w: i32 = screen_width() - 2 * GB_X0\n        if visible_count == 0 {\n            draw_fill(GB_X0, GB_CONTENT_Y, w, GB_ROW_H, pal_card)\n            draw_text_small(\"NO ONLINE MODES\", GB_X0 + 4, GB_CONTENT_Y + (GB_ROW_H / 2) - 2, font_pal_grey)\n            return\n        }\n        featured_top = scroll_follow(cursor_mode, featured_top, GB_LIST_V, visible_count)\n        for (var r: i32 = 0; r < GB_LIST_V + 1; r = r + 1) {\n            var i: i32 = featured_top + r\n            if i >= visible_count { break }\n            var y: i32 = GB_CONTENT_Y + r * GB_ROW_STRIDE\n            var mi: i32 = visible_modes[i]\n            draw_row_gb_bg(GB_X0, y, w, cursor_mode == i, pal_card)\n            var ry: i32 = y + (GB_ROW_H / 2) - 2\n            draw_text_small(mode_label(mi), GB_X0 + 4, ry, font_pal_white)\n            var pc: i32 = mode_counts[mi]\n            if pc < 0 { pc = 0 }\n            draw_int_small(pc, GB_X0 + w - 8, ry, font_pal_cream)\n        }\n    }\n\n    function draw_lobby_gb() {\n        draw_header_gb()\n        draw_tabs_gb()\n        if section == SECTION_CUSTOM {\n            draw_custom_section_gb()\n        } else {\n            draw_featured_section_gb()\n        }\n    }\n\n    function draw_settings_button() {\n        // Same selection convention as the END button: grey fill +\n        // white text when unfocused, yellow fill + black text when\n        // focused.\n        if focused_col == COL_SETTINGS {\n            draw_fill(SETTINGS_X, SETTINGS_Y, SETTINGS_W, SETTINGS_H, font_pal_yellow)\n            draw_text(\"SETTINGS\", SETTINGS_X + (SETTINGS_W - measure_text(\"SETTINGS\")) / 2, SETTINGS_Y + 4, font_pal_black)\n        } else {\n            draw_fill(SETTINGS_X, SETTINGS_Y, SETTINGS_W, SETTINGS_H, btn_pal)\n            draw_text(\"SETTINGS\", SETTINGS_X + (SETTINGS_W - measure_text(\"SETTINGS\")) / 2, SETTINGS_Y + 4, font_pal_white)\n        }\n    }\n\n    // Re-open the keyboard. chosen_name is left intact so the user can\n    // backspace from their current nickname rather than retype from\n    // scratch; the server still has the old name until they hit END.\n    function open_settings() {\n        view = VIEW_NAMING\n        kb_cursor = 0\n    }\n\n    function draw_naming_view() {\n        // Title centred across the safe region (x in [8, 248)).\n        var tx: i32 = 8 + (240 - measure_text(\"ENTER YOUR NAME\")) / 2\n        draw_text(\"ENTER YOUR NAME\", tx, 24, font_pal_yellow)\n        draw_name_preview()\n        draw_keyboard()\n    }\n\n    // Start on the lobby view. The server auto-assigns a default\n    // nickname (PLAYER<slot>) on connect, so there's nothing to ask\n    // the player for up front — they can hop into a mode immediately\n    // and rename via SETTINGS if they care. Called once on the first\n    // frame.\n    function init_view() {\n        view = VIEW_LOBBY\n        // FEATURED experiences playable on the local ROM. NORMAL (index 0)\n        // is NOT a networked experience — \"play offline with no mods\" now\n        // lives in CUSTOM (START with nothing selected) — so the loop starts\n        // at index 1, leaving FEATURED as purely the networked GHOST\n        // experiences. Done once on first frame; local_rom_hash() is stable.\n        visible_count = 0\n        for (var i: i32 = 1; i < MODE_COUNT; i = i + 1) {\n            if mode_is_playable(i) {\n                visible_modes[visible_count] = i\n                visible_count = visible_count + 1\n            }\n        }\n        // CUSTOM bundles playable on this ROM, same ROM-gate as above. An\n        // unrecognised cartridge gets an empty list (just the START row =\n        // plain offline play). The list holds BUNDLE indices, not raw sp-mod\n        // indices — the 4 SMB ghost scripts fold into one drillable bundle.\n        bundle_visible_count = 0\n        for (var j: i32 = 0; j < BUNDLE_COUNT; j = j + 1) {\n            if bundle_is_playable(j) {\n                visible_bundles[bundle_visible_count] = j\n                bundle_visible_count = bundle_visible_count + 1\n            }\n        }\n        // Open on FEATURED when this ROM has any networked experience;\n        // otherwise drop straight into CUSTOM so an unrecognised cartridge\n        // still has something to do.\n        if visible_count > 0 {\n            section = SECTION_FEATURED\n        } else {\n            section = SECTION_CUSTOM\n        }\n    }\n\n    event frame(frame_num: i32) {\n        if !did_init {\n            pause_rom()\n            init_view()\n            did_init = true\n        }\n\n        // Dormant after a singleplayer launch: the spawned mods own the\n        // screen now and the ROM is running. The lobby lingers only as\n        // the silent root of their subtree — draw nothing.\n        if launched { return }\n\n        draw_panel()\n\n        // The naming view owns the full screen — no brand mark, no\n        // sidebar, no divider. The lobby view brings them back along\n        // with the section browser.\n        if view == VIEW_NAMING {\n            draw_naming_view()\n            return\n        }\n\n        // PARKED: right-edge player sidebar (divider + \"PLAYERS\" header\n        // + roster). The server still sends the slot table; the live\n        // headcount now rides in the title band instead (draw_header).\n        // Re-enable draw_sidebar_divider + draw_player_sidebar together\n        // when the full roster comes back.\n        // Advance the selection gleam: re-point gleam[1] to this frame's\n        // shade so every cursor frame drawn below shimmers in step.\n        gleam[1] = gleam_cycle[(frame_num / GLEAM_SPEED) % GLEAM_STEPS]\n\n        // The drill-in is a modal sub-screen over CUSTOM: it owns the view\n        // until the player backs out, so it renders instead of the tab strip.\n        if view == VIEW_DRILL {\n            if lo_compact() {\n                draw_drill_view_gb()\n            } else {\n                draw_drill_view()\n            }\n            draw_error_banner()\n            if error_ticks > 0 { error_ticks = error_ticks - 1 }\n            return\n        }\n\n        if lo_compact() {\n            draw_lobby_gb()\n        } else {\n            draw_header()\n            draw_tab_strip()\n            if section == SECTION_CUSTOM {\n                draw_custom_section()\n            } else {\n                draw_featured_section()\n            }\n        }\n        draw_error_banner()\n        if error_ticks > 0 { error_ticks = error_ticks - 1 }\n    }\n\n    function handle_naming_input(button: i32) {\n        if button == BUTTON_LEFT {\n            if kb_cursor == KB_END { return }    // single-cell button, no horizontal moves\n            var col: i32 = kb_cursor % KB_COLS\n            if col > 0 { kb_cursor = kb_cursor - 1 }\n            return\n        }\n        if button == BUTTON_RIGHT {\n            if kb_cursor == KB_END { return }\n            var col: i32 = kb_cursor % KB_COLS\n            if col < KB_COLS - 1 { kb_cursor = kb_cursor + 1 }\n            return\n        }\n        if button == BUTTON_UP {\n            if kb_cursor == KB_END {\n                // Pop back into the grid at row 5, col 2 (middle of\n                // the bottom row) — same place DOWN would have left.\n                kb_cursor = (KB_ROWS - 1) * KB_COLS + KB_COLS / 2\n                return\n            }\n            if kb_cursor >= KB_COLS { kb_cursor = kb_cursor - KB_COLS }\n            return\n        }\n        if button == BUTTON_DOWN {\n            if kb_cursor == KB_END { return }\n            if kb_cursor >= (KB_ROWS - 1) * KB_COLS {\n                kb_cursor = KB_END\n                return\n            }\n            kb_cursor = kb_cursor + KB_COLS\n            return\n        }\n        if button == BUTTON_A {\n            if kb_cursor == KB_END {\n                confirm_name()\n                return\n            }\n            if chosen_name_len < NAME_INPUT_MAX {\n                chosen_name[chosen_name_len] = u8(kb_char(kb_cursor))\n                chosen_name_len = chosen_name_len + 1\n            }\n            return\n        }\n        if button == BUTTON_B {\n            if chosen_name_len > 0 {\n                chosen_name_len = chosen_name_len - 1\n                chosen_name[chosen_name_len] = u8(0)\n            }\n            return\n        }\n    }\n\n    // Top-level lobby input. LEFT/RIGHT switch the focused section (the\n    // two tabs); UP/DOWN/A/START act within the focused section. LEFT to\n    // FEATURED is a no-op when this ROM has no networked experiences, so an\n    // unrecognised cartridge can't strand the cursor on an empty tab.\n    function handle_lobby_input(button: i32) {\n        if button == BUTTON_LEFT {\n            if visible_count > 0 { section = SECTION_FEATURED }\n            return\n        }\n        if button == BUTTON_RIGHT {\n            // From FEATURED, RIGHT switches to CUSTOM. Already in CUSTOM,\n            // RIGHT is the intuitive \"open\" gesture for a drillable bundle\n            // (it was otherwise a no-op) — a second, discoverable way in\n            // alongside SELECT.\n            if section == SECTION_FEATURED {\n                section = SECTION_CUSTOM\n            } else {\n                open_drill_at_cursor()\n            }\n            return\n        }\n        if section == SECTION_CUSTOM {\n            handle_custom_input(button)\n        } else {\n            handle_featured_input(button)\n        }\n    }\n\n    // FEATURED section: a single-select vertical list. A / START\n    // confirms, handing off to the server via MSG_PICK_MODE (pick_mode).\n    function handle_featured_input(button: i32) {\n        if button == BUTTON_UP {\n            if cursor_mode > 0 { cursor_mode = cursor_mode - 1 }\n            return\n        }\n        if button == BUTTON_DOWN {\n            if cursor_mode + 1 < visible_count { cursor_mode = cursor_mode + 1 }\n            return\n        }\n        if button == BUTTON_A {\n            pick_mode()\n            return\n        }\n        if button == BUTTON_START {\n            pick_mode()\n            return\n        }\n    }\n\n    // CUSTOM section: sp_cursor 0 is the START action at the TOP of the list;\n    // 1..bundle_visible_count are the ROM-playable BUNDLE rows (via\n    // visible_bundles). UP/DOWN move; A launches from START or toggles the\n    // whole bundle under the cursor; SELECT drills into a multi-member bundle\n    // to toggle its individual members; the Start button launches from\n    // anywhere. See docs/lobby.md §\"The drill-in\".\n    function handle_custom_input(button: i32) {\n        if button == BUTTON_UP {\n            if sp_cursor > 0 { sp_cursor = sp_cursor - 1 }\n            return\n        }\n        if button == BUTTON_DOWN {\n            if sp_cursor < bundle_visible_count { sp_cursor = sp_cursor + 1 }\n            return\n        }\n        if button == BUTTON_A {\n            if sp_cursor == 0 {\n                launch_singleplayer()\n            } else {\n                // Toggle the whole bundle (all available members). An entirely\n                // undeployed bundle has no available members, so this is a\n                // no-op — the card already reads UNAVAILABLE.\n                toggle_bundle(visible_bundles[sp_cursor - 1])\n            }\n            return\n        }\n        if button == BUTTON_SELECT {\n            // A second, equivalent way into the drill (D-pad RIGHT is the\n            // other). One-member bundles have nothing to expand.\n            open_drill_at_cursor()\n            return\n        }\n        if button == BUTTON_START {\n            launch_singleplayer()\n            return\n        }\n    }\n\n    // Open the drill-in for the bundle under the CUSTOM cursor, when it is a\n    // multi-member bundle with at least one deployed member. Shared by the\n    // SELECT and D-pad RIGHT entry paths (docs/lobby.md §\"The drill-in\").\n    function open_drill_at_cursor() {\n        if sp_cursor <= 0 { return }\n        var b: i32 = visible_bundles[sp_cursor - 1]\n        if bundle_len(b) > 1 && bundle_avail_count(b) > 0 {\n            drill_bundle = b\n            drill_cursor = 0\n            drill_top = 0\n            view = VIEW_DRILL\n        }\n    }\n\n    // Drill-in input: move over the open bundle's members, A toggles the\n    // member under the cursor, B or SELECT backs out to the CUSTOM list, and\n    // Start is still a launch shortcut. drill_bundle is guaranteed multi-\n    // member and >=1-available by handle_custom_input's guard.\n    function handle_drill_input(button: i32) {\n        var n: i32 = bundle_len(drill_bundle)\n        if button == BUTTON_UP {\n            if drill_cursor > 0 { drill_cursor = drill_cursor - 1 }\n            return\n        }\n        if button == BUTTON_DOWN {\n            if drill_cursor + 1 < n { drill_cursor = drill_cursor + 1 }\n            return\n        }\n        if button == BUTTON_A {\n            var m: i32 = bundle_first(drill_bundle) + drill_cursor\n            // Can't toggle an undeployed member — the row draws UNAVAILABLE.\n            if sp_available(m) {\n                sp_selected[m] = !sp_selected[m]\n            }\n            return\n        }\n        // B, SELECT, or D-pad LEFT all back out to the CUSTOM list — LEFT\n        // mirrors the RIGHT-to-open gesture so \"in and out\" is one axis.\n        if button == BUTTON_B || button == BUTTON_SELECT || button == BUTTON_LEFT {\n            view = VIEW_LOBBY\n            drill_bundle = -1\n            return\n        }\n        if button == BUTTON_START {\n            launch_singleplayer()\n            return\n        }\n    }\n\n    event input(button: i32, pressed: bool) {\n        if !pressed { return }\n        // Dormant after a singleplayer launch: do NOT consume — every\n        // button must reach the spawned mods and the running ROM. The\n        // lobby is just a silent root at this point.\n        if launched { return }\n        consume_input()\n        if view == VIEW_NAMING {\n            handle_naming_input(button)\n            return\n        }\n        if view == VIEW_DRILL {\n            handle_drill_input(button)\n            return\n        }\n        handle_lobby_input(button)\n    }\n\n    event receive(data: u8[]) {\n        if len(data) < 1 { return }\n        for (var i: i32 = 0; i < MAX_PLAYERS; i = i + 1) {\n            other_occupied[i] = false\n        }\n        var count: i32 = i32(data[0])\n        var off: i32 = 1\n        // Occupancy only — one slot id per connected player.\n        for (var r: i32 = 0; r < count; r = r + 1) {\n            if off + 1 > len(data) { return }\n            var s: i32 = i32(data[off])\n            off = off + 1\n            if s < 0 || s >= MAX_PLAYERS { continue }\n            other_occupied[s] = true\n        }\n        // Trailing per-mode live counts. Each entry is a little-endian\n        // u16. We only consume what's actually in the frame so older\n        // server builds (or a future shorter packet) degrade to \"0\n        // shown\" rather than reading past the end.\n        for (var i: i32 = 0; i < MODE_COUNT; i = i + 1) {\n            if off + MODE_COUNT_BYTES > len(data) {\n                mode_counts[i] = 0\n            } else {\n                mode_counts[i] = i32(data[off]) + i32(data[off + 1]) * 256\n            }\n            off = off + MODE_COUNT_BYTES\n        }\n        // Trailing availability flags: MODE_COUNT mode bytes then\n        // SP_MOD_COUNT sp bytes (1 = the entry's script is deployed, 0 =\n        // absent). Present on current servers; an older or shorter frame\n        // leaves avail_known false, so every entry stays available rather\n        // than being falsely greyed. See docs/lobby-script-catalog.md.\n        if off + MODE_COUNT + SP_MOD_COUNT <= len(data) {\n            for (var i: i32 = 0; i < MODE_COUNT; i = i + 1) {\n                mode_avail[i] = (i32(data[off + i]) != 0)\n            }\n            off = off + MODE_COUNT\n            for (var i: i32 = 0; i < SP_MOD_COUNT; i = i + 1) {\n                sp_avail[i] = (i32(data[off + i]) != 0)\n            }\n            off = off + SP_MOD_COUNT\n            avail_known = true\n        }\n        clamp_cursors()\n    }\n}\n"}]}