Dicey Table

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.

TypeScript
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#

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.

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

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

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

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

TypeScript
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#

Surface A — table script · type

How a region lays out its widgets (on a phone: the corner slot its widgets land in).

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

TypeScript
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#

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.

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

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

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

TypeScript
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#

UiChoiceOption#

Surface A — table script · type

One option of a choice list: a button (a card picture included), plus a description line.

TypeScript
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#

Surface A — table script · type

A choice list. With max above 1 it needs a confirm button, which carries the picks.

TypeScript
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#

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 in label. With both, entityId wins while it exists; an entity that is gone falls back to cardId, then to label as text. label (at most 120 characters) is required: it is the fallback and what a screen reader announces.
TypeScript
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#

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.

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

TypeScript
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#

UiValue#

Surface A — table script · type

A value HUD state holds: text, a number, a yes/no, or nothing.

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

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

TypeScript
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:

  1. bot: decide for the bot.
  2. dismissed: the seat declined; treat it as their "no".
  3. cancelled: nobody is going to answer; carry on without, or ask again later.
  4. timedOut: actionId already holds your default, so you can usually treat it as an answer.
  5. Otherwise read actionId (and value for 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.

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

TypeScript
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:

  1. When the script starts, ui.show each widget once, with text that binds to state: title: "{phase}".
  2. As the game moves, change state with ui.set and ui.setMany; every binding redraws by itself.
  3. Say passing news with ui.toast and keep a history with ui.log.
  4. Ask one seat a question with ui.prompt and carry on with the answer.
  5. Handle presses on your actions, choice and panel widgets in ui.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#

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#

TypeScript
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

TypeScriptui.show.ts
// 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

ui.update#

TypeScript
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

TypeScriptui.update.ts
// 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.hide#

TypeScript
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

TypeScriptui.hide.ts
// 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#

TypeScript
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

TypeScriptui.set.ts
// 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#

TypeScript
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

TypeScriptui.setMany.ts
// 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#

TypeScript
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

TypeScriptui.toast.ts
// 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 — the banner kind, 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#

TypeScript
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

TypeScriptui.log.ts
// 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

  • UiWidget — the log kind and maxLines.
  • ui.toast — for news that should not be kept.

ui.prompt#

TypeScript
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-host on 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, budget and 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

TypeScriptui.prompt.ts
// 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

ui.onAction#

TypeScript
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

TypeScriptui.onAction.ts
// 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

Did this page answer your question?