# Custom controllers

Give each player the controller your game needs: a buzzer for a quiz, paper for the artist, a covered role card, or a keypad for a vault. Compose it from parts and Controller+ draws it in the player's finish. The controller fills the phone screen, with no HTML forms, scrolling or system keyboard.

![Ink Heist drawing controller with a private word, tools and paper](/assets/surfaces/artist-720.webp)

Send the view with `room.view(playerId, view)` or `host.setView(playerId, view)`. Import builders from `@controller-plus/sdk/surface`, or `https://controller.plus/sdk/src/surface.mjs` in a browser. [Surface reference](sdk-surface.md) covers every exported function.

## Describe a controller

`surface({ id, root, turn?, accent?, minApp? })` wraps a part tree as `{ kind: 'surface', surface: { v: 1, id, root, turn?, accent? } }`. Set `root` to a part, row or column. The optional `minApp` is copied onto the view but does not enforce a version gate; use `createRoom({ requires })` when the game truly requires an app update.

Keep `id` stable while updating a timer, score or prompt within the same interaction. A new ID resets typed text, selected toggles and local drawing. Include the round/phase in the ID when those should reset. Part paths identify locations; avoid moving active controls around while someone is using them.

`turn: { you, name, color }` shows whose turn it is. `you: true` lights the local turn indicator/glow. Name is at most 24 characters; color defaults to `#5ee08a`. `accent` colors supported highlights, including the keyboard's submit key. It does not replace the player's chosen finish. Colors use `#RRGGBB` or `#RRGGBBAA`.

## Layout

The renderer works in design units, with a 450-unit height. Rows split horizontally; columns split vertically. Safe areas leave space around notches and the home indicator. A canvas keeps its aspect ratio and centers in its slot. Parts do not create scroll views.

| Common option | Default/bounds | Effect |
| --- | --- | --- |
| `id` on a part | Optional, ≤40 characters | Descriptive part ID; layout paths still determine hit locations. |
| `weight` | 1; 0…50 | Share of remaining space along the parent's axis. |
| `size` | Absent; if supplied 8…2000, invalid number becomes 40 | Fixed extent along the parent's axis; takes precedence over weight. A row child's size is width; a column child's size is height. |
| `gap` on row/column | 14; 0…80 | Space between children in design units. |
| `children` on row/column | Required array, first 12 used | Nested parts; empty groups disappear. |

`spacer({ size?, weight? })` reserves empty space. Fixed children are allocated before weighted children. Choose modest fixed sizes and leave most area flexible. Huge gaps, too many keys, or oversized fixed children can defeat useful layout even after normalization. Test small screens; the tree limit is not a recommendation to fill the screen with 160 controls.

Include at least one LCD. The topmost LCD receives the single MENU key; if several are level, the rightmost wins. Do not create a second game key labeled MENU. A surface without an LCD has no automatically placed MENU key.

## LCD

`lcd(options = {})` is the controller's display. All common layout options apply.

| Option | Default/bounds | Effect |
| --- | --- | --- |
| `title` | `''`, ≤40 characters | Small heading. |
| `text` | `''`, ≤60 | Main line. |
| `sub` | `''`, ≤90 | Supporting line. |
| `timer` | Optional `{ left, total }` | Seconds remaining/total. Left 0…86400 (default 0), total 1…86400 (default 60). Counts down locally from the last received view; host remains authoritative. |
| `meter` | Optional `{ label, value, max }` | Label ≤12; value 0…1e9 (default 0); max 1…1e9 (default 1). Shows ink, charge or other progress. |
| `tips` | Up to 12 nonempty strings, ≤60 each | Rotating tips; browser advances every 4.5 seconds. |
| `flash` | Optional `{ text, tone, id }` | Text ≤30; ID ≤40 (defaults to text); tone defaults normal. Browser shows an unseen ID once for about 1.4 seconds. Use a new ID for each occurrence. |
| `entry` | False | Show the surface keyboard's current text or slots. The keyboard must also be present. |
| `pages` | Optional `{ index, count }` | Page dots; index rounded/clamped 0…63 (default 0), count 1…64 (default 1). Game must change the content and page index. |
| `secret` | False | Cover the screen until the player holds it. The data is still on that player's phone. |
| `cover` | `'Hold to see'`, ≤40 | Text on a secret cover. |
| `tone` | Normal when absent | `normal`, `good`, `bad`, `alert`, `dim`; changes text emphasis. |

