Controller+BETADownload

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

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 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. 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

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

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

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.

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

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

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.

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.