# In-game menus and match setup

Menus inside the game, drawn over it as panels: a shop, an upgrade screen, a command card, and a **match
setup** where the host picks the options (lives, map, fog), each player makes their own picks
(race, class) and players join teams. Three parts:

- `Menu` (`@voxelparty/sdk`): the panel, in the platform's UI style. Mouse, keyboard, gamepad and touch.
- `Setup` (`@voxelparty/sdk/core`): the synced match configuration. Headless, so rules and tests use it.
- `SetupMenu` (`@voxelparty/sdk`): the screen for a `Setup`, built on `Menu`.

## Contents
1. `Menu`
2. `Setup`
3. `SetupMenu`
4. Recipe: a match-based standalone game
5. Bots and `vp check`
6. Drop-in rules
7. Testing
8. Pitfalls

---

## 1. `Menu`

```ts
import { Menu } from '@voxelparty/sdk';

// B opens it and closes it again (a key needs `keyboard` in game.json's input)
const shop = new Menu({ title: 'Tower shop', sub: '120 gold', key: 'KeyB' });
const towers = shop.cards('tower', {
  label: 'Build',
  choices: TOWERS.map((t) => ({ value: t.id, label: t.name, blurb: `${t.cost} gold`, icon: icons[t.id], disabled: gold < t.cost })),
  keep: true,                                   // a shop: clicking buys, it doesn't select
  onChange: (id) => buy(id),
});
shop.toggle('auto', { label: 'Auto-upgrade', value: false, onChange: (on) => (autoUpgrade = on) });
shop.button('done', { text: 'Done', onClick: () => shop.close() });

shop.onOpen(() => towers.update({ choices: … }));   // fresh prices whenever it opens
// later: rows re-render only when their options change
towers.update({ choices: … });
shop.setTitle('Tower shop', `${gold} gold`);
// dispose(): shop.dispose();
```

| Row | Call | Value |
|---|---|---|
| Segmented buttons | `menu.choice(key, { label, choices, value, onChange })` | one of `choices` |
| Card grid | `menu.cards(key, { label, choices, value?, columns?, compact?, keep?, onChange })` | one of `choices` (or null) |
| Switch | `menu.toggle(key, { label, value, onChange })` | boolean |
| Stepper | `menu.number(key, { label, value, min, max, step?, format?, onChange })` | number |
| Button | `menu.button(key, { text, tone?: 'go' \| 'warn' \| 'plain', onClick })` | |
| Text | `menu.text(key, 'Plain text')` or `{ text \| html, tone?: 'head' \| 'muted' }` | |
| Players | `menu.people(key, { people: [{ name, color, tag?, me? }] })` | |