One controller has shared text-entry state. Use one keyboard and one entry LCD per interaction. Browser flash state is also shared across LCDs, so send one flash at a time. Changing a surface ID does not clear the browser's set of previously seen flash IDs.

## Keys

`keys(list, { shape = 'round', columns?, ...layout })` creates a tray. Up to 32 keys, 1–8 explicit columns, or automatic columns. Each key has its own options:

| Key option | Default/bounds | Effect |
| --- | --- | --- |
| `id` | `'key-' + index`, ≤40 | Stable key identity. Use distinct IDs within the surface. |
| `label` | `''`, ≤14 | Printed text. Keep short enough to read at the smallest target size. |
| `icon` | Optional recognized name | Draw one of the icons listed below. Unknown names disappear. |
| `color` | Optional hex | Key color. |
| `fill` | False; requires a valid color | On a screen key, fill the keycap with that color instead of only tinting its lit contents. The browser controller and the iOS app draw it; an app without it shows the lit display. |
| `badge` | None; 1 to 12 characters after trimming | A short live value drawn as a pill across the top of the key, such as `LIVE 1:02:11`, `REC`, a count or a countdown. Screen readers read it as the key's value. |
| `badgeTone` | Normal; `"alert"` or `"good"` | The badge's colour: red for alert (on air, recording), green for good, dark otherwise. |
| `gestures` | None; a list of `"hold"` and `"double"` | Gestures the key also answers. With `hold`, a press longer than 450 ms sends the event once with `payload.gesture: "hold"` and the release sends nothing. With `double`, two taps within 280 ms send one event with `payload.gesture: "double"`; a single tap is sent after that window. Taps keep their payload unchanged. |
| `event` | Optional string, truncated to 32 | Event sent on release over the same key. Must also satisfy protocol event-name syntax. |
| `payload` | Object, with an `event` | Extra event data. Keep small, JSON-safe and nonsecret unless intended for this phone. |
| `button` | Integer 0…15 | Hold a standard gamepad button down while touched. Emits input, not a custom event. Prefer this alone, rather than combining with event/tool behavior. |
| `toggle` | Optional string ≤20 | Selection group. Press selects this key locally; selecting another key replaces it. |
| `value` | Optional, stringified ≤40 | Value sent by a confirm key; defaults to the selected key's ID. Tool values retain their own types. |
| `selected` | False | Initial toggle selection, or a host-controlled visual highlight for a key without a toggle. Same-ID updates preserve local toggle choices. |
| `send` | Optional group name ≤20 | Confirm key sends its `event` with `{ ...payload, target: selected.value ?? selected.id }`. Disabled until a selection exists. |
| `disabled` | False | Ignore presses and draw disabled. Still validate on the game host. |
| `tool` | Optional `color`, `width`, `undo`, `clear` | Control the first canvas on the surface. Color/width selection stays local; undo/clear sends an event. |
| Tool `value` | Hex color, or width clamped 1…24 (default 4) | Ink or brush width. Use `drawingTools` for consistent tool keys. |
| Key `size` | Optional 1…24 (default 4 if invalid) | Brush-dot visual size; does not allocate the key's layout rectangle. |
| `image` | Optional data URL ≤90,000 characters | Normalizer accepts base64 PNG/GIF/JPEG/WebP. Current browser key painting does not render this field. Use labels/icons as the functional content. View transport limits still apply. |

### Shapes

| Shape | Effect |
| --- | --- |
| `round` | Circular rubber keys. A lone key grows into a buzzer; multiple keys pack into a cluster. |
| `square` | Rectangular keys with a capped height, suitable for digits. |
| `tile` | Broad labeled selection keys, useful for answers and player names. |
| `screen` | Square display-style keys, useful for a dial's current value. |
| `paddle` | Rectangular keys that use the available height, useful for large actions. |

### Icons

All accepted names: `undo`, `clear`, `check`, `cross`, `back`, `next`, `play`, `pause`, `up`, `down`, `left`, `right`, `star`, `heart`, `eye`, `lock`, `bell`, `mic`, `mic-off`, `speaker`, `speaker-off`, `volume-up`, `volume-down`, `plus`, `minus`, `skip-next`, `skip-prev`, `refresh`, `gamepad`, `tab`, `home`, `camera`, `chat`, `dot`.

Icons name appearance, not an OS action. A mic icon does not request a microphone permission. Game events do not authorize mouse, keyboard or system control.

## Canvas

`canvas(options = {})` creates drawing paper. `drawingTools({ colors = [], widths = [], undo = true, clear = true } = {})` returns ready-made keys. First color and middle width start selected. Put tools beside the canvas in a row.

| Option | Default/bounds | Effect |
| --- | --- | --- |
| `aspect` | 1.5; 0.5…3 | Width divided by height. |
| `event` | `'stroke'`, ≤32 characters | Stroke event name. |
| `enabled` | true | False blocks new drawing. |
| `clearEvent` | Absent; renderer uses `'clear'` | Clear-tool event name, ≤32. |
| `undoEvent` | Absent; renderer uses `'undo'` | Undo-tool event name, ≤32. |
| `ink` | null or `{ value, max }` | Value 0…1e6 (default 0), max 1…1e6 (default 1). Drawing blocks at zero; host must enforce the actual ink budget. |

The host can publish `{ canvas: [{ points, color, width, stroke? }, ...] }`. The browser restores these strokes when its local drawing is empty and clears local strokes when the shared array becomes empty. It does **not** reconcile every nonempty replacement into an already drawn canvas. This supports returning artists and clear operations; it is not a general concurrent drawing synchronizer. Keep one active canvas per surface, bound stored strokes, and render the authoritative drawing on the shared game screen.

## Keyboard

`keyboard(options = {})` is drawn in the controller. Pair it with `lcd({ entry: true })`. No browser input element or system keyboard is opened.

| Option | Default/bounds | Effect |
| --- | --- | --- |
| `language` | `'auto'`, ≤20 characters | Resolve a layout tag. Auto follows player language; unsupported layouts fall back to English. |
| `charset` | `'letters'` | `letters`, `text` (space/punctuation), or `digits`. |
| `maxLength` | 24; 1…64 | Maximum visible characters in editor state. Use integer values. |
| `slots` | Absent; when supplied 1…24, invalid becomes 6 | Fixed letter boxes and entry length. In browser it takes precedence over maxLength. |
| `submit` | `{ event: 'submit', label: 'ENTER' }` | Event ≤32 characters, label ≤10. Sends `{ text }` after trimming; empty text is not sent. Browser clears entry after submit. |
| `enabled` | true | False disables typing. |

There are 65 layout definitions, with Hangul composition, kana pages and Indic signs. Chinese is pinyin letters without Hanzi candidates. The data API's alternate characters are not exposed by every touch renderer. See [Languages and composition](sdk-keyboards.md). The host must validate text independently and should not assume every language can be entered with every client UI.

![Name and guess entry using a drawn keyboard and LCD slots](/assets/surfaces/guess-720.webp)

## Trackpad

`trackpad({ event = 'pointer', clickEvent = 'click', scrollEvent = 'scroll', enabled = true, ...layout } = {})`. Event names truncate to 32 characters. One finger moves; a short tap clicks; two fingers scroll or right-click; holding then moving drags. Browser hold threshold is 450 ms. Trackpad parts send game events only. Interpret them inside the game, or use the desktop app's separately approved system-control path.

## Events and payloads

The game receives `{ playerId, event, payload }` through `room.on('event', ...)` or `host.on('event', ...)`. The event name is the name you supplied on the part/key.

| Action | Payload |
| --- | --- |
| Ordinary event key | Supplied `payload`, or `{}`. Fires on release over the same key; canceled press does not activate. |
| Confirm/send key | `{ ...payload, target: selected.value ?? selected.id }`. Target is normally a string. |
| Toggle without event | No host event. Selection stays local until a send key commits it. |
| Held `button` key | No custom event. Corresponding `input.buttons[index]` is 1 while held, 0 after release. |
| Canvas stroke | `{ points: [[x,y], ...], color, width, stroke }`. Coordinates 0…1, rounded to three decimals; up to 20 points per browser chunk, overlapping one point between chunks. Stroke counter is local to the surface. No final/end flag. |
| Canvas undo | `{ stroke: number \| null }`. Remove all authoritative chunks of that player's last stroke. |
| Canvas clear | `{}`. |
| Keyboard submit | `{ text }`. Whitespace trimmed. |
| Trackpad move | `{ dx, dy }`, browser integer deltas capped to ±200 per animation-frame batch. |
| Trackpad scroll | `{ dx, dy }`, browser integer deltas capped to ±50. |
| Trackpad click/drag | `{ button: 0 \| 2, down: boolean }`. A click sends down then up. |

Secret reveal, page dots and the turn lamp send no game event. Trackpads, keyboards and drawing tools maintain local state; do not combine several of each on one controller without checking the client's behavior.

## Compatibility

`SURFACE_APPS` marks iOS/Android 1.0.0 as the first surface versions. Browser/unspecified clients qualify; unknown platforms do not. `room.view` and `host.setView` call `viewFor` for the selected phone. Supply `view.legacy` if an older app can still play. If absent, it gets a waiting screen asking for an update.

Generic `choices` and `waiting` views automatically become surfaces for capable phones. Other kinds pass through. A secret cover hides information visually; it is not encryption against the owner of that phone. Never send another player's secret in hidden fields, public state or a legacy fallback.

## Limits

| Item | Limit |
| --- | --- |
| Transport | 14,000 bytes per private view; 16,384 bytes for the enclosing client command. A legacy view is selected before sending, but keep both branches bounded. |
| Surface | Version 1; ID ≤60; 160 parts plus keys; root depth 0 through 6. |
| Group | First 12 children; gap 0…80; weight 0…50; optional size 8…2000. |
| Key tray | First 32 keys; 1–8 explicit columns. |
| Text/numbers | Truncated/clamped to the option-table bounds. Invalid nodes are dropped. |
| Shared canvas | Must fit the host's public-state budget: 14,000 bytes on relay, 180,000 on direct Node host. Bound points/chunks yourself. |

Use lowercase protocol-safe event names, even though surface normalization only truncates them. A visually disabled key is not authorization. Reject stale-phase submissions, duplicate votes and unauthorized drawings on the host.

## Quiz buzzer

This complete module gives every joining phone one buzzer. The first valid buzz wins; all phones then see the result. Call `nextQuestion()` to reset. It is intentionally a single-question buzzer, ready to connect to your own question display.

![Quiz controller with four answer keys](/assets/surfaces/quiz.webp)

The pictured answer pad uses the same LCD and key parts. The example below uses one round buzzer.

<!-- picture: Quiz buzzer with one round BUZZ key beneath the question LCD. -->

<!-- example: quiz -->
```js
import { createRoom } from '@controller-plus/sdk/relay-host';
import { surface, column, lcd, keys } from '@controller-plus/sdk/surface';
export const room = await createRoom({ game: { id: 'quiz-example', name: 'Quiz buzzer' }, localControllers: false });
export let winner = null;
let round = 1;
function show(id) {
  const name = winner ? room.players.get(winner)?.name || 'A player' : '';
  const view = surface({ id: `quiz/${round}/${winner || 'open'}`, root: column([
    lcd({ size: 100, title: `Question ${round}`, text: winner ? `${name} buzzed first` : 'Ready to answer?' }),
    keys([{ id: 'buzz', label: 'BUZZ', event: 'buzz', color: '#d93a3f', disabled: winner !== null }]),
  ]) });
  view.legacy = { kind: 'choices', title: 'Quiz', text: winner ? `${name} buzzed first` : 'Ready?',
    submitted: winner !== null, choices: [{ id: 'buzz', label: 'Buzz', event: 'buzz' }] };
  room.view(id, view);
}
room.on('join', ({ playerId }) => show(playerId));
room.on('event', ({ playerId, event }) => {
  if (event !== 'buzz' || winner !== null || !room.players.get(playerId)?.connected) return;
  winner = playerId;
  for (const id of room.players.keys()) show(id);
  room.feedback(playerId, 'ui.confirm');
});
export function nextQuestion() { winner = null; round++; for (const id of room.players.keys()) show(id); }
```

## Drawing pad

This Ink Heist-style round makes the first player the artist and the rest guessers. Only the artist receives the word. It checks authorship, point ranges, ink, colors and widths. A conservative point budget keeps shared drawing data small enough for the relay. Call `newDrawing()` for a new round.

<!-- example: drawing -->
```js
import { createRoom } from '@controller-plus/sdk/relay-host';
import { surface, column, row, lcd, keys, canvas, keyboard, drawingTools } from '@controller-plus/sdk/surface';
export const room = await createRoom({ game: { id: 'drawing-example', name: 'Drawing round' }, localControllers: false });
const colors = ['#292d37', '#e7604e', '#417ac5'];
export let artist = null;
export let strokes = [];
let round = 1, word = 'rocket', pointsLeft = 200;
const solved = new Set();
function show(id) {
  const mine = id === artist;
  const root = mine ? column([
    lcd({ size: 92, title: 'Your word', text: word, meter: { label: 'Ink', value: pointsLeft, max: 200 } }),
    row([keys(drawingTools({ colors, widths: [2, 4, 8] }), { size: 220, columns: 3 }),
      canvas({ aspect: 1.5, ink: { value: pointsLeft, max: 200 } })]),
  ]) : column([
    lcd({ size: 100, title: solved.has(id) ? 'Correct!' : 'Guess the drawing', entry: true }),
    keyboard({ charset: 'letters', maxLength: 24, enabled: !solved.has(id), submit: { event: 'guess', label: 'GUESS' } }),
  ]);
  room.view(id, { ...surface({ id: `drawing/${round}/${mine ? 'artist' : 'guess'}`, root,
    turn: { you: mine, name: room.players.get(artist)?.name || 'Artist', color: '#e7604e' } }),
    legacy: { kind: 'waiting', title: 'Drawing round', text: 'Open this invite in a browser to draw or guess' } });
}
function publish() { room.state({ canvas: strokes }); for (const id of room.players.keys()) show(id); }
room.on('join', ({ playerId }) => { artist ??= playerId; show(playerId); room.state({ canvas: strokes }); });
room.on('event', ({ playerId, event, payload: p }) => {
  if (!room.players.get(playerId)?.connected) return;
  if (event === 'stroke' && playerId === artist) {
    if (!Array.isArray(p.points) || !p.points.length || p.points.length > 20 || pointsLeft < p.points.length) return;
    if (p.points.some(q => !Array.isArray(q) || q.length !== 2 || q.some(v => !Number.isFinite(v) || v < 0 || v > 1))) return;
    if (!Number.isInteger(p.stroke) || p.stroke < 1 || p.stroke > 1000000) return;
    const chunk = { points: p.points.map(q => q.map(v => Math.round(v * 1000) / 1000)),
      color: colors.includes(p.color) ? p.color : colors[0], width: [2, 4, 8].includes(p.width) ? p.width : 4, stroke: p.stroke };
    const next = [...strokes, chunk];
    if (new TextEncoder().encode(JSON.stringify({ canvas: next })).length > 12000) return;
    strokes = next; pointsLeft -= p.points.length; publish();
  } else if (playerId === artist && event === 'undo') {
    const last = strokes.at(-1)?.stroke;
    if (last === undefined || (p.stroke != null && p.stroke !== last)) return;
    const removed = strokes.filter(s => s.stroke === last);
    pointsLeft = Math.min(200, pointsLeft + removed.reduce((n, s) => n + s.points.length, 0));
    strokes = strokes.filter(s => s.stroke !== last); publish();
  } else if (playerId === artist && event === 'clear') {
    strokes = []; publish(); // Clearing does not refund the ink budget.
  } else if (playerId !== artist && event === 'guess' && !solved.has(playerId)) {
    if (typeof p.text !== 'string' || p.text.length > 24) return;
    if (p.text.trim().toLowerCase() === word) { solved.add(playerId); room.feedback(playerId, 'game.win'); show(playerId); }
  }
});
export function newDrawing(nextWord = 'cactus') {
  word = String(nextWord).slice(0, 24); round++; strokes = []; pointsLeft = 200; solved.clear(); publish();
}
```

The server drawing is authoritative. The browser's same-ID canvas does not replay every remote undo into existing local strokes; its own undo tool removes the local stroke immediately. For a new round, the changed ID resets local drawing before the new shared state arrives. Add your game's guess-attempt cooldown and round timer before using this as a competitive guessing game.

## Secret role with voting

The Midnight Table pattern uses a covered role LCD, player tiles and a confirm key. Roles and votes stay in host maps. Each player gets only their own role. A vote is accepted once, for another connected player.

![Secret role and voting controller](/assets/surfaces/vampire.webp)

<!-- example: voting -->
```js
import { createRoom } from '@controller-plus/sdk/relay-host';
import { surface, row, column, lcd, keys } from '@controller-plus/sdk/surface';
export const room = await createRoom({ game: { id: 'voting-example', name: 'Secret vote' }, localControllers: false });
const roles = new Map();
export const votes = new Map();
function show(id) {
  const choices = [...room.players.values()].filter(p => p.id !== id && p.connected);
  const done = votes.has(id);
  const side = done || !choices.length ? lcd({ title: 'Vote', text: done ? 'Vote received' : 'Waiting for another player' }) : column([
    lcd({ size: 80, title: 'Vote', text: 'Choose a player' }),
    keys(choices.map(p => ({ id: p.id, label: p.name, value: p.id, toggle: 'target' })), { shape: 'tile', columns: 2 }),
    keys([{ id: 'vote', label: 'VOTE', event: 'vote', send: 'target', color: '#d93a3f' }], { shape: 'tile', size: 66 }),
  ]);
  const view = surface({ id: `vote/1/${done ? 'done' : 'open'}`, root: row([
    lcd({ title: 'Your role', text: roles.get(id), secret: true, cover: 'Hold to see your role' }), side,
  ]) });
  view.legacy = { kind: 'secret-role', title: 'Secret vote', role: roles.get(id), text: 'Choose a player',
    submitted: done, choices: choices.map(p => ({ id: p.id, label: p.name, event: 'vote' })) };
  room.view(id, view);
}
const refresh = () => { for (const id of room.players.keys()) if (roles.has(id)) show(id); };
room.on('join', ({ playerId }) => { if (!roles.has(playerId)) roles.set(playerId, roles.size ? 'villager' : 'vampire'); refresh(); });
room.on('leave', refresh);
room.on('event', ({ playerId, event, payload }) => {
  if (event !== 'vote' || votes.has(playerId) || !roles.has(playerId)) return;
  const target = payload.target;
  if (target === playerId || typeof target !== 'string' || !room.players.get(target)?.connected) return;
  votes.set(playerId, target); show(playerId); room.feedback(playerId, 'ui.confirm');
});
```

This demonstrates private delivery and voting, not random role assignment or a complete deduction game. Assign roles securely and freeze the eligible roster at your real round boundary. A covering animation never replaces per-player delivery.

## Code dial pad

The Signal Vault pattern separates a private clue from shared dials. Each phone chooses a dial locally on the host, then a number. Only the host checks the answer. A successful unlock replaces the pad with a held gamepad button.

![Code dial controller with clue, four dials and digits](/assets/surfaces/vault.webp)

