Concepts
Game UI (HUD)
The Game UI, or HUD - status lines, prompts, action strips, logs and hints drawn off the table, private per seat, from a table script or a mod.
On this page17 sections
- Where text belongs: HUD, table text or chat
- Widgets, state and bindings
- Widget kinds
- Placement: regions and attachments
- In VR and AR
- Audiences and privacy
- Timing
- Prompts
- Actions
- Buttons
- The look: tones, accents, icons, fonts and images
- Styling widgets
- Limits
- Worked examples
- When to still use world.setTextLabel
- What is not there yet
- See also
The Game UI, or HUD, is everything a game tells or asks a player off the table: what phase it is, whose move it is and what they may do, a question with buttons, a list to pick from, a running history, each seat's counters, a hint pointing at a pile, the extra lines on the game-over window. The platform draws it in the app's own style, in places it chooses for each device, and it never sits on the felt where a card or a die could cover it.
You describe widgets; the platform draws them. A widget is plain data: a kind, where it
goes, who sees it, its text, and a closed vocabulary for its look. There is no HTML, no CSS, no
URL and no code anywhere in it, and that is what lets the same widget work on a desktop, a phone
and a tablet and in a VR headset, and stay private when it should be.
Both script surfaces have it, with the same widget shapes and their own names:
| Table Scripting | Mod Scripting | |
|---|---|---|
| Entry point | the ui global (reference) |
api.uiShow, api.uiUpdate, api.uiHide, api.uiSet, api.uiToast, api.uiLog, api.uiPrompt (reference) |
| A press arrives at | ui.onAction |
the onUiAction hook |
| Capability | none | ui (and subscribe-events for onUiAction) |
| Writes return | nothing; a refusal is a diagnostic | a promise of ModUiWriteResult |
Every write runs on the host and is refused anywhere else, like every other change to the table. The examples on this page are table scripts unless they say otherwise; the mod calls take the same objects.
Where text belongs: HUD, table text or chat#
This is the decision you make most often, so make it on purpose.
| What you want to say | Put it in | Why |
|---|---|---|
| The phase, whose move it is, what the player may do now | A status widget |
It stays true until you change it, a late joiner sees it, and a private line can tell one seat what to do without telling the others. |
| A question the game waits on | ui.prompt |
One seat sees it, the answer comes back to the code that asked, and a timeout and a bot seat are handled for you. |
| A roll the rules call for ("roll 3d6 to attack") | world.requestRoll |
The seat's own dice button shows exactly that roll and rolls it, and the result comes back to the code that asked. A bot's seat is rolled by the host. No spawned dice, no "Roll" button of your own. |
| Something that just happened, needing no answer | ui.toast |
It shows for a few seconds and goes. It is never saved. |
| The history of the game | A log widget and ui.log |
A scrolling list the player can reopen, capped so it never grows without bound. |
| Each seat's numbers | A player-panel widget |
One row per seat in the player list, read from per-seat state. |
| Buttons the game offers (New game, Pass, Resign) | An actions widget |
Real buttons with an enabled rule, an active look and a confirm step, not a spawned 3-D button. |
| Whose turn it is, the score shown on the seat plates | world.setTurn, world.setScore |
The table draws those itself, the same way in every game. Keep using them alongside the HUD. |
| Words that are part of the board: a printed caption, square names, a track | world.setTextLabel |
They belong in the world and stay on the board as the camera moves. See below. |
| A line the table says out loud, that belongs in the conversation | world.broadcast |
Chat is where players talk; keep it for narration, not for state. |
The rule of thumb: if a player reads it to know what to do, it is HUD. If it would be printed on the physical game, it is table text. If you would say it across the table, it is chat.
Widgets, state and bindings#
Three ideas carry the whole feature.
- A widget is shown once with
ui.showand has anidyou choose. Showing the sameidagain replaces it;ui.updatechanges it in place;ui.hideremoves it. - HUD state is a small table of values (text, numbers, yes/no) you write with
ui.setandui.setMany. A value can be shared, one per seat, or one per seat and private to it. - A binding is
{key}inside a widget's text. It reads the state and redraws when the state changes.{seat.key}reads the viewer's own seat's value, so one widget says something different to each player.{key|fallback}shows the fallback while the key is unset.
So the usual shape of a game's HUD is: show the widgets once when the script starts, then only
ever change state. A status line that reads "{phase}" follows the game without being shown
again, and changing a value is a far smaller update than replacing a widget.
Text in a widget can use the same light formatting as a tooltip: **bold**, *italic*,
[color=red]…[/color] and - bullets. That formatting comes only from the text you wrote.
A value is always drawn as plain text, so a player's name containing ** is drawn as **,
never as formatting.
Everything belongs to its owner. Widgets, state keys and log lines belong to your game's scripts (or, for a mod, to that mod). Two scripts in the same game share them; a different game's or Asset pack's scripts, and every mod, have their own, and none of them can show, change, hide, read or press yours.
Widget kinds#
Pick the kind that says what the widget is for; the platform decides how it looks on each device.
| Kind | What it is for | Fields it reads | Default region |
|---|---|---|---|
status |
A persistent line or panel: the phase, whose move, what to do | title, text, body, note |
upper |
banner |
A notice that shows for a few seconds and goes | text (or title), timing.duration (default 4 s) |
the notice stack |
prompt |
A question with buttons or a choice list, optionally timed | title, body, text, actions, choices, timeoutSeconds, defaultAction, image |
sheet |
actions |
A strip of buttons that stays: New game, Pass, Resign | title, actions |
lower |
choice |
A list or card picker, without the question framing | choices, image |
sheet |
result |
Extra lines on the platform's game-over window | headline, lines, body, image |
center |
player-panel |
One row per occupied seat in the player list | counters, badges, note, image |
start |
counter |
One number with a label | label, value, max |
upper-end |
meter |
A bar between min and max |
label, value, min, max |
upper-end |
log |
A scrolling history you add lines to, drawn like a chat transcript: one compact row per line, a line with a seat in that seat's colour |
title, maxLines (at most 200) |
end |
hint |
A short coach mark, best attached to an entity, zone or seat | text (or title) |
upper |
timer |
A visible countdown | label, seconds (1 to 3600, from when it is shown) |
upper-end |
panel |
A box of basic widgets: text, buttons, checkboxes, selects | title, body, children, image, backgroundImage |
center |
card |
A card spotlight: one card drawn large, such as the card attacking or being played, with stat chips and its counters | title, card, stats, counters |
lower |
A log line may name a seat (ui.log's seat option): the
line is drawn with a bar and a faint tint in that seat's colour (the colour of its seat plate), and
consecutive lines of the same seat join into one block, like several messages from one sender in a
chat. Like a chat, the newest line is at the bottom: the log follows new lines while the
player is reading the latest, keeps their place if they have scrolled up (with a New lines
button to jump back down), and a collapsed log shows its newest line. Write the player's name in the text yourself; the seat only colours it. A tone still
colours the text, so a seat's winning line can be both its colour and success. In VR the log
shows the text and tone only.
A result widget joins the game-over window while that window is open (after
world.setGameOver); it adds lines and never
replaces the window's own standings or buttons. Shown earlier, it is an ordinary card in the
center region.
A panel is the bridge to the older basic widgets, and it is how a table script gets them at
all. Its children are basic widget definitions, each with an id. A button, checkbox or
select child is pressable: the press reaches your action handler with childId set and
actionId "click" or "change". An input child stays read-only, because free text would
make a press an unbounded message.
Placement: regions and attachments#
There are no screen positions. A widget names a region, an attachment, or both, and
order (a whole number, smaller first) sorts widgets that share a region. Within its region a
widget can be sized, aligned and nudged with style, and a whole region can
lay its widgets out in a row or a column.
Regions are upper-start, upper, upper-end, start, center, end, lower-start,
lower and lower-end, plus sheet, the slot for questions. There is no region called "top":
the word is reserved by the script scanner.
| Desktop | Tablet (touch) | Phone (compact touch) | VR and AR | |
|---|---|---|---|---|
| The nine regions | A grid laid over the table view that keeps clear of the toolbar, the dice dock, the hand drawer, the match bar, chat and the platform's own banners. | upper-start, upper, upper-end and start fold into upper, drawn as a one-line pill the player taps to open. end and the three lower regions fold into lower. center stays. |
The regions stay apart, each in a corner the phone's own buttons leave free, so nothing spans the screen or covers the hand. upper-start, upper and start are the one-line pill. upper-end is a small stack on the right edge, under the dice button. Every actions widget docks as a row of buttons in the bottom-right corner. Each log, and anything else placed at end or in a lower region, sits behind a tab in the bottom-left corner that opens a drawer and counts new log lines. Both corners move up only when the open hand would be underneath them. |
All of them go to the tray: a panel to the side of the player's off hand, a little below eye level, that follows them lazily (it moves only after they turn or walk away, never while they are pointing at it). Status lines, banners, logs, counters, meters, timers, action strips and unattached hints live there, paged when they do not fit. |
sheet (prompts and choices) |
A dialog the player can tab through, marked as game content. Escape or × tucks it away into a pill without answering. | A bottom sheet above the hand drawer, with buttons at least 44 px tall. | The same bottom sheet, at most 560 px wide so a landscape table stays visible at the sides. The answer buttons (and a choice list's confirm button) stay pinned at the bottom while the options scroll. | A dialog that opens within reach, facing the player and fixed in the room. One at a time, with a pager for the rest; panel and result widgets open here too. × hides it without answering. |
| Banners | Up to four at once. | Up to two at once. | Up to two at once. | On the tray. |
player-panel |
The player list. | Inside the upper pill. |
Inside the upper pill. |
On the tray, and the player's own line on the wrist watch. |
A phone is a touch screen whose shorter side is under 600 px, in either orientation (a phone on
its side is about 900 × 400). The phone layout keeps the regions you chose, so place widgets by
what they are, not by where they land on a desktop. Put the game's moves in actions widgets
(they become the bottom-right button row) and keep counters you want visible on a phone in
upper-end. Preview it with the UI Layout tab's phone frames (iPhone, Android and a small phone,
each upright and on its side), which draw the phone's own buttons where they sit at the table.
On a phone the docked buttons are drawn small: the icon with a short label under it, so give every
action an icon and keep labels to a word or two.
Per-device placement#
placement.devices changes where a widget goes on one kind of screen without touching the
others. Its keys are desktop (a mouse-driven screen), tablet (a tablet-size touch screen) and
phone (the compact touch screen above); each takes:
region: another region on that screen only.hidden: true: leave it off that screen. Apromptorchoiceis never hidden, so a question is always answerable.menu: true(anactionswidget only): fold it behind one button that opens its buttons. Use it for controls a player rarely needs, such as New game or a settings toggle, so the phone's button row holds only the moves.style: style keys for that screen only, merged over the widget's ownstyle(see Styling widgets).
{ "id": "game-controls", "kind": "actions", "placement": { "region": "lower-end", "devices": { "phone": { "menu": true } } } }
VR and AR take none of these overrides.
In VR and AR every button answers both the controller's ray and a fingertip press, so a prompt can
be answered with hand tracking even with the hand ray switched off. A select inside a panel
steps through its declared options. In AR the tray and the dialog stay put in the room.
Attachments pin a widget to something in the world: { to: "entity", id } or
{ to: "entity", tag } (exactly one of the two), { to: "zone", id }, or { to: "seat", seat }.
An attached widget is drawn as a label floating in the scene beside its target, so it stays beside
it as the camera moves. Only widgets nobody needs to press can float: a prompt,
actions, choice, panel, result or log with an attachment is drawn in its region
instead. If the target is not on the table, the widget falls back to its region, or its kind's
default. Give an attached hint a region too, so it still shows somewhere sensible. In VR and AR
a widget that names both an attachment and a region is drawn at its attachment; on a flat screen,
at its region.
A player-panel attached to { to: "seat" } also floats at each seat, beside its nameplate.
In VR and AR#
A headset draws the same widgets from the same script calls; nothing is written twice. Each widget goes to one of three places:
- The tray. Status lines, banners and toasts, logs, counters, meters, timers, action strips, unattached hints and the player list share one small panel beside the player's off hand, a little below eye level. It stays where it is until the player turns or walks away, then glides after them, and it never moves while a ray or a fingertip is on it. When it holds more than fits, a pager in its header turns the page.
- The dialog. Prompts, choices,
panelandresultwidgets open one at a time in a panel within reach, facing the player and fixed in the room, with a pager when several are waiting. Long option lists and long button rows page inside it, so every button stays reachable. - The table. An attached widget nobody needs to press floats beside its entity, zone or seat, as it does on a flat screen.
In AR the tray and the dialog stay put in the room, like the table menu.
The dialog's ✕ hides it on this headset only: it never answers, and the tray offers
Show prompt to bring it back. A widget marked dismissible gets a real dismiss instead — its
✕ sends the same dismiss a flat screen sends — and a – for the local hide. A dismissible
widget on the tray gets a ✕ Dismiss row under it.
A button with confirm asks first here too: the dialog swaps to your question with Cancel and
a button carrying the pressed button's own label, and the action goes to the host only when the
player confirms. If the question stops applying (the prompt was answered elsewhere or timed out),
the confirm closes by itself.
Inside a panel, text, button and checkbox children work as they do on a flat screen. A
select is a stepped list: arrows on either side move through its declared options, so a player
can never pick a value you did not offer. An input is read-only in a headset and says
Edit on a flat screen. Button images and icon images in a panel are drawn only when they are
paths to images in your project, the same rule as everywhere else.
Audiences and privacy#
audience says who sees a widget and who may press it. Leave it out for everyone.
| Audience | Who sees it |
|---|---|
{ scope: "all" } |
Everyone, spectators included. The default. |
{ scope: "players" } |
Everyone in a seat. |
{ scope: "spectators" } |
Everyone watching without a seat. |
{ scope: "host" } |
The host's player only. |
{ scope: "seats", seats: ["red"] } |
The players in those seats. |
{ scope: "teams", teams: ["Rebels"] } |
The players on those teams. |
{ scope: "seat-each" } |
One copy per seat, each private to its seat. {seat.key} reads that seat's values. |
{ scope: "actor" } |
Whoever pressed the button your action handler is answering right now. |
{ scope: "peers", peerIds: [...] } |
Those connections. |
Only all is public. Every other audience is private, and private means the other players'
clients never receive it. The host sends each player only their own private widgets and
private values, on a separate per-player message, and keeps them out of the table state everyone
else receives. A seat-private hint cannot be read off another player's screen, out of their
browser's developer tools, or by a modified client, because it was never sent there. Private
seat state works the same way: ui.set(key, value, seat, { private: true }).
Who is in an audience is decided by the host from its own list of who sits where, at the moment of a press as well as when the widget is shown. A player who changes seat stops seeing the old seat's private widgets and starts seeing the new seat's.
Spectators see all widgets and widgets addressed to spectators. They can press a widget
only when it says allow: ["spectators"], and they never receive a seat's prompt.
actor is for answering a press privately: "you cannot do that yet" to the person who
pressed, and nobody else. It works only inside your action handler, before its first await;
anywhere else it is refused.
The host is trusted, as it already is. The host's computer runs your scripts and holds every seat's private HUD, exactly as it already holds the order of every deck and the face of every hidden card. A saved table carries the private HUD too, so whoever can open the host's saves can read it. A game that needs a secret even from the host cannot get one from the HUD.
Timing#
timing: { duration }removes a widget that many seconds after it is shown, from 0.5 to 600. Abannerwith no duration lasts 4 seconds. Each player's clock starts when their client first shows it, so a slow connection does not cut a banner short.timing: { until: "action" }closes a widget on its first press. With aseat-eachaudience, each seat's copy closes when that seat presses.- A prompt closes when it is answered or when its
timeoutSeconds(1 to 600) runs out. ui.toastis not a widget at all: it is sent once to the people it is for, shows for itsseconds(default 4), and is never saved or sent to anyone who joins later. Abannerwidget shown withui.show, by contrast, is part of the table until it expires, so a player who joins in the middle still sees it.- When your scripts restart, their prompts and timed widgets are cleared, and an
unanswered
ui.promptresolvescancelled. Persistent widgets and state stay, so draw your HUD again from your own state when a script starts. The examples below do. - Undo puts the HUD back as it was with the rest of the table.
Prompts#
ui.prompt(seat, spec) (api.uiPrompt in a mod) asks
one seat a question and waits:
- It resolves with the button or option pressed (
actionId), and for a several-pick choice the picks (value). - After
timeoutSecondsit resolvestimedOut: truewith yourdefaultAction, oractionId: nullwithout one. - It resolves
cancelled: truewhen nobody sits in the seat, the seat empties while it waits, the prompt is replaced by another with the sameidor hidden, or your scripts restart. - A bot's seat resolves at once with
bot: true. Bots never see prompts; your script decides for them. - One question per seat at a time. A second prompt to the same seat waits until the first is answered, at most 8 deep, and its timeout starts when it actually appears.
Long lists get a filter box. A choice list (on a prompt or a choice widget) with more than
12 options shows a search box above them: the player types and the list narrows to options whose
label or description contains the text, ignoring case and accents, with options whose label
starts with it first. ↑ and ↓ move the highlight, Enter picks it, Escape clears the text.
On a phone the box sits at the head of the bottom sheet. VR has no keyboard, so a headset player
gets a strip of letter tabs (All, then each first letter the options use) that narrows the
paged list instead. Set search: true to show the box on a shorter list, search: { placeholder: "Name a subtype" } to set its hint (80 characters at most), or search: false to never show it.
What the player types never leaves their screen: the answer is still the id of an option you
offered, checked by the host like any other pick.
An answer to a ui.prompt resolves that promise and is not also sent to ui.onAction. A
prompt widget you show yourself with ui.show reports to ui.onAction like any other widget.
A roll is not a prompt. When the rules say a player rolls, ask with
world.requestRoll(seat, spec) instead of a prompt with a
"Roll" button: the seat's dice button (bottom right of the table) shows the label, the dice and the
area, its ROLL throws them, and the promise resolves with the faces. It follows the same rules as a
prompt: one per seat (a new one replaces the old), a timeout (by default the host then rolls for
the seat), and a bot's or empty seat rolled by the host at once (byHost: true). Unlike a prompt,
it is public: everyone sees what was asked.
Actions#
Every press is data: the widget's id, the button's id, and for a choice the picks. There
is no callback in the widget and no URL. The host checks every press against its own copy of the
widget before your handler sees it: the widget is still showing, the presser is in its audience
right now, the button exists and is enabled for them, a prompt has not been answered already,
and every picked value is one you offered. A press that fails any of these never reaches you.
Your handler (ui.onAction, or onUiAction in a
mod) then receives the presser's seat (from the host's own seat list, null for a spectator)
and peerId. Check the seat before you act on it: being allowed to press a button is not the
same as it being their turn.
Buttons#
Every button (in actions, a prompt, a choice option or a choice's confirm) is the same shape: an
id, a label, and optional icon, iconImage, image, tone, accent and emphasis.
A button has five states, and each may override the label, icon, images, tone, accent and
emphasis. A state you leave out inherits from default.
| State | When it shows |
|---|---|
default |
Normally. |
hover |
The pointer is over it; in VR, the ray is on it or a fingertip is close. Skipped on touch screens, which have no hover. |
pressed |
While it is being clicked or touched. |
active |
While its activeWhen condition holds: a toggle that is on, a selected option. |
disabled |
While its enabledWhen condition does not hold. A disabled button sends nothing, and the host refuses a press on it anyway. |
When more than one applies, disabled wins, then pressed, active, hover and default.
confirm: "Are you sure?" makes the platform ask first, in its own dialog.
Picking cards#
Any button (an option in a choice list, a prompt's button, a button on an actions strip) can be
drawn as a card: the card's picture, with the label under it. Use it wherever the player is
choosing a card, such as a discard, a target, a deck search or a mulligan, so they pick the
card they can see rather than its name. Name the card one of two ways:
| Field | Names | Who sees its face |
|---|---|---|
entityId |
A card (or a pile, by its top card) on the table. | Each player sees what the table lets them see: the face of a card in their own hand or face up, the back of a card in another player's hand, face down on the table, or in a hidden zone. Safe in a widget everyone sees. |
cardId |
A card id from your game's card catalogue (data/cardSchema.json), or one in a deck on the table, for a card that is not an entity. |
Everyone the widget reaches, face up. It tells them which card it is, exactly as writing its name in label would, so name a hidden card only in a widget for a seat that may know it, such as a ui.prompt to that seat. |
With both, entityId wins while the entity is on the table, and a hidden entity draws its back
and never falls back to cardId. The art is drawn on each player's own screen from the game's own
card art, the same pictures the table and the hand use, so a script never names a picture or a URL.
A card whose art cannot be found (or is still loading) shows a blank card with its label.
Counters ride along. A card named by entityId shows its counters on the picture, exactly as
the table draws them: the same counters, art, colours and labels, the count from 2 up, and only the
counters that player may see. A small card tile shows at most three chips; past that the last one
reads "+N" for the rest. A cardId card is not an entity, so it has no counters. There is nothing
to switch on: if the entity has counters the player can see, they are drawn.
How the cards are laid out:
- A choice list with any card in it becomes a grid of cards. On a phone it is one row the player scrolls sideways, with the answer row pinned under it. Several picks show a tick on each chosen card.
- A prompt's buttons or an
actionsstrip: the card buttons form one row of cards, which scrolls sideways when it is too long, and the plain buttons (Pass, Keep hand) follow in their own row. - A closer look: resting the mouse on a card, or holding a finger on it, shows it large beside the list, with its counters. Holding a finger never picks the card; a tap does. The closer look needs a mouse or a finger: there is no keyboard shortcut for it yet.
A mulligan is a choice list of the hand (min: 1, max: the hand size, a confirm that sends
the picks) plus a plain "Keep hand" button in actions: pressing it answers with no picks. The
worked example asks a discard, a mulligan and a deck search.
In VR the buttons show their labels.
Card spotlights#
A card widget draws one card large, so every player can see which card is attacking, being
played or being targeted without finding it on the table. It shows the card's picture, a row of
stat chips under it, and the card's counters on the picture with their counts. Two
spotlights in the same region sit side by side, so an attacker and its defender appear together.
It is not a button and cannot be pressed, but resting the mouse on the picture, or holding a
finger on it, opens the same closer look as a card button, with the counters drawn larger.
| Field | What it does |
|---|---|
card |
Required. { entityId?, cardId?, label }. entityId and cardId name the card exactly as a card button does: an entity is drawn as each player may see it (the back of a card hidden from them), a cardId is a disclosure to everyone the widget reaches. An entity that is gone falls back to cardId, then to label as text. label (at most 120 characters) is what a screen reader announces. |
stats |
Up to 8 chips: { label, value, change?, icon?, iconImage? }. label is at most 24 characters; value may bind state ("{seat.attackerPower}") and is drawn cut to 24. change is "same" (the default, drawn white), "up" (green) or "down" (red), with an arrow as well, so the colour is never the only cue. A chip with an iconImage (a declared project image, such as your game's own speed symbol) or an icon draws that symbol instead of the label text; the label is still what a screen reader announces and what the chip's tooltip shows, and is drawn as text again if the image cannot be loaded. |
counters |
true (the default) draws the entity's counters on the picture exactly as the table draws them: the same counters, art, colours and labels, and the count from 2 up. A counter hidden from a player is not drawn for them. false leaves them off. Only an entityId card has counters. |
title |
A small heading over the card, such as "Attacking". |
The platform does not work out change for you. Your rules know the printed value and the
current one; say which way it moved. Update a spotlight with
ui.update when a stat changes mid-attack, and hide it
when the moment is over. The worked example shows both cards of an attack.
On a phone the spotlights get their own row above the action buttons, drawn smaller. In VR the tray shows the card's label and its stats as text (see What is not there yet).
Conditions#
enabledWhen, activeWhen and a widget's when are conditions: small trees of data, not
code. { state: "phase", is: "bidding" }, { state: "seat.canPass", truthy: true },
{ state: "round", in: [1, 2] }, { variant: ["classic"] }, combined with all, any and
not, at most 4 deep and 16 nodes. They are checked for each viewer, so seat. keys read the
viewer's own values.
The look: tones, accents, icons, fonts and images#
You choose what a widget means; the platform chooses how that looks. theme takes:
tone:neutral,info,success,warning,dangeroraccent. A role, never a colour.accent:red,orange,yellow,green,teal,blue,purple,pink,gray, or a#rrggbbcolour, which the platform lightens if it would be hard to read on the dark interface.icon: a name from the list below.iconImage: a picture from your repository drawn where the icon would be (it wins overicon, which stays the fallback). Status, log, counter, meter, timer, hint and panel headers, prompts and banners all draw it.font: one of the eight below.density:compactorcomfortable.emphasis:subtle,normalorstrong.
Icons come from a fixed list, because the app ships only the icons it draws. A name outside
it is refused when you show the widget: add, add_circle, arrow_back, arrow_downward,
arrow_upward, auto_awesome, back_hand, block, bolt, bookmark, campaign, casino,
category, chat, check, check_circle, chess, chevron_left, chevron_right, circle,
close, emoji_events, error, event, event_seat, explore, favorite, flag, flip,
gavel, group, groups, help, history, hourglass_empty, hourglass_top, info,
inventory_2, key, lightbulb, local_fire_department, lock, lock_open, menu_book,
military_tech, more_horiz, pause, person, play_arrow, playing_cards, psychology,
redo, refresh, remove, repeat, replay, restart_alt, rocket_launch, rule,
schedule, sell, shield, shuffle, skip_next, smart_toy, speed, sports_esports,
star, star_half, star_outline, stop, thumb_up, timer, toll, touch_app, tune,
undo, visibility, visibility_off, warning, workspace_premium.
Fonts come from a vetted list that ships with DiceyTable itself, so a game can never make a player's browser download anything. Every family is under the SIL Open Font License.
font |
Family | Suits |
|---|---|---|
platform |
The app's own | Everything; the default |
serif |
Merriweather | Long, readable text |
display-classic |
Cinzel | Classical and fantasy titles |
display-bold |
Bangers | Comic and party games |
sci-fi |
Orbitron | Science fiction |
mono |
JetBrains Mono | Scores and tables of numbers |
retro |
Press Start 2P | Pixel and arcade |
handwritten |
Caveat | Notes and casual games |
Images are files in your game's own repository, named by a repo-relative path such as
ui/header.webp, and resolved exactly like a model or texture path. Only .png, .jpg, .jpeg
and .webp are accepted. A URL of any kind (http:, https:, data:, blob:), an absolute
path, a .. segment, a query and SVG are all refused. Images go on panel (image as a header,
backgroundImage behind the body), prompt, result, choice and player-panel widgets, and on
every button state (iconImage in place of an icon, image as the whole face of the button). A
player panel's counters and badges take iconImage too, so a game can show its own stat symbols
(cost, speed, power, health) in place of the generic icons. Any button can also be drawn as a
card with entityId or cardId (see Picking cards). An image that fails to
load falls back to the label (or the icon), so always give a button a label too: it is also what a
screen reader announces.
Game symbols that change per seat. A widget's image, theme.iconImage and a counter's or
badge's iconImage may bind one state key instead of naming a file: "{seat.sideIcon}", or
"{seat.sideIcon|ui/side-neutral.png}" with a fallback (UiImageRef).
In a player-panel the seat. key is read for each row's own seat, so one widget shows every
player's faction or side symbol. The value is drawn only when it is itself a repo-relative
.png/.jpg/.jpeg/.webp path of your game; anything else (a URL included) draws the fallback
or nothing. A private seat value is drawn only for its own seat. Buttons and backgroundImage
always name a file.
Styling widgets#
style gives a widget its own size, spacing, alignment, colours, border, type and button layout.
It is not CSS: it is a closed list of keys (UiStyle),
each a number in a fixed range or a word from a fixed list, and the platform builds the CSS itself.
A key that is not on the list is refused when you show the widget, and so is a layout that has one.
| Group | Keys |
|---|---|
| Size | width (px, "40%" of the screen, "auto" or "fill"), minWidth, maxWidth, height, maxHeight |
| Place in the region | align (start, center, end, stretch), offsetX / offsetY (a nudge of up to 200 px) |
| Spacing | padding (0-48), margin (0-64): one number, or { x, y, above, below, start, end }; gap (0-32) |
| Colour and border | background, backgroundOpacity, color, borderWidth, borderColor, borderStyle, radius, shadow |
| Type | fontSize (10-32), fontWeight, textAlign, lineClamp |
Buttons (actions, prompt, choice) |
buttons.direction (row/column), buttons.wrap, buttons.size (sm/md/lg), buttons.presentation (beside, under, icon-only, label-only), buttons.align |
Colours are the accent vocabulary: a palette name or #rrggbb. A text colour that would be hard to
read on its background is lightened or darkened until it reads, and a light background with no
text colour gets dark text.
Put what should differ by screen in placement.devices.<desktop|tablet|phone>.style. It is merged
over style on that screen only, so tightening the phone never changes the desktop:
{
"id": "moves",
"kind": "actions",
"style": { "buttons": { "size": "lg" }, "radius": 18 },
"placement": {
"region": "lower",
"devices": { "phone": { "style": { "buttons": { "size": "sm", "presentation": "icon-only" } } } }
}
}
A UI layout can also lay out whole regions, in its regions map
(UiRegionStyle): direction (column or row),
align, justify (start, center, end, between), gap, wrap, maxWidth, maxHeight
and a nudge. A region entry's devices.<desktop|tablet|phone> holds keys for that screen only,
merged over the entry there. The platform copies each region's style to the widgets in it when the
layout is shown; a script can set it on a widget as placement.regionStyle (and per screen as
placement.devices.<class>.regionStyle). The first widget in a region that sets one decides how
that region is laid out.
{ "regions": { "upper": { "direction": "row", "gap": 12, "devices": { "phone": { "direction": "column", "gap": 4 } } } } }
On a phone the regions are not drawn as such: the compact layout deals widgets into its own
corner slots. A slot takes a region's style (with that region's devices.phone keys) when every
widget in it was placed in that one region:
| Phone slot | Takes the style of |
|---|---|
| The stats stack under the dice button | upper-end |
| The centre | center |
| The action dock (bottom-right) | the region every actions widget in it was placed in, when they all share one |
| The card spotlight row | the region every card in it was placed in, when they all share one |
| The status pill, the log tab | never: they are one-tap platform controls |
On a phone a region style only ever works inside the slot's own corner: maxWidth and maxHeight
can make the slot smaller but never larger, and the nudge moves a slot only away from the screen
edges it sits against (the stats stack left or down, the dock left or up, the card row up). The
safe areas and the room left for the phone's own buttons stay the platform's.
What stays the platform's:
- Where regions are. A widget never gets a screen position, only a size and a nudge of up to 200 px from where its region puts it.
- The screen's edges. A widget is never wider than the screen (less the platform's margins) or taller than 85% of it; content past a fixed height scrolls.
- The table's own controls. The menu, toolbar, status pills, dice, hand and chat always draw above your widgets, so a big or nudged widget can be covered but can never cover them.
- Questions. A
promptorchoicekeeps the sheet's size and place; only its look (colours, border, type, padding, buttons) is styled. - A phone's corners. On a phone, region styles reach the compact slots (above) but never move a slot out of its corner or past the phone's own buttons.
- VR and AR draw their own panels and ignore
style.
Limits#
Every limit is checked by the host. A write that breaks one is refused whole and nothing changes.
| Limit | Value |
|---|---|
| Widgets one game's scripts (or one mod) may have at once, queued prompts included | 128 |
| State keys per owner | 64 |
| Characters in any one text field | 500 (button labels 120) |
| Buttons in one widget | 12 |
| Options in one choice list | 200 |
| Characters in a choice list's search placeholder | 80 |
| Counters or badges on a player panel | 8 each |
Stat chips on a card spotlight |
8 (label 24 characters, value drawn at 24) |
Lines on a result |
20 |
Basic widgets in one panel |
64 |
Lines a log keeps |
200 (the oldest fall off) |
| One widget, serialised | 16 KB |
| All public HUD on the table, every owner together | 64 KB |
| Prompts waiting per seat per owner | 8 |
| Toasts per owner | 4 per second |
| Condition depth and size | 4 deep, 16 nodes |
timing.duration |
0.5 to 600 seconds |
| Image path | 256 characters |
style sizes |
0-1600 px, or 1-100% of the screen |
style padding / margin / gap |
0-48 / 0-64 / 0-32 px |
style nudge (offsetX, offsetY) |
-200 to 200 px |
style border / radius / font size / line clamp |
0-8 px / 0-48 px / 10-32 px / 1-12 lines |
HUD writes also share the per-update budget that limits how fast any script or mod can change
on-screen UI, so a loop that rewrites a widget hundreds of times in one go is refused partway.
Write when something changes, never from onTick.
Worked examples#
A status panel, a log and the score#
A dice race. The widgets are shown once; after that, only state changes. Each seat's points live in the player list, and the winner gets extra lines on the game-over window.
// content/scripting-api/examples/concepts.game-ui-status-log.ts
// Scene script: a dice race's HUD. A status line says what is happening, a
// counter shows the round, a log keeps the history, and every seat's score
// sits in the player list. Nothing here is drawn on the table.
//
// The widgets are shown ONCE. After that the script only changes STATE with
// ui.set / ui.setMany, and every {binding} that reads it redraws by itself.
const TARGET = 20;
let round = 1;
function drawHud(): void {
ui.show({
id: "status",
kind: "status",
placement: { region: "upper" },
theme: { icon: "casino" },
title: "{phase}",
body: "First to " + TARGET + " points wins. Roll the die on your turn."
});
ui.show({ id: "round", kind: "counter", label: "Round", value: "{round}" });
ui.show({
id: "scores",
kind: "player-panel",
// One row per occupied seat; {seat.score} reads THAT row's seat.
counters: [{ label: "Points", value: "{seat.score|0}", max: String(TARGET), icon: "star" }]
});
ui.show({ id: "log", kind: "log", title: "History", placement: { region: "end" }, maxLines: 50 });
ui.setMany({ phase: "Waiting for the first roll", round });
}
const scores: Record<string, number> = {};
globalEvents.onDiceRollResult.add((roll) => {
if (roll.seat === null) {
return;
}
const seat = roll.seat;
scores[seat] = (scores[seat] ?? 0) + roll.total;
// A public per-seat value: every player's list shows every seat's score.
ui.set("score", scores[seat], seat);
// `seat` draws the line in that seat's colour, like a message in a chat.
ui.log(seat + " rolled " + roll.total + " (" + scores[seat] + " in all).", { seat });
if (scores[seat] >= TARGET) {
ui.set("phase", seat + " wins!");
ui.log(seat + " reached " + TARGET + ".", { tone: "success", seat });
// Extra lines for the platform's game-over screen. They add to it; the
// screen's own standings and buttons stay.
ui.show({
id: "result",
kind: "result",
headline: seat + " wins the race",
lines: Object.keys(scores).map((name) => name + ": " + scores[name] + " points")
});
world.setGameOver({ winners: [seat] });
}
});
globalEvents.onTurnStarted.add((turn) => {
if (turn.seat === null) {
return;
}
ui.setMany({ phase: turn.seat + " to roll", round });
});
globalEvents.onTurnEnded.add(() => {
round += 1;
});
// Persistent widgets and state survive a script restart, but showing them
// again is harmless and makes a fresh table look the same as a resumed one.
drawHud();
Each seat's side symbol#
One player panel shows every seat's side as the game's own picture. The badge's image binds
{seat.sideIcon}, so each row reads its own seat's value; the counter uses a fixed stat symbol.
// content/scripting-api/examples/concepts.game-ui-side-symbols.ts
// Scene script: every seat's SIDE symbol in the player list, from the game's
// own images. ONE player-panel widget shows each row its own picture,
// because "{seat.sideIcon}" reads the state of the seat that row describes.
//
// The pictures are files of this game (ui/side-light.png ...). A state value
// is drawn only when it is such a path, so the script sets exact paths.
const SIDE_ICONS: Record<string, string> = {
Light: "ui/side-light.png",
Dark: "ui/side-dark.png"
};
ui.show({
id: "players",
kind: "player-panel",
counters: [
// A static image in place of an icon: the same for every seat.
{ label: "Force", value: "{seat.force|0}", iconImage: "ui/stat-power.png" }
],
badges: [
// A BOUND image: each seat's own symbol, or the neutral one while unset.
{ text: "{seat.side|Choosing}", iconImage: "{seat.sideIcon|ui/side-neutral.png}" }
]
});
function chooseSide(seat: string, side: string): void {
// Public per-seat values: every player's list shows every seat's side.
ui.setMany({ side, sideIcon: SIDE_ICONS[side] ?? "ui/side-neutral.png" }, seat);
}
globalEvents.onSeatChanged.add((change) => {
if (change.seat === "red") chooseSide("red", "Light");
if (change.seat === "blue") chooseSide("blue", "Dark");
});
A prompt with choices#
One question picks a single option; the other picks two and confirms. Each handles a bot, a timeout and a seat that empties.
// content/scripting-api/examples/concepts.game-ui-prompt-choices.ts
// Scene script: two questions asked with ui.prompt. A pawn that reaches the
// last row asks its owner what it becomes (pick one), and a new player picks
// two starting bonuses out of four (pick several, then confirm).
//
// ui.prompt shows the question to that ONE seat only and resolves with the
// answer, so the code that asked is the code that carries on.
async function askPromotion(seat: string): Promise<string> {
const result = await ui.prompt(seat, {
title: "Promote your pawn",
body: "Choose the piece it becomes.",
choices: {
// A single-pick list with no confirm: pressing an option answers.
options: [
{ id: "queen", label: "Queen", icon: "chess" },
{ id: "rook", label: "Rook" },
{ id: "bishop", label: "Bishop" },
{ id: "knight", label: "Knight" }
]
},
timeoutSeconds: 30,
defaultAction: "queen"
});
// A bot never sees a prompt; nobody answering, or the seat emptying,
// falls back to the same sensible default.
if (result.bot || result.cancelled || result.actionId === null) {
return "queen";
}
return result.actionId;
}
async function askBonuses(seat: string): Promise<readonly string[]> {
const result = await ui.prompt(seat, {
id: "bonuses-" + seat,
title: "Starting bonuses",
body: "Pick **two**.",
choices: {
options: [
{ id: "gold", label: "Gold", description: "Start with 3 extra coins.", icon: "toll" },
{ id: "scout", label: "Scout", description: "Look at the next card of the deck.", icon: "visibility" },
{ id: "shield", label: "Shield", description: "Ignore the first attack.", icon: "shield" },
{ id: "haste", label: "Haste", description: "Take the first turn.", icon: "speed" }
],
min: 2,
max: 2,
// Several picks need a confirm button; the picks arrive in result.value.
confirm: { id: "done", label: "Take these" }
}
});
if (result.cancelled || result.bot || !result.value) {
return ["gold", "scout"];
}
return result.value;
}
void (async () => {
const piece = await askPromotion("white");
world.log("white promotes to " + piece + ".");
const bonuses = await askBonuses("red");
world.log("red takes " + bonuses.join(" and ") + ".");
})();
A long list with a filter box#
A card says "name a subtype" and the game has dozens. The list gets a filter box on its own above 12 options; the second question turns it on for a short list.
// content/scripting-api/examples/concepts.game-ui-choice-search.ts
// Scene script: a card says "name a subtype", and the game has dozens of
// them. A long list gets a filter box: the player types "tro" and only
// Trooper, Rebel Trooper and friends are left (VR players get a row of
// letter tabs instead). Lists above 12 options get the box on their own;
// `search` turns it on for a short list, sets its hint, or turns it off.
//
// What the player types stays on their screen. The answer is still just
// the id of one option you offered, and the host checks it is one of them.
const SUBTYPES: readonly string[] = [
"Astromech", "Bounty Hunter", "Clone", "Droid", "Ewok", "Gungan",
"Jedi", "Pilot", "Rebel", "Scout", "Sith", "Smuggler", "Trooper", "Wookiee"
];
function subtypeId(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
}
async function nameASubtype(seat: string): Promise<string | null> {
const result = await ui.prompt(seat, {
title: "Name a subtype",
body: "Your units of that subtype get **+1 power** this turn.",
choices: {
options: SUBTYPES.map((name) => ({ id: subtypeId(name), label: name })),
// 14 options would get the box anyway; this sets its hint.
search: { placeholder: "Type a subtype, such as Trooper" }
},
timeoutSeconds: 60,
dismissible: true
});
if (result.bot || result.cancelled || result.actionId === null) {
return null;
}
// actionId is an id from SUBTYPES: never text the player typed.
return SUBTYPES.find((name) => subtypeId(name) === result.actionId) ?? null;
}
async function nameACardType(seat: string): Promise<string | null> {
const result = await ui.prompt(seat, {
title: "Name a card type",
choices: {
options: [
{ id: "character", label: "Character" },
{ id: "vehicle", label: "Vehicle" },
{ id: "starship", label: "Starship" },
{ id: "weapon", label: "Weapon" },
{ id: "battle", label: "Battle" }
],
// Only five options: no box unless you ask for one.
search: true
}
});
return result.actionId;
}
void (async () => {
const subtype = await nameASubtype("red");
if (subtype !== null) world.log("red names " + subtype + ".");
const cardType = await nameACardType("blue");
if (cardType !== null) world.log("blue names " + cardType + ".");
})();
Picking cards from a hand and a deck#
A discard, a mulligan with a Keep hand button, and a deck search, each answered by picking the card's picture (Picking cards). The hand is named by entity, so each screen draws what that player may see; the deck search names catalogue ids, so it goes to one seat only.
// content/scripting-api/examples/concepts.game-ui-card-choice.ts
// Scene script: three card questions answered by picking PICTURES of cards
// rather than their names.
//
// 1. Discard: one pick from the seat's hand. Each option names the card's
// table entity with `entityId`, and each player's screen draws what that
// player may see of it: the face of a card in their own hand, the back of
// anyone else's.
// 2. Mulligan: pick the cards to shuffle back (several picks and a confirm),
// or press "Keep hand", a plain button beside the cards.
// 3. Deck search: the cards are not entities, so each option names a card id
// from the game's card catalogue with `cardId`. Everyone the prompt reaches
// sees those faces, so it goes to one seat only, which ui.prompt does.
//
// Always give a label: it is drawn while the art loads or if it cannot be
// found, and it is what a screen reader announces.
type HandCard = { id: string; name: string };
async function handOf(tag: string): Promise<HandCard[]> {
const cards = await world.getAllObjects({ kind: "card", tag });
return cards.map((card) => ({ id: card.id, name: card.name ?? "Card" }));
}
async function askDiscard(seat: string, hand: readonly HandCard[]): Promise<string | null> {
if (hand.length === 0) return null;
const result = await ui.prompt(seat, {
title: "Discard a card",
choices: {
// One pick and no confirm: clicking a card answers.
options: hand.map((card, index) => ({ id: "d" + index, label: card.name, entityId: card.id }))
},
timeoutSeconds: 60,
defaultAction: "d0"
});
const index = result.actionId === null ? 0 : Number(result.actionId.slice(1));
const picked = hand[index];
return picked ? picked.id : null;
}
async function askMulligan(seat: string, hand: readonly HandCard[]): Promise<string[]> {
if (hand.length === 0) return [];
const result = await ui.prompt(seat, {
title: "Keep this hand?",
body: "Pick the cards to shuffle back, or keep them all.",
choices: {
options: hand.map((card, index) => ({ id: "m" + index, label: card.name, entityId: card.id })),
min: 1,
max: hand.length,
// The picked option ids arrive in result.value.
confirm: { id: "shuffle-back", label: "Shuffle these back" }
},
// A plain button beside the cards: no picks needed.
actions: [{ id: "keep", label: "Keep hand", emphasis: "strong" }]
});
if (result.actionId !== "shuffle-back" || !result.value) {
return []; // kept, timed out, or a bot's seat
}
const back: string[] = [];
for (const optionId of result.value) {
const card = hand[Number(optionId.slice(1))];
if (card) back.push(card.id);
}
return back;
}
async function searchDeck(seat: string, catalogueIds: readonly string[]): Promise<string | null> {
if (catalogueIds.length === 0) return null;
const result = await ui.prompt(seat, {
title: "Search your deck",
body: "Take one card into your hand.",
choices: {
options: catalogueIds.slice(0, 60).map((cardId, index) => ({ id: "s" + index, label: cardId, cardId }))
}
});
if (result.actionId === null) return null;
return catalogueIds[Number(result.actionId.slice(1))] ?? null;
}
void (async () => {
const hand = await handOf("red-hand");
const putBack = await askMulligan("red", hand);
world.log("red shuffles back " + putBack.length + " card(s).");
const discarded = await askDiscard("red", hand);
if (discarded !== null) world.log("red discards a card.");
const found = await searchDeck("red", ["AOTC-001", "AOTC-014", "AOTC-027"]);
if (found !== null) world.log("red takes " + found + ".");
})();
Showing an attack#
Two card spotlights while one card attacks another: the attacker and the defender side by side at the bottom of the screen, each with its speed, power and health coloured by how they moved from the printed values, and their counters on the pictures.
// content/scripting-api/examples/concepts.game-ui-card-spotlight.ts
// Scene script: while one card attacks another, both are drawn large at the
// bottom of every screen, the attacker first and the defender beside it.
// Each shows its speed, power and health as stat chips: a value the game has
// raised from the printed one is green, a lowered one is red, an unchanged
// one is white. Each chip draws the game's own stat symbol (a declared image
// in the project) instead of the word; the word is still what a screen reader
// announces and what the chip's tooltip says. The cards' counters (damage, shields and so on) are drawn on
// the pictures with their counts, exactly as on the table.
//
// `card.entityId` names the table entity, so each player sees what they may
// see of it (the back of a card hidden from them). `card.label` is required:
// it is the text drawn when there is no picture, and what a screen reader
// announces.
type Stats = { speed: number; power: number; health: number };
type Fighter = { entityId: string; name: string; now: Stats; printed: Stats };
function change(now: number, printed: number): "up" | "down" | "same" {
return now > printed ? "up" : now < printed ? "down" : "same";
}
function spotlight(id: string, title: string, fighter: Fighter, order: number): UiWidget {
const { now, printed } = fighter;
return {
id,
kind: "card",
title,
placement: { region: "lower", order },
card: { entityId: fighter.entityId, label: fighter.name },
stats: [
{ label: "Speed", value: String(now.speed), change: change(now.speed, printed.speed), iconImage: "ui/stat-speed.webp" },
{ label: "Power", value: String(now.power), change: change(now.power, printed.power), iconImage: "ui/stat-power.webp" },
{ label: "Health", value: String(now.health), change: change(now.health, printed.health), iconImage: "ui/stat-health.webp" }
]
// Counters are drawn by default; `counters: false` leaves them off.
};
}
function showAttack(attacker: Fighter, defender: Fighter): void {
ui.show(spotlight("attacker", "Attacking", attacker, 0));
ui.show(spotlight("defender", "Defending", defender, 1));
}
function endAttack(): void {
ui.hide(["attacker", "defender"]);
}
void (async () => {
const [first, second] = await world.getAllObjects({ kind: "card", tag: "in-play" });
if (!first || !second) return;
showAttack(
{ entityId: first.id, name: first.name ?? "Attacker", now: { speed: 40, power: 6, health: 5 }, printed: { speed: 40, power: 4, health: 5 } },
{ entityId: second.id, name: second.name ?? "Defender", now: { speed: 30, power: 3, health: 2 }, printed: { speed: 30, power: 3, health: 4 } }
);
// A stat changes mid-attack: update just that widget.
ui.update("defender", {
stats: [
{ label: "Speed", value: "30", iconImage: "ui/stat-speed.webp" },
{ label: "Power", value: "3", iconImage: "ui/stat-power.webp" },
{ label: "Health", value: "1", change: "down", iconImage: "ui/stat-health.webp" }
]
});
endAttack();
})();
A seat-private hint#
A line in the shared status widget that each player reads differently, and a hint pinned beside the draw pile that only the active player receives.
// content/scripting-api/examples/concepts.game-ui-private-hint.ts
// Scene script: coaching that only the player it is for can see. Each turn,
// the active seat gets (1) its own "what to do now" line inside the shared
// status widget, and (2) a hint pinned beside the draw pile.
//
// Both are PRIVATE: the host sends them to that seat's player alone. The other
// players' clients never receive the text, so it cannot leak through a
// screenshot, a browser tool or a modified client.
ui.show({
id: "status",
kind: "status",
title: "{phase}",
// {seat.howTo} reads the VIEWER's own seat. "|" gives a fallback, so a
// player with nothing private to read (and every spectator) sees no line.
body: "{seat.howTo|}"
});
globalEvents.onTurnStarted.add((turn) => {
const seat = turn.seat;
if (seat === null) {
return;
}
ui.set("phase", seat + " to play");
// Private seat state: only `seat` ever receives this value.
ui.set("howTo", "Draw a card, then play one or pass.", seat, { private: true });
// A private widget: the audience is one seat, so it rides the private
// channel too. It is drawn in the world, beside the entity tagged
// "draw-pile", and goes away by itself after 20 seconds. If that entity is
// missing it falls back to the "upper" region instead.
ui.show({
id: "hint-draw",
kind: "hint",
audience: { scope: "seats", seats: [seat] },
placement: { attach: { to: "entity", tag: "draw-pile" }, region: "upper" },
text: "Draw from here",
theme: { icon: "touch_app" },
timing: { duration: 20 }
});
});
globalEvents.onTurnEnded.add(() => {
// Clear the line for everyone: null removes a value. Without a seat this
// would touch only the shared (non-seat) value, so name each seat.
for (const player of world.getPlayers()) {
if (player.seat !== null) {
ui.set("howTo", null, player.seat, { private: true });
}
}
ui.hide("hint-draw");
});
A host "New game" bar#
Controls only the host sees, with a confirm step, an enabled rule and a button that changes its label and icon while it is active.
// content/scripting-api/examples/concepts.game-ui-new-game.ts
// Scene script: a control strip only the host sees, with a "New game" button
// that asks first and a Pause button that turns into Resume while paused.
//
// The audience is the host, so this is private to the host's screen: the
// other players never receive it, and a click from anyone else is refused by
// the host before ui.onAction ever runs.
ui.show({
id: "host-controls",
kind: "actions",
audience: { scope: "host" },
placement: { region: "lower-end" },
actions: [
{
id: "new-game",
label: "New game",
icon: "restart_alt",
// The platform draws its own "Are you sure?" step before anything is sent.
confirm: "Start over? The current game is lost."
},
{
id: "pause",
label: "Pause",
icon: "pause",
// While the state key "paused" is true the button draws its ACTIVE
// state. Every state you leave out inherits from the default look.
activeWhen: { state: "paused", is: true },
states: {
active: { label: "Resume", icon: "play_arrow", tone: "accent" },
disabled: { label: "Game over" }
},
// Pressable only while a game is running. The host checks this again
// when the click arrives, so a stale client cannot get past it.
enabledWhen: { state: "running", is: true }
}
]
});
let paused = false;
function startGame(): void {
paused = false;
ui.setMany({ running: true, paused: false });
ui.toast("A new game begins.", { tone: "info", icon: "play_arrow" });
world.setGameOver(null);
}
ui.onAction.add((event) => {
if (event.widgetId !== "host-controls") {
return;
}
if (event.actionId === "new-game") {
startGame();
} else if (event.actionId === "pause") {
paused = !paused;
ui.set("paused", paused);
ui.toast(paused ? "The host paused the game." : "The game goes on.", { tone: "warning" });
}
});
startGame();
A mod that asks with api.uiPrompt#
The same ideas from a mod script. Every api.ui* call returns a promise, and a player's copy of
the mod has its calls rejected, because only the host draws the HUD.
// content/scripting-api/examples/concepts.game-ui-mod-prompt.js
// Mod script: at the start of each turn, ask the player whose turn it is to
// name trumps, then show the answer to everyone in a status line.
// manifest capabilities.allowed: ["subscribe-events", "ui"]
//
// Every api.ui* call is HOST ONLY. This script runs on every peer, so a
// player's copy of it reaches the same calls and has them rejected; the
// try/catch blocks are what keep that copy quiet.
/** @param {ModApi} api */
exports.setup = async function setup(api) {
try {
await api.uiShow({
id: "trumps",
kind: "status",
placement: { region: "upper" },
theme: { icon: "playing_cards" },
title: "Trumps: {trump|not chosen yet}"
});
} catch (error) {
return;
}
api.on("onTurnStart", async (turn) => {
if (turn.seat === null) {
return;
}
let result;
try {
result = await api.uiPrompt(turn.seat, {
title: "Name trumps",
choices: {
options: [
{ id: "hearts", label: "Hearts" },
{ id: "diamonds", label: "Diamonds" },
{ id: "clubs", label: "Clubs" },
{ id: "spades", label: "Spades" }
]
},
timeoutSeconds: 45,
defaultAction: "spades"
});
} catch (error) {
// An invalid prompt, or this copy is not the host's.
return;
}
// A bot never sees prompts: decide for it. A seat that emptied, or a
// prompt that was replaced, answers nothing.
let trump = result.actionId;
if (result.bot) {
trump = "spades";
}
if (result.cancelled || trump === null) {
return;
}
await api.uiSet({ trump });
await api.uiToast(turn.seat + " names " + trump + ".", { tone: "info" });
});
};
When to still use world.setTextLabel#
Text labels are for words that are part of the world: the kind a printer would put on the physical board or mat. Square and column names along a board's edge, the name printed beside each supply pile, the word "Discard" on a seat's mat, a scoreboard your game models as an object on the table. They lie flat, stay on the board as the camera moves, and are seen by everyone.
Do not use them for the phase, whose turn it is, what to do next, a warning, a result, or a button. Those used to be drawn as text on the felt only because nothing else existed, and they had real costs: every player saw every instruction, a card dropped on them hid them, they read upside down from half the seats, and a spawned "Pass" button could be knocked off the table. All of those belong in the HUD now.
What is not there yet#
- A prompt chime in VR. A new prompt is silent in a headset.
- Card pictures in VR and the Edit Mode preview. A button drawn as a card shows its label
there instead of the picture, and a
cardspotlight on the VR tray shows its label and stats as text, without the picture or the counter chips. - Authored UI layouts. Widgets are created from scripts only. Showing a widget by
idmeans re-showing one your script showed before; designing a layout in Edit Mode and attaching it to a variant is not available yet. dismissibleanduntil: "dismissed"are accepted and do nothing yet. A player can tuck a prompt away with Escape or ×, but that never answers it or tells your script.- Edit Mode preview. With ▶ Play Scripts running, the editor draws the HUD as the host
sees it with no seat: public and host widgets, banners and toasts. Widgets addressed to a seat
are not shown, and
ui.promptresolvescancelledbecause nobody is sitting down. Play-test prompts at a table.
See also#
- The
uiglobal — every member and widget shape, for table scripts. - The mod
apiobject —api.uiShowand the six calls beside it. - Mod Capabilities — what the
uicapability gates. - Choosing a surface — which script surface to write your game in.
- Host authority — why every HUD write runs on the host.
world.setTextLabel— words that belong on the board.- Highlights and clicks — outline entities (privately per seat, like private state) and hear which one a player clicked.
- Known limitations — the gaps above, with their status.