- Every row takes `label`, `hint` (a small line), `disabled` (greyed) and `readOnly` (shows its
  value, can't be changed: "the host picks"). A choice is a bare value (`20`) or
  `{ value, label?, blurb?, icon?, color?, badge?, disabled?, people? }`.
- **Icons:** `icon` is any image URL: `textureDataURL('gold')` (small images stay pixel-sharp) or a
  rendered model, `ctx.engine.icon(mesh)`. Make icons once, not every time you update a row.
- Each call returns a `MenuRow`: `row.value`, `row.set(v)` (quietly), `row.update({ … })`,
  `row.show(on)`, `row.remove()`. Adding a key that exists replaces that row in place.
  `menu.row(key)`, `menu.remove(key)`, `menu.clear()`.
- **Compact cards** (`compact: true`): small cards with the icon beside the name and blurb,
  left-aligned, about 150 px wide. For a shop with a dozen items that should fit a 720p screen.
- `menu.open()`, `close()`, `show(on)`, `isOpen`, `setTitle(title, sub?)`, `closable`, `key`,
  `onChange((key, value) => …)`, `onOpen(cb)` (just before it shows: fill in rows), `onClose(cb)`, `dispose()`.
- Options:
  - `title`, `sub`; `closable` (Esc, ✕ and a click beside it close it; default true);
  - `modal` (default true; see below);
  - `at: 'centre' | 'left' | 'right' | 'bottom'`, `width` (px, default 460);
  - `key: 'KeyB'` (or several): opens it, and closes it again. It opens only while no modal menu is up
    and `keyWhen()` (if given) says yes: `keyWhen: () => inShopZone`. It closes only a closable menu.
    Don't also read that key in your game. `menu.key = null` turns it off;
  - `pad: 'y'` (or several, or null for none): the pad button that opens and closes it, like `key`.
    Default: the pad button your `buttons` gave its key (`buttons: { shop: { keys: ['KeyB'] } }`
    and `key: 'KeyB'` share one), else VIEW. Two keyed menus without buttons both want VIEW: the
    older one gets it, so give the other its own `pad`;
  - `columns: 2`: choices, switches and steppers side by side (headings, cards, buttons and text span
    the width); one column on a phone. With `width` around 700, for a tall setup;
  - `compact: true`: tighter gaps, labels and buttons all round;
  - `className: 'my-shop'`: your class on the menu's root (`.kmenu`), to restyle it from your own CSS
    (`.my-shop .kmenu-card { … }`).

**Modal** (the default) is what a shop or a setup wants. While it's open:
- the pointer is unlocked (pointer-lock games), and taken back on the click or key that closes
  the menu; if it closes by itself (the host started the match), the player's next click on the
  game locks it again (`defineGame({ pointerLock: true })` does that);
- the game's input reads idle: `input.blocked` is true, and every action, key, mouse button, the
  wheel and `look()` read as nothing; presses during the menu never count afterwards;
- the arrows (or WASD) move between buttons, Enter or Space presses one, Esc (or its `key`) closes it;
  left and right change a choice, a stepper or a switch in place;
- a gamepad steers it the same way: the d-pad or left stick moves (held, it repeats), A presses, B
  (or its pad button) closes, LB / RB jump a row up or down. A ring shows the focus while they steer
  with keys or a pad, and a menu that opens while they're on a pad shows it at once;
- clicks never reach the game (the page behind is dimmed).

**Not modal** (`modal: false`): a panel beside the game that you use *while playing*: a shop in a
MOBA, a command card, a build bar. The game keeps all of its input: nothing is held, the pointer
stays as it is, and keys go to the game. Clicks on the panel are the panel's (they never reach the
game); clicks beside it are the game's. Its buttons never keep the keyboard's focus, so Space and
Enter stay your jump and your cast.

```ts
// A shop you can use mid-fight: B toggles it, at the side, only near your fountain.
this.shop = new Menu({ title: 'Shop', modal: false, at: 'left', width: 420, key: 'KeyB', keyWhen: () => this.atFountain() });
this.shop.cards('items', { choices: [], compact: true, keep: true, columns: 2, onChange: (id) => this.buy(id) });
this.shop.onOpen(() => this.shop.row('items')?.update({ choices: this.itemCards() }));
// update(): leave the fountain and it closes
if (this.shop.isOpen && !this.atFountain()) this.shop.close();
```

## 2. `Setup`

```ts
import { Setup } from '@voxelparty/sdk/core';

const setup = new Setup(link, {
  options: {                                                       // the leader sets these
    lives: { label: 'Lives', choices: [20, 30, 50], default: 30 },
    map: { label: 'Map', choices: [{ value: 'small', label: 'Small' }, { value: 'large', label: 'Large' }], default: 'small' },
    speed: { label: 'Creep speed', min: 0.5, max: 2, step: 0.25, default: 1, format: (v) => `${v}×` },
    fog: { label: 'Fog of war', default: false },
    teams: { label: 'Teams', choices: [{ value: 2, label: '2 teams' }, { value: 0, label: 'Free for all' }], default: 2 },
  },
  picks: {                                                         // each player's own
    race: { label: 'Race', default: 'ice', choices: [
      { value: 'ice', label: 'Ice', blurb: 'Slows', icon: iceIcon },
      { value: 'fire', label: 'Fire', blurb: 'Burns', icon: fireIcon },
    ] },
  },
  teams: { count: (o) => o.teams, names: ['Red', 'Blue'] },       // or a number; below 2 = no teams
  autoStart: 30,                                                   // optional: starts by itself after 30 s
});
```

A setting is a **choice** (`choices` + `default`), a **range** (`min`, `max`, `step?` + `default`)
or a **toggle** (just a boolean `default`), each with a `label` and an optional `hint`. Values are
typed from the spec: `setup.options.lives` is a `number`, `setup.pick(pid).race` a `string`.