<!-- example: dials -->
```js
import { createRoom } from '@controller-plus/sdk/relay-host';
import { surface, column, row, lcd, keys } from '@controller-plus/sdk/surface';
export const room = await createRoom({ game: { id: 'dial-example', name: 'Code dials' }, localControllers: false });
export const dials = [0, 0, 0, 0];
const selected = new Map();
const answer = [1, 2, 3, 4];
export let unlocked = false;
function show(id) {
  const index = selected.get(id) || 0;
  const root = unlocked ? column([
    lcd({ size: 96, title: 'Lock open', text: 'Hold to charge' }),
    keys([{ id: 'hold', label: 'HOLD', button: 0, color: '#d93a3f' }]),
  ]) : column([
    lcd({ size: 96, title: 'Code clue', text: 'Count from one to four', sub: 'Pick a dial, then a digit' }),
    row([
      keys(dials.map((n, i) => ({ id: `dial-${i}`, label: String(n), selected: i === index, event: 'select', payload: { index: i } })), { shape: 'screen', columns: 2 }),
      keys([1,2,3,4,5,6,7,8,9,0].map(n => ({ id: `digit-${n}`, label: String(n), event: 'dial', payload: { value: n } })), { shape: 'square', columns: 3 }),
      keys([{ id: 'open', label: 'OPEN', event: 'open' }], { shape: 'tile', size: 130 }),
    ]),
  ]);
  room.view(id, { ...surface({ id: `dials/${unlocked ? 'hold' : 'code'}`, root }),
    legacy: { kind: 'waiting', title: 'Code dials', text: 'Open this invite in a browser to use the keypad' } });
}
const refresh = () => { room.state({ dials, unlocked }); for (const id of room.players.keys()) show(id); };
room.on('join', ({ playerId }) => { if (!selected.has(playerId)) selected.set(playerId, 0); show(playerId); });
room.on('event', ({ playerId, event, payload: p }) => {
  if (unlocked || !selected.has(playerId)) return;
  if (event === 'select' && Number.isInteger(p.index) && p.index >= 0 && p.index < 4) { selected.set(playerId, p.index); show(playerId); }
  if (event === 'dial' && Number.isInteger(p.value) && p.value >= 0 && p.value <= 9) { dials[selected.get(playerId)] = p.value; refresh(); }
  if (event === 'open') {
    unlocked = dials.every((n, i) => n === answer[i]);
    room.feedback(playerId, unlocked ? 'game.unlock' : 'game.warning'); refresh();
  }
});
```

For asymmetric clues, store a different clue per player as Signal Vault does. Keep the answer and other players' fragments out of public state. Read button 0 from `input` to implement the charge phase, and release it on disconnect.

## Name entry

This example asks for a game nickname with the drawn keyboard. The nickname is your game's display name; it does not rename the transport's existing roster entry. The host removes control characters, checks the length and renders it as text.

<!-- example: name-entry -->
```js
import { createRoom } from '@controller-plus/sdk/relay-host';
import { surface, column, lcd, keyboard } from '@controller-plus/sdk/surface';
export const room = await createRoom({ game: { id: 'name-example', name: 'Choose your name' }, localControllers: false });
export const names = new Map();
function show(id) {
  if (names.has(id)) return room.view(id, { kind: 'waiting', title: 'Welcome', text: names.get(id) });
  room.view(id, { ...surface({ id: 'name/1', root: column([
    lcd({ size: 110, title: 'Your name', entry: true, sub: 'Up to 24 characters' }),
    keyboard({ language: 'auto', charset: 'text', maxLength: 24, submit: { event: 'name', label: 'JOIN' } }),
  ]) }), legacy: { kind: 'waiting', title: 'Name entry', text: 'Open this invite in a browser to enter your name' } });
}
room.on('join', ({ playerId }) => show(playerId));
room.on('event', ({ playerId, event, payload }) => {
  if (event !== 'name' || names.has(playerId) || !room.players.get(playerId)?.connected || typeof payload.text !== 'string' || payload.text.length > 128) return;
  const name = payload.text.replace(/[\p{Cc}\p{Cf}]/gu, '').trim();
  const count = [...new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(name)].length;
  if (!count || count > 24) return;
  names.set(playerId, name);
  room.state({ players: [...names].map(([id, name]) => ({ id, name })) });
  show(playerId);
});
```

Show the returned name with `textContent` in a browser game. Keep the `playerId` for identity even when two players choose the same name. For game flow around these controllers, continue with [Recipes](recipes.md).
