Table Scripting (Surface A)
Game UI (ui)
The ui global for table scripts - show HUD widgets, bind their text to state, ask a seat with ui.prompt and answer presses in ui.onAction.
On this page23 sections
The ui global is a table script's way to draw the Game UI, the HUD: status lines, banners,
prompts, action strips, choice lists, player panels, counters, a log and hints, drawn by the
platform off the table. This page is the reference for every member and every shape it takes.
Game UI (HUD) is the guide: read it first for what each
widget kind is for, where the platform puts it on each device, who can see it, and five worked
examples.
A mod reaches the same widgets through api.uiShow, api.uiUpdate, api.uiHide, api.uiSet,
api.uiToast, api.uiLog and api.uiPrompt, with the ui capability. They take the same
objects under ModUi* names, and are documented on the mod api page.
Four facts every entry on this page depends on#
Every write runs on the host. ui changes the table, so like every other write it is refused
on any other peer. ui.prompt rejects with not-host there; the other calls report a diagnostic.
A refusal is a diagnostic, not an exception. ui.show, ui.update, ui.hide, ui.set,
ui.setMany, ui.toast and ui.log return nothing. When the host refuses one (an unknown
kind, font or icon, a URL, a field over its cap, too many widgets) nothing changes and a line
naming the first problem goes to the script console in Edit Mode and the event log at a live
table. That line is the first place to look when a widget does not appear.
Your widgets are your game's. Widgets, state keys and log lines belong to your game's scripts. Two scripts in the same game share them; no other game, Asset pack or mod can touch them, and you cannot touch theirs.
Restarting your scripts clears prompts and timed widgets, not the rest. Persistent widgets and state stay on the table, so draw your HUD again from your own state when a script starts.
The generated declarations follow.
UiTone#
Surface A — table script · type
A tone: the platform's colour role for a widget, a button or a line. Never a colour of your own.
declare type UiTone = "neutral" | "info" | "success" | "warning" | "danger" | "accent";
A tone: the platform's colour role for a widget, a button, a counter, a badge, a toast or a log
line. One of "neutral", "info", "success", "warning", "danger" or "accent". It says what a
thing means; the platform decides what that looks like.
How, why and when to use it#
Pick the tone for the message's meaning: warning for "not yet", danger for "you lost a life",
success for "you win", info for neutral news, accent for the thing you want the eye drawn to.
Players learn one meaning per colour across every game, which is why there is no free colour here.
A widget's or button's accent (a named palette colour or #rrggbb) is the place for your game's
own colour, on top of a tone.
Gotchas#
Any other string is refused when you write it. The whole call is refused; it does not fall back
to neutral.
Not the basic widgets' variants. A basic button's variant: "primary" is a different, older
vocabulary; "primary" is not a tone.
See also#
UiWidget—theme.tone.UiButtonStyle— a button's tone per state.- The look.
UiRegion#
Surface A — table script · type
Where a widget goes on a flat screen: a REGION, never a pixel position. sheet is the slot for
questions (a dialog on desktop, a bottom sheet on a phone). The platform decides what each
region means on each device.
declare type UiRegion =
| "upper-start" | "upper" | "upper-end"
| "start" | "center" | "end"
| "lower-start" | "lower" | "lower-end"
| "sheet";
Where a widget goes on a flat screen: a region, never a pixel position. The nine regions are
"upper-start", "upper", "upper-end", "start", "center", "end", "lower-start",
"lower" and "lower-end"; "sheet" is the slot for questions.
How, why and when to use it#
Name the region that matches the widget's job and let the platform fit it around its own controls.
Each kind has a sensible default (status in upper, actions in lower, log in end, prompts
in sheet), so most widgets need no region at all.
What a region means depends on the device:
| Region | Desktop | Phone and tablet |
|---|---|---|
upper-start, upper, upper-end, start |
Their own places in a grid over the table view, clear of the toolbar, dice dock, hand drawer, match bar and chat | Folded into upper, a one-line pill the player taps to open |
end, lower-start, lower, lower-end |
Their own places | Folded into lower |
center |
The middle | The middle |
sheet |
A dialog | A bottom sheet above the hand drawer |
Widgets that share a region are sorted by placement.order, smaller first.
Gotchas#
There is no region called "top". The word is reserved by the script scanner, which rejects any
script that contains it; upper is the same place.
A phone folds regions together, so two widgets you placed far apart on a desktop can sit next to
each other there. Use order to say which comes first.
In VR and AR there are no regions. Every region goes to the tray beside the player, and sheet
is a dialog within reach.
See also#
UiSize#
Surface A — table script · type
A size: pixels (240) or a percent of the table screen ("40%"). A width may also be
"auto" (fit the content, the default) or "fill" (as wide as its region); a height "auto".
Whatever you ask for, a widget never draws wider than the screen or taller than 85% of it.
declare type UiSize = number | `${number}%`;
A size in a UiStyle or UiRegionStyle: a whole number of pixels
(0-1600) or a percent of the table screen written as text, "1%" to "100%". A width may also be
"auto" (fit the content) or "fill" (as wide as its region), and a height "auto".
How, why and when to use it#
Use pixels for something that should keep its size (a 240 px score box) and percents for something that should follow the screen (a status line 40% of the screen wide).
Gotchas#
A percent is of the whole screen, not the region. "50%" on a phone held sideways is half of
that phone's width. Every size is capped to the screen's width (less the platform's edge margins)
and 85% of its height.
See also#
UiSpacing#
Surface A — table script · type
Spacing in pixels: one number for every side, or per side (above, below, start, end, or x / y for both of a pair).
declare type UiSpacing = number | { x?: number; y?: number; above?: number; below?: number; start?: number; end?: number };
Padding or margin in a UiStyle: one whole number of pixels for every side, or
{ x?, y?, above?, below?, start?, end? } per side. x sets start and end, y sets above
and below, and a named side wins over them. Padding is 0-48 px, margin 0-64 px.
How, why and when to use it#
Tighten a widget on a phone (padding: 4) or give a panel room to breathe
(padding: { x: 18, y: 12 }). start is the left side in left-to-right text.
Gotchas#
A side left out of the object is 0. { above: 8 } means 8 above and nothing on the other sides,
not "8 above and the platform's padding elsewhere".
See also#
UiColor#
Surface A — table script · type
A colour: a palette name or "#rrggbb".
declare type UiColor = "red" | "orange" | "yellow" | "green" | "teal" | "blue" | "purple" | "pink" | "gray" | `#${string}`;
A colour in a UiStyle: a palette name ("red", "orange", "yellow", "green",
"teal", "blue", "purple", "pink", "gray") or "#rrggbb". The same vocabulary as
theme.accent.
How, why and when to use it#
Match your game's art: a faction colour for a border, a parchment background for a rules panel.
Palette names are tuned for the platform's dark surface; #rrggbb gives you any colour.
Gotchas#
Only these two forms. "#abc", rgb(…), transparent and colour names outside the palette are
refused. A text colour is lightened or darkened until it reads on its background.
See also#
UiStyle#
Surface A — table script · type
A widget's look and size, from a CLOSED list of keys (never CSS; an unknown key is refused).
Every number is clamped: sizes up to 1600 px, padding 0-48, margin 0-64, gap 0-32, the nudge
(offsetX / offsetY) ±200, border 0-8, radius 0-48, font size 10-32, lineClamp 1-12.
A text color is lightened or darkened until it reads on its background. The platform's own
controls always draw above your widgets.
declare type UiStyle = {
width?: UiSize | "auto" | "fill";
minWidth?: UiSize;
maxWidth?: UiSize;
height?: UiSize | "auto";
maxHeight?: UiSize;
/** Its place across its region. `stretch` is the region's full width. */
align?: "start" | "center" | "end" | "stretch";
/** A nudge from where the platform puts it, in pixels (±200). */
offsetX?: number;
offsetY?: number;
padding?: UiSpacing;
margin?: UiSpacing;
/** Space between its own children (buttons, chips, rows), in pixels. */
gap?: number;
background?: UiColor;
/** 0-100 (default 92). */
backgroundOpacity?: number;
color?: UiColor;
borderWidth?: number;
borderColor?: UiColor;
borderStyle?: "none" | "solid" | "dashed" | "dotted" | "double";
radius?: number;
shadow?: "none" | "soft" | "strong";
fontSize?: number;
fontWeight?: "regular" | "medium" | "semibold" | "bold" | "heavy";
textAlign?: "start" | "center" | "end";
/** Cut each paragraph of the body to this many lines. */
lineClamp?: number;
/** `actions`, `prompt` and `choice` buttons: their row, size and how each shows its icon and label. */
buttons?: {
direction?: "row" | "column";
wrap?: boolean;
size?: "sm" | "md" | "lg";
presentation?: "beside" | "under" | "icon-only" | "label-only";
align?: "start" | "center" | "end" | "stretch";
};
};
A widget's look and size, from a closed list of keys: UiWidget.style, and per screen in
placement.devices.<desktop|tablet|phone>.style, which is merged over it on that screen only. It
is not CSS: there are no property names, class names, selectors or url(…), and a key that is not
listed here is refused.
| Group | Keys | Values |
|---|---|---|
| Size | width, minWidth, maxWidth, height, maxHeight |
a UiSize; width may also be "auto" or "fill" (as wide as its region), height "auto" |
| Place | align |
"start", "center", "end", "stretch": where it sits across its region |
offsetX, offsetY |
a nudge in pixels, -200 to 200 (positive = right / down) | |
| Spacing | padding (0-48), margin (0-64) |
a UiSpacing |
gap |
0-32 px between its own children (buttons, chips, rows) | |
| Colour | background, color, borderColor |
a UiColor |
backgroundOpacity |
0-100 (default 92) | |
| Border | borderWidth (0-8), radius (0-48) |
pixels |
borderStyle |
"none", "solid", "dashed", "dotted", "double" |
|
shadow |
"none", "soft", "strong" |
|
| Type | fontSize |
10-32 px; headings and labels scale with it |
fontWeight |
"regular", "medium", "semibold", "bold", "heavy" |
|
textAlign |
"start", "center", "end" |
|
lineClamp |
1-12: cut each paragraph of the body to that many lines | |
| Buttons | buttons.direction |
"row" or "column" |
buttons.wrap |
true wraps a row, false keeps one scrolling line |
|
buttons.size |
"sm", "md" (the platform's), "lg" |
|
buttons.presentation |
"beside", "under", "icon-only", "label-only" |
|
buttons.align |
"start", "center", "end", "stretch" |
How, why and when to use it#
Use it to make a widget fit your game: a wider status line, a tight column of action buttons on a
phone, a parchment-coloured rules panel, a big bold score. Leave a key out to keep the platform's
value. Put the keys that should differ by screen in placement.devices.phone.style (or tablet,
desktop) rather than in style, so changing the phone never moves anything on a desktop.
Gotchas#
The platform still owns placement. A widget stays in its region; align and the nudge move it
within reach of where the platform puts it, never to a screen position. Whatever size you ask for,
a widget is never wider than the screen or taller than 85% of it, and the table's own controls
(menu, toolbar, dice, hand, chat) always draw above your widgets.
Text colour is kept readable. A color too close to its background is lightened or darkened
until it reads, and a light background with no color gets dark text.
A question keeps the sheet's size. On a prompt or choice only the look applies (colours,
border, type, padding, buttons); the sheet's size and place are the platform's.
An unknown key is an error. ui.show / ui.update refuse a widget whose style names a key
that is not in this list, so a typo is caught rather than ignored. VR draws its own panel and
ignores style.
See also#
UiRegionStyle— how a whole region lays out its widgets.UiWidget.- Styling widgets.
UiRegionStyle#
Surface A — table script · type
How a region lays out its widgets (on a phone: the corner slot its widgets land in).
declare type UiRegionStyle = {
direction?: "column" | "row";
align?: "start" | "center" | "end" | "stretch";
justify?: "start" | "center" | "end" | "between";
gap?: number;
wrap?: boolean;
maxWidth?: UiSize;
maxHeight?: UiSize;
offsetX?: number;
offsetY?: number;
};
How one region lays out its widgets: { direction?, align?, justify?, gap?, wrap?, maxWidth?, maxHeight?, offsetX?, offsetY? }. A UI layout sets it per region in its regions map (with
devices.<desktop|tablet|phone> keys for one screen, merged over it there); a widget carries it as
placement.regionStyle (and per screen in placement.devices.<class>.regionStyle).
| Key | What it does |
|---|---|
direction |
"column" stacks the widgets (the platform's); "row" sets them side by side. |
align |
"start", "center", "end", "stretch": where the widgets sit across the region. |
justify |
"start", "center", "end", "between": how they spread along it. |
gap |
0-32 px between the widgets. |
wrap |
A row wraps onto more rows (true) or keeps one line (false). |
maxWidth, maxHeight |
A UiSize in px or % of the screen; more content scrolls. |
offsetX, offsetY |
Move the whole region, -200 to 200 px. |
How, why and when to use it#
Lay a row of counters across the upper edge, put two status lines side by side, or let the lower
region grow wider for a long action bar. In a UI layout, set it once in the layout's regions; the
platform copies it to the widgets in that region when the layout is shown.
Gotchas#
The first widget in a region decides. When widgets from different layouts (or a script) style the same region differently, the first one in draw order wins.
On a phone it styles a corner slot. A phone deals widgets into its own slots: upper-end's
style reaches the stats stack, center's the centre, and the action dock or card row takes the
style of the one region all of its widgets were placed in. The status pill and the log tab are
never styled. A slot stays in its corner: the max sizes only shrink it and the nudge only moves it
away from the screen's edges.
sheet is not a region you can style. Questions always use the platform's sheet.
See also#
UiAudience#
Surface A — table script · type
Who sees a widget (and may press it). Everything except "all" is private to its audience.
declare type UiAudience =
| { scope: "all" }
| { scope: "host" }
| { scope: "players" }
| { scope: "spectators" }
| { scope: "seats"; seats: readonly string[] }
| { scope: "teams"; teams: readonly string[] }
| { scope: "seat-each" }
| { scope: "actor" }
| { scope: "peers"; peerIds: readonly string[] };
Who sees a widget, and who may press it. { scope: "all" }, the default, is the only public
audience. Every other audience is private: the host sends the widget only to the players in it,
and nobody else's computer ever receives it.
| Audience | Who |
|---|---|
{ scope: "all" } |
Everyone, spectators included |
{ scope: "players" } |
Everyone in a seat |
{ scope: "spectators" } |
Everyone watching without a seat |
{ scope: "host" } |
The host's player |
{ scope: "seats", seats: [...] } |
The players in those seats |
{ scope: "teams", teams: [...] } |
The players on those teams |
{ scope: "seat-each" } |
One copy per seat, each private to its seat |
{ scope: "actor" } |
Whoever pressed the widget your action handler is answering |
{ scope: "peers", peerIds: [...] } |
Those connections |
How, why and when to use it#
Use a private audience for anything that would spoil the game if the wrong person read it: what
this seat may do now, a secret objective, a choice of cards from their own hand, the host's
controls. Use seat-each when every seat gets the same widget about its own situation: a "Done"
button each seat presses once, a status line that reads each seat's private values through
{seat.key}.
Membership is decided by the host from its own list of who sits where, both when the widget is shown and when it is pressed. A player who changes seat loses the old seat's private widgets and gains the new seat's.
Gotchas#
actor works only inside an action handler, before its first await. Anywhere else the call is
refused. It becomes that player's connection when the widget is shown, so it stays with that person
even if they later change seat.
Spectators never see a seat's widgets, and press a widget only when it says
allow: ["spectators"].
A seat-each prompt or choice, and a seat-each widget with until: "action", close one seat at
a time. Each seat answers once; the widget goes away when every occupied seat has.
Private is private from the other players, not from the host. The host's computer runs your scripts and holds every private widget, as it already holds every hidden card.
See also#
UiWidget—audienceandallow.ui.set— private seat state, the same privacy for values.- Audiences and privacy.
UiCondition#
Surface A — table script · type
A condition on HUD state, checked for each viewer. seat.<key> reads the VIEWER's own seat's
value. No expressions: these node shapes only, at most 4 deep and 16 nodes.
declare type UiCondition =
| { state: string; is: string | number | boolean }
| { state: string; in: readonly (string | number | boolean)[] }
| { state: string; truthy: boolean }
| { variant: readonly string[] }
| { all: readonly UiCondition[] }
| { any: readonly UiCondition[] }
| { not: UiCondition };
A condition on HUD state, checked for each viewer: a small tree of data, never an expression or
code. A widget's when, a button's enabledWhen and activeWhen, and a player-panel badge's when
all take one.
| Node | True when |
|---|---|
{ state: "phase", is: "bidding" } |
The value equals this text, number or boolean |
{ state: "round", in: [1, 2, 3] } |
The value equals one of these (at most 32) |
{ state: "seat.canPass", truthy: true } |
The value is truthy (with truthy: false: it is not, or it is unset) |
{ variant: ["classic", "quick"] } |
The table is playing one of these game variants |
{ all: [...] }, { any: [...] }, { not: ... } |
Combine the above |
state names a key the way a binding does: phase for a shared value, seat.canPass for the
viewer's own seat's value, public or private.
How, why and when to use it#
Conditions let one widget serve every situation without the script redrawing it: show the bidding
strip only when the phase is bidding, enable Pass only for the player whose turn it is
({ state: "seat.myTurn", truthy: true }), draw a toggle active while a value is set. Because each
viewer evaluates it with their own seat's values, one actions widget shown to everyone can be
enabled for exactly one player.
The host evaluates enabledWhen again, for the person pressing, before your handler runs, so a
button that looks enabled on an out-of-date screen still cannot be pressed when it should not be.
Gotchas#
At most 4 deep and 16 nodes. Deeper or larger trees are refused.
No comparisons and no arithmetic. There is no "greater than". Set a boolean such as
canAfford from your script and test that.
An unset key equals nothing and is not truthy. { not: { state: "x", is: 1 } } is true while
x is unset.
Every seat. key is unset for a spectator, who has no seat.
See also#
UiImageRef#
Surface A — table script · type
An image you may BIND: a repo-relative raster path (ui/side-light.png), or ONE state binding
whose value is such a path — "{seat.sideIcon}", "{phaseIcon}", or with a fallback image
"{seat.sideIcon|ui/side-neutral.png}". The whole string is the binding. The value is drawn
only when it is itself a repo-relative PNG / JPEG / WebP path of your game (never a URL);
anything else draws the fallback, or no image. In a player-panel a seat. binding reads each
row's own seat, so one widget shows every seat its own symbol. A private seat value is drawn
only for its own seat.
declare type UiImageRef = string;
An image field that may bind state. Either a repo-relative raster path from your game's own
repository (ui/side-light.png), or exactly ONE binding whose value is such a path:
"{seat.sideIcon}", "{phaseIcon}", or with a fallback image "{seat.sideIcon|ui/side-neutral.png}".
The whole string is the binding; there is no text around it.
These fields take one: a widget's image, theme.iconImage, and a counter's or badge's
iconImage. Button images and backgroundImage stay plain paths.
How, why and when to use it#
Use a binding when ONE widget should show a different picture per seat or per moment: each
player's faction or side symbol in the player list, the current phase's emblem in a status header.
Declare the key as a string state key, set each seat's value to a path with
ui.set, and the widget follows it. Each seat's side symbol
is a worked example.
In a player-panel a seat. binding reads each row's own seat, so every player sees every
seat's symbol. Anywhere else seat. reads the viewer's own seat, like a text binding.
Gotchas#
The value is drawn only when it is itself a repo-relative .png/.jpg/.jpeg/.webp path of
your game. A URL (https:, data:, blob:), an absolute path, .., SVG, a number or an unset
key draws the fallback, or no image (a counter or badge then shows its icon). Nothing is fetched
from anywhere but your own repository.
A private seat value is drawn only for its own seat. The image follows the same privacy as the value: another seat's private value never reaches this player, so their row shows the fallback.
Publishing checks the fallback, not the values. The fallback and every plain path must be a declared PNG, JPEG or WebP file of your game; the values your script sets at run time cannot be known in advance, so keep the symbols in your repository and set their exact paths.
See also#
UiButtonStyle#
Surface A — table script · type
How one button state looks. Every field is optional; a state you leave out inherits from
default. Images are repo-relative raster paths (ui/pass.webp), never URLs.
declare type UiButtonStyle = {
label?: string;
icon?: string;
iconImage?: string;
image?: string;
tone?: UiTone;
accent?: string;
emphasis?: "subtle" | "normal" | "strong";
};
How one button state looks: label, icon, iconImage, image, tone, accent and
emphasis, all optional. A UiButton carries one of these for its base look and one
for each of its five states in states.
| Field | What it sets |
|---|---|
label |
The text, at most 120 characters. Bindings such as {seat.bet} are allowed. |
icon |
A name from the HUD icon list. |
iconImage |
A small image from your repository, drawn where an icon would be. |
image |
A full image face: the button is drawn as this picture, and the label stays as the text a screen reader announces. |
tone |
A UiTone. |
accent |
A palette name (red, orange, yellow, green, teal, blue, purple, pink, gray) or #rrggbb. |
emphasis |
"subtle", "normal" or "strong". |
How, why and when to use it#
Use the state styles to make a button say what it will do: a Pause button that reads Resume
while the game is paused, a Bet button that turns danger when the bet is all-in, a greyed "Wait
for your turn" while it is disabled. A state you leave out inherits from default, so you only
write what changes.
Gotchas#
Images are repo-relative raster paths only: .png, .jpg, .jpeg or .webp from your game's
repository, such as ui/buttons/pass.webp. A URL of any kind, an absolute path, .. and SVG are
refused.
An image that fails to load falls back to the label. Give every image button a label.
An unknown icon name refuses the whole widget when you show it.
See also#
UiButton#
Surface A — table script · type
One HUD button (in actions, a prompt, a choice list). A press reaches ui.onAction (or
resolves ui.prompt) as data: the widget id and this id. There is no code and no URL.
declare type UiButton = UiButtonStyle & {
/** Unique within its widget. Letters, digits, `_ . : -`, at most 48 characters. */
id: string;
/** Per-state styling: `default`, `hover`, `pressed`, `active` and `disabled`. */
states?: { default?: UiButtonStyle; hover?: UiButtonStyle; pressed?: UiButtonStyle; active?: UiButtonStyle; disabled?: UiButtonStyle };
/** Pressable only while this holds for the viewer. The host checks it again. */
enabledWhen?: UiCondition;
/** Draw the `active` state while this holds for the viewer. */
activeWhen?: UiCondition;
/** Ask "Are you sure?" with this text first. */
confirm?: string;
/**
* Draw a card as this button: the card is the button, with the label under it. Use it for
* "pick a card" choices and mulligans. Resolved on each player's own screen, never a URL:
* - `entityId`: a table entity (a card, or a pile whose top card shows). Each player sees
* what they may see of it: its face, or its BACK when it is hidden from them (another
* player's hand, a face-down card). Safe in a widget everyone sees.
* - `cardId`: a card id from your game's card catalogue or a deck on the table, for a card
* that is not an entity. Everyone the widget reaches sees that face, like its name in
* `label`, so use it only in a widget the audience may know it from.
* With both, `entityId` wins while it exists. Keep a `label`: it is the fallback when the
* art cannot be drawn and what a screen reader announces.
*/
cardId?: string;
/** See `cardId`. */
entityId?: string;
};
One HUD button, in an actions widget, a prompt, or as a choice list's confirm. It is a
UiButtonStyle (its base look) plus an id, per-state styles, the conditions
that enable it and make it active, and an optional confirm step. A press reaches
ui.onAction, or resolves ui.prompt, as data: the widget's id and
this id. There is no code and no URL in a button.
| Field | What it does |
|---|---|
id |
Unique within its widget. Letters, digits, _ . : -, at most 48 characters. |
states |
{ default?, hover?, pressed?, active?, disabled? }, each a UiButtonStyle. |
enabledWhen |
A UiCondition: pressable only while it holds for the viewer. |
activeWhen |
A UiCondition: drawn in its active state while it holds. |
confirm |
Text for an "Are you sure?" step the platform draws before the press is sent, at most 200 characters. |
entityId |
Draws the button as this table entity's card, as each player may see it: its face, or its back when it is hidden from them (another player's hand, face down, a hidden zone). |
cardId |
Draws the button as this card from your game's card catalogue (or a deck on the table), face up for everyone the widget reaches. At most 128 characters. |
How, why and when to use it#
The five states, and when each shows:
| State | Shows when |
|---|---|
default |
Normally. Refines the base fields. |
hover |
The pointer is over it. Never on a touch screen. |
pressed |
While it is being clicked or touched. |
active |
While activeWhen holds: a toggle that is on. |
disabled |
While enabledWhen does not hold. |
When several apply, disabled wins, then pressed, active, hover and default.
Use enabledWhen rather than refusing in your handler where you can: a disabled button tells the
player before they press, and the host refuses a press on it anyway, so your handler never sees
one. Use confirm for anything that cannot be undone, such as New game or Resign.
Card pictures. With entityId or cardId the button is drawn as the card, its label under it,
for a "pick a card" question, a target or a mulligan. On a prompt or an actions strip the card
buttons form their own row of cards, ahead of the plain buttons. Resting the mouse on one, or holding
a finger on it, shows it large. See Picking cards.
Gotchas#
A button needs a label, an image face or a card. One with none of them is refused. Give a card button a label anyway: it shows while the art loads or if it cannot be found, and a screen reader reads it.
cardId is a disclosure; entityId is not. Every player the widget reaches sees a cardId's
face, like its name in the label. An entityId is drawn per player from what the table lets them
see, so it is safe in a widget everyone sees. With both, entityId wins while the entity exists.
Ids must be unique within the widget, across its buttons, its options and its confirm.
enabledWhen is checked twice: on the viewer's screen, and by the host for the presser when
the press arrives. It is a rule about who may press, not only a look.
Bindings in a label are resolved once, from your text. A value containing { or ** is drawn
literally; a player's name cannot inject a binding or formatting.
See also#
UiButtonStyle— what each state can change.UiChoiceOption— a button in a choice list.ui.onAction— where a press arrives.- A host "New game" bar — states,
activeWhen,enabledWhenandconfirmtogether.
UiChoiceOption#
Surface A — table script · type
One option of a choice list: a button (a card picture included), plus a description line.
declare type UiChoiceOption = UiButton & {
description?: string;
};
One option in a choice list. It is a UiButton, with everything a button has
(including a card picture, entityId or cardId), plus a description line.
| Field | What it does |
|---|---|
description |
A second line under the label, at most 500 characters. |
entityId |
Draws this table entity as a card: each player sees what they may see of it (its back when it is hidden from them). See UiButton. |
cardId |
Draws this card from your game's card catalogue, face up for everyone the widget reaches. See UiButton. |
How, why and when to use it#
Use plain options for named choices (Queen, Rook, Bishop, Knight). Use entityId or cardId for a
card picker, "choose a card to discard", "pick a target" or a mulligan, so the player picks the card
they can see rather than its name. A list with any card in it is drawn as a grid of cards (one
sideways-scrolling row on a phone).
The option's id is what comes back: in actionId for a single pick, in value for several. The
card it shows never does.
Gotchas#
cardId shows the face to everyone the widget reaches. It is like writing the card's name in the
label. Name a hidden card only in a prompt to the seat that may know it; for a card on the table use
entityId, which hides it per player.
Always give a label, even on a card option. It is drawn while the art loads or if it cannot be found, and it is what a screen reader announces.
An option needs a label, an image face or a card. One with none of them is refused.
Option ids share one namespace with the widget's buttons and confirm, and must be unique among them.
See also#
UiChoiceList— how many may be picked.UiButton.
UiChoiceList#
Surface A — table script · type
A choice list. With max above 1 it needs a confirm button, which carries the picks.
declare type UiChoiceList = {
options: readonly UiChoiceOption[];
min?: number;
max?: number;
confirm?: UiButton;
/**
* A filter box above the options that narrows them as the player types (label and
* description; prefix matches first). `true` shows it, `false` never does, and
* `{ placeholder }` shows it with that hint (at most 80 characters). Omitted, it shows once
* there are more than 12 options. VR shows a letter strip instead. The typed text stays on the
* player's screen: you still receive only the option id(s) they picked.
*/
search?: boolean | { placeholder?: string };
};
A list of options to pick from, with how many may be picked. A prompt or
choice widget carries it as choices.
| Field | What it does |
|---|---|
options |
1 to 200 UiChoiceOptions. |
min |
The fewest picks allowed. Default 1. |
max |
The most picks allowed. Default 1. |
confirm |
A UiButton that sends the picks. Required when max is more than 1. |
search |
The filter box above the options: true shows it, false never does, { placeholder } shows it with that hint (at most 80 characters). Left out, it shows when there are more than 12 options. |
How, why and when to use it#
One pick (max 1, the default, no confirm): pressing an option answers. The option's id
arrives as actionId.
Several picks (max above 1, with a confirm): the player toggles options, then presses the
confirm. The confirm's id arrives as actionId, and the picked option ids as value. The confirm is
enabled only while the number picked is between min and max.
Long lists (a subtype, a card name, a country) get a filter box: the player types and the list
narrows to options whose label or description contains the text, labels that start with it
first. ↑/↓ and Enter pick from the keyboard, and Escape clears the text. VR players get a strip of
letter tabs instead, since a headset has no keyboard. Add a description to an option to make it
findable by more than its label.
Gotchas#
The typed text is never sent. It only narrows what the player sees on their own screen; the answer is an option id like any other. There is no free-text answer: to ask for a name, offer the names.
min ≤ max ≤ the number of options, or the widget is refused.
The host checks the picks. Every picked id must be one of your options, and their number must be
within min and max; anything else never reaches you.
A choice list queues like a prompt. A second prompt or choice widget to the same audience
waits until the first is answered.
See also#
ui.prompt— asking with a choice list.- A prompt with choices — one pick and two picks, worked through.
- A long list with a filter box —
search, worked through.
UiCardRef#
Surface A — table script · type
The card a card widget (the card spotlight) draws. Named like a card button, never a URL:
entityId: a table entity. Each player sees what they may see of it: its face, or its BACK when it is hidden from them. Safe in a widget everyone sees.cardId: a card id from your game's card catalogue or a deck on the table. Everyone the widget reaches sees that face, like its name inlabel. With both,entityIdwins while it exists; an entity that is gone falls back tocardId, then tolabelas text.label(at most 120 characters) is required: it is the fallback and what a screen reader announces.
declare type UiCardRef = {
entityId?: string;
cardId?: string;
label: string;
};
The card a card widget (a card spotlight) draws: { entityId?, cardId?, label }.
| Field | What it does |
|---|---|
entityId |
A table entity. Each player sees what they may see of it: its face, or its back when it is hidden from them (another player's hand, a face-down card). Its counters are drawn on the picture. Safe in a widget everyone sees. |
cardId |
A card id from your game's card catalogue, or one in a deck on the table. Everyone the widget reaches sees that face, like its name in label. |
label |
Required, at most 120 characters, and may bind state. The text drawn when there is no picture, and what a screen reader announces. |
How, why and when to use it#
Name the card the moment is about: the attacker, the card being played, the target. Use entityId
for a card on the table, so each player's screen follows what the table lets them see and the
counters come with it. Use cardId only for a card that is not an entity, and only in a widget
whose audience may know it.
Gotchas#
With both, entityId wins while the entity exists. A hidden entity draws its back and never
falls back to cardId. An entity that has left the table falls back to cardId, then to label
as text.
label is not the name players see on the card. It is shown under the picture and as the
fallback, so make it right for every player in the audience: do not write a hidden card's name in a
widget everyone sees.
See also#
UiCardStat— the stat chips under the card.UiWidget.- Card spotlights.
UiCardStat#
Surface A — table script · type
One stat chip on a card spotlight, such as { label: "Power", value: "5", change: "up" }.
label is at most 24 characters. value may bind state ("{seat.attackerPower}") and is
drawn cut to 24 characters. change says how the value compares with the printed one:
"same" (the default) is drawn white, "up" green and "down" red. Your script decides it;
the platform does not compare anything. A chip with a symbol (iconImage or icon) draws the
symbol INSTEAD of the label text: the label is still its accessible name and tooltip ("Speed 50"),
and is drawn as text again if the image cannot be loaded.
declare type UiCardStat = {
label: string;
value: string;
change?: "up" | "down" | "same";
/** A HUD icon name, drawn in place of the label. */
icon?: string;
/**
* A small image instead of `icon` (`"ui/stat-speed.webp"`), drawn in place of the label. A
* declared project raster file; may bind state: see `UiImageRef`.
*/
iconImage?: UiImageRef;
};
One stat chip on a card spotlight: { label, value, change?, icon?, iconImage? }, such as
{ label: "Power", value: "6", change: "up" }.
| Field | What it does |
|---|---|
label |
At most 24 characters, such as "Speed". Drawn as text unless the chip has a symbol; always its accessible name and tooltip ("Speed 50"). |
value |
Text, at most 64 characters as written; it may bind state ("{seat.attackerPower}") and is drawn cut to 24 characters. |
change |
How the value compares with the printed one: "same" (the default) is drawn white, "up" green and "down" red, each with an arrow. An unknown value reads as "same". |
icon |
An icon from the HUD icon list, drawn instead of the label text. |
iconImage |
A small picture instead of icon (and of the label text), such as "ui/stat-speed.webp": a UiImageRef. |
How, why and when to use it#
Give a card spotlight the numbers the moment turns on, such as speed, power and health in a
fight. Your rules know the printed value and the current one, so you decide change; the
platform compares nothing. When a value moves mid-attack, send the new stats with
ui.update.
Gotchas#
A widget may carry at most 8 stats, and only a card widget may carry them. Anything else is
refused.
A symbol replaces the word. With icon or iconImage the chip draws the symbol and the value
(and the arrow), not the label; the label is still announced and shown as the tooltip. If the image
cannot be drawn the chip falls back to the named icon, else to the label text, so it never loses
what it measures. An iconImage must be a declared, pre-loaded project image like every other HUD
image: a layout that names an undeclared one fails its check.
Colour is not the whole message. The arrow and the screen reader's "raised" / "lowered" say the
same thing, and the VR tray shows the arrow in text. Keep change honest: a value that went up and
back down again is "same".
See also#
UiWidget#
Surface A — table script · type
One Game UI widget: what a game tells or asks a player OFF the table. The platform owns the
look and the placement; you pick a kind, a region or an attachment, an audience, and the
closed theme vocabulary. Text is always text (never markup), and {key} / {seat.key}
bindings read the state you set with ui.set.
Kinds: status, banner, prompt, actions, choice, result, player-panel,
counter, meter, log, hint, timer, panel (a tree of basic widgets in
children; a button, checkbox or select child is pressable and reaches
ui.onAction with childId) and card (the card spotlight: one card drawn large, with
stats chips and the card's counters, at the bottom centre by default; several sit side by
side). Fonts come from the platform's own list, icons from the HUD
icon list, and images are repo-relative raster paths only.
declare type UiWidget = {
/** Unique among your widgets. Letters, digits, `_ . : -`, at most 48 characters. */
id: string;
kind: "status" | "banner" | "prompt" | "actions" | "choice" | "result" | "player-panel" | "counter" | "meter" | "log" | "hint" | "timer" | "panel" | "card";
placement?: {
region?: UiRegion;
attach?: { to: "seat"; seat?: string } | { to: "entity"; id?: string; tag?: string } | { to: "zone"; id: string };
order?: number;
/**
* Place it differently on one kind of screen without touching the others: `desktop` (mouse),
* `tablet` (touch, tablet size) or `phone` (a small touch screen). `region` moves it there,
* `hidden` leaves it off that screen (never a prompt or choice), `menu` folds an
* `actions` widget behind one button that opens its buttons, and `style` changes its look on
* that screen only (merged over the widget's own `style`).
*/
devices?: Partial<Record<"desktop" | "tablet" | "phone", { region?: UiRegion; hidden?: boolean; menu?: boolean; style?: UiStyle; regionStyle?: UiRegionStyle }>>;
/** How the widget's region lays out its widgets (the first widget in a region that sets it wins). */
regionStyle?: UiRegionStyle;
};
/** Size, spacing, alignment, colours, border, type and button layout. See `UiStyle`. */
style?: UiStyle;
/** Default: everyone. `actor` is whoever pressed the button `ui.onAction` is handling right now. */
audience?: UiAudience;
/** Spectators may press it too (default: never). */
allow?: readonly "spectators"[];
/** Show it only while this holds for the viewer. */
when?: UiCondition;
/**
* `duration` in seconds (0.5 to 600). `until` says what else closes it: `"action"` the first
* press (a `seat-each` widget: each seat's own press); `"dismissed"` a viewer's dismiss (its ✕,
* the same as `dismissible: true`); `"hidden"` only your own hide or the duration — no viewer can
* dismiss it, even with `dismissible`.
*/
timing?: { duration?: number; until?: "dismissed" | "action" | "hidden" };
theme?: {
tone?: UiTone;
accent?: string;
icon?: string;
/** An image drawn where `icon` would be (and instead of it). May bind state: see `UiImageRef`. */
iconImage?: UiImageRef;
font?: "platform" | "serif" | "display-classic" | "display-bold" | "sci-fi" | "mono" | "retro" | "handwritten";
density?: "compact" | "comfortable";
emphasis?: "subtle" | "normal" | "strong";
};
title?: string;
body?: string;
text?: string;
headline?: string;
note?: string;
label?: string;
value?: string;
min?: number;
max?: number;
lines?: readonly string[];
actions?: readonly UiButton[];
choices?: UiChoiceList;
timeoutSeconds?: number;
defaultAction?: string;
/**
* Viewers get a ✕ that DISMISSES it, checked by the host like a press (they must see it and be
* allowed to act on it). A `seat-each` widget closes for that seat only; any other audience closes
* for everyone. You hear it in `ui.onAction` with `dismissed: true`; a dismissed
* `ui.prompt` resolves `cancelled` with `dismissed: true`. Without it a prompt's ✕ only hides the
* question on that viewer's screen (it stays open). Ignored with `timing.until: "hidden"`.
*/
dismissible?: boolean;
/**
* A list of counters (`player-panel`). `iconImage` (instead of `icon`) may bind state, e.g.
* `"{seat.sideIcon}"`: see `UiImageRef`.
*
* On a `card` widget it is a yes/no instead (default `true`): draw the entity's counter chips
* over the picture with their counts, exactly as the table shows them (the same counters, art,
* colours and labels, and a counter hidden from a player is not drawn for them).
*/
counters?: readonly { label: string; value: string; max?: string; icon?: string; iconImage?: UiImageRef; tone?: UiTone }[] | boolean;
badges?: readonly { text: string; icon?: string; iconImage?: UiImageRef; tone?: UiTone; when?: UiCondition }[];
maxLines?: number;
seconds?: number;
/** A header image (`panel`, `prompt`, `result`, `choice`, `player-panel`). May bind state. */
image?: UiImageRef;
/** `panel` only. A static repo-relative raster path. */
backgroundImage?: string;
/** `panel` only: basic widgets (`{ id, type, parentId?, props }`), each with an id. */
children?: readonly Record<string, unknown>[];
/** `card` only, and required there: the card the spotlight draws. `title` is a small heading over it ("Attacking"). */
card?: UiCardRef;
/** `card` only: at most 8 stat chips, drawn in order under the card. */
stats?: readonly UiCardStat[];
};
One Game UI widget: what a game tells or asks a player off the table. You choose a kind, where
it goes, who sees it, its text, and a closed vocabulary for its look; the platform owns the look and
the placement on every device. Text is always text, never markup, and {key} / {seat.key}
bindings read the state you set with ui.set.
Fields every kind has
| Field | What it does |
|---|---|
id |
Unique among your game's widgets. Letters, digits, _ . : -, starting with a letter or digit, at most 48. |
kind |
One of the fourteen kinds below. |
placement |
{ region?, attach?, order? }. A UiRegion; an attachment { to: "entity", id }, { to: "entity", tag }, { to: "zone", id } or { to: "seat", seat? }; order from -1000 to 1000, smaller first. |
audience |
A UiAudience. Default everyone. |
allow |
["spectators"] lets spectators press it. Default: they cannot. |
when |
A UiCondition: shown only while it holds for the viewer. |
timing |
{ duration?, until? }. duration in seconds, 0.5 to 600. until says what else closes it: "action" the first press (on a seat-each widget, each seat's own press closes that seat's copy); "dismissed" a viewer's dismiss, the same as dismissible: true; "hidden" only your own ui.hide or the duration, never a viewer. |
dismissible |
true gives viewers a ✕ that closes the widget, checked by the host like a press. See below. |
theme |
{ tone?, accent?, icon?, iconImage?, font?, density?, emphasis? }. iconImage is a UiImageRef drawn instead of the icon. See the look. |
Fields by kind
| Kind | Fields it reads |
|---|---|
status |
title, text, body, note |
banner |
text (or title); lasts 4 seconds unless timing.duration says otherwise |
prompt |
title, body, text, actions, choices, timeoutSeconds (1 to 600), defaultAction, image |
actions |
title, actions (1 to 12 UiButtons) |
choice |
title, body, choices (a UiChoiceList), image |
result |
headline, lines (at most 20), body, image |
player-panel |
counters and badges (8 each), note, image |
counter |
label, value, max |
meter |
label, value, min, max |
log |
title, maxLines (1 to 200) |
hint |
text (or title) |
timer |
label, seconds (1 to 3600, counted from when it is shown) |
panel |
title, body, children (at most 64 basic widgets), image, backgroundImage |
card |
title, card (a UiCardRef, required), stats (at most 8 UiCardStats), counters (true or false) |
On a card widget (a card spotlight) counters is true or false instead of a list: whether to
draw the entity's counter chips on the picture, as the table does (default true). See
Card spotlights.
A counter is { label, value, max?, icon?, iconImage?, tone? }, where value and max are text
that may bind, such as "{seat.force|0}". A badge is { text, icon?, iconImage?, tone?, when? },
drawn only while its when holds. iconImage is a small picture drawn instead of icon — a
UiImageRef, so "{seat.sideIcon}" shows each seat's own symbol.
How, why and when to use it#
Pass it to ui.show, once, with a stable id. Then change what it says through state
(ui.set), change its structure with ui.update (a
UiWidgetPatch, where null removes a field), and
remove it with ui.hide. A widget designed in the
UI layout editor is this same shape. Game UI (HUD) says which kind
to use for what.
Text fields (title, body, text, headline, note, label, value, lines, button labels,
counter values) may bind state with {key}, {seat.key} and {key|fallback}. The longer fields
(body, note) may also use **bold**, *italic*, [color=red]…[/color] and - bullets.
Gotchas#
Validation is strict. An unknown kind, font or icon, an image that is not a repo-relative
.png/.jpg/.jpeg/.webp path, a text field over 500 characters, a widget over 16 KB, a prompt
with neither actions nor choices, an actions widget with no buttons, a defaultAction that
names no button, an image on a kind that cannot carry one, a card widget with no card, or
card / stats on any other kind: each refuses the whole write, with a
diagnostic, and nothing changes.
A field the widget does not have is dropped, not refused, and a field a kind does not read is kept and ignored.
Images only on some kinds. image on panel, prompt, result, choice and player-panel;
backgroundImage and children on panel only. image, theme.iconImage and counter / badge
iconImage may bind one state key (UiImageRef); a bound value that is not a
repo-relative raster path draws the fallback or nothing, never a URL.
Attached widgets that can be pressed are not attached. A prompt, actions, choice, panel,
result or log with an attachment is drawn in its region. A missing target falls back to the
region too.
A panel's button, checkbox and select children are pressable; a press arrives at
ui.onAction with childId set. Every child needs an id, and a parentId must
name another child of the same panel. An input child is read-only.
Dismissing: who, what closes, and what you hear. A widget is dismissible when it has
dismissible: true or timing.until: "dismissed", unless it has timing.until: "hidden", which
wins. A dismissible widget shows a ✕ to every viewer who may act on it: its audience, and
spectators only with allow: ["spectators"]. The host checks a dismiss like a press (audience,
spectator rule, the seat-each copy, when, a prompt's freshness). A seat-each widget then
closes for that seat only; any other audience closes the widget for everyone in it. Your game
hears it in ui.onAction as dismissed: true with actionId: null; a dismissible
ui.prompt resolves cancelled with dismissed: true instead. Without
dismissible, a prompt's ✕ only hides it on that viewer's own screen and it stays open.
Use until: "hidden" for anything a viewer must not close, such as a status line someone could
otherwise dismiss for the whole table.
See also#
ui.show.UiAudience,UiCondition,UiButton.- Game UI (HUD) — kinds, regions and limits in one place.
UiValue#
Surface A — table script · type
A value HUD state holds: text, a number, a yes/no, or nothing.
declare type UiValue = string | number | boolean | null;
A value HUD state can hold: text (at most 500 characters), a finite number, true or false. In a
write, null means "remove this value"; it is never stored.
How, why and when to use it#
Pass one to ui.set or ui.setMany. A binding draws it as text: a whole
number bare, any other number rounded to at most two decimal places, a boolean as true or
false. A UiCondition compares it as it is.
Keep values small and meaningful: a phase name, a count, a flag such as canPass. Build sentences
in the widget's text around them, not in the value.
Gotchas#
NaN and Infinity are refused, and so is an object or an array.
A value is drawn as plain text. ** or { in a value is shown as written, never as formatting
or a binding.
See also#
UiPromptSpec#
Surface A — table script · type
What ui.prompt asks. A prompt widget built for you, shown to that seat only.
declare type UiPromptSpec = {
/** Default: a fresh id. Prompting again with the same id replaces the open one. */
id?: string;
title?: string;
body?: string;
/** The answers. A prompt needs `actions` or `choices`. */
actions?: readonly UiButton[];
choices?: UiChoiceList;
/** Answer with `defaultAction` (or nothing) after this many seconds. */
timeoutSeconds?: number;
defaultAction?: string;
/**
* The seat may decline: its ✕ (or Escape) closes the question and the promise resolves
* `cancelled` with `dismissed: true`. Default false: the ✕ only hides it on their screen.
*/
dismissible?: boolean;
/** Default `sheet`. */
region?: UiRegion;
theme?: UiWidget["theme"];
/** The question's look: colours, border, type and its buttons' layout (its size and place are the sheet's). */
style?: UiStyle;
image?: UiImageRef;
};
What ui.prompt asks. The platform builds a prompt widget from these fields, shown
to the one seat you name, in the sheet region unless you pick another.
| Field | What it does |
|---|---|
id |
Optional. Asking again with the same id replaces the open question (the earlier promise resolves cancelled). Leave it out for a fresh id each time. |
title, body |
The question. body may use bold, italic, colour and bullets. |
actions |
Up to 12 UiButtons. |
choices |
A UiChoiceList. |
timeoutSeconds |
1 to 600. After this, the prompt resolves timedOut with defaultAction. |
defaultAction |
The button or option id a timeout answers with. Must name one of them. |
dismissible |
true lets the seat decline: its ✕ (or Escape) closes the question and the promise resolves cancelled with dismissed: true. Default false: the ✕ only hides it on their screen and it stays pending. |
region |
A UiRegion. Default "sheet". |
theme |
As on UiWidget. |
image |
A header image from your repository. |
How, why and when to use it#
A prompt needs actions, choices, or both: buttons such as Keep and Redraw, or a list such as the
four promotion pieces. Use actions for two to four short answers, choices for a longer list,
anything with a description or a card face, or several picks.
Give every question the game cannot continue without a timeoutSeconds and a defaultAction, so a
player who stepped away does not stall the table.
Set dismissible on a question with a sensible "no thanks" (an optional purchase, a reaction the
player may pass on) and treat dismissed as that answer. Leave it off for a question the game
needs answered: the player can still hide it to look at the table, but not make it go away.
Gotchas#
Only these fields are used. A prompt is always kind: "prompt", its audience is always the seat
you asked, and it closes when answered; placement, audience, timing and when cannot be set
here. Show a prompt widget with ui.show for anything else.
An invalid spec rejects the promise with invalid-prompt: …, naming the problem.
Fields outside this list are dropped, not refused: the prompt is built from these fields only.
See also#
UiPromptResult#
Surface A — table script · type
How a ui.prompt ended. Exactly one of answered / timedOut / cancelled / bot applies.
declare type UiPromptResult = {
/** The button or option pressed (a choice's confirm id for a multi-pick), or null. */
readonly actionId: string | null;
/** A multi-pick choice's picked option ids. */
readonly value?: readonly string[];
/** The seat that was asked. */
readonly seat: string;
/** Who answered, or null. A bot seat's peer id when `bot` is true. */
readonly peerId: string | null;
/** Nobody answered in `timeoutSeconds`; `actionId` is the default action, if any. */
readonly timedOut: boolean;
/** The prompt went away unanswered: the seat was empty or emptied, it was replaced, it expired, it was dismissed, or your scripts restarted. */
readonly cancelled: boolean;
/** With `cancelled`: the seat DISMISSED a `dismissible` prompt (declined to answer). `peerId` is who did. */
readonly dismissed?: true;
/** A bot plays that seat: it never sees prompts, so decide for it. */
readonly bot?: true;
};
How a ui.prompt ended. Exactly one of four things happened: it was answered, it timed
out (timedOut), it went away unanswered (cancelled, with dismissed when the seat declined a
dismissible prompt), or the seat is a bot's (bot).
| Field | Meaning |
|---|---|
actionId |
The button or option pressed (a several-pick list's confirm id); on a timeout, the defaultAction or null; otherwise null. |
value |
A several-pick list's picked option ids. Absent otherwise. |
seat |
The seat that was asked. |
peerId |
Who answered, or who declined when dismissed is set. null for a timeout or any other cancel; the bot's peer id when bot is true. |
timedOut |
Nobody answered in timeoutSeconds. |
cancelled |
Nobody sat in the seat, the seat emptied, the prompt was replaced or hidden, the seat declined it (dismissed), or your scripts restarted. |
dismissed |
true, with cancelled, when the seat closed a dismissible prompt instead of answering. Absent otherwise. |
bot |
true when a bot plays the seat. Bots never see prompts. |
How, why and when to use it#
Check the unusual outcomes first and fall through to the answer:
bot: decide for the bot.dismissed: the seat declined; treat it as their "no".cancelled: nobody is going to answer; carry on without, or ask again later.timedOut:actionIdalready holds your default, so you can usually treat it as an answer.- Otherwise read
actionId(andvaluefor several picks).
Gotchas#
peerId says who pressed, not who should have. Membership was checked by the host; the seat's
current player is the only one who can answer.
Check dismissed before cancelled. A declined prompt has both set, so a cancelled check
written first swallows the decline.
The object is frozen. Copy what you need.
See also#
ui.prompt— with an example that handles all four outcomes.
UiActionEvent#
Surface A — table script · type
A press on one of your widgets, as ui.onAction receives it. Data only.
declare type UiActionEvent = {
readonly widgetId: string;
/** The button / option id. For a panel child: `"click"` or `"change"`. Null for a prompt that timed out with no default, and for a dismiss. */
readonly actionId: string | null;
/** A choice confirm's picks, or a panel child's new value (one string). */
readonly value?: readonly string[];
/** The presser's seat (null for a spectator), from the host's own roster. */
readonly seat: string | null;
/** The presser's peer id; null for a timeout. */
readonly peerId: string | null;
/** For a `seat-each` widget: whose instance was pressed. */
readonly instanceSeat: string | null;
/** A prompt timed out (nobody pressed anything). */
readonly timedOut: boolean;
/** A basic widget inside a `panel` widget was pressed: its id. */
readonly childId?: string;
/** That child's own `onClick` / `onChange` / `hook` name, or null. */
readonly hook?: string | null;
/**
* The viewer DISMISSED a `dismissible` (or `until: "dismissed"`) widget instead of pressing
* anything; `actionId` is null. On a `seat-each` widget only `instanceSeat`'s copy closed.
*/
readonly dismissed?: true;
};
A press on, a timeout of, or a dismiss of one of your game's widgets, as ui.onAction receives
it. Data only, checked by the host against its own copy of the widget and its own list of who sits
where.
| Field | Meaning |
|---|---|
widgetId |
The widget pressed. |
actionId |
The button or option id. For a several-pick confirm, the confirm's id. For a panel child, "click" or "change". For a prompt that timed out, its defaultAction or null. For a dismiss, null. |
value |
A several-pick list's picks, or a panel child's new value as one string. |
seat |
The presser's (or dismisser's) seat, from the host's own seat list. null for a spectator and for a timeout. |
peerId |
The presser's (or dismisser's) connection. null for a timeout. |
instanceSeat |
For a seat-each widget, whose copy was pressed or dismissed. Otherwise null. |
timedOut |
A prompt widget timed out; nobody pressed anything. |
childId |
For a press inside a panel, the basic widget's id. |
hook |
That basic widget's own onClick / onChange / hook name, or null. |
dismissed |
true when the viewer closed a dismissible widget (dismissible: true or timing.until: "dismissed") with its ✕ instead of pressing anything. Absent otherwise. |
How, why and when to use it#
Switch on widgetId, then actionId, and check seat before acting on your game's rules. Trust
seat and peerId: the host took them from its own records, never from the pressing client.
instanceSeat and seat are the same for a seat-each widget pressed by its own seat. Use
instanceSeat when you need to know which copy was answered, seat when you need to know who.
Gotchas#
An answer to a pending ui.prompt does not arrive here; it resolves that promise instead. So
does declining a dismissible ui.prompt.
Check dismissed before actionId. A dismiss has actionId: null, like a prompt that timed out
with no default; dismissed (or timedOut) tells them apart. By the time it arrives the widget is
already closed: for everyone, or for instanceSeat only on a seat-each widget.
A checkbox child's value is the text "true" or "false", not a boolean.
The event is frozen. Copy what you need.
See also#
Ui#
Surface A — table script · interface · 9 members
The Game UI (the HUD): status lines, timed banners, prompts, action strips, choice lists, player panels, counters, a log and hints, drawn by the platform OFF the table on desktop, phone and VR. Use it instead of table text labels and spawned buttons for anything a player reads or presses.
Host-authoritative like every write: refused on a peer. Widgets, state and logs belong to your game's scripts (this script scope); no other script or mod can change them, and they cannot change yours. Prompts and timed widgets are cleared when your scripts restart; persistent widgets and state stay, so re-draw from your own state when your script starts.
Reached from a script as the injected global ui.
declare const ui: Ui;
The Game UI singleton: what the game tells or asks a player off the table.
The ui global: the Game UI, or HUD. Status lines, timed banners, prompts, action strips, choice
lists, player panels, counters, a log and hints, drawn by the platform off the table on
desktop, phone, tablet and VR, in the app's own style, and private to one seat when you ask.
How, why and when to use it#
Use it for anything a player reads to know what to do or presses: the phase, whose move it is and what they may do, a question the game waits on, a New game button, the game's history, each seat's counters. Before the HUD existed, games wrote these as text labels on the felt and spawned 3-D buttons for them; both are now the wrong tool (see Where text belongs).
The usual shape of a game's HUD:
- When the script starts,
ui.showeach widget once, with text that binds to state:title: "{phase}". - As the game moves, change state with
ui.setandui.setMany; every binding redraws by itself. - Say passing news with
ui.toastand keep a history withui.log. - Ask one seat a question with
ui.promptand carry on with the answer. - Handle presses on your
actions,choiceandpanelwidgets inui.onAction.
Gotchas#
Host only. Every write is refused on a peer that is not the host, like every other change to the table.
Writes return nothing; refusals are diagnostics. An invalid widget, an unknown icon or font, a
URL or a field over its cap changes nothing and writes ui.show("id") was ignored: … to the
script console (Edit Mode) or the event log (a live table).
Widgets, state and logs belong to your game's scripts. Every script in the game shares them. No other game, Asset pack or mod can change or read them, and you cannot change theirs.
Prompts and timed widgets are cleared when your scripts restart; persistent widgets and state stay. Draw the HUD again from your own state when your script starts; showing a widget that is already there is harmless.
In VR the same widgets move into the headset. Status lines and action strips gather on a tray beside the player, prompts open as a dialog within reach. See Placement.
See also#
- Game UI (HUD) — widget kinds, regions per device, privacy, and five worked examples.
ui.show— put a widget on screen.ui.prompt— ask one seat and wait.ui.onAction— answer a press.world.setTextLabel— for words that belong on the board itself.
Members#
| Signature | Description | Returns |
|---|---|---|
show(widget: string | UiWidget, overrides?: UiWidgetPatch) |
Show a widget, or replace the one with the same id (it shows again, from the start). A string re-shows a widget you showed before, with overrides merged over it. Refused (a diagnostic, nothing shown) for an invalid widget: an unknown kind, font or icon, a URL, or a field over its cap. |
void |
update(id: string, patch: UiWidgetPatch) |
Change a SHOWING widget in place: it keeps its clock (a banner does not restart) and a prompt keeps its answer slot. A null in patch removes that field. A widget that is not showing is refused (a diagnostic), not re-shown. |
void |
hide(id: string | readonly string[]) |
Remove one of your widgets (or several). Unknown ids are ignored. | void |
set(key: string, value: UiValue, seat?: string, options?: { private?: boolean }) |
Set one HUD state value (null removes it). With seat, that seat's value ({seat.key} in a binding); with { private: true } too, only that seat ever receives it. |
void |
setMany(values: Readonly<Record<string, UiValue>>, seat?: string, options?: { private?: boolean }) |
Set several state values at once (null removes one). |
void |
toast(text: string, options?: { tone?: UiTone; icon?: string; seconds?: number; to?: UiAudience }) |
A timed banner (default 4 seconds), to everyone or to to. At most 4 per second. |
void |
log(text: string, options?: { widget?: string; tone?: UiTone; seat?: string }) |
Add a line to a log widget (default id "log"). Show the log widget first. seat marks the line as that seat's (its colour, and consecutive lines of one seat grouped, like a chat). |
void |
prompt(seat: string, spec: UiPromptSpec) |
Ask ONE seat a question and wait for the answer. Resolves when the seat answers, when timeoutSeconds runs out (timedOut), or with cancelled when nobody sits there, the seat empties, the prompt expires or is replaced, or your scripts restart. A bot seat resolves at once with bot: true: decide for it. One open prompt per seat; later ones wait their turn. A dismissible prompt the seat dismisses resolves cancelled with dismissed: true. |
Promise<UiPromptResult> |
onAction |
A press on any of your widgets, or a dismiss (dismissed: true). An answer to ui.prompt resolves its promise instead. |
ScriptDelegate<[event: UiActionEvent]> |
ui.show#
show(widget: string | UiWidget, overrides?: UiWidgetPatch): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Show a widget, or replace the one with the same id (it shows again, from the start). A
string re-shows a widget you showed before, with overrides merged over it. Refused (a
diagnostic, nothing shown) for an invalid widget: an unknown kind, font or icon, a URL, or a
field over its cap.
Shows a widget, or replaces the one you showed before with the same id. Passing a string instead
shows a widget by id: one your game's scripts showed before, or else one an attached
UI layout declares, with overrides merged over it.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
widget |
string or UiWidget |
yes | A whole widget, or the id of one shown before or declared by an attached UI layout. |
overrides |
UiWidgetPatch |
no | Merged over the widget before it is shown, one level deep: each field you pass replaces that field whole, so a theme you pass replaces the whole theme, and a null removes that field. id never changes. |
How, why and when to use it
Show each widget once, when your script starts, and give it a stable id. After that, change
what it says by changing the state its text binds to (ui.set), not by showing it
again: a binding redraws by itself, and a state change is a much smaller update than a whole
widget.
Show it again when the widget itself should start over: a banner that should run its full
duration again, a prompt-style actions strip for a new round. Replacing restarts the widget's
clock and gives a prompt a fresh answer slot. To change a showing widget without restarting it,
use ui.update.
The string form is for widgets that come and go: hide a widget, then ui.show("bid-actions")
brings back the definition you last showed under that id, optionally with overrides.
It is also how a script brings up the moments of a UI layout designed in the
UI layout editor. When your game's scripts start, an
attached layout's furniture (status lines, player panels, counters, meters, logs, hints, action
strips and panels) is already showing, but its banner, prompt, choice, result and timer
widgets wait for you, and so does any widget whose audience is actor (show that one from
ui.onAction). ui.show("round-over", { headline: "Red wins" }) shows the layout's round-over
result with your headline. The host looks first for a widget your scripts showed under
that id, then for one an attached layout declares.
Example
// content/scripting-api/examples/ui.show.ts
// Scene script: a status line whose text follows HUD state. Show it once;
// after that, ui.set changes what it says without showing it again.
ui.show({
id: "phase",
kind: "status",
placement: { region: "upper" },
theme: { icon: "flag" },
title: "Round {round}",
text: "Phase: {phase}"
});
ui.set("round", 1);
ui.set("phase", "Draw");
// Later: the same widget, re-shown from its remembered definition with one
// field changed. A string id only works for a widget your scripts showed.
function enterSuddenDeath(): void {
ui.show("phase", { theme: { icon: "warning", tone: "danger" }, title: "Sudden death" });
}
enterSuddenDeath();
Gotchas
An invalid widget is refused whole, and nothing is shown. An unknown kind, font or icon
name, an image that is not a repo-relative .png/.jpg/.jpeg/.webp path (or, where a
UiImageRef is allowed, one {key} binding), a field over its
cap, a prompt with neither actions nor choices, a defaultAction that names no button: each
writes ui.show("id") was ignored: … to the script console or event log, naming the first
problems, and leaves any widget already showing under that id as it was.
A field the widget does not have is dropped, not refused. TypeScript catches a misspelt field in an object you write out; one built from data at run time just loses it.
Replacing restarts the widget. A banner's clock starts again, a timer counts down from the
beginning, and a prompt shown with the id of a pending ui.prompt replaces it, which
resolves that promise cancelled.
A string id nobody declared is ignored, with the diagnostic no widget with that id was shown before, and no attached UI layout declares one. The host remembers the definitions your game's
scripts gave it (the most recent 256) and the widgets of the layouts attached to your game, never a
mod's widgets. A mod's api.uiShow does not see layouts at all: a layout belongs to the game's
table scripts.
One widget per call, and caps per game. Your game's scripts may hold 128 widgets at once, each at most 16 KB, and all public HUD on the table at most 64 KB together.
See also
UiWidget— every field, by kind.UiWidgetPatch— whatoverridesmay say.ui.update— change a showing widget without restarting it.ui.hide— take it away.- Game UI (HUD) — which kind to use for what.
ui.update#
update(id: string, patch: UiWidgetPatch): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Change a SHOWING widget in place: it keeps its clock (a banner does not restart) and a prompt
keeps its answer slot. A null in patch removes that field. A widget that is not showing
is refused (a diagnostic), not re-shown.
Changes a widget that is showing, in place. Unlike ui.show, it keeps the widget's
clock (a banner or timer does not restart) and a prompt keeps its answer slot, so a press already
on its way still counts.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
id |
string |
yes | A widget your game's scripts showed and that is still showing. |
patch |
UiWidgetPatch |
yes | Merged over the definition one level deep: each field you pass replaces that field whole, a field you leave out keeps its value, and a null removes that field. id never changes. |
How, why and when to use it
Use it when a widget's wording or buttons change while it should keep running: the text of a 30-second auction banner as bids come in, a prompt's body as the situation changes, a disabled button relabelled.
If the change is to a value, prefer state: bind the text to {key} and call
ui.set. Use ui.update when the structure itself changes.
Use null to take something off a showing widget: { timing: null } stops a timed widget from
closing on its own, { when: null } drops its condition, { dismissible: null } takes away its ✕.
Example
// content/scripting-api/examples/ui.update.ts
// Scene script: a timed hint whose text changes while it is showing.
// ui.update keeps its clock running; ui.show would start it again.
ui.show({
id: "auction",
kind: "hint",
placement: { region: "upper" },
text: "Bidding is open",
timing: { duration: 30 }
});
let bids = 0;
function recordBid(seat: string): void {
bids += 1;
ui.update("auction", { text: `${bids} bids so far - ${seat} leads` });
}
// null REMOVES a field. Without its timing the hint no longer closes on
// its own: it stays until the script hides it.
function closeBidding(winner: string): void {
ui.update("auction", { text: `Sold to ${winner}`, timing: null, theme: { tone: "success" } });
}
recordBid("red");
closeBidding("red");
Gotchas
A widget that is not showing is refused, not re-shown. One that expired, was hidden, was
answered or was cleared by a restart writes ui.update("id") was ignored: that widget is not showing (show it again with ui.show). Show it again if you meant to bring it back.
The patched widget is checked like a new one. If the result is invalid (say the patch names an unknown icon), nothing changes and the diagnostic says why.
A theme or actions you pass replaces the whole field. Pass the full button list to change
one button.
{ timing: null } does not make a banner stay. A banner without a duration lasts 4 seconds,
counted from when it was first shown, so a banner past that mark closes at once. Use a hint or
status, as the example does, for a message you may want to keep up.
It works on any showing widget your game's scripts own, including the ones an attached
UI layout showed at start or that you showed by id with
ui.show. Changing kind to or from prompt/choice is refused; re-show it instead.
See also
ui.show— replace or re-show, restarting the widget.ui.set— change a value its text binds to.UiWidgetPatch— what a patch may say.UiWidget.
ui.hide#
hide(id: string | readonly string[]): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Remove one of your widgets (or several). Unknown ids are ignored.
Removes one of your game's widgets, or several at once. Unknown ids are ignored, so it is safe to call for a widget that already expired or was never shown.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
id |
string or readonly string[] |
yes | One id, or a list. |
How, why and when to use it
Use it when a widget stops being true: the bidding strip once bidding ends, a hint once the player has done what it suggested, a status panel for a phase that is over. Hiding several in one call is one change on the wire rather than several.
To change what a widget says, do not hide and re-show it: change its state with
ui.set or patch it with ui.update.
Example
// content/scripting-api/examples/ui.hide.ts
// Scene script: the bidding widgets only make sense during bidding, so the
// phase change takes them all away in one call.
ui.show({ id: "bid-status", kind: "status", text: "Bidding: {highBid|no bids yet}" });
ui.show({
id: "bid-actions",
kind: "actions",
actions: [{ id: "pass", label: "Pass" }, { id: "raise", label: "Raise" }]
});
function endBidding(): void {
ui.hide(["bid-status", "bid-actions"]);
ui.set("phase", "Play");
}
ui.onAction.add((event) => {
if (event.widgetId === "bid-actions" && event.actionId === "pass") {
endBidding();
}
});
Gotchas
Hiding a pending prompt answers it cancelled. If the id belongs to a
ui.prompt that is still waiting, its promise resolves with cancelled: true.
Hiding a widget does not remove its state. Values set with ui.set stay until you set them to
null, so a widget shown again later reads them immediately.
A hidden log widget keeps its lines. Show it again with the same id and the history is back;
while it is hidden, ui.log into it is refused.
You can only hide your own. An id that belongs to another game's scripts or to a mod hides nothing.
See also
ui.show— and its string form, to bring a hidden widget back.UiWidget—timing.duration, for a widget that should go away on its own.
ui.set#
set(key: string, value: UiValue, seat?: string, options?: { private?: boolean }): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Set one HUD state value (null removes it). With seat, that seat's value ({seat.key} in a
binding); with { private: true } too, only that seat ever receives it.
Sets one HUD state value, the data your widgets' {key} bindings read. null removes it. With
seat, it sets that seat's value, which a binding reads as {seat.key}; with
{ private: true } as well, only that seat's player ever receives it.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
key |
string |
yes | A letter, then letters, digits or _, at most 48. No seat. prefix: that is how a binding reads a seat value. |
value |
UiValue |
yes | Text (at most 500 characters), a finite number, true, false, or null to remove the value. |
seat |
string |
no | A seat id such as "red". Omit for one shared value. |
options |
{ private?: boolean } |
no | private: true sends the value to that seat's player only. Needs seat. |
How, why and when to use it
State is how a HUD follows the game without being redrawn. Show a widget once with text such as
"{phase}" or "Force: {seat.force|0}", then change the values as the game moves. There are
three kinds of value, and choosing between them is the whole design:
| Call | Stored as | Who receives it | Read in a binding as |
|---|---|---|---|
ui.set("phase", "Draw") |
one shared value | everyone | {phase} |
ui.set("force", 3, "red") |
red's value, public | everyone | {seat.force}: each viewer reads their own seat's; a player-panel row reads its row's seat |
ui.set("howTo", "Play a card", "red", { private: true }) |
red's value, private | red's player only | {seat.howTo}, for red only |
A public per-seat value is right for anything every player may know about every seat: scores, hand sizes, resources. A private one is right for anything only its seat should know: what to do now, a secret role, the cards they may legally play. Private values never reach the other players' computers at all.
Example
// content/scripting-api/examples/ui.set.ts
// Scene script: one shared value, one public value per seat, and one private
// value per seat. The status line reads all three.
ui.show({
id: "turn-status",
kind: "status",
title: "Phase: {phase}",
// Each viewer reads THEIR OWN seat's values. "|" gives a fallback for a
// viewer with no value (and for every spectator).
text: "Cards in your hand: {seat.handSize|-}",
body: "{seat.tip|}"
});
ui.set("phase", "Draw");
// Public per-seat values: everyone may know how many cards each seat holds.
ui.set("handSize", 5, "red");
ui.set("handSize", 7, "blue");
// A private per-seat value: only red's player ever receives it.
ui.set("tip", "You may play a second card this turn.", "red", { private: true });
// Removing a private value is a private write too: name the seat and pass
// private again, or the call removes red's PUBLIC "tip" instead.
function clearTip(): void {
ui.set("tip", null, "red", { private: true });
}
clearTip();
Gotchas
A private value must name its seat. { private: true } without a seat is refused.
To remove a private value, pass private: true again. Public and private values of a seat
are kept apart; ui.set("tip", null, "red") removes red's public tip and leaves the private one.
Writing one does replace the other, so a seat never holds both.
A binding cannot read another seat's private value. {seat.key} always means the viewer's own
seat; there is no way to name a different seat, because that seat's private values are not on this
player's computer.
Unset is not empty. {key} with no value draws nothing, and {key|fallback} draws the
fallback. Set a value to "" to draw an empty string on purpose.
64 key names per game. A name counts once, however many seats hold a value under it. A new name past the cap is refused with a diagnostic; changing an existing one always works.
An attached UI layout can make a key private for you. A key the
UI layout attached to your game declares with scope seat
and private is written privately whenever you name a seat, without { private: true }: so
ui.set("tip", "Play a card", "red") reaches red only, and ui.set("tip", null, "red") removes
red's private value. Without a seat the declaration does not apply, and the call writes a shared
value as usual.
Layout defaults are written first. At every start of your scripts, before any of them runs, the
host writes the defaults the attached layouts declare. Your first ui.set of a key simply
overrides its default; set it again from your own state if the game is resuming.
Write on change, not every tick. Each write is a change sent to every player (or, for a private value, to one).
See also
ui.setMany— several values in one change.- UI layout editor — declare keys, defaults and privacy without code.
UiValue— what a value can be.UiCondition—when,enabledWhenandactiveWhenread the same state.- Audiences and privacy.
ui.setMany#
setMany(values: Readonly<Record<string, UiValue>>, seat?: string, options?: { private?: boolean }): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Set several state values at once (null removes one).
Sets several HUD state values in one change. null removes a value. seat and
{ private: true } apply to every value in the call, exactly as they do for ui.set.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
values |
Readonly<Record<string, UiValue>> |
yes | Key to value. At most 64 keys in one call. |
seat |
string |
no | Every value becomes that seat's value. |
options |
{ private?: boolean } |
no | private: true sends every value to that seat's player only. Needs seat. |
How, why and when to use it
Use it when several values change together and should be seen together: the score line at the end of a round, the phase and the round number when a turn starts. Every player gets one update in which all of them changed, so nobody sees a half-updated line, and one call costs the table one change rather than one per value.
To set the same key for several seats, call it once per seat; a single call sets one seat (or the shared values).
Example
// content/scripting-api/examples/ui.setMany.ts
// Scene script: the score line changes as a whole at the end of a round,
// so every value goes out together and nobody sees half an update.
ui.show({
id: "score",
kind: "status",
title: "Round {round}: {phase}",
text: "Red {redScore} - Blue {blueScore}"
});
let round = 1;
function endRound(redScore: number, blueScore: number): void {
round += 1;
ui.setMany({ redScore, blueScore, round, phase: "Scoring" });
}
globalEvents.onTurnStarted.add(() => {
ui.setMany({ round, phase: "Playing" });
});
endRound(3, 2);
Gotchas
All or nothing. If any key is invalid (a bad name, a value that is not text, a finite number,
a boolean or null), the whole call is refused and nothing changes.
undefined means remove. A value of undefined is sent as null, so a property you meant
to leave out but spelled with an undefined value removes that key.
An empty object does nothing, and is not an error.
Keys an attached UI layout declares private go privately. With a seat, any key the attached
UI layout declares with scope seat and private is written
to that seat only, even without { private: true }. A call that mixes such keys with ordinary ones
is split into two writes, one public and one private, each checked on its own (so all-or-nothing
holds for each half, not for the call); null on a
declared-private key removes the private value. Without a seat the declarations do not apply.
See also
ui.toast#
toast(text: string, options?: { tone?: UiTone; icon?: string; seconds?: number; to?: UiAudience }): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
A timed banner (default 4 seconds), to everyone or to to. At most 4 per second.
Shows a short notice that goes away by itself: 4 seconds unless you say otherwise, to everyone unless you name an audience. A toast is sent once and never saved.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
text |
string |
yes | 1 to 500 characters. Plain text. |
options.tone |
UiTone |
no | Default neutral. |
options.icon |
string |
no | A name from the HUD icon list. |
options.seconds |
number |
no | 0.5 to 600. Default 4. |
options.to |
UiAudience |
no | Default everyone. { scope: "actor" } inside a ui.onAction handler sends it to whoever pressed. |
How, why and when to use it
Use a toast for news that needs no answer and is not worth keeping: "Red drew two cards", "Blue passes", "Not your turn" to the player who pressed too early. It is the HUD's answer to a chat line that scrolls away, without cluttering chat.
If the notice must still be there for a player who reconnects, or you want to change its text
while it shows, use a banner widget with ui.show instead: that one is part of the
table until it expires. If it is the state of the game, use a status widget.
Example
// content/scripting-api/examples/ui.toast.ts
// Scene script: short news that needs no answer. One toast for everyone, and
// a private one for the player who pressed a button they may not use yet.
ui.show({
id: "turn-actions",
kind: "actions",
actions: [{ id: "end-turn", label: "End turn", icon: "skip_next" }]
});
let activeSeat: string | null = null;
globalEvents.onTurnStarted.add((turn) => {
activeSeat = turn.seat;
if (turn.seat !== null) {
ui.toast(turn.seat + " to play", { tone: "info", icon: "event_seat" });
}
});
ui.onAction.add((event) => {
if (event.widgetId !== "turn-actions") {
return;
}
if (event.seat !== activeSeat) {
// Only the presser sees this. "actor" works here, before any await.
ui.toast("It is not your turn yet.", { tone: "warning", seconds: 3, to: { scope: "actor" } });
return;
}
world.log(event.seat + " ends the turn.");
});
Gotchas
Four per second. Your game's scripts may raise at most 4 toasts a second; more are refused with
a diagnostic. A burst of news is usually better as one toast, or as ui.log lines.
Never saved, never replayed. A player who joins or reconnects after a toast was sent does not see it, and undo does not bring it back.
Fewer show on a phone. A desktop shows up to four toasts at once, a phone or tablet two; older ones make way.
An unknown tone or icon refuses the toast rather than drawing it plainly.
See also
UiWidget— thebannerkind, a notice that is part of the table.ui.log— a history the player can scroll back through.world.broadcast— a line in chat.
ui.log#
log(text: string, options?: { widget?: string; tone?: UiTone; seat?: string }): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Add a line to a log widget (default id "log"). Show the log widget first. seat marks
the line as that seat's (its colour, and consecutive lines of one seat grouped, like a chat).
Adds a line to one of your log widgets: the game's own scrolling history, read like a chat with
the newest line at the bottom. The
widget is the one with id "log" unless you name another.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
text |
string |
yes | 1 to 500 characters. Plain text. |
options.widget |
string |
no | The log widget's id. Default "log". |
options.tone |
UiTone |
no | Colours this line's text. Default neutral. |
options.seat |
string |
no | A seat id. The line is that seat's: drawn with a bar and a faint tint in the seat's colour, and joined to the lines of the same seat next to it, like messages from one sender in a chat. |
How, why and when to use it
Use a log for what happened, in order, so a player who looked away can catch up: moves, rolls, cards played, who passed. It replaces the old habit of broadcasting every move into chat, where it buried what people were saying, and of drawing the last few moves as text on the felt.
Show the log widget first, once, then add lines as things happen. On a desktop it opens in its
region (end by default); on a phone it is collapsed behind its title and shows the newest line.
An open log follows new lines while the player is at the bottom; if they have scrolled up to read
back, it keeps their place and shows a New lines button instead. Lines added together appear
in the order you added them, so write one line per step of a move.
A log follows its widget's audience. A log shown to one seat is that seat's private history, and its lines never reach anyone else.
Example
// content/scripting-api/examples/ui.log.ts
// Scene script: a scrollable game history. The log widget must be showing
// before any line is added to it; it keeps the newest 100 here.
ui.show({ id: "log", kind: "log", title: "Game log", placement: { region: "end" }, maxLines: 100 });
globalEvents.onDiceRollResult.add((roll) => {
ui.log(roll.actorName + " rolled " + roll.total + ".");
});
globalEvents.onCardDrawn.add((card) => {
ui.log("A card was drawn: " + (card.name ?? "a card") + ".", { tone: "info" });
});
Gotchas
Show the widget first. A line for a log widget that is not showing (never shown, hidden, or
not a log) is refused: ui.log was ignored: show a log widget with id "log" first.
Lines are capped. A log keeps maxLines (default and maximum 200); the oldest fall off. Lines
count towards the table's 64 KB of public HUD, so keep them short.
seat only colours the line. It adds no name: write who did it in the text. A seat that is
not a seat id refuses the line (ui.log was ignored: \seat` must be a seat id.`). The VR tray
ignores it.
A line is text, not a template. {key} in a log line is drawn literally; build the string
yourself.
See also
ui.prompt#
prompt(seat: string, spec: UiPromptSpec): Promise<UiPromptResult>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Ask ONE seat a question and wait for the answer. Resolves when the seat answers, when
timeoutSeconds runs out (timedOut), or with cancelled when nobody sits there, the seat
empties, the prompt expires or is replaced, or your scripts restart. A bot seat resolves at
once with bot: true: decide for it. One open prompt per seat; later ones wait their turn.
A dismissible prompt the seat dismisses resolves cancelled with dismissed: true.
Rejects with an Error whose message starts with a code:
not-hoston a peer;invalid-prompt: …for a bad seat or spec (the detail is in the script log too);ui-refused: …when the table's Game UI refuses it: 128 of your prompts are already waiting, or a host cap —queue-full(8 queued for that seat),cap-widgets,cap-section,cap-private-slice,budgetand the like. Catch it and try later, or decide without asking.
Asks one seat a question and waits for the answer. The platform builds a prompt widget from
your spec, shows it to that seat's player only (a dialog on desktop and in VR, a bottom sheet on a phone),
and resolves the promise when they answer, when time runs out, or when the question goes away.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
seat |
string |
yes | The seat to ask, such as "red". |
spec |
UiPromptSpec |
yes | Needs actions (buttons) or choices (a list), or both. |
Returns
Promise<UiPromptResult>. Exactly one of these applies:
| Outcome | actionId |
Other fields |
|---|---|---|
| Answered | The button or option pressed (for a several-pick list, the confirm button's id) | value: the picked option ids, for a several-pick list. peerId: who answered. |
| Timed out | Your defaultAction, or null without one |
timedOut: true |
| Declined | null |
cancelled: true and dismissed: true: the seat closed a dismissible prompt instead of answering. peerId: who closed it. |
| Went away | null |
cancelled: true: nobody sat in the seat, the seat emptied while it waited, a prompt or widget with the same id replaced it, it was hidden, or your scripts restarted |
| A bot's seat | null |
bot: true, at once |
It rejects (the question is never shown) with an Error whose message starts with a code:
| Message starts with | When |
|---|---|
not-host |
This peer is not the host. Checked first. |
invalid-prompt: … |
seat is not a seat id, spec is not an object, or the spec fails the strict check (no actions or choices, a defaultAction that names no button, an unknown icon…). The text after the colon names the problem; a spec problem is also written to the script log. |
ui-refused: … |
The table would not take it: 128 of your game's prompts are already waiting, or a host cap refused the widget. The reason follows the colon: queue-full (8 prompts already queued for that seat), cap-widgets, cap-section, cap-private-slice, budget and the like. |
ui-unavailable |
The table has no Game UI to draw into. |
A rejection is not an answer. Catch it, as the example does, and decide without asking: keep the
default, skip the optional step, or try again on a later turn. A rejection you do not catch ends the
async code that awaited it, which usually stalls your game.
How, why and when to use it
Use it when the game cannot go on until one player decides: keep or redraw, which piece a pawn becomes, which card to discard, whether to pay a cost. The answer comes back to the code that asked, so the rules read top to bottom instead of being split between the question and a handler somewhere else.
Always plan for every outcome. Bots never see prompts, so decide for them. Give a question that
must be answered a timeoutSeconds and a defaultAction, so a player who walked away does not stall
the table. Treat cancelled as "nobody is going to answer this".
For a long list of answers (name a subtype, name a card type), use choices: above 12 options the
player gets a filter box to type into (letter tabs in VR), and choices.search sets its hint or
turns it on or off. The answer is still one of your option ids, never the typed text. See
UiChoiceList.
For a question several seats answer at once, or one that should stay up while the game continues,
show a prompt or choice widget yourself with ui.show (audience: { scope: "seat-each" } gives each seat its own copy) and handle the answers in
ui.onAction.
Example
// content/scripting-api/examples/ui.prompt.ts
// Scene script: ask one seat to keep or redraw their opening hand, and
// handle every way the question can end - including the seat declining
// (dismissible) and the platform refusing to ask at all (a rejection).
async function askMulligan(seat: string): Promise<boolean> {
let result: UiPromptResult;
try {
result = await ui.prompt(seat, {
title: "Opening hand",
body: "Keep these cards, or shuffle them back and draw again?",
actions: [
{ id: "keep", label: "Keep", icon: "check", tone: "success" },
{ id: "redraw", label: "Redraw", icon: "shuffle" }
],
timeoutSeconds: 60,
defaultAction: "keep",
// The seat may close the question instead of answering it.
dismissible: true
});
} catch (error) {
// "not-host", "invalid-prompt: ..." or "ui-refused: ...": decide
// without asking rather than stall the game.
const reason = error instanceof Error ? error.message : String(error);
world.log(`Could not ask ${seat} (${reason}); they keep.`);
return false;
}
if (result.bot) {
// A bot never sees the question: the script decides for it.
return false;
}
if (result.dismissed) {
world.log(`${seat} declined to choose and keeps.`);
return false;
}
if (result.cancelled) {
world.log(`Nobody answered for ${seat}; the question went away.`);
return false;
}
if (result.timedOut) {
world.log(`${seat} ran out of time and keeps.`);
}
return result.actionId === "redraw";
}
void (async () => {
for (const player of world.getPlayers()) {
if (player.seat !== null && (await askMulligan(player.seat))) {
world.log(`${player.seat} redraws.`);
}
}
})();
Gotchas
A bot seat resolves at once with bot: true. Bots never see prompts. Decide for them.
An empty seat resolves cancelled: true, not an error. So does a seat that empties while it
waits.
One question per seat at a time. A second prompt to the same seat waits until the first is answered (at most 8 waiting), and its timeout starts only when it appears. Asking several seats at once is fine: each has its own queue.
Answers come here, not to ui.onAction. A press that answers a pending ui.prompt resolves
its promise and is not also delivered to ui.onAction.
Prompting again with the same id replaces the question, and the earlier promise resolves
cancelled. Leave id out and every prompt gets a fresh one.
Without dismissible, a player may only tuck the question away. Escape or × hides it on their
screen to look at the table; that does not answer it. The prompt stays open and pending, behind a
pill they can tap to reopen.
With dismissible: true, the seat may decline. On desktop the dialog then shows a minimise
button (hide it for now, as above) and a ✕ that closes the question for good; Escape closes it
too. In VR the dialog shows "–" (hide) and ✕ (decline). Declining resolves the promise
{ actionId: null, cancelled: true, dismissed: true, peerId, timedOut: false, seat }; it does
not also reach ui.onAction. Offer it for questions that have a sensible "no thanks".
Nobody in Edit Mode sits in a seat, so under ▶ Play Scripts every prompt resolves cancelled.
Play-test prompts at a table.
In VR it opens as a dialog within reach, answered with the controller ray or a fingertip. One dialog shows at a time; others wait behind a pager.
See also
UiPromptSpec— what you can ask.UiPromptResult— what comes back.UiChoiceList— a list to pick from, including several picks.- Game UI (HUD): prompts.
ui.onAction#
readonly onAction: ScriptDelegate<[event: UiActionEvent]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
A press on any of your widgets, or a dismiss (dismissed: true). An answer to ui.prompt resolves its promise instead.
Fires on the host for a press on any of your game's widgets (a button in an actions or prompt
widget, an option or confirm in a choice, a pressable child of a panel), for a prompt widget
that timed out, and for a viewer dismissing a dismissible widget. The handler receives a UiActionEvent the host has already
checked against its own copy of the widget and its own list of who sits where.
Parameters
The handler receives one UiActionEvent: widgetId, actionId, value,
seat, peerId, instanceSeat, timedOut, dismissed for a dismiss, and for a panel child
childId and hook.
How, why and when to use it
Subscribe once, when your script starts, and switch on widgetId and then actionId. Every
actions strip, choice list and pressable panel you show reports here.
Before your handler runs, the host has refused any press that is not legitimate: the widget is not
showing, the presser is not in its audience right now, the button does not exist or is disabled for
them, the prompt was already answered, or a picked value is not one you offered. What it has not
checked is your game's rules. A button shown to every player can be pressed by any of them, so check
event.seat against whose turn it is before acting.
{ scope: "actor" } as an audience means "whoever pressed this". It works for any ui call made
inside this handler, before its first await: answer the presser privately with a toast or a
widget, without knowing their peer id.
Example
// content/scripting-api/examples/ui.onAction.ts
// Scene script: an action strip and the one handler that answers it.
// The host has already checked the press; the handler checks the rules.
ui.show({
id: "turn-actions",
kind: "actions",
placement: { region: "lower" },
actions: [
{ id: "draw", label: "Draw", icon: "playing_cards" },
{ id: "pass", label: "Pass", icon: "skip_next", confirm: "Pass without drawing?" }
]
});
ui.show({ id: "log", kind: "log", title: "Turns" });
let activeSeat: string | null = null;
globalEvents.onTurnStarted.add((turn) => {
activeSeat = turn.seat;
});
ui.onAction.add((event) => {
if (event.widgetId !== "turn-actions") {
return;
}
if (event.seat === null || event.seat !== activeSeat) {
// Only the person who pressed sees this, and only from inside this
// handler: "actor" means nothing after an await.
ui.toast("Wait for your turn.", { tone: "warning", to: { scope: "actor" } });
return;
}
if (event.actionId === "draw") {
ui.log(`${event.seat} draws a card.`);
} else if (event.actionId === "pass") {
ui.log(`${event.seat} passes.`);
}
});
Gotchas
An answer to a pending ui.prompt does not fire here; it resolves that promise instead. A
prompt widget you showed yourself with ui.show does report here, and so does its timeout
(timedOut: true, actionId set to its defaultAction or null, peerId null).
A panel child reports with childId. actionId is "click" for a button and "change"
for a checkbox or select; value holds the new value as one string ("true"/"false" for a
checkbox, the option's value for a select); hook is the child's own onClick/onChange/hook
name, if it has one.
A dismiss arrives as dismissed: true with actionId: null. A widget with
dismissible: true or timing.until: "dismissed" (and not until: "hidden") shows a ✕ to every
viewer who may act on it. The host checks a dismiss exactly as it checks a press (audience, the
spectator rule, the seat-each copy, when, and that the prompt is still the one shown), then
closes the widget: for that seat only on a seat-each widget, for everyone otherwise. The event
tells you it happened; the widget is already gone. A declined ui.prompt resolves its promise
instead and never fires here.
seat is null for a spectator, who can press only a widget that says allow: ["spectators"].
Presses on another game's or a mod's widgets never reach you, and yours never reach them.
A handler that throws is reported in the script console and does not stop the other handlers.
See also
UiActionEvent— every field.ui.prompt— when you want the answer where you asked.UiButton—enabledWhen,activeWhenandconfirm.- Game UI (HUD): actions.