| Read | |
|---|---|
| `setup.phase` | `'setup'` (choosing) or `'play'` (a match is on) |
| `setup.options` | the match options (defaults until set); the same object until something changes |
| `setup.pick(pid?)` | a player's picks (default: yours), defaults for anything not chosen |
| `setup.teams`, `setup.team(pid?)`, `setup.members(t)`, `teamName(t)`, `teamColor(t)` | team count (0 = none), a player's team (-1 = none), who's on one |
| `setup.match` | matches started so far (1 during the first) |
| `setup.leader`, `setup.canEdit` | who sets the options (the host's player, or the first player if the host isn't playing; null with only CPUs), and whether that's you |
| `setup.startsAt` | when it starts by itself (`link.now()` time), or null |
| `setup.leaderAway` | the leader is still on the title card: `autoStart` waits for them |
| `setup.synced` | this client has the host's state (a joiner, for a moment, doesn't) |
| `setup.asking` | host: still asking the room whether anyone has the state (a reload; at most a second) |
| `setup.fresh` | this client started the setup from scratch because nobody had one: a new session, so no kept world (`link.onKept`) is coming, and a game that waits for one can start at once |

| Change | Who | |
|---|---|---|
| `setup.set(name, value)` | the leader, or host code | a match option |
| `setup.choose(name, value, pid?)` | anyone for themselves (any time); host code for anyone (CPUs) | a pick; yours shows at once |
| `setup.joinTeam(t, pid?)` | anyone for themselves, during the setup, if teams stay within one of each other; the leader (or host code) for anyone, any time | a team |
| `setup.start()` / `setup.reopen()` | the leader, or host code | play / back to the setup; picks and teams stay |
| `setup.looking(on)` | the leader (`SetupMenu` does it for you) | whether they can see the setup; `autoStart` waits while they can't |

