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

- Source: https://diceytable.com/docs/scripting-api/game-ui
- Last verified: 2026-10-08

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)](https://diceytable.com/docs/scripting/concepts/game-ui) 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](https://diceytable.com/docs/scripting-api/api#apiuishow).

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

```ts
declare type UiTone = "neutral" | "info" | "success" | "warning" | "danger" | "accent";
```

A tone: the platform's colour **role** for a widget, a button, a counter, a badge, a toast or a log
line. One of `"neutral"`, `"info"`, `"success"`, `"warning"`, `"danger"` or `"accent"`. It says what a
thing *means*; the platform decides what that looks like.

### How, why and when to use it

Pick the tone for the message's meaning: `warning` for "not yet", `danger` for "you lost a life",
`success` for "you win", `info` for neutral news, `accent` for the thing you want the eye drawn to.
Players learn one meaning per colour across every game, which is why there is no free colour here.

A widget's or button's `accent` (a named palette colour or `#rrggbb`) is the place for your game's
own colour, on top of a tone.

### Gotchas

**Any other string is refused** when you write it. The whole call is refused; it does not fall back
to `neutral`.

**Not the basic widgets' variants.** A basic `button`'s `variant: "primary"` is a different, older
vocabulary; `"primary"` is not a tone.

### See also

- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget) — `theme.tone`.
- [`UiButtonStyle`](https://diceytable.com/docs/scripting-api/game-ui#uibuttonstyle) — a button's tone per state.
- [The look](https://diceytable.com/docs/scripting/concepts/game-ui#the-look-tones-accents-icons-fonts-and-images).

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

```ts
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

- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget) — `placement`.
- [Placement](https://diceytable.com/docs/scripting/concepts/game-ui#placement-regions-and-attachments) — regions and attachments on every device.

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

```ts
declare type UiSize = number | `${number}%`;
```

A size in a [`UiStyle`](https://diceytable.com/docs/scripting-api/game-ui#uistyle) or [`UiRegionStyle`](https://diceytable.com/docs/scripting-api/game-ui#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

- [`UiStyle`](https://diceytable.com/docs/scripting-api/game-ui#uistyle).

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

```ts
declare type UiSpacing = number | { x?: number; y?: number; above?: number; below?: number; start?: number; end?: number };
```

Padding or margin in a [`UiStyle`](https://diceytable.com/docs/scripting-api/game-ui#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

- [`UiStyle`](https://diceytable.com/docs/scripting-api/game-ui#uistyle).

## UiColor

_Surface A — table script · type_

A colour: a palette name or `"#rrggbb"`.

```ts
declare type UiColor = "red" | "orange" | "yellow" | "green" | "teal" | "blue" | "purple" | "pink" | "gray" | `#${string}`;
```

A colour in a [`UiStyle`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uistyle).

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

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uispacing) |
| | `gap` | 0-32 px between its own children (buttons, chips, rows) |
| Colour | `background`, `color`, `borderColor` | a [`UiColor`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uiregionstyle) — how a whole region lays out its widgets.
- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget).
- [Styling widgets](https://diceytable.com/docs/scripting/concepts/game-ui#styling-widgets).

## UiRegionStyle

_Surface A — table script · type_

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

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#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

- [`UiStyle`](https://diceytable.com/docs/scripting-api/game-ui#uistyle) — one widget's own look and size.
- [`UiRegion`](https://diceytable.com/docs/scripting-api/game-ui#uiregion).

## UiAudience

_Surface A — table script · type_

Who sees a widget (and may press it). Everything except `"all"` is private to its audience.

```ts
declare type UiAudience =
  | { scope: "all" }
  | { scope: "host" }
  | { scope: "players" }
  | { scope: "spectators" }
  | { scope: "seats"; seats: readonly string[] }
  | { scope: "teams"; teams: readonly string[] }
  | { scope: "seat-each" }
  | { scope: "actor" }
  | { scope: "peers"; peerIds: readonly string[] };
```

Who sees a widget, and who may press it. `{ scope: "all" }`, the default, is the only **public**
audience. Every other audience is **private**: the host sends the widget only to the players in it,
and nobody else's computer ever receives it.

| Audience | Who |
|---|---|
| `{ scope: "all" }` | Everyone, spectators included |
| `{ scope: "players" }` | Everyone in a seat |
| `{ scope: "spectators" }` | Everyone watching without a seat |
| `{ scope: "host" }` | The host's player |
| `{ scope: "seats", seats: [...] }` | The players in those seats |
| `{ scope: "teams", teams: [...] }` | The players on those teams |
| `{ scope: "seat-each" }` | One copy per seat, each private to its seat |
| `{ scope: "actor" }` | Whoever pressed the widget your action handler is answering |
| `{ scope: "peers", peerIds: [...] }` | Those connections |

### How, why and when to use it

Use a private audience for anything that would spoil the game if the wrong person read it: what
this seat may do now, a secret objective, a choice of cards from their own hand, the host's
controls. Use `seat-each` when every seat gets the same widget about **its own** situation: a "Done"
button each seat presses once, a status line that reads each seat's private values through
`{seat.key}`.

Membership is decided by the host from its own list of who sits where, both when the widget is
shown and when it is pressed. A player who changes seat loses the old seat's private widgets and
gains the new seat's.

### Gotchas

**`actor` works only inside an action handler, before its first `await`.** Anywhere else the call is
refused. It becomes that player's connection when the widget is shown, so it stays with that person
even if they later change seat.

**Spectators never see a seat's widgets**, and press a widget only when it says
`allow: ["spectators"]`.

**A `seat-each` prompt or choice, and a `seat-each` widget with `until: "action"`, close one seat at
a time.** Each seat answers once; the widget goes away when every occupied seat has.

**Private is private from the other players, not from the host.** The host's computer runs your
scripts and holds every private widget, as it already holds every hidden card.

### See also

- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget) — `audience` and `allow`.
- [`ui.set`](https://diceytable.com/docs/scripting-api/game-ui#uiset) — private seat state, the same privacy for values.
- [Audiences and privacy](https://diceytable.com/docs/scripting/concepts/game-ui#audiences-and-privacy).

## UiCondition

_Surface A — table script · type_

A condition on HUD state, checked for each viewer. `seat.<key>` reads the VIEWER's own seat's
value. No expressions: these node shapes only, at most 4 deep and 16 nodes.

```ts
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

- [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#uibutton) — `enabledWhen` and `activeWhen`.
- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget) — `when`.
- [`ui.set`](https://diceytable.com/docs/scripting-api/game-ui#uiset) — the state conditions read.

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

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uiset), and the widget follows it. [Each seat's side symbol](https://diceytable.com/docs/scripting/concepts/game-ui#each-seats-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

- [The look](https://diceytable.com/docs/scripting/concepts/game-ui#the-look-tones-accents-icons-fonts-and-images).
- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget).
- [`ui.set`](https://diceytable.com/docs/scripting-api/game-ui#uiset).

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

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#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](https://diceytable.com/docs/scripting/concepts/game-ui#the-look-tones-accents-icons-fonts-and-images). |
| `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`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uibutton) — the states and when each applies.
- [Buttons](https://diceytable.com/docs/scripting/concepts/game-ui#buttons).

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

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uionaction), or resolves [`ui.prompt`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt), 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`](https://diceytable.com/docs/scripting-api/game-ui#uibuttonstyle). |
| `enabledWhen` | A [`UiCondition`](https://diceytable.com/docs/scripting-api/game-ui#uicondition): pressable only while it holds for the viewer. |
| `activeWhen` | A [`UiCondition`](https://diceytable.com/docs/scripting-api/game-ui#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](https://diceytable.com/docs/scripting/concepts/game-ui#picking-cards).

### Gotchas

**A button needs a label, an image face or a card.** One with none of them is refused. Give a card
button a label anyway: it shows while the art loads or if it cannot be found, and a screen reader
reads it.

**`cardId` is a disclosure; `entityId` is not.** Every player the widget reaches sees a `cardId`'s
face, like its name in the label. An `entityId` is drawn per player from what the table lets them
see, so it is safe in a widget everyone sees. With both, `entityId` wins while the entity exists.

**Ids must be unique within the widget**, across its buttons, its options and its confirm.

**`enabledWhen` is checked twice**: on the viewer's screen, and by the host for the presser when
the press arrives. It is a rule about who may press, not only a look.

**Bindings in a label are resolved once, from your text.** A value containing `{` or `**` is drawn
literally; a player's name cannot inject a binding or formatting.

### See also

- [`UiButtonStyle`](https://diceytable.com/docs/scripting-api/game-ui#uibuttonstyle) — what each state can change.
- [`UiChoiceOption`](https://diceytable.com/docs/scripting-api/game-ui#uichoiceoption) — a button in a choice list.
- [`ui.onAction`](https://diceytable.com/docs/scripting-api/game-ui#uionaction) — where a press arrives.
- [A host "New game" bar](https://diceytable.com/docs/scripting/concepts/game-ui#a-host-new-game-bar) — states, `activeWhen`, `enabledWhen` and `confirm` together.

## UiChoiceOption

_Surface A — table script · type_

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

```ts
declare type UiChoiceOption = UiButton & {
  description?: string;
};
```

One option in a choice list. It is a [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uibutton). |
| `cardId` | Draws this card from your game's card catalogue, face up for everyone the widget reaches. See [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uichoicelist) — how many may be picked.
- [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#uibutton).

## UiChoiceList

_Surface A — table script · type_

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

```ts
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](https://diceytable.com/docs/scripting-api/game-ui#uichoiceoption) 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 [`UiChoiceOption`](https://diceytable.com/docs/scripting-api/game-ui#uichoiceoption)s. |
| `min` | The fewest picks allowed. Default 1. |
| `max` | The most picks allowed. Default 1. |
| `confirm` | A [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#uibutton) that sends the picks. Required when `max` is more than 1. |
| `search` | The filter box above the options: `true` shows it, `false` never does, `{ placeholder }` shows it with that hint (at most 80 characters). Left out, it shows when there are more than 12 options. |

### How, why and when to use it

**One pick** (`max` 1, the default, no `confirm`): pressing an option answers. The option's id
arrives as `actionId`.

**Several picks** (`max` above 1, with a `confirm`): the player toggles options, then presses the
confirm. The confirm's id arrives as `actionId`, and the picked option ids as `value`. The confirm is
enabled only while the number picked is between `min` and `max`.

**Long lists** (a subtype, a card name, a country) get a filter box: the player types and the list
narrows to options whose label or description contains the text, labels that *start* with it
first. ↑/↓ and Enter pick from the keyboard, and Escape clears the text. VR players get a strip of
letter tabs instead, since a headset has no keyboard. Add a `description` to an option to make it
findable by more than its label.

### Gotchas

**The typed text is never sent.** It only narrows what the player sees on their own screen; the
answer is an option id like any other. There is no free-text answer: to ask for a name, offer the
names.

**`min` ≤ `max` ≤ the number of options**, or the widget is refused.

**The host checks the picks.** Every picked id must be one of your options, and their number must be
within `min` and `max`; anything else never reaches you.

**A choice list queues like a prompt.** A second `prompt` or `choice` widget to the same audience
waits until the first is answered.

### See also

- [`ui.prompt`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) — asking with a choice list.
- [A prompt with choices](https://diceytable.com/docs/scripting/concepts/game-ui#a-prompt-with-choices) — one pick and two picks, worked through.
- [A long list with a filter box](https://diceytable.com/docs/scripting/concepts/game-ui#a-long-list-with-a-filter-box) — `search`, worked through.

## UiCardRef

_Surface A — table script · type_

The card a `card` widget (the card spotlight) draws. Named like a card button, never a URL:
- `entityId`: a table entity. Each player sees what they may see of it: its face, or its
  BACK when it is hidden from them. Safe in a widget everyone sees.
- `cardId`: a card id from your game's card catalogue or a deck on the table. Everyone the
  widget reaches sees that face, like its name 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.

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uicardstat) — the stat chips under the card.
- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget).
- [Card spotlights](https://diceytable.com/docs/scripting/concepts/game-ui#card-spotlights).

## UiCardStat

_Surface A — table script · type_

One stat chip on a card spotlight, such as `{ label: "Power", value: "5", change: "up" }`.
`label` is at most 24 characters. `value` may bind state (`"{seat.attackerPower}"`) and is
drawn cut to 24 characters. `change` says how the value compares with the printed one:
`"same"` (the default) is drawn white, `"up"` green and `"down"` red. Your script decides it;
the platform does not compare anything. A chip with a symbol (`iconImage` or `icon`) draws the
symbol INSTEAD of the label text: the label is still its accessible name and tooltip ("Speed 50"),
and is drawn as text again if the image cannot be loaded.

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uiupdate).

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

- [`UiCardRef`](https://diceytable.com/docs/scripting-api/game-ui#uicardref).
- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget).
- [Card spotlights](https://diceytable.com/docs/scripting/concepts/game-ui#card-spotlights).

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

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uiset).

**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`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uiaudience). Default everyone. |
| `allow` | `["spectators"]` lets spectators press it. Default: they cannot. |
| `when` | A [`UiCondition`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uihide) 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`](https://diceytable.com/docs/scripting-api/game-ui#uiimageref) drawn instead of the icon. See [the look](https://diceytable.com/docs/scripting/concepts/game-ui#the-look-tones-accents-icons-fonts-and-images). |

**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 [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#uibutton)s) |
| `choice` | `title`, `body`, `choices` (a [`UiChoiceList`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uicardref), required), `stats` (at most 8 [`UiCardStat`](https://diceytable.com/docs/scripting-api/game-ui#uicardstat)s), `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](https://diceytable.com/docs/scripting/concepts/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uiimageref), so `"{seat.sideIcon}"` shows each seat's own symbol.

### How, why and when to use it

Pass it to [`ui.show`](https://diceytable.com/docs/scripting-api/game-ui#uishow), once, with a stable `id`. Then change what it says through state
([`ui.set`](https://diceytable.com/docs/scripting-api/game-ui#uiset)), change its structure with [`ui.update`](https://diceytable.com/docs/scripting-api/game-ui#uiupdate) (a
[`UiWidgetPatch`](https://diceytable.com/docs/scripting-api/types#uiwidgetpatch), where `null` removes a field), and
remove it with [`ui.hide`](https://diceytable.com/docs/scripting-api/game-ui#uihide). A widget designed in the
[UI layout editor](https://diceytable.com/docs/editor/item-editors/ui-layout) is this same shape. [Game UI (HUD)](https://diceytable.com/docs/scripting/concepts/game-ui#widget-kinds) 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`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uionaction) 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`](https://diceytable.com/docs/scripting-api/game-ui#uionaction) as `dismissed: true` with `actionId: null`; a dismissible
[`ui.prompt`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) resolves `cancelled` with `dismissed: true` instead. Without
`dismissible`, a prompt's ✕ only hides it on that viewer's own screen and it stays open.

**Use `until: "hidden"` for anything a viewer must not close**, such as a status line someone could
otherwise dismiss for the whole table.

### See also

- [`ui.show`](https://diceytable.com/docs/scripting-api/game-ui#uishow).
- [`UiAudience`](https://diceytable.com/docs/scripting-api/game-ui#uiaudience), [`UiCondition`](https://diceytable.com/docs/scripting-api/game-ui#uicondition), [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#uibutton).
- [Game UI (HUD)](https://diceytable.com/docs/scripting/concepts/game-ui) — kinds, regions and limits in one place.

## UiValue

_Surface A — table script · type_

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

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uiset) or [`ui.setMany`](https://diceytable.com/docs/scripting-api/game-ui#uisetmany). 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`](https://diceytable.com/docs/scripting-api/game-ui#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

- [`ui.set`](https://diceytable.com/docs/scripting-api/game-ui#uiset).

## UiPromptSpec

_Surface A — table script · type_

What `ui.prompt` asks. A prompt widget built for you, shown to that seat only.

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) 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 [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#uibutton)s. |
| `choices` | A [`UiChoiceList`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uiregion). Default `"sheet"`. |
| `theme` | As on [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uishow) 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

- [`ui.prompt`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt).
- [`UiPromptResult`](https://diceytable.com/docs/scripting-api/game-ui#uipromptresult).

## UiPromptResult

_Surface A — table script · type_

How a `ui.prompt` ended. Exactly one of answered / `timedOut` / `cancelled` / `bot` applies.

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) 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`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) — 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.

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uionaction) 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.onAction`](https://diceytable.com/docs/scripting-api/game-ui#uionaction).
- [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#uibutton).

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

```ts
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](https://diceytable.com/docs/scripting/concepts/game-ui#where-text-belongs-hud-table-text-or-chat)).

The usual shape of a game's HUD:

1. When the script starts, [`ui.show`](https://diceytable.com/docs/scripting-api/game-ui#uishow) each widget once, with text that binds to state:
   `title: "{phase}"`.
2. As the game moves, change state with [`ui.set`](https://diceytable.com/docs/scripting-api/game-ui#uiset) and [`ui.setMany`](https://diceytable.com/docs/scripting-api/game-ui#uisetmany); every
   binding redraws by itself.
3. Say passing news with [`ui.toast`](https://diceytable.com/docs/scripting-api/game-ui#uitoast) and keep a history with [`ui.log`](https://diceytable.com/docs/scripting-api/game-ui#uilog).
4. Ask one seat a question with [`ui.prompt`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) and carry on with the answer.
5. Handle presses on your `actions`, `choice` and `panel` widgets in [`ui.onAction`](https://diceytable.com/docs/scripting-api/game-ui#uionaction).

### 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](https://diceytable.com/docs/scripting/concepts/game-ui#placement-regions-and-attachments).

### See also

- [Game UI (HUD)](https://diceytable.com/docs/scripting/concepts/game-ui) — widget kinds, regions per device, privacy, and five worked examples.
- [`ui.show`](https://diceytable.com/docs/scripting-api/game-ui#uishow) — put a widget on screen.
- [`ui.prompt`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) — ask one seat and wait.
- [`ui.onAction`](https://diceytable.com/docs/scripting-api/game-ui#uionaction) — answer a press.
- [`world.setTextLabel`](https://diceytable.com/docs/scripting-api/world#worldsettextlabel) — for words that belong on the board itself.

### Members

| Signature | Description | Returns |
|---|---|---|
| [`show(widget: string \| UiWidget, overrides?: UiWidgetPatch)`](https://diceytable.com/docs/scripting-api/game-ui#uishow) | 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)`](https://diceytable.com/docs/scripting-api/game-ui#uiupdate) | 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[])`](https://diceytable.com/docs/scripting-api/game-ui#uihide) | Remove one of your widgets (or several). Unknown ids are ignored. | `void` |
| [`set(key: string, value: UiValue, seat?: string, options?: { private?: boolean })`](https://diceytable.com/docs/scripting-api/game-ui#uiset) | 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 })`](https://diceytable.com/docs/scripting-api/game-ui#uisetmany) | Set several state values at once (`null` removes one). | `void` |
| [`toast(text: string, options?: { tone?: UiTone; icon?: string; seconds?: number; to?: UiAudience })`](https://diceytable.com/docs/scripting-api/game-ui#uitoast) | 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 })`](https://diceytable.com/docs/scripting-api/game-ui#uilog) | 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)`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) | 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`](https://diceytable.com/docs/scripting-api/game-ui#uionaction) | 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

```ts
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](https://diceytable.com/docs/editor/item-editors/ui-layout) declares, with `overrides` merged over it.

#### Parameters

| Name | Type | Required | Notes |
|---|---|---|---|
| `widget` | `string` or [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget) | yes | A whole widget, or the `id` of one shown before or declared by an attached UI layout. |
| `overrides` | [`UiWidgetPatch`](https://diceytable.com/docs/scripting-api/types#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`](https://diceytable.com/docs/scripting-api/game-ui#uiset)), 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`](https://diceytable.com/docs/scripting-api/game-ui#uiupdate).

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](https://diceytable.com/docs/editor/item-editors/ui-layout). 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

```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`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) replaces it, which
resolves that promise `cancelled`.

**A string id nobody declared is ignored**, with the diagnostic `no widget with that id was shown
before, and no attached UI layout declares one`. The host remembers the definitions your game's
scripts gave it (the most recent 256) and the widgets of the layouts attached to your game, never a
mod's widgets. A mod's `api.uiShow` does not see layouts at all: a layout belongs to the game's
table scripts.

**One widget per call, and caps per game.** Your game's scripts may hold 128 widgets at once,
each at most 16 KB, and all public HUD on the table at most 64 KB together.

#### See also

- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget) — every field, by kind.
- [`UiWidgetPatch`](https://diceytable.com/docs/scripting-api/types#uiwidgetpatch) — what `overrides` may say.
- [`ui.update`](https://diceytable.com/docs/scripting-api/game-ui#uiupdate) — change a showing widget without restarting it.
- [`ui.hide`](https://diceytable.com/docs/scripting-api/game-ui#uihide) — take it away.
- [Game UI (HUD)](https://diceytable.com/docs/scripting/concepts/game-ui#widget-kinds) — which kind to use for what.

### ui.update

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uishow), 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`](https://diceytable.com/docs/scripting-api/types#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`](https://diceytable.com/docs/scripting-api/game-ui#uiset). 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

```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](https://diceytable.com/docs/editor/item-editors/ui-layout) showed at start or that you showed by id with
`ui.show`. Changing `kind` to or from `prompt`/`choice` is refused; re-show it instead.

#### See also

- [`ui.show`](https://diceytable.com/docs/scripting-api/game-ui#uishow) — replace or re-show, restarting the widget.
- [`ui.set`](https://diceytable.com/docs/scripting-api/game-ui#uiset) — change a value its text binds to.
- [`UiWidgetPatch`](https://diceytable.com/docs/scripting-api/types#uiwidgetpatch) — what a patch may say.
- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget).

### ui.hide

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uiset) or patch it with [`ui.update`](https://diceytable.com/docs/scripting-api/game-ui#uiupdate).

#### Example

```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`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) 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`](https://diceytable.com/docs/scripting-api/game-ui#uilog) 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`](https://diceytable.com/docs/scripting-api/game-ui#uishow) — and its string form, to bring a hidden widget back.
- [`UiWidget`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget) — `timing.duration`, for a widget that should go away on its own.

### ui.set

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#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

```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](https://diceytable.com/docs/editor/item-editors/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`](https://diceytable.com/docs/scripting-api/game-ui#uisetmany) — several values in one change.
- [UI layout editor](https://diceytable.com/docs/editor/item-editors/ui-layout) — declare keys, defaults and privacy without code.
- [`UiValue`](https://diceytable.com/docs/scripting-api/game-ui#uivalue) — what a value can be.
- [`UiCondition`](https://diceytable.com/docs/scripting-api/game-ui#uicondition) — `when`, `enabledWhen` and `activeWhen` read the same state.
- [Audiences and privacy](https://diceytable.com/docs/scripting/concepts/game-ui#audiences-and-privacy).

### ui.setMany

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uiset).

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

```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](https://diceytable.com/docs/editor/item-editors/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.set`](https://diceytable.com/docs/scripting-api/game-ui#uiset) — the three kinds of value, explained.
- [`UiValue`](https://diceytable.com/docs/scripting-api/game-ui#uivalue).

### ui.toast

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uitone) | no | Default `neutral`. |
| `options.icon` | `string` | no | A name from the [HUD icon list](https://diceytable.com/docs/scripting/concepts/game-ui#the-look-tones-accents-icons-fonts-and-images). |
| `options.seconds` | `number` | no | 0.5 to 600. Default 4. |
| `options.to` | [`UiAudience`](https://diceytable.com/docs/scripting-api/game-ui#uiaudience) | no | Default everyone. `{ scope: "actor" }` inside a [`ui.onAction`](https://diceytable.com/docs/scripting-api/game-ui#uionaction) 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`](https://diceytable.com/docs/scripting-api/game-ui#uishow) instead: that one is part of the
table until it expires. If it is the state of the game, use a `status` widget.

#### Example

```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`](https://diceytable.com/docs/scripting-api/game-ui#uilog) 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`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget) — the `banner` kind, a notice that is part of the table.
- [`ui.log`](https://diceytable.com/docs/scripting-api/game-ui#uilog) — a history the player can scroll back through.
- [`world.broadcast`](https://diceytable.com/docs/scripting-api/world#worldbroadcast) — a line in chat.

### ui.log

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#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

```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`](https://diceytable.com/docs/scripting-api/game-ui#uiwidget) — the `log` kind and `maxLines`.
- [`ui.toast`](https://diceytable.com/docs/scripting-api/game-ui#uitoast) — for news that should not be kept.

### ui.prompt

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#uipromptspec) | yes | Needs `actions` (buttons) or `choices` (a list), or both. |

#### Returns

`Promise<`[`UiPromptResult`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#uishow) (`audience: { scope:
"seat-each" }` gives each seat its own copy) and handle the answers in
[`ui.onAction`](https://diceytable.com/docs/scripting-api/game-ui#uionaction).

#### Example

```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`](https://diceytable.com/docs/scripting-api/game-ui#uionaction).

**Prompting again with the same `id` replaces the question**, and the earlier promise resolves
`cancelled`. Leave `id` out and every prompt gets a fresh one.

**Without `dismissible`, a player may only tuck the question away.** Escape or × hides it on their
screen to look at the table; that does not answer it. The prompt stays open and pending, behind a
pill they can tap to reopen.

**With `dismissible: true`, the seat may decline.** On desktop the dialog then shows a minimise
button (hide it for now, as above) **and** a ✕ that closes the question for good; Escape closes it
too. In VR the dialog shows "–" (hide) and ✕ (decline). Declining resolves the promise
`{ actionId: null, cancelled: true, dismissed: true, peerId, timedOut: false, seat }`; it does
**not** also reach `ui.onAction`. Offer it for questions that have a sensible "no thanks".

**Nobody in Edit Mode sits in a seat**, so under ▶ Play Scripts every prompt resolves `cancelled`.
Play-test prompts at a table.

**In VR it opens as a dialog within reach**, answered with the controller ray or a fingertip. One
dialog shows at a time; others wait behind a pager.

#### See also

- [`UiPromptSpec`](https://diceytable.com/docs/scripting-api/game-ui#uipromptspec) — what you can ask.
- [`UiPromptResult`](https://diceytable.com/docs/scripting-api/game-ui#uipromptresult) — what comes back.
- [`UiChoiceList`](https://diceytable.com/docs/scripting-api/game-ui#uichoicelist) — a list to pick from, including several picks.
- [Game UI (HUD): prompts](https://diceytable.com/docs/scripting/concepts/game-ui#prompts).

### ui.onAction

```ts
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`](https://diceytable.com/docs/scripting-api/game-ui#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`](https://diceytable.com/docs/scripting-api/game-ui#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

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

- [`UiActionEvent`](https://diceytable.com/docs/scripting-api/game-ui#uiactionevent) — every field.
- [`ui.prompt`](https://diceytable.com/docs/scripting-api/game-ui#uiprompt) — when you want the answer where you asked.
- [`UiButton`](https://diceytable.com/docs/scripting-api/game-ui#uibutton) — `enabledWhen`, `activeWhen` and `confirm`.
- [Game UI (HUD): actions](https://diceytable.com/docs/scripting/concepts/game-ui#actions).