All return false when you may not (or the value isn't one of the setting's). Invalid values are
never sent. `setup.onStart(cb)` runs when a match starts on this client (also for someone who
joins while one is on); `setup.onChange(cb)` after anything changes (options, picks, teams, the
phase, the leader, who's here). Both return an unsubscribe. **Call `setup.update()` every frame**:
it sends what's waiting, retries, takes over when this client becomes host, and starts on time.

**Netcode**, so you don't have to: host-authoritative over `link.sendEvent`. The host keeps the
whole state and broadcasts it when it changes (at most 10 a second) and when someone joins;
everyone else sends requests and sees their own picks at once. Every client keeps the latest
state, so when the host leaves the next one carries on from it: options, picks, teams, the
countdown. Leavers are dropped from teams, joiners get the default picks and the smallest team,
CPUs get picks from the host. Everything from the network is checked against the spec; garbage
is ignored. Offline and solo, you are the host and the leader.

## 3. `SetupMenu`

```ts
import { SetupMenu } from '@voxelparty/sdk';

this.setupMenu = new SetupMenu(this.setup, { players: ctx.players, flow: ctx.flow, title: 'Tower Wars' });
```
- Opens by itself while `setup.phase === 'setup'`, once play has started (after the title card's
  "Click to play"; pass `flow` so it waits), and closes when the match starts.
- The leader sees the options to edit and **Start** (with the countdown, if any); everyone else
  sees them read-only ("Ana sets up the match") and "Waiting for Ana to start…". Everyone playing
  sees their picks (choices with a blurb or an icon as cards) and the teams: a card per team with
  its players, click one to join it. Without teams, a row of who's in. Names and colours come from
  `players` (`ctx.players`), with CPUs tagged.
- **Mid-match:** `setupMenu.show()` opens your picks (options and teams read-only) with Done, so a
  joiner can change the defaults: give it a key (`key: 'KeyP'` opens and closes it) and say so in a hint.
- Options: `players`, `flow`, `title`, `startText` (default 'Start'), `auto` (default true; false:
  you call `show()`), `key` (opens your picks mid-match, and closes them), `menu` (`{ at, width, columns, compact, className }`). `dispose()` with the game.
- **Rows of your own:** `setupMenu.menu` is the `Menu`. Add rows to it once (a how-to-play line,
  your record, a personal key layout) and they stay, whatever the setup does: they sit between the
  setup's rows (options, picks, teams) and its Start button. Update them with `menu.row(key)?.update(…)`.
- **A tall setup** (8 options and picks): `menu: { width: 720, columns: 2 }` puts the options side by
  side (one column on a phone); add `compact: true` for tighter rows.
- **The auto-start waits for the leader.** While the leader is on the title card their menu tells the
  setup they're away (`setup.looking(false)`): `autoStart`'s countdown waits, and runs from the top
  once they click through. Everyone else sees "Waiting for Ana to start…". A setup screen of your own
  instead of `SetupMenu`: call `setup.looking(shown)` from it every frame.

## 4. Recipe: a match-based standalone game

Matches inside one endless run: setup → play → results → setup again, and nobody ever leaves the
game. (`flow.end` would end the run instead: the next run is a new `create()`, and so a new setup
from the defaults.)

```ts
// rules.ts: headless
import { Setup, mulberry32, type MinigameLink } from '@voxelparty/sdk/core';

export const SETUP = {
  options: {
    lives: { label: 'Lives', choices: [20, 30, 50], default: 30 },
    map: { label: 'Map', choices: [{ value: 'small', label: 'Small' }, { value: 'large', label: 'Large' }], default: 'small' },
  },
  picks: { race: { label: 'Race', default: 'ice', choices: RACES } },
  teams: { count: 2 },
};
export const makeSetup = (link: MinigameLink) => new Setup(link, SETUP);

export class Match {
  constructor(readonly o: { lives: number; map: string }, seed: number) { this.rand = mulberry32(seed); … }
  addPlayer(pid: string, race: string, team: number) { … }   // at the start, and for joiners
  over(): number | null { … }                                // the winning team, once decided
}
```
```ts
// game.ts
class TowerWars implements GameStage {
  readonly view: ArenaStage;
  private setup: ReturnType<typeof makeSetup>;
  private setupMenu: SetupMenu<typeof SETUP.options, typeof SETUP.picks>;
  private match: Match | null = null;
  private tally = new Map<number, number>();      // wins per team, this run
  private nextAt = 0;                             // host: when to reopen the setup (link time)

  constructor(private ctx: GameContext) {
    this.view = arenaStage(ctx.engine, { … });
    this.setup = makeSetup(ctx.link);
    this.setupMenu = new SetupMenu(this.setup, { players: ctx.players, flow: ctx.flow, title: 'Tower Wars', key: 'KeyP' });   // P: change your race mid-match
    this.setup.onStart(() => this.startMatch());   // everyone, including joiners mid-match
  }

  private startMatch() {
    const { link } = this.ctx, s = this.setup;
    // The same map on every client for match N: seed from the run's seed and the match number.
    this.match = new Match(s.options, link.seed ^ (s.match * 0x9e37));
    for (const p of link.players) this.match.addPlayer(p.id, s.pick(p.id).race, s.team(p.id));
    this.nextAt = 0;
  }

  onPlayers(players: readonly LinkPlayer[], joined: readonly LinkPlayer[]) {
    // Drop-in: a joiner plays the running match with the default picks and the smallest team.
    for (const p of joined) this.match?.addPlayer(p.id, this.setup.pick(p.id).race, this.setup.team(p.id));
  }

  update(dt: number) {
    const { flow, link, input } = this.ctx, s = this.setup;
    s.update();
    if (s.phase !== 'play' || !this.match || !flow.live) return this.drawLobby(dt); // the map, behind the menu
    // … play the match (host-authoritative: vp docs netcode) …
    const winner = this.match.over();
    if (link.isHost && winner !== null) {
      if (!this.nextAt) {
        this.nextAt = link.now() + 5000;             // in the snapshot too, so a new host keeps it
        this.tally.set(winner, (this.tally.get(winner) ?? 0) + 1);
        flow.hud.banner(`${s.teamName(winner).toUpperCase()} WINS!`, s.teamColor(winner));   // clients: from the snapshot
      } else if (link.now() >= this.nextAt) s.reopen();   // everyone back to the setup; picks and teams stay
    }
  }

  dispose() { this.setupMenu.dispose(); this.setup.dispose(); this.view.dispose(); }
}
```
- `setup.onStart` builds the match on every client from the same options and seed; the host then
  runs it as usual. A player's pick can change mid-match (a joiner changing from the default):
  read picks when they matter (a spawn), and copy them into your world.
- Between matches, keep the scoreboard (`this.tally`) in the host's snapshot like any host state.
- While the setup is up the menu has the input: draw the arena behind it (a preview of the map
  the options describe is a nice touch, `setup.onChange` redraws it).

## 5. Bots and `vp check`

- **CPUs' picks** are made by the host when a CPU turns up: `bots: 'random'` (default: a random
  choice for each choice pick), `'default'`, or your own `bots: (pid, setup) => ({ race: … })`
  (checked like any pick). Host code can also `setup.choose('race', 'fire', cpuPid)` any time.
- CPUs go onto teams like anyone (the smallest team).
- **Nobody to press Start** (only CPUs; `vp check` runs every seat as a CPU with `link.you` null):
  the setup starts by itself after `botStart` seconds (default 2), so automated checks play
  matches. It's cancelled if a player turns up.
- `autoStart: n` starts by itself `n` seconds after the setup opens (and after each `reopen`), so a
  room isn't stuck on a leader who sits in the setup without pressing Start. Everyone sees the
  countdown. It waits while the leader is still on the title card (section 3), and a leader who
  leaves hands over to the next one, whose countdown starts at once.
- **A host that reloaded** waits a moment for its kept world (`link.onKept`) before starting a
  match, or it would wipe the scores. A new session has none coming: skip the wait when
  `setup.fresh` (`if (!kept && !setup.fresh && now - born < 4000) return;`).

## 6. Drop-in rules

A setup menu is fine in a match-based game as long as (design.md §0):
- **Joiners drop straight in.** Someone who joins mid-match plays at once with the default picks
  and the smallest team (`onStart` runs for them, `onPlayers` too). Never make them wait for the
  next match to play; let them change their picks with `setupMenu.show()`.
- **A solo player isn't kept waiting.** Alone, you're the leader: the menu's Start is right there.
  Keep the setup short (a few options, playable defaults), and consider `autoStart`.
- **Only CPUs start by themselves** (section 5).
- The defaults are a good game. Options are for variety, not required reading.

## 7. Testing

`Setup` is headless: drive it in `FakeRoom` like the rest of your netcode (`vp docs netcode` §6).
```ts
const room = new FakeRoom({ mg: { id: 'td' }, seed: 1, players: [{ id: 'p0' }, { id: 'p1' }, { id: 'c0', cpu: true }], clients: ['p0', 'p1'] });
const setups = room.links.map((l) => makeSetup(l));
const run = (ms: number) => room.run(() => setups.forEach((s) => s.update()), { until: room.now + ms });
run(500);
setups[1].choose('race', 'fire');                 // a client's pick
run(500);
expect(setups[0].pick('p1').race).toBe('fire');
setups[0].start();
run(500);
expect(setups[1].phase).toBe('play');
room.leave('p0');                                  // the host leaves: p1 carries on, and leads now
run(1500);
expect(setups[1].canEdit).toBe(true);
```
Worth asserting: clients' picks reach the host; a joiner gets the state, defaults and a team; a
leaver's team shrinks; after the host leaves the options survive; with only CPUs it starts.

## 8. Pitfalls

- **`setup.update()` every frame**, or nothing auto-starts and a new host never takes over.
- **Don't cache `isHost` or `canEdit`**: the host (and the leader) can change mid-setup.
- **One run, many matches.** Each run is a new `create()` and a new `Setup` at the defaults. Use
  `reopen()` between matches rather than `flow.end`, unless a fresh setup per run is what you want.
- **Seed per match** (`link.seed ^ setup.match`), so every client builds the same map, and match 2
  isn't match 1 again.
- **No lobby inside the lobby.** Don't hold joiners in a waiting screen; don't ask players to ready
  up in your menu (the platform's lobby did that). Start, play, reopen.
- **Picks change** (a joiner changes their race mid-match): read them when they matter.
- **Menus hold the input.** While any modal `Menu` is open, `input` reads idle (`input.blocked`):
  don't treat that as the player standing still on purpose, and close your menus when a match
  starts or the run ends. Don't open a modal menu over the title card (`SetupMenu` waits for
  `flow.live`).
- **A key that opens a menu goes in its `key` option**, not in your `update()`: a modal menu holds
  the input, so `input.keys.pressed('KeyO')` never sees the press that should close it again. It
  needs `keyboard` in game.json's `input`. The menu's own keys (arrows, WASD, Enter, Space, Esc) are
  handled while it's open.
- **Text is escaped**, except `menu.text(key, { html })`: escape player names you put in it (`esc()`).
- **Sizes.** Up to 16 players, a handful of options and picks: the state is well under 2 KB. Don't
  put a map or a replay in the options; seed them instead.
