Types
The declared types a table script meets that are not World, ObjectHandle or ObjectData, plus the one
ambient call that is not a method on any of them, declareVariables. Some are unions or
aliases used as values; others are records handed to you by a call. None of them is constructed — you write
object literals and tuples, and the editor checks them against these declarations.
Three of them are worth reading before you write anything. Vec3 is a tuple, not an {x, y, z}
object, and its units are feet — that is the single most common surprise for anyone who has written a mod
first. ObjectKind names all nine kinds the engine has, and then accepts any
other string as well. And there are two action types, not one:
ObjectAction is what a script may request and
ObservedObjectAction is what an event delivers.
The core types#
| Type | Kind | What it is for |
|---|---|---|
Vec3 |
type alias | [x, y, z] in feet for a position, degrees for a rotation. Every coordinate in table scripting. |
ObjectKind |
union | What an entity is — the required field of a spawn, and the filter getAllObjects narrows on. |
ObjectAction |
union | The 13 action names a script may request, and the vocabulary behind the nine mutator methods. |
ObservedObjectAction |
union | The 23 names an action event can deliver — the 13 above plus lift, flick, press, peek, the three search* and the three reveal-*. |
ObjectDestroyedReason |
union | The four ways an entity can leave the table, on EventContext.reason. Only one of them is a deletion. |
ButtonObject |
interface | The handle a button narrows to — eventName and the onPressed delegate. |
SpawnObjectOptions |
interface | The whole description of a new entity: one required field and the optional rest. |
PlayerInfo |
interface | One connected peer — id, name, seat, team, host flag. |
TurnInfo |
interface | Whether turn order is on, and whose turn it is. |
DiceRollSummary |
interface | One finished batch dice roll — the roller, the notation, every face, the total, and how many were unreadable. |
declareVariables |
const | The one call that declares the typed slots a script needs filled in from the entity inspector. |
ScriptVariableSpec |
union | One declared slot — its type, and the kind/tag filters the inspector enforces. |
ScriptVariableValue |
conditional type | What each declared slot reads back as, and when it reads null. |
The generated declarations below this intro are the authoritative list; a few smaller records
(ObjectActionOptions, SeatZoneInfo, DealToOptions) are documented there too.
Members, one row each#
| Member | Type | What it is for |
|---|---|---|
SpawnObjectOptions.kind |
ObjectKind |
Required. A non-empty string, or the spawn resolves null. |
SpawnObjectOptions.name |
string |
Sets the entity's label — its slug — not its human name. |
SpawnObjectOptions.position |
Vec3 |
Where it appears. Defaults to [0, 1, 0], one foot up. |
SpawnObjectOptions.rotation |
Vec3 |
Starting Euler angles. Dropped rather than defaulted when malformed. |
SpawnObjectOptions.presetId |
string |
A standard-library preset, for the model and a die's collision hull. |
SpawnObjectOptions.metadata |
Record<string, unknown> |
Freeform data, copied shallowly, write-once from a script. |
SpawnObjectOptions.tags |
string[] |
The only spawn field world.getAllObjects can filter on. |
PlayerInfo.peerId |
string |
The identity. Key everything on this. |
PlayerInfo.displayName |
string | null |
The name to print. Optional, not unique. |
PlayerInfo.seat |
string | null |
Their seat, or null for a spectator. |
PlayerInfo.team |
string | null |
Their team, or null — the default at most tables. |
PlayerInfo.isHost |
boolean |
true on exactly one row — the machine the scripts run on. |
TurnInfo.enabled |
boolean |
Whether turn order is running at all. |
TurnInfo.activePeerId |
string | null |
Whose turn it is. null has two causes. |
DiceRollSummary.rollId |
string |
Identifies the throw. The dice from it carry the same id. |
DiceRollSummary.actorPeerId |
string |
Who rolled. Always present — key on this. |
DiceRollSummary.actorName |
string |
Their display name, frozen at emit. Not unique. |
DiceRollSummary.seat |
string | null |
Their seat, or null — a seatless player can still roll. |
DiceRollSummary.target |
"tray" | "table" |
Their own bounded region, or the shared table. |
DiceRollSummary.notation |
string |
3d6+1d20, grouped by face count, not by preset. |
DiceRollSummary.dice |
ReadonlyArray<…> |
Per die: objectId, preset, sides, and the face or null. |
DiceRollSummary.total |
number |
Sum of the readable faces only. |
DiceRollSummary.cocked |
number |
How many dice reported no face. Read it before trusting total. |
What is not here#
ObjectData, ObjectHandle and their ten fields live on
ObjectHandle. EventContext and ScriptDelegate live on
Events, with the delegates that use them.
The mod surface has its own, differently shaped versions of several of these — Vector3 is an object, not a
tuple, and TableObjectKind names all nine kinds. They are documented on api and
are not interchangeable with anything on this page.
See also#
World— the calls that take and return these types.ObjectHandle—ObjectData's ten fields, documented there.- Events —
EventContext,ScriptDelegate, and every delegate's payload. - Action vocabularies — the authoritative comparison behind the two action types.
- Object kinds — what each of the nine kinds does.
- Choosing a surface — why the mod types differ.
- Known limitations — every documented gap, in one list.
Vec3#
Surface A — table script · type
A [x, y, z] position/rotation triple in table space.
declare type Vec3 = [number, number, number];
A position or a rotation, as a three-element tuple: [x, y, z]. Positions are absolute table-space coordinates
in feet; rotations are Euler angles in degrees. Every coordinate a table script reads or writes is one of
these — ObjectData.position, ObjectData.rotation, SpawnObjectOptions.position and rotation, and the
arguments to setPosition and setRotation.
How, why and when to use it#
You are moving a piece three feet to the left, and the first thing you need to know is what shape the API wants.
Vec3 is a plain array, so you read it by destructuring and write it by building a new one — there is no
constructor, no clone, and no x/y/z properties to reach for. The alternative shape, an object with
x/y/z, is what the mod API uses and what the replicated entity state uses internally; if you have written
a mod first, this is the thing that will trip you. Build the tuple inline at the call site rather than mutating
one you read, because the array a handle hands you is its own cached copy and writing into it changes nothing.
Example#
// content/scripting-api/examples/vec3.ts
// Scene script: Vec3 is a plain [x, y, z] tuple in FEET, not an object with
// x/y/z properties. Destructure it to read, and build a fresh tuple to write.
function distanceFeet(a: Vec3, b: Vec3): number {
const dx = a[0] - b[0];
const dy = a[1] - b[1];
const dz = a[2] - b[2];
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
const ORIGIN: Vec3 = [0, 0, 0];
async function tidyStrays(): Promise<void> {
const entities = await world.getAllObjects();
if (entities.length === 0) {
world.log("Nothing on the table yet.");
return;
}
for (const entity of entities) {
const [x, y, z] = entity.position;
const away = distanceFeet(entity.position, ORIGIN);
world.log(`${entity.name ?? entity.kind} at [${x}, ${y}, ${z}] ft is ${away.toFixed(2)} ft from the middle.`);
if (away > 6) {
// One foot above the table so it drops onto the surface.
entity.setPosition([0, 1, 0]);
entity.setRotation([0, 0, 0]);
world.log(`${entity.id} was recalled to the middle of the table.`);
}
}
}
void tidyStrays();
The script console prints one line per entity, for example
red-die at [4.2, 0.35, -1.1] ft is 4.35 ft from the middle.
Gotchas#
Units are feet, and y is up. [0, 1, 0] is one foot above the table origin — the default spawn height, so
a new piece drops onto the surface rather than clipping through it.
Mod scripting uses a different shape. Surface B's Vector3 is { x, y, z }, not a tuple, and the two are
not interchangeable. The one exception on that side is api.playSound's position, which really is a
[number, number, number] tuple. See Vector3.
Writing into the array you read does nothing. handle.position[0] = 3 mutates the sandbox's cache and posts
no intent. Call setPosition with a new tuple.
Bad input is coerced, not rejected. The sandbox turns each element into a number and replaces any non-finite
result with 0; an array shorter than three elements is dropped entirely — setPosition posts nothing at all,
and SpawnObjectOptions.position falls back to [0, 1, 0].
Rotations are absolute, not relative. setRotation([0, 90, 0]) faces the entity at 90°; it does not turn it
by 90°. rotate() is the relative quarter-turn.
See also#
ObjectData.position— the read side.ObjectHandle.setPosition— the write side, and its coercion rules.SpawnObjectOptions.position— the default when you omit it.Vector3— the mod-side shape, for comparison.- Choosing a surface — why the two surfaces differ at all.
ObjectKind#
Surface A — table script · type
The kinds of objects that can exist on a DiceyTable table.
declare type ObjectKind =
| "card" | "deck" | "die" | "token" | "board" | "bag" | "custom" | "card-holder" | "button" | (string & {});
What kind of thing an entity is. It is the value of ObjectData.kind, the required field of
SpawnObjectOptions, and the filter world.getAllObjects({ kind }) narrows on. The union names all nine kinds and
then opens up with (string & {}), which keeps autocomplete useful while still accepting any string.
How, why and when to use it#
Almost every action behaves differently by kind — shuffle reorders a deck and is refused outright on a bag,
draw does nothing at all to a die — so branching on kind is how a script that operates on whatever is on the table
avoids doing something absurd. The alternative is tagging entities yourself at authoring time and filtering on
the tag, which is better whenever your rule is about this game's categories ("scoring die", "player marker")
rather than the platform's. Use kind for platform behavior, tags for game meaning; world.getAllObjects
accepts either.
Example#
// content/scripting-api/examples/objectkind.ts
// Scene script: kind decides what an entity will do with an action, so branch
// on it before acting. An unrecognized kind is silently replaced with
// "custom" at spawn time, so read the kind back when it comes from data.
const KINDS: ObjectKind[] = ["card", "deck", "die", "token", "board", "bag", "custom", "card-holder", "button"];
async function actByKind(): Promise<void> {
const entities = await world.getAllObjects();
const counts = new Map<string, number>();
for (const entity of entities) {
counts.set(entity.kind, (counts.get(entity.kind) ?? 0) + 1);
if (entity.kind === "die") {
entity.roll();
} else if (entity.kind === "deck" || entity.kind === "bag") {
entity.shuffle();
}
}
for (const kind of KINDS) {
world.log(`${kind}: ${counts.get(kind) ?? 0}`);
}
}
async function spawnFromData(requested: string): Promise<void> {
// `requested` came from data, so it may be a typo. The union's (string & {})
// arm lets it compile; the sandbox replaces anything unknown with "custom".
const entity = await world.spawnObject({ kind: requested, name: "spawned", position: [0, 1, 2] });
world.log(entity === null ? "Spawn request was malformed." : `Requested "${requested}", got back "${entity.kind}".`);
}
void actByKind();
void spawnFromData("button");
void spawnFromData("dice"); // typo -> "custom"
The script console prints one line per kind — card: 52, deck: 1, die: 2, and zeros for the rest — followed
by Requested "button", got back "button". and Requested "dice", got back "custom".
Gotchas#
The union names all nine kinds, card-holder and button included, and matches
TABLE_OBJECT_KINDS (packages/shared/src/tableObjects/kinds/index.ts) and the sandbox's own accepted list
(apps/web/src/scripting/sandbox/tableScriptSandbox.html, KNOWN_KINDS). It named fewer once —
card-holder was added alongside the per-kind handle types and button with the button object — but a script
written against an older declaration compiled anyway, because of the (string & {}) arm below.
An unrecognized kind becomes "custom", silently. The sandbox checks the spawn's kind against its list of
nine and substitutes "custom" for anything else — no error, no console line. A typo such as "dice" produces
a plain custom entity that never rolls. Read handle.kind back when the kind comes from data rather than a
literal, as the example does.
The (string & {}) arm is what makes the typo compile. It exists so a future kind is usable before the
declaration catches up; the cost is that TypeScript cannot catch a misspelling for you.
kind is fixed at spawn. Nothing in the table-scripting API changes an existing entity's kind.
See also#
ObjectData.kind— reading it back off a handle.SpawnObjectOptions.kind— the required field, and its coercion.world.getAllObjects— filtering by kind or by tag.- Object kinds — what each of the nine kinds is and how it behaves.
- Action vocabularies — which actions do something on which kind.
ObjectAction#
Surface A — table script · type
Actions a script can REQUEST on an object (host-validated allowlist).
declare type ObjectAction =
| "flip" | "rotate" | "lock" | "unlock" | "tap" | "untap"
| "shuffle" | "draw" | "deal" | "split" | "combine" | "roll" | "delete";
The 13 action names a table script is allowed to request. The union is mirrored exactly by the host's
allowlist (apps/web/src/scripting/TableScriptHost.ts, SCRIPT_SAFE_OBJECT_ACTIONS), which re-checks every
object-action intent the sandbox posts. It is the vocabulary behind the nine ObjectHandle methods that post
actions. What an event delivers is the wider
ObservedObjectAction — request and observe are deliberately
two types.
How, why and when to use it#
You are looking for the method that taps a card, or annotating a helper that takes an action you are about to request. This is the type for the second case and a misleading answer to the first, so read the Gotcha before you rely on it. When you need the authoritative comparison — what the engine has, what a script may ask for, what a mod may ask for — go to Action vocabularies, which owns that table; this entry is only about the declared union.
Example#
// content/scripting-api/examples/objectaction.ts
// Scene script: the union names 13 actions, and only 9 of them have a method
// on ObjectHandle. Map the callable ones explicitly rather than assuming a
// method exists for every name in the type.
const CALLABLE: ObjectAction[] = ["flip", "rotate", "lock", "unlock", "shuffle", "draw", "deal", "roll", "delete"];
const NO_METHOD: ObjectAction[] = ["tap", "untap", "split", "combine"];
function perform(entity: ObjectHandle, action: ObjectAction): boolean {
switch (action) {
case "flip": entity.flip(); return true;
case "rotate": entity.rotate(); return true;
case "lock": entity.lock(); return true;
case "unlock": entity.unlock(); return true;
case "shuffle": entity.shuffle(); return true;
case "draw": entity.draw(); return true;
case "deal": entity.deal(); return true;
case "roll": entity.roll(); return true;
case "delete": entity.destroy(); return true;
default: return false;
}
}
async function demonstrate(): Promise<void> {
const decks = await world.getAllObjects({ kind: "deck" });
const deck = decks[0];
if (!deck) {
world.log("Add a deck to the table and restart the scripts.");
return;
}
world.log(`${CALLABLE.length} callable actions, ${NO_METHOD.length} with no method: ${NO_METHOD.join(", ")}.`);
world.log(`shuffle dispatched: ${String(perform(deck, "shuffle"))}; tap dispatched: ${String(perform(deck, "tap"))}.`);
}
void demonstrate();
The script console prints 9 callable actions, 4 with no method: tap, untap, split, combine. and then
shuffle dispatched: true; tap dispatched: false.
Gotchas#
Known gap. Four of the 13 have no method on
ObjectHandle:tap,untap,splitandcombine. They are in the union and the host's allowlist accepts them, so every layer below the typed API is ready — only the calling surface is missing (packages/shared/src/scripting.ts).deletelooks like the same problem and is not: it is reachable, asdestroy(). Until a method exists, model tapping as your own state — a tag, or saved data — and read it back withhandle.refresh(). See Known limitations.
It is not the type an event hands you. onObjectAction and ObjectHandle.onAction are declared with
ObservedObjectAction, which adds lift, flick,
reveal-all, reveal-team-a and reveal-team-b. Passing an observed action into a helper typed
ObjectAction does not compile, which is the point — narrow first.
The five engine actions missing from the union are a deliberate boundary, not an oversight. lift and
flick are drag mechanics driven by pointer input, and the three reveal-* actions are hidden-information
reveals the host must own. Action vocabularies explains why.
An action the host refuses is dropped with a diagnostic and nothing else. The intent never reaches the runtime, no event fires, and the only signal is a line in the script console.
See also#
- Action vocabularies — the three vocabularies compared, and which asymmetries are deliberate.
ObjectHandle— the nine methods that post an action.ObservedObjectAction— the wider union an event delivers.globalEvents.onObjectAction— the delegate that delivers the wider one.ObjectHandle.destroy— the method whose name differs from its action.- Known limitations — the full list of documented gaps.
ObservedObjectAction#
Surface A — table script · type
Actions an onAction / onObjectAction handler can OBSERVE. Wider than
ObjectAction: the engine also raises the drag mechanics (lift,
flick) and the hidden-information reveals, which a script may watch but
may not request. Write a default branch; do not rely on a never check.
declare type ObservedObjectAction =
| ObjectAction
| "lift" | "flick" | "reveal-all" | "reveal-team-a" | "reveal-team-b" | "press" | "peek"
| "search" | "search-pull" | "search-close"
| "tip-out" | "empty" | "clear-source";
The 23 action names an action handler can receive. It is
ObjectAction — the 13 a script may request — plus the ten the engine
raises but no script can ask for: lift, flick, press, peek, search, search-pull, search-close,
reveal-all, reveal-team-a and reveal-team-b. Both
action delegates are declared with it:
globalEvents.onObjectAction
and ObjectHandle.onAction.
How, why and when to use it#
You are writing a handler and want to know which strings can actually arrive, so that a switch over them is not
quietly wrong. This is that list, and it is the reason there are two action types rather than one: request and
observe are different vocabularies, and collapsing them would either hide five real events or advertise five
calls that the host's allowlist refuses. Annotate a helper with ObservedObjectAction when it takes an action off
an event; annotate it with ObjectAction when it takes one you are about to request. The authoritative comparison
of all three vocabularies — engine, table script, mod — is on
Action vocabularies; this entry is only about the declared union.
Example#
// content/scripting-api/examples/observedobjectaction.ts
// Scene script: an action handler observes more names than a script may
// request. Split the two vocabularies explicitly, then keep a default branch
// for anything the engine gains later.
const OBSERVE_ONLY: ObservedObjectAction[] = [
"lift",
"flick",
"press",
"peek",
"search",
"search-pull",
"search-close",
"reveal-all",
"reveal-team-a",
"reveal-team-b"
];
function isObserveOnly(action: ObservedObjectAction): boolean {
return OBSERVE_ONLY.includes(action);
}
globalEvents.onObjectAction.add((entity, action, context) => {
if (isObserveOnly(action)) {
world.log(`${context.actor} caused ${action} on ${entity.id} - observed, never requestable.`);
return;
}
switch (action) {
case "flip":
world.log(`${entity.id} was flipped by ${context.actor}.`);
break;
case "shuffle":
world.log(`${entity.id} was shuffled by ${context.actor}.`);
break;
case "delete":
world.log(`${entity.id} is going away.`);
break;
default:
// A name this script does not handle - including one added to the engine
// after it was written - lands here instead of falling through silently.
world.log(`${entity.id}: unhandled action ${action}.`);
break;
}
});
world.log("Action observer is running.");
Dragging a card prints a1b2c3d4 caused lift on obj-3 - observed, never requestable.; flipping it prints
obj-3 was flipped by a1b2c3d4.
Gotchas#
Write a default branch anyway. The union is complete for the engine as it stands, but the payload the
runtime sends is a plain string typed as the engine's own action list, and the two lists are maintained by hand in
different files (packages/shared/src/scripting.ts, packages/shared/src/tableObjects.ts). A never check that
proves your switch exhaustive today becomes a compile error the day an action is added, which is fine — a
handler with no default becomes silently wrong instead, which is not.
The ten observe-only names are a deliberate boundary, not an oversight. lift and flick are drag mechanics
driven by pointer input, press is a button's own click, and peek, the three search* actions and the three
reveal-* actions are hidden-information reveals the host must own — see
DeckObject.onSearched for what a search event does and
does not carry. Asking for one is not a type error — the escape hatch is a different type — it is
refused by the host's allowlist, which drops the intent with a diagnostic in the script console and no event.
It is an observe type only. Passing an ObservedObjectAction where an ObjectAction is expected does not
compile, which is the point. Narrow first, as the example does.
See also#
ObjectAction— the 13 a script may request, and the four with no method.DeckObject.onSearched— the three newest observe-only names, and why they are observe-only.globalEvents.onObjectAction— the table-wide delegate declared with this type.ObjectHandle.onAction— the entity-scoped one.- Action vocabularies — engine, table script and mod compared.
- Events and delegates — ordering, multiplicity and cancellation.
ObjectDestroyedReason#
Surface A — table script · type
Why an entity was destroyed, on EventContext.reason of an
onObjectDestroyed / onDestroyed event. There are four removal paths and
only one of them is somebody asking for it:
"deleted"— thedeleteaction was applied (a menu, a peer, or a script'sdestroy())."depleted"— the last card was drawn off a deck and the empty deck went."converted"— a one-card deck became a plain card; the deck entity went. The card that replaces it raises its ownonObjectCreated."absorbed"— acombinefolded the entity into a stack.context.containerIdnames the surviving stack that took its cards.
Write a default branch: this union grows when the engine grows a removal path.
declare type ObjectDestroyedReason = "deleted" | "depleted" | "converted" | "absorbed";
The four ways an entity can leave the table, on context.reason of an
onObjectDestroyed or
onDestroyed event. Only one of them —
"deleted" — is somebody asking for the entity to go; the other three are the runtime tidying up after a
draw or a merge, and two of them mean the cards are still in play inside something else.
Returns#
"deleted" | "depleted" | "converted" | "absorbed". Each value corresponds to exactly one runtime path
(apps/web/src/playcanvas/TabletopRuntime.ts):
| Value | What happened | Raised by | containerId |
|---|---|---|---|
"deleted" |
The delete action was applied — a menu, a peer's intent, or a script's destroy(). |
applyObjectAction |
absent |
"depleted" |
The last card was drawn off a deck, so the empty deck was removed. Nobody asked to delete it. | removeDepletedDeck |
absent |
"converted" |
A one-card deck became a plain card. The deck entity went; the card raises its own onObjectCreated. |
convertDeckToLastCard |
absent |
"absorbed" |
A combine folded the entity into a stack. Its cards survive inside the survivor. |
combineIntoStack, mergeCardLikeIntoDeck |
the surviving stack's id |
Applies to: every object kind. "depleted" and "converted" can only ever describe a deck, and
"absorbed" only a card or a deck, but nothing stops a future kind reaching the same paths — branch on
the value, not on the kind you expect.
How, why and when to use it#
You are keeping a set of the entities in play, and a player drags two cards together. Before this discriminator
existed, the merge either raised nothing at all or looked identical to a delete, so your set drifted — the two
cards were "gone" while their faces were sitting in a pile that could still be drawn from. reason is what makes
the difference readable: on "absorbed" the contents survive and
EventContext.containerId tells you where they went; on
"deleted" they do not.
The other real use is attribution. "depleted" and "converted" are the runtime cleaning up after a draw, so a
"who removed my deck?" message that fires on them is wrong. Check the reason first, then read
EventContext.actor.
Example#
// content/scripting-api/examples/objectdestroyedreason.ts
// Scene script: an entity can leave the table four ways and only one of them is
// somebody asking for it. Branch on context.reason before you treat a
// disappearance as a loss - a card absorbed into a pile is still in play.
const inPlay = new Set<string>();
globalEvents.onObjectCreated.add((entity) => {
inPlay.add(entity.id);
});
globalEvents.onObjectDestroyed.add((entityId, context) => {
inPlay.delete(entityId);
const reason: ObjectDestroyedReason | undefined = context.reason;
switch (reason) {
case "absorbed":
// Still in the game, just inside something else now.
world.log(`${entityId} was folded into ${context.containerId ?? "an unnamed stack"}.`);
break;
case "depleted":
world.log(`${entityId} was an empty deck and went away on its own.`);
break;
case "converted":
// The replacement card raises its own onObjectCreated, so `inPlay` is
// already correct by the time anyone reads it.
world.log(`${entityId} was a one-card deck and became a card.`);
break;
case "deleted":
world.log(`${context.actor} removed ${entityId}.`);
break;
default:
// A reason this script does not know - including one added to the engine
// after it was written - lands here instead of being silently ignored.
world.log(`${entityId} left the table for an unrecognised reason: ${String(reason)}.`);
break;
}
});
world.log("Lifetime tracker is running.");
Dropping one card onto another prints two was folded into lines — one per absorbed card, both naming the new
deck. Drawing a deck down to nothing prints was an empty deck and went away on its own.
Gotchas#
An absorbed entity is gone; its card is not. This is the product rule and it is not negotiable: a card
absorbed into a deck and later drawn back out is a new entity with a new id. Entity ids are not preserved
across a merge and never will be — a preset deck's 52 cards were never entities in the first place, so no
contract could hold uniformly. Reconnect on card identity: the cardId in the deck's metadata.cards
entries, which survives absorb → shuffle → draw, and the label the drawn card is created with. A Map keyed on
entity id is the wrong data structure for anything that has to survive a merge.
Write a default branch. The union is complete for the engine as it stands, and the payload is a plain
string that arrives across a postMessage boundary. A never check that proves your switch exhaustive today
becomes a compile error the day a removal path is added, which is fine; a handler with no default becomes
silently wrong instead, which is not.
reason is optional in the type. It is declared reason?: ObjectDestroyedReason on
EventContext because the same context object is passed to every
other event, where it is undefined. Inside a destroy handler the sandbox always fills it.
"converted" is two events, not one. The deck's onObjectDestroyed and the replacement card's
onObjectCreated are separate fan-outs. Nothing links them for you except the position they share.
See also#
EventContext.reason— the field this type lives on.EventContext.containerId— the surviving stack, on"absorbed".globalEvents.onObjectDestroyed— the table-wide delegate.ObjectHandle.onDestroyed— the entity-scoped twin.- Events and delegates — the per-action ordering table, including
combine.
ObjectMenuItemRegistration#
Surface A — table script · interface · 5 members
A context-menu entry this script adds to entities on the table.
Registered with world.addObjectMenuItem, drawn BELOW the built-in actions, and reported
back through globalEvents.onObjectMenuItem (or refObject.onMenuItem) when a
player clicks it.
match is data, not a callback#
There is deliberately no "show this when this function returns true". Table scripts run on the
HOST alone, while the context menu is drawn on every peer — so the peer that has to decide
whether to show your item is not running your script, and a function could not cross the
sandbox boundary to reach it anyway. match is a filter the table evaluates for you, on
every peer, against replicated state.
It is AND across the fields you set, OR within objectIds and kinds, and
tagMatch decides within tags. Omit match entirely to target EVERY entity.
For a condition match cannot express — "only on the back rank" — put the test in the
HANDLER, or move the condition into a tag the script maintains.
A context-menu entry a table script contributes to entities on the table, passed to
world.addObjectMenuItem.
Two required fields — an id you choose and a label players read — plus an optional
match that decides which entities it appears on,
and two presentation hints.
How, why and when to use it#
Think of it as a declaration, not a command: you are describing an entry that should exist, and the table
decides moment to moment which entities show it. That is why match is data rather than a callback — the menu
is drawn on every peer, and only the host is running your script, so the filter has to be something a peer can
evaluate on its own.
Everything that depends on game state belongs in the handler instead. See
globalEvents.onObjectMenuItem.
Gotchas#
match is data, not a callback, and every field on this type is plain JSON for the same reason: the
registration is replicated to peers that are not running your script, so nothing on it can be a function.
An invalid registration is dropped rather than thrown. A missing id or label raises an author diagnostic;
anything that fails schema validation is discarded so it cannot poison the replicated snapshot for the table.
See also#
world.addObjectMenuItem— the call that takes it.globalEvents.onObjectMenuItem— the click it produces.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
Your own id for the item, unique within this script. Handed back to the handler, and the key world.removeObjectMenuItem takes. Registering the same id twice REPLACES it, which is how you relabel one. |
string |
label |
The caption on the button. | string |
match |
Which entities the item appears on. Omit for all of them. | { objectIds?: string[]; kinds?: ObjectKind[]; tags?: string[]; /** "all"requires every tag (the default);"any" requires at least one. */ tagMatch?: "all" | "any"; } |
danger |
Draw it as a destructive action, like Delete. | boolean |
order |
Sort order among script items. Script items always follow the built-in actions. | number |
objectmenuitemregistration.id#
id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Your own id for the item, unique within this script. Handed back to the handler, and the
key world.removeObjectMenuItem takes. Registering the same id twice REPLACES it,
which is how you relabel one.
Your own identifier for the entry, unique within this script.
Returns
string, required, 1–96 characters.
How, why and when to use it
It comes back to you as the second argument of
onObjectMenuItem, and it is the key
removeObjectMenuItem takes — so pick something you will
recognise in a switch, not a generated id.
Registering the same id twice replaces the entry. That is the supported way to relabel one, or to narrow its
match, without a remove-then-add round trip.
Ids are scoped to the script realm that registered them, so two different scripts may both use "promote"
without colliding, and neither can remove or overwrite the other's.
Gotchas
Registering the same id twice replaces the entry rather than adding a second one. That is the supported way
to relabel an entry or narrow its match; it is also how a script that re-registers on every tick quietly does
nothing instead of hitting the 100-entry cap.
An empty id is refused, with an author diagnostic in the Script Errors tab rather than a silent drop.
Ids are scoped to the script realm. Two scripts may both use "promote"; neither can see, replace or remove
the other's, because the scope is stamped by the host and never taken from your call.
See also
world.addObjectMenuItem— where you set it.world.removeObjectMenuItem— which takes it back.
objectmenuitemregistration.label#
label: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The caption on the button.
The caption players see on the menu button.
Returns
string, required, 1–64 characters.
How, why and when to use it
Write it as the verb the player is performing — Promote, Rally, Give to owner — and keep it short: the context menu sits under the pointer and a long label pushes the whole menu off small screens.
An empty label is refused with an author diagnostic rather than drawn as a blank button, and one over 64 characters fails schema validation and is dropped.
Gotchas
An empty label is refused with an author diagnostic, and one over 64 characters fails schema validation and is dropped — the registration never reaches the snapshot, so nothing appears in any player's menu.
It is not an identifier. Labels are for reading; match on
id in the handler, never on the label.
See also
objectMenuItemRegistration.danger— how it is styled.world.addObjectMenuItem— where you set it.
objectmenuitemregistration.match#
match?: {
objectIds?: string[];
kinds?: ObjectKind[];
tags?: string[];
/** `"all"` requires every tag (the default); `"any"` requires at least one. */
tagMatch?: "all" | "any";
};
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Which entities the item appears on. Omit for all of them.
Which entities the entry appears on. Omit it entirely to target every entity on the table.
Returns
An optional object:
| Field | Type | Notes |
|---|---|---|
objectIds |
string[] |
Only these entities, by id. |
kinds |
ObjectKind[] |
Only these kinds. |
tags |
string[] |
Only entities carrying these tags. |
tagMatch |
"all" | "any" |
How tags is read. "all" is the default. |
The fields combine with AND: an entry setting both kinds and tags appears only on entities that satisfy
both. Within objectIds and kinds the values are OR. Within tags, tagMatch decides.
An empty object (match: {}) is the same as omitting it: every entity.
How, why and when to use it
Prefer tags. An id list breaks the moment a piece is re-spawned, and a kind filter is usually too broad —
kinds: ["token"] catches every token on the table, while tags: ["pawn"] catches the ones the rule is about.
Tags also give you a lever for conditions: a script that maintains a promotable tag can make an entry appear
and disappear without touching the registration.
⚠ This is a filter, not a predicate. There is no callback form, and that is deliberate — the peer drawing the menu is not the peer running your script. Conditions over game state go in the handler.
Gotchas
It is a filter, not a predicate, and there is no callback form. Table scripts run on the host alone while the menu is drawn on every peer, so the peer deciding whether to show your entry is not running your script — and a function could not cross the sandbox boundary to reach it anyway. Conditions over game state belong in the handler.
An empty match: {} means every entity, not none. If you meant "nothing for now", remove the entry instead.
It is re-evaluated every time a menu opens, so an entry automatically covers entities spawned after it was registered, and stops covering an entity whose tags change.
The host checks it again on click. A peer's view can be stale and an intent can be forged, so the handler is never reached for an entity the filter excludes.
See also
world.addObjectMenuItem— where you set it.globalEvents.onObjectMenuItem— where runtime conditions go instead.
objectmenuitemregistration.danger#
danger?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Draw it as a destructive action, like Delete.
Draw the entry as a destructive action, the way Delete is drawn.
Returns
boolean, optional. Defaults to false.
How, why and when to use it
Set it for entries that discard, remove or otherwise cannot be undone. It changes styling only — nothing confirms on the player's behalf, so an entry that genuinely destroys something should still ask.
Gotchas
It changes styling only. Nothing is confirmed on the player's behalf, and nothing is blocked — an entry that really destroys something should still ask before doing it.
See also
objectMenuItemRegistration.label— the text it styles.world.addObjectMenuItem— where you set it.
objectmenuitemregistration.order#
order?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Sort order among script items. Script items always follow the built-in actions.
Sort order among this table's script entries.
Returns
number, optional integer, 0–100000. Defaults to 0.
How, why and when to use it
Entries sort by order first and then by label, so leaving it unset gives alphabetical order — fine until two
entries have a natural sequence (Promote to Queen before Promote to Knight), which is what this is for.
⚠ It cannot reorder against the built-in actions. Script entries are always drawn below Flip, Lock, Delete
and the rest, whatever order says. A script cannot move, hide or displace the actions a player relies on.
Gotchas
It cannot reorder against the built-in actions. Script entries are always drawn below Flip, Lock, Delete and the rest, whatever this says. A script cannot move, hide or displace the actions a player relies on.
It sorts across scripts, not just your own. Two mods contributing entries to the same entity interleave by
order, so a large number is not a way to claim the bottom of the list reliably.
See also
world.addObjectMenuItem— where you set it.
ObjectActionOptions#
Surface A — table script · interface · 1 members
Options common to the cosmetic-bearing object actions.
What the cosmetic-bearing object actions accept. One field today:
silent.
How, why and when to use it#
Optional everywhere it appears. Omit it and an action behaves exactly as a player's click does — which is the right default, because a script's action should look like an action.
Gotchas#
Cosmetic only. Nothing here changes what the action DOES, so a silent shuffle and a loud one produce the same deck order.
See also#
Members#
| Signature | Description | Returns |
|---|---|---|
silent |
Perform the action without its animation or sound. | boolean |
objectactionoptions.silent#
silent?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Perform the action without its animation or sound.
A script that shuffles three times during setup does not want three riffles, and a deck shuffled before the table is dealt has no audience for the flourish. The resulting state is identical either way - this suppresses only the presentation, on every client.
Perform the action without its animation or sound.
How, why and when to use it
For setup, not for play. A script that shuffles three times while dealing does not want three riffle sounds and three 0.8-second spins, and a deck shuffled before anyone is looking has no audience for the flourish.
Leave it off for anything a player is meant to notice. The animation and the sound are how a table tells someone that something happened.
Gotchas
Silences the action on every client, not just the one that ran the script. Peers derive the shuffle spin from the broadcast sound event, so suppressing the sound suppresses the spin everywhere — which is the intent, but it does mean you cannot make an action silent for yourself and audible for everyone else.
The resulting state is identical either way. This is presentation, never behaviour.
Example
// content/scripting-api/examples/objectactionoptions.silent.ts
// Scene script: shuffle the deck three times during setup, silently.
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!setup") {
void prepare();
}
});
async function prepare(): Promise<void> {
const decks = await world.getAllObjects({ kind: "deck" });
for (const deck of decks) {
for (let pass = 0; pass < 3; pass += 1) {
deck.shuffle({ silent: true });
}
}
world.broadcast("Table ready.");
}
See also
ContainerItem#
Surface A — table script · interface · 4 members
One KIND of piece stored in a container, as a run of identical copies.
A bag's contents are run-length encoded — twenty identical black stones are ONE entry with
count: 20, not twenty entries — so this describes a group, never an individual piece.
Pieces in a container are not entities: they have no id, no position and no owner, and
nothing here can be passed to world.getObjectById. Take one out with
BagObject.takeObject and you get a real entity with an id of its own.
One sort of piece stored in a container, as a run of identical copies. It is what
BagObject.items is an array of, and the only shape on this
surface that describes something inside a bag that is not a card.
A bag's contents are run-length encoded: twenty identical black stones are one ContainerItem with
count: 20, not twenty entries. So an entry always describes a group, never an individual piece — there is no id
here, no position, no owner, and nothing that
world.getObjectById will accept. A piece only becomes an entity
again when takeObject hands one back with an id of its
own.
How, why and when to use it#
Use it to answer what is in here, and how many of each — a menu of what a player may draw, a setup check that
the bag was stocked correctly, a scoreboard counting what is left. The four fields are exactly that answer:
key addresses a run, name labels it,
kind says what a drawn copy will be, and count is how many.
The pairing that does the real work is key plus takeObject({ key }): read the runs, let a player choose one,
and take from that run rather than at random. That is the whole of the "choose a tile" pattern, and it needs
nothing else.
Gotchas#
The array is not the draw order. A bag draws at random by default, and even in "stack"/"queue" mode a run
is a group rather than a position. Nothing about the order of
items predicts what comes out next.
Only a "bag" form has any. A "holder" — an open bowl or tray — keeps its pieces as ordinary entities
resting in it, so its items is always empty and
world.getAllObjects is where they are. An infinite container
stores nothing either.
Cards are a different lane. A bag holds cards or pieces, never both. A bag of cards reports them through
ContainerObject.cards as
ContainerCard entries and leaves items empty.
A very large bag is truncated. The sandbox copies at most 256 runs onto a handle
(apps/web/src/scripting/sandbox/tableScriptSandbox.html), which is the same ceiling the shared schema puts on
stored runs — so it bounds how many sorts of piece one bag can hold, not how many pieces.
See also#
BagObject.items— the array these live in.BagObject.takeObject— turning a run into an entity.ContainerCard— the card-lane equivalent.- Object kinds — what a
bagcan hold.
Members#
| Signature | Description | Returns |
|---|---|---|
key |
The identity key for this sort of piece — the handle takeObject({ key }) takes to draw one deliberately rather than at random. |
string |
name |
The piece's display name, falling back to its slug. What to put in a menu or a log line. | string |
kind |
ObjectKind |
|
count |
How many copies of this piece the container holds. Always 1 or more. | number |
containeritem.key#
readonly key: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The identity key for this sort of piece — the handle takeObject({ key }) takes to draw
one deliberately rather than at random.
Derived from what the piece IS (kind, model, colour, material, scale) and from nothing about where it was: two pieces that look alike share a key and collapse into one run, and a blue cube never shares a key with a red one. Treat it as opaque — it is stable within a session and across a save, and its spelling is not part of this contract.
The identity of this sort of piece, and the handle
takeObject({ key }) takes to draw one deliberately
rather than at random. Treat it as opaque: it is stable within a session and across a save, and its spelling
is not part of this contract.
Returns
A non-empty string. It is derived by containerItemIdentityKey
(packages/shared/src/tableContainers.ts) from what a piece is and from nothing about where it was — the
kind, the model reference, the look (colour, per-slot materials, material id) and the scale, rounded to 1e-4.
Ignored: id, slug, display name, transform, face-up state, lock, owner seat, tags, physics and sound overrides.
How, why and when to use it
Two pieces that look alike share a key, which is exactly what collapses a bowl of go stones into one run. Read a
key when you want to name a run in a later call: show the player their options from
items, remember the key they picked, and pass it back to
takeObject. Nothing else on this surface accepts one.
Gotchas
A blue cube never shares a key with a red one. Colour and material variants are deliberately different pieces, so that a piece dropped into a bowl of another colour is refused rather than silently recoloured on the way back out. Do not expect "same shape" to mean "same key".
Compare it; never parse it, and do not persist it. The derivation is an implementation detail, and a key written into saved data and compared against one derived by a later build is a bug waiting for a release.
An unknown key is not an error. takeObject({ key }) resolves null when the key names no run — the same
null an empty bag gives you. Handing out a different piece from the one asked for would be worse.
See also
ContainerItem— the run this addresses.BagObject.takeObject— the one call that takes a key.ContainerItem.name— what to show a player instead of the key.
containeritem.name#
readonly name: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The piece's display name, falling back to its slug. What to put in a menu or a log line.
What to call this sort of piece in a menu or a log line. The sandbox fills it from the stored definition's
displayName, falling back to its slug when the author set no human name
(apps/web/src/scripting/sandbox/tableScriptSandbox.html).
Returns
A string, possibly empty. An empty string means the stored definition carried neither name — rare, but a
name || "piece" fallback costs one character and stops a blank button.
How, why and when to use it
This is the display half of a run, and key is the identity half. Build a "what would you
like to draw?" list out of name and count, and carry key alongside it as the value
you pass back to takeObject. It is also the right thing
to put in a broadcast: "Anna drew a Black Stone" reads as a game event, where a key does not.
Gotchas
It is not unique, and it is not an address. Two runs can share a name — an author may have given a red and a
blue meeple the same display name — while their keys differ. Matching on name to decide what to draw can take
the wrong run; match on key.
It is a display name first and a slug only as a fallback. Where no human name was authored it is the slug,
but where one exists it is that instead. So do not treat this value as the slug-and-identity that
ObjectData.name carries on a real entity.
It describes the run, not a piece. Every copy in the run answers to it; a run of 20 has one name, not 20.
See also
ContainerItem.key— the identity to match on.ContainerItem.count— the other half of a menu line.- Identity, names and tags — the three names and what each is for.
containeritem.kind#
readonly kind: ObjectKind;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
What a piece from this run becomes when it is taken out — the
ObjectKind of the entity
takeObject creates, and the kind its handle is typed
as.
Returns
An ObjectKind. The sandbox falls back to "custom" when the stored definition names no kind, so this is never
absent and never empty.
How, why and when to use it
Read it to decide whether a run is worth drawing at all before you draw it: a rule that only cares about dice
can skip every run whose kind is not "die" without taking a piece out to find out. It is also what lets one
generic "draw something" helper branch its follow-up — position a token, roll a die — on the same value the
resulting handle will report.
Gotchas
It is not the container's kind. The container is a bag; this says what is inside it, which is something
else entirely. Two runs in one bag can differ — a bag takes any mix of pieces.
"card" and "deck" never appear here. Cards are the other lane: a bag holds cards or pieces, and a card
bag reports through ContainerObject.cards with an
empty items. A card run is not something the drop path can create.
The union is open. ObjectKind ends in (string & {}), so write a default branch rather than assuming the
named kinds are all there will ever be.
See also
ObjectKind— the union, and why it is open.ContainerItem— the run this describes.BagObject.takeObject— where the kind becomes an entity.
containeritem.count#
readonly count: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
How many copies of this piece the container holds. Always 1 or more.
How many copies of this piece the container holds. Always 1 or more — a run that reaches zero is removed from
items rather than left behind at 0.
Returns
A positive integer, counting this run only. The bag's total is the sum across runs.
How, why and when to use it
It is the number a player wants to see next to name — "Black Stone ×180" — and the number
a rule checks before it commits to something: is there a meeple left in this colour, has the bag of tiles run low
enough to end the round. Summing it across items gives the true total in the bag:
bag.items.reduce((sum, item) => sum + item.count, 0).
Gotchas
It is not stackCount, and the two can disagree. The container's public
stackCount is the wire-facing count and is clamped
to 1000 (MAX_CONTAINER_PUBLIC_COUNT, packages/shared/src/tableContainers.ts), so a bag of 2,000 stones
publishes 1000. It is also never below 1, so an empty bag still reports stackCount: 1 while its items
array is empty. Sum count when you need the real number; read stackCount when you want what every peer sees.
A run is capped too. The shared schema refuses a run above 10,000 copies, and a bag above 256 runs, so a "stock it with a million" setup script fails at the drop rather than at the draw.
It changes under you. Every draw, every drop and every tip-out rewrites the runs, so an items array you
captured before an await describes a bag that may no longer be in that shape. Re-read it.
See also
ContainerItem.name— the label that goes beside it.BagObject.items— the array to sum.ObjectData.stackCount— the public, clamped count.
ButtonObject#
Surface A — table script · interface · 2 members
A pressable button. Pressing it dips the cap, plays its sound and raises
onPressed — the whole point of the kind, and the only event it adds.
eventName is the author-set name from the button's configuration, so one
handler can serve several buttons and branch on which was pressed rather than
comparing object ids.
A pressable button: a coloured cap in a frame that dips when a player presses it, plays its
sound and raises onPressed. Both silhouettes — rectangular and
round — are the same button kind and share this handle; the shape is configuration, not a
different type.
Everything about a button's appearance (size, colours, frame, and the text or image on its
cap) lives in the object's own configuration, not on this handle. A script reacts to presses
and reads eventName; it does not restyle the button mid-game.
How, why and when to use it#
Use a button when a rule needs an explicit, deliberate input rather than a piece being moved — ending a turn, ringing a bell, committing a bid. A button is the one control on the table whose only purpose is to be pressed, so a press carries no ambiguity about intent the way "a token was dropped in a zone" does.
Gotchas#
press is an action a script can observe, never one it can request. A script that wants
to do the button's job should simply do it — simulating a press would let a script fake input
attributed to a seat, which is exactly what the host-authoritative model exists to prevent.
See also#
ObjectHandle— everything a button inherits.- Button objects — the configuration a button carries.
Members#
| Signature | Description | Returns |
|---|---|---|
eventName |
The configured event name for this button (default "button-press"). |
string |
onPressed |
Fires when a player presses this button. | ScriptDelegate<[ButtonObject, EventContext]> |
buttonobject.eventName#
readonly eventName: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The configured event name for this button (default "button-press").
The author-configured name this button emits when pressed, defaulting to "button-press".
How, why and when to use it
It exists so one handler can serve several buttons and branch on which was pressed without hard-coding object ids. Ids are generated per spawn, so a script written against them breaks the moment the table is rebuilt from a save; a name set in the button's configuration is stable across saves, republishes and re-places.
Gotchas
Names are not unique and are not validated for collisions — two buttons may deliberately share one when they should do the same thing. If you need to tell those two apart anyway, compare the object rather than the name.
An empty name is not possible: the configuration schema requires at least one character and
falls back to "button-press".
See also
onPressed— where you read this.
buttonobject.onPressed#
readonly onPressed: ScriptDelegate<[ButtonObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a player presses this button.
Fires when a player presses this button — the reason the kind exists. It is
onAction narrowed to the press
action, and fires immediately after it on the same handle.
Applies to: button. It is declared on ButtonObject only.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[ButtonObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | ButtonObject |
The same handle you subscribed on. |
| 2 | EventContext |
The seat that pressed it, or an unseated actor. |
How, why and when to use it
Attach the rule to the button rather than filtering a table-wide action feed: the handler then cannot drift out of step with which buttons exist, and reads as what it is — "when this button is pressed, do this".
Gotchas
The dip animation and the sound are not contingent on your handler. They start on the press itself, so throwing from here does not cancel the press or roll back the feedback the player already saw.
A press by a player with no seat still fires, with an unseated actor. Guard on the seat if your rule requires one — do not assume it is present.
Example
A full deal routine: shuffle, three face-down rows into each seat's card zones, three face-up rows on top, then three rounds into hands — each round starting with whoever pressed the button and moving clockwise.
// content/scripting-api/examples/buttonobject.onPressed.ts
// Object script on the button. `refObject` is a ButtonObject.
//
// Seven, Twos & Tens deal:
// 1. shuffle
// 2. one FACE-DOWN card to each seat's CardZone1, then CardZone2, then CardZone3
// 3. the same three zones again FACE UP, stacked on the face-down ones
// 4. three rounds into each seat's hand
// Every round starts with whoever pressed the button and moves clockwise, so each active
// player ends with 9 cards: 3 face down, 3 face up over them, 3 in hand.
// Clockwise seat order, matching PLAYER_SEAT_OPTIONS.
const SEAT_ORDER = ["red", "blue", "green", "yellow", "purple", "orange", "pink", "cyan"];
const CARD_ZONES = ["CardZone1", "CardZone2", "CardZone3"];
const HAND_ROUNDS = 3;
/** Pause between cards so the deal reads as a deal rather than appearing all at once. */
const DEAL_PAUSE = 0.08;
/**
* Seats to deal to, clockwise, starting from `startSeat`.
*
* Only seats that are BOTH occupied and actually authored in the scene are included: a
* player at a seat the scene has no zones for cannot be dealt to, and dealing to an empty
* seat would strand cards on the table.
*/
function dealOrder(startSeat: string | null, seatedSeats: string[]): string[] {
const seated = SEAT_ORDER.filter((seat) => seatedSeats.indexOf(seat) !== -1);
if (seated.length === 0) {
return [];
}
// An unseated presser (or a seat with no zones) just starts the deal at the head of the
// order rather than refusing to deal.
const startIndex = startSeat ? seated.indexOf(startSeat) : -1;
const offset = startIndex === -1 ? 0 : startIndex;
const ordered: string[] = [];
for (let step = 0; step < seated.length; step += 1) {
ordered.push(seated[(offset + step) % seated.length]);
}
return ordered;
}
refObject.onPressed.add(async (button, context) => {
const decks = await world.getAllObjects({ kind: "deck" });
const deck = decks[0];
if (!deck) {
world.log("Deal: no deck on the table.");
return;
}
// Seats that are both occupied AND authored with zones in this scene.
const zones = await world.getSeatZones();
const seatsWithZones: string[] = [];
for (const zone of zones) {
// `seat` is null for a TABLE zone, which belongs to nobody and seats no player.
if (zone.seat !== null && seatsWithZones.indexOf(zone.seat) === -1) {
seatsWithZones.push(zone.seat);
}
}
const players = world.getPlayers();
const occupied = players
.map((player) => player.seat)
.filter((seat): seat is string => typeof seat === "string" && seat.length > 0);
const seated = seatsWithZones.filter((seat) => occupied.indexOf(seat) !== -1);
// EventContext carries the actor's PEER ID, not their seat, so map it here. An unseated
// presser (or the host acting as "Host"/"Script") leaves this null and the deal simply
// starts at the head of the clockwise order.
const presser = players.find((player) => player.peerId === context.actor);
const order = dealOrder(presser ? presser.seat : null, seated);
if (order.length === 0) {
world.log("Deal: nobody is seated.");
return;
}
deck.shuffle();
// Let the shuffle land before drawing from it, so the deal uses the shuffled order.
await world.wait(0.35);
// Rows 1-3: face DOWN, one zone at a time, all the way round before moving on. A null
// result means the deck ran out or the seat has no zone by that name; stop rather than
// grinding through 50 more no-ops.
for (const zoneName of CARD_ZONES) {
for (const seat of order) {
const dealt = await deck.dealTo({ seat, zoneName, faceDown: true });
if (!dealt) {
world.log(`Deal: could not deal to ${seat}'s ${zoneName}. Stopping.`);
return;
}
await world.wait(DEAL_PAUSE);
}
}
// Rows 4-6: face UP, laid over the face-down card in the same zone.
for (const zoneName of CARD_ZONES) {
for (const seat of order) {
await deck.dealTo({ seat, zoneName, faceDown: false, stack: true });
await world.wait(DEAL_PAUSE);
}
}
// Three rounds into hands. No zoneName = a real hand deal: the card is owned by the seat,
// takes its slot in the fan and is hidden from everyone else.
for (let round = 0; round < HAND_ROUNDS; round += 1) {
for (const seat of order) {
await deck.dealTo({ seat, faceDown: true });
await world.wait(DEAL_PAUSE);
}
}
world.log(
`${context.actor} dealt ${order.length} player(s): 3 down, 3 up, ${HAND_ROUNDS} in hand.`
);
});
See also
eventName— how to tell several buttons apart.
SpawnObjectOptions#
Surface A — table script · interface · 9 members
Definition for spawning a new object.
The single argument to world.spawnObject. One required field —
kind — and six optional ones. The sandbox turns it into a table object definition, generates the entity's id
itself, and posts a spawn intent; nothing else in table scripting takes this type.
How, why and when to use it#
Think of it as the whole description of a new entity, because it is: there is no second call that adjusts an
entity's tags, metadata or name afterwards. Whatever you want an entity to carry for the rest of the session has
to be in this object at spawn. The alternative is to place the entity in the mod's setup.json while authoring,
which gives you the full object schema — color, scale, physics, components, parenting — instead of these seven
fields. Spawn from a script when the entity's existence depends on something you only learn at run time;
pre-place when it does not.
Gotchas#
Seven fields is much less than the entity schema has. No color, no scale, no physics, no parenting, no
components, no displayName, no owner seat. Those exist on the replicated entity state and are not reachable
from a spawn — set them by authoring the entity instead, or by pointing presetId at a standard preset.
Every field is coerced, not validated. A malformed value is silently substituted or dropped rather than
raising. The one thing that fails the whole call is a missing or non-string kind, which resolves null before
anything is sent.
The sandbox's checks are looser than the host's. A definition that passes in the frame can still be rejected by the host's schema — an over-long tag is the usual cause, and the rejection takes the entire spawn with it. See Tag validation is asymmetric.
You cannot choose the id. The sandbox generates one of the form script-… and the host honors it, which is
why the returned handle addresses the right entity from the first tick.
See also#
world.spawnObject— the call, its return value and whatnullmeans.ObjectKind— the valueskindaccepts.Vec3— the shape ofpositionandrotation.- Object state — the full entity schema these seven fields are a slice of.
- Known limitations — the full list of documented gaps.
Members#
| Signature | Description | Returns |
|---|---|---|
kind |
ObjectKind |
|
name |
string |
|
position |
Vec3 |
|
rotation |
Vec3 |
|
scale |
Scale, per axis. Every component must be above zero — a zero or negative scale is refused and the entity spawns at its default size. For an imported model, pass the scale of an existing entity of the same set (other.scale); the model's sidecar does not supply it. |
Vec3 |
presetId |
Preset id from the standard object library (e.g. "die-d6", "deck-standard"). | string |
metadata |
Record<string, unknown> |
|
tags |
string[] |
|
container |
Make this entity a CONTAINER. Only meaningful with kind: "bag"; ignored otherwise. |
{ form: "holder" | "bag"; infinite?: boolean; secretContents?: boolean; /** Infinite only: the piece a draw spawns. A definition with no position of its own. */ source?: { kind: ObjectKind; name?: string; presetId?: string; rotation?: Vec3; scale?: Vec3; /** #rrggbb. */ color?: string; metadata?: Record<string, unknown>; tags?: string[]; }; } |
spawnobjectoptions.kind#
kind: ObjectKind;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The platform kind of the entity to create. The only required field on
SpawnObjectOptions, and the only one whose absence stops the
call outright.
Returns
Accepts an ObjectKind. It must be a non-empty string: pass nothing,
null, an empty string or a non-string and world.spawnObject resolves null without sending anything to the
host. A non-empty string that is not one of the nine the sandbox knows — card, deck, die, token,
board, bag, custom, card-holder, button — is replaced with "custom", with no error and no log line.
How, why and when to use it
kind decides what the entity is for the rest of its life: whether it can be shuffled, drawn from, rolled or
flipped, and what physics it gets. Choose it from the behavior you need, not from what the piece looks like — art
comes from presetId and from the mod's assets, and a custom entity with a die model still will not roll.
Where a game concept has no platform equivalent, spawn a token or a custom and carry the meaning in
tags.
Gotchas
The silent "custom" substitution is the one to watch. kind: "dice" compiles, spawns, and produces a plain
entity that never rolls. Read handle.kind back whenever the value comes from data rather than a literal.
It also becomes the default label. Omit name and the
entity's slug is the kind string, so three unnamed dice all get the label die.
See also
ObjectKind— every value the field accepts.world.spawnObject— the only place this field is used.SpawnObjectOptions.presetId— how the entity gets a model.- Object kinds — what each kind does.
spawnobjectoptions.name#
name?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity's label — its slug and machine key — despite the field being called name. It is not the human
name shown in the Hierarchy, and there is no field here that sets one.
Returns
Accepts a string, optional. A non-empty string is truncated to 80 characters; anything else, including an
empty string, falls back to the kind string. It is written to the definition's label, which is what
ObjectData.name reads back and what world.getAllObjects has no filter for.
How, why and when to use it
For a card, label is the card's identity — it drives hidden-information redaction, so renaming a card for
readability changes which card it is. For every other kind it is a machine key you choose, and the reason to set
it deliberately is that it is the only self-describing thing about an entity a script can read later. Give
spawned entities a predictable label (scoring-die-1, marker-north) so a script that finds them again can tell
them apart; leave it unset only when the entity is disposable. If you want a display name, put your own string
in metadata and read it back from
ObjectData.metadata.
Gotchas
Known gap. A table script can neither read nor write an entity's
displayName. This field writeslabel, andObjectData.namereadslabelback (apps/web/src/scripting/sandbox/tableScriptSandbox.html,stateToData), so an entity a script spawns has no human name at all until somebody sets one in the editor. Spawning, labelling and reading the label all work correctly — only the second name is missing. Carry a display string inmetadataif you need one. See Known limitations.
Truncation is silent. An 81-character name becomes an 80-character label with no warning, so two long names that share a prefix can collide.
Labels are not enforced unique from a script. The editor treats a slug as unique within a scene; nothing
stops a script spawning three entities with the same label. Address entities by id, not by label.
See also
ObjectData.name— the read side, and which name it really is.SpawnObjectOptions.metadata— where a display string belongs.- IDs, names and tags —
id,labelanddisplayName, defined once. - Known limitations — the full list of documented gaps.
spawnobjectoptions.position#
position?: Vec3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Where the entity appears, as an absolute table-space Vec3 in feet.
Returns
Accepts a Vec3, optional. Defaults to [0, 1, 0] — one foot above the table origin, so the entity drops
onto the surface instead of clipping through it. A value that is not an array of at least three entries falls
back to that default; individual entries that are not finite numbers become 0.
How, why and when to use it
Spawning several entities in a row without a position stacks them all at the origin, where physics shoves them
apart in whatever direction it likes. Space them yourself — an index times a spacing, as
world.spawnObject's example does — whenever you create more than
one. The alternative is spawning them on top of each other deliberately, which is a reasonable way to build a
pile and a poor way to lay out player markers. Keep y at 1 unless you have a reason: dropping is what settles a
piece onto the table.
Gotchas
Units are feet, and there is no bounds check. A position off the table is accepted; the entity falls. The host nudges strays back onto the surface in some cases, and that is not something to rely on.
A short array is not a partial position. [1, 2] is not "x and y" — it fails the length check and the whole
value is replaced with the default.
It is the entity's own position, not an offset. There is no relative spawn.
Spawning at a seat is not supported here. No field places an entity into a player's hand. Spawn it on the
table and move it, or use deal() on a container, which
sends one item to every authored seat.
See also
Vec3— the tuple shape and its coercion rules.SpawnObjectOptions.rotation— the same shape, with a different default behavior.ObjectHandle.setPosition— moving an entity after it exists.world.spawnObject— a worked example that spaces several spawns out.
spawnobjectoptions.rotation#
rotation?: Vec3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity's starting orientation, as Euler angles in degrees, in the same
Vec3 tuple shape as position.
Returns
Accepts a Vec3, optional. Omit it and the entity spawns unrotated — the field is left off the definition
entirely rather than defaulted to [0, 0, 0], which is the one place its handling differs from
position. A value that is not an array of at least
three entries is dropped the same way; individual non-finite entries become 0.
How, why and when to use it
Set it when the entity's facing is part of the game: a card that has to start face-down ([180, 0, 0]), a board
laid out along the table's other axis, markers that face their owner. Otherwise leave it off — spawning
unrotated and then calling rotate() or
flip() costs an extra intent and a snapshot for the same
result. The rule of thumb: anything you know at spawn belongs in the spawn.
Gotchas
A face-down card is an X rotation of 180 degrees, and there is no faceDown field here — orientation is the
only way a spawn expresses which way up a card starts. ObjectData.faceUp reads the resulting state back.
Angles are absolute, not relative, and they are degrees, not radians.
A malformed value is silently dropped, not defaulted. Passing [1, 2] gives you an unrotated entity, not an
error — the same input to position would give you the default [0, 1, 0].
See also
Vec3— the tuple shape, and why rotations are absolute.SpawnObjectOptions.position— the same shape, defaulted rather than dropped.ObjectHandle.setRotation— turning an entity after it exists.ObjectData.faceUp— reading which way up it ended.
spawnobjectoptions.scale#
scale?: Vec3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Scale, per axis. Every component must be above zero — a zero or negative scale is refused
and the entity spawns at its default size. For an imported model, pass the scale of an
existing entity of the same set (other.scale); the model's sidecar does not supply it.
The entity's scale per axis, in the same Vec3 tuple shape as position.
Returns
Accepts a Vec3, optional. Omit it and the entity spawns at its kind's default size. Every component must be
above zero: a zero or negative scale is refused and the entity spawns at its default size instead. Components
above 100 are clamped to 100.
How, why and when to use it
Set it whenever you spawn an imported model that should match pieces already on the table. For an imported model, scale is a multiplier on the model's own size, and it lives on each placed entity — the model's sidecar does not carry it. So a script spawning a new chess queen must say what scale the set was placed at, or the queen arrives at the default token size, a fraction of the height of its neighbours.
The reliable source is an entity of the same set: copy its scale.
Replacing a piece is the common case — a promoted pawn keeps the pawn's position, rotation and scale.
Gotchas
It is a multiplier, not a size in feet, for imported models. A chess set placed at [3, 3, 3] is three times
its modelled size; [3, 3, 3] here means the same thing, not three feet.
Zero and negative scales are refused, not clamped. A degenerate scale would build a collider the physics engine cannot handle, so it never reaches it.
See also
ObjectData.scale— the value to copy from an existing entity.SpawnObjectOptions.rotation— the other field a replacement copies.
spawnobjectoptions.presetId#
presetId?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Preset id from the standard object library (e.g. "die-d6", "deck-standard").
The id of an entry in the standard object library, such as die-d6 or deck-standard. The sandbox writes it
into the definition's metadata.standardPresetId, and the runtime uses it to attach the preset's 3D model — and,
for kind: "die", its convex-hull collision shape.
Returns
Accepts a string, optional. A non-empty string is stored; anything else, including an empty string, is ignored
and no standardPresetId is written. It is applied after your own metadata is copied, so it overrides a
standardPresetId you set there yourself.
How, why and when to use it
Without a preset a spawned entity gets the kind's plain default shape — a die with no preset is a box that
tumbles like a box. presetId is how a script gets a real d6, a standard 52-card deck or a proper token without
shipping any assets of its own. The alternative is a mod asset referenced from the entity's metadata, which is
what you want for game-specific art and is not something a spawn can set up on its own. Use a preset for the
generic tabletop furniture; author the entity in the editor when it needs your own model.
Gotchas
It only brings the model and, for a die, the collision shape. Color and scale still come from the kind defaults, so a preset does not make a spawned entity identical to the same preset placed in the editor.
An unknown preset id is not an error. Nothing validates the string in the sandbox; a typo gives you the kind's default appearance and no diagnostic.
It does not imply a kind. presetId: "die-d6" on kind: "card" writes the metadata and leaves you with a
card. Set both.
The library of ids is not readable from a script. There is no call that lists presets — take ids from the standard object presets reference.
See also
SpawnObjectOptions.metadata— the bag this is written into, and why it wins.SpawnObjectOptions.kind— the field that actually decides behavior.world.spawnObject— a worked example usingdie-d6.- Object kinds — the kind defaults a preset does not replace.
spawnobjectoptions.metadata#
metadata?: Record<string, unknown>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
A freeform property bag copied onto the new entity's definition. It is the only way a spawn attaches arbitrary
data to an entity, and it is readable afterwards through
ObjectData.metadata.
Returns
Accepts a Record<string, unknown>, optional. The sandbox takes a shallow copy — nested objects are shared
by reference with whatever you passed in — and ignores a non-object value entirely.
presetId is written into the copy afterwards, so it
overrides a standardPresetId key of your own.
How, why and when to use it
metadata is where a script puts everything the entity schema has no field for: which player a marker belongs
to, what a token is worth, the display string a script cannot otherwise set. It replicates in every snapshot and
survives a save, so it is durable. The alternative for anything you only need to find entities by is
tags, because world.getAllObjects({ tag }) filters on a
tag and has no metadata filter at all — searching by metadata means fetching everything and filtering in your own
code. Tag what you query; put in metadata what you read once you have found it.
Gotchas
It is write-once from a script. There is no method that changes an entity's metadata after it exists. Model
anything that has to change with
ObjectHandle.setSavedData instead.
Values type as unknown when you read them back. ObjectData.metadata is a
Readonly<Record<string, unknown>>, so every read needs a typeof check or a cast before you can use it.
The runtime uses this bag too. Reserved keys already live here — scriptId attaches an object script,
standardPresetId selects a preset, cardId and sourceDeckId are written onto drawn cards,
grabbableWhileParented exempts a child from grab escalation. Namespace your own keys so you cannot collide with
one.
It rides every snapshot. A large bag is re-sent to every peer on every keyframe. Keep it small.
See also
ObjectData.metadata— the read side, and itsunknownvalues.SpawnObjectOptions.tags— the field you can actually filter on.ObjectHandle.setSavedData— per-entity state that can change.- Object state — where
metadatasits in the full entity schema.
spawnobjectoptions.tags#
tags?: string[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The entity's author tags. This is the only field on a spawn that
world.getAllObjects can filter on, which makes it the field
that decides whether a script can find its own entities again.
Returns
Accepts a string[], optional. The sandbox drops any entry that does not match [a-z0-9_-]+ — lower case,
digits, underscore and hyphen only — then keeps at most the first 100. If nothing survives, no tags field
is written at all. A non-array value is ignored.
How, why and when to use it
You spawn a die per player at the start of a round and need to find exactly those dice next round, not the ones
players brought themselves. Tag them at spawn and query the tag: world.getAllObjects({ tag: "scoring-die" }).
The alternative is keeping the handles in an array, which is faster and correct right up to the first script
restart, a host migration or a reload — the array is gone and the tag is still on the entities. Hold handles for
the current operation; tag anything a later run has to rediscover.
Gotchas
Known gap. The frame's filter and the host's schema disagree on length. The sandbox accepts a matching tag of any length (
apps/web/src/scripting/sandbox/tableScriptSandbox.html,spawnObject); the host then parses the definition with the shared schema, which caps a tag at 32 characters (packages/shared/src/tableObjects.ts). The parse fails, and it fails for the whole spawn — no entity, no event, and a diagnostic in the script console as the only signal. Both filters are correct in isolation; they have drifted apart. Keep tags to 32 characters or fewer. See Known limitations.
Platform dt: tags can never be written from a script. The pattern forbids :, so the reserved namespace is
unreachable — and ObjectData.tags never contains one either.
Uppercase is dropped, not lower-cased. "ScoringDie" fails the pattern and disappears silently, leaving you
querying a tag nothing carries. Write tags in the form you will query them.
Tags are write-once from a script. Nothing changes an entity's tags after it exists.
See also
world.getAllObjects— the query this field exists for.ObjectData.tags— the read side.SpawnObjectOptions.metadata— for data you read rather than query.- Tags and groups — the
dt:namespace and the authoring rules. - Known limitations — the full list of documented gaps.
spawnobjectoptions.container#
container?: {
form: "holder" | "bag";
infinite?: boolean;
secretContents?: boolean;
/** Infinite only: the piece a draw spawns. A definition with no position of its own. */
source?: {
kind: ObjectKind;
name?: string;
presetId?: string;
rotation?: Vec3;
scale?: Vec3;
/** `#rrggbb`. */
color?: string;
metadata?: Record<string, unknown>;
tags?: string[];
};
};
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Make this entity a CONTAINER. Only meaningful with kind: "bag"; ignored otherwise.
form decides what sort it is — "holder" is an open bowl whose pieces stay real
entities, "bag" is closed and stores them. infinite makes it a dispenser that never
runs out, in which case source is the piece every draw spawns; an infinite container
with no source adopts the first piece dropped into it.
There is deliberately no way to spawn a bag with pieces already inside it. Stock it with
putObject, which takes the same host-side route a player dropping pieces in does, so
capacity and piece-type rules apply to a script exactly as they do to a player.
secretContents hides the contents from every peer but the host. It is expensive —
a non-empty secret bag disables snapshot delta compression for the WHOLE table, the same
cost a deck of face-down cards carries — so leave it off unless the game needs it.
Make the entity you are spawning a container. Only meaningful with kind: "bag" — the sandbox drops it for
any other kind — and it is the only way a script can create one, since
form and
infinite are read-only on the handle afterwards.
Returns
The four sub-fields, all optional except form:
| Field | Type | Notes |
|---|---|---|
form |
"holder" | "bag" |
"holder" is an open bowl whose pieces stay real entities; "bag" is closed and stores them. An unrecognised value falls back to "bag". |
infinite |
boolean |
A dispenser that never runs out. |
source |
an object definition | Infinite only — the piece every draw spawns. |
secretContents |
boolean |
Hide the contents from every peer but the host. Expensive; see below. |
How, why and when to use it
Use it in setup, when the game needs a supply the author did not place — a bag per player, a bowl that appears only in a variant. Spawn it, then stock it.
There is deliberately no way to spawn a bag with pieces already in it. Stock it with
putObject, which takes the same host-side route a
player dropping pieces in does — so capacity, the one-lane rule and piece-type matching apply to a script exactly
as they do to a player. A contents option here would be a second stocking path with none of those checks,
which is why there isn't one.
Gotchas
source is refused on anything but an infinite container. The shared schema rejects it outright, and the
sandbox drops it before it can travel — which turns what would have been a rejected spawn into a working one,
silently missing the source. Set infinite: true in the same call or leave source out.
An infinite container with no source adopts the first piece dropped into it. That is deliberate: it is how
an author sets a supply's type in play. Until then, draws resolve null.
⚠ secretContents is a table-wide cost, not a per-bag one. A non-empty secret bag disables snapshot delta
compression for the whole table and puts the host on per-peer full snapshots — exactly the cost a deck of
face-down cards already carries. Leave it off unless the game needs it. A piece bag's contents are unseen
rather than secret by default: everybody watched each piece go in, and which piece the next draw yields is
decided by the host's private RNG at draw time, not derivable from the list.
A holder is not free either. Its pieces stay real entities, so a bowl of 200 stones is 200 rigidbodies. A
"bag" stores them as runs and costs almost nothing.
kind still has to be "bag". Passing container alongside kind: "token" is not an error; the option is
simply ignored and you get a token.
See also
BagObject.form— what you are choosing between.BagObject.infinite— the dispenser behaviour.BagObject.putObject— how to stock what you spawned.world.spawnObject— the call this option belongs to.
SeatZoneInfo#
Surface A — table script · interface · 8 members
One seat zone, in world space — what a script needs to put something INTO a zone.
Seat zones are authored in the scene document and are never replicated, so before this existed a script had no way to find out where a seat's card zones were and therefore no way to deal into them. This is a read-only projection of the runtime's live zone boxes: plain data, no handles, and a copy rather than a live reference.
id is the authored zone id and is only unique WITHIN its seat, so always qualify it
with seat. name is the authored label ("HandZone", "CardZone1") and is what a
script normally matches on, because it survives re-authoring in a way ids do not.
One seat zone, in world space — the shape of what
world.getSeatZones() returns.
How, why and when to use it#
Seat zones are authored in the scene and never replicated, so this projection is the only
way a script can learn where a seat's zones are. Match on name and hand the result to
dealTo, or use position/size to place something yourself.
Gotchas#
It is a copy, taken when you asked. Zones do not move during play, but do not cache it across a scene reload.
See also#
Members#
| Signature | Description | Returns |
|---|---|---|
seat |
The owning seat, or null for a table zone that belongs to nobody. |
string | null |
id |
string |
|
name |
string |
|
zoneType |
ZoneType |
|
primary |
True for the seat's primary zone of its type (a seat may own several). | boolean |
position |
World-space centre [x, y, z]. |
Vec3 |
size |
World-space box size [x, y, z]. |
Vec3 |
rotationY |
Y rotation in degrees, so a script can orient what it places to face the seat. | number |
seatzoneinfo.seat#
readonly seat: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
The owning seat, or null for a table zone that belongs to nobody.
Which seat owns this zone — "red", "blue", and so on.
How, why and when to use it
Zone ids are only unique within a seat, so this is half of every zone's real identity. Group by it to find "every zone belonging to the players who are actually here".
Gotchas
A seat having zones does not mean anyone is sitting there. Cross-check against
world.getPlayers() before dealing, or cards
land at an empty seat.
See also
id— the other half of the identity.
seatzoneinfo.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
The authored zone id.
How, why and when to use it
Use it when you need to tell two same-named zones of one seat apart. For most scripts
name is the better key.
Gotchas
Only unique within its seat. Always qualify it with seat — a bare
id matches a zone at every seat.
See also
name— usually what you want instead.
seatzoneinfo.name#
readonly name: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
The zone's AUTHORED name — "HandZone", "CardZone1" — or "" for a zone that was never
named.
How, why and when to use it
This is the key scripts match on, and what
DealToOptions.zoneName takes. Name your zones in the seat-zone
editor and a script can address them; leave them unnamed and it cannot.
Gotchas
Empty string, not undefined, when unnamed — and unnamed is the default. The generated
default table has no named zones at all, so a script written against names finds nothing
there.
Names are authored free text: nothing guarantees a seat has a CardZone1, and nothing stops
two zones sharing a name. Handle "not found" rather than assuming.
See also
seatzoneinfo.zoneType#
readonly zoneType: ZoneType;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
What kind of zone this is — hand, area, hidden, and the rest.
How, why and when to use it
Use it to find a seat's hand without knowing what the author named it, or to skip zone types your rule does not apply to.
Gotchas
It can be null for a zone authored with no type. Do not assume every zone is typed.
See also
primary— which of several zones of a type is the main one.
seatzoneinfo.primary#
readonly primary: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
True for the seat's primary zone of its type (a seat may own several).
Whether this is the seat's main zone of its type.
How, why and when to use it
A seat may own several zones of one type; the primary one is where the platform itself puts
things — it is the hand zone the built-in deal() targets, and the one dealTo uses when
you pass no zoneName.
Gotchas
Nothing guarantees exactly one primary zone per type. Treat it as a preference, not a uniqueness guarantee.
See also
seatzoneinfo.position#
readonly position: Vec3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
World-space centre [x, y, z].
The zone's world-space centre, [x, y, z].
How, why and when to use it
Only needed when you are placing something yourself rather than using
dealTo. Combine with size to scatter within the
zone instead of stacking at its centre.
Gotchas
It is the zone's centre, including its Y plane — drop something at exactly this point and it
sits in the zone plane. Add a little clearance, which is what dealTo does for you.
See also
seatzoneinfo.size#
readonly size: Vec3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
World-space box size [x, y, z].
The zone box's world-space size, [x, y, z].
How, why and when to use it
Use it to lay several objects out inside one zone — a row of cards along size[0], say —
rather than piling them at the centre.
Gotchas
This is the box's full extent, not a half-extent. Halve it before offsetting from
position.
See also
seatzoneinfo.rotationY#
readonly rotationY: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Y rotation in degrees, so a script can orient what it places to face the seat.
The zone's yaw in degrees.
How, why and when to use it
Seats face the table from different sides, so a card placed without this reads upside-down
for most of the table. Apply it when you orient something you placed yourself; dealTo
already does.
Gotchas
Degrees, not radians, and yaw only — zones are not pitched or rolled.
See also
deck.dealTo()— applies this for you.
SnapPointInfo#
Surface A — table script · interface · 5 members
One scene snap point, as a script sees it — a plain copy, not a live reference.
Snap points are the positions a dropped piece is pulled to: the squares of a board, the slots
of a tableau. They carry a label an author sets in Edit Mode, which is what makes a move
log read "e2 to e4" rather than a pair of coordinates.
Only SCENE snap points are listed — the ones placed with the editor's snap-point tool. The
older per-board metadata.snapPoints / snapGrid forms have no id and no label, so
there is nothing useful to hand back for them.
One scene snap point as a script sees it — a plain copy, never a live reference. Returned by
world.getSnapPoints and
world.getSnapPointAt.
How, why and when to use it#
Snap points are the positions a dropped piece is pulled to — the squares of a board, the slots of a tableau. Their
label is what lets a script name a place: a move log that prints "e2 to e4" instead of two sets of coordinates.
Gotchas#
Only SCENE snap points appear — the ones placed with the editor's snap-point tool. The older per-board
metadata.snapPoints / snapGrid forms have no id and no label, so there is nothing useful to hand back for them.
It is a copy. Nothing on it updates when the scene is edited.
See also#
world.getSnapPointAt— which point a position is on.world.getSnapPoints— all of them.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
label |
The author's label, e.g. "e4". Snap points nobody named read "Snap point". |
string |
position |
World-space position [x, y, z]. |
Vec3 |
rotationY |
Yaw in degrees a piece is turned to when it snaps here. | number |
snapRadius |
How close (horizontally, in feet) a drop has to land to be pulled here. | number |
snappointinfo.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The snap point's stable identifier.
Returns
string.
How, why and when to use it
Compare ids, not labels, when asking whether two positions are on the same point — the move log does exactly that to ignore a piece picked up and put back where it was. Two points can share a label; they never share an id.
Gotchas
It is opaque. It is generated by the editor (snap-…) and means nothing to a player — print the
label.
See also
SnapPointInfo.label— the human name.
snappointinfo.label#
readonly label: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The author's label, e.g. "e4". Snap points nobody named read "Snap point".
The name an author gave the point in Edit Mode — "e4", "Discard", "Boardwalk".
Returns
string, up to 80 characters.
How, why and when to use it
This is the field to print. It is what turns a move log from coordinates into something that reads like a scoresheet.
Gotchas
An unnamed point reads "Snap point", the editor's default — not an empty string. A script that wants to fall
back to something better has to test for that literal.
Labels are not unique. Two points may carry the same one; use id
to tell them apart.
See also
world.getSnapPoints— checking a whole board is labelled.
snappointinfo.position#
readonly position: Vec3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
World-space position [x, y, z].
Where the point is, in world space.
Returns
Vec3 — [x, y, z], in feet.
How, why and when to use it
Pass it to setPosition to put a piece on a named point — build a map from label to position once with
world.getSnapPoints, then place pieces by name.
Gotchas
y is where the point was authored, usually the table surface. A piece placed at exactly this height will sit
inside a board that stands above it; keep the piece's own height and take only x and z.
See also
SnapPointInfo.snapRadius— how far from here a drop still counts.
snappointinfo.rotationY#
readonly rotationY: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Yaw in degrees a piece is turned to when it snaps here.
The yaw, in degrees, a piece is turned to when it snaps here.
Returns
number.
How, why and when to use it
Use it when placing a piece on a point from a script, so it faces the way a hand-dropped piece would.
Gotchas
It only sets yaw. A piece keeps its own tilt and whether it is face-up.
See also
SnapPointInfo.position— where it is.
snappointinfo.snapRadius#
readonly snapRadius: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
How close (horizontally, in feet) a drop has to land to be pulled here.
How close a drop has to land, horizontally, to be pulled to this point.
Returns
number, in feet, above 0 and at most 2.
How, why and when to use it
Mostly for understanding why a drop did or did not snap. On a board it is typically a little under half the square pitch, so every point of every square belongs to exactly one point.
Gotchas
Overlapping radii are resolved by distance. Where two radii overlap, the nearer point wins — that is the rule
getSnapPointAt applies, and the one the table snaps with.
See also
world.getSnapPointAt— the rule this feeds.
DealToOptions#
Surface A — table script · interface · 4 members
What DeckObject.dealTo accepts.
What deck.dealTo() accepts: which seat, which zone, which way up,
and whether to stack.
How, why and when to use it#
seat is the only required field. Leave zoneName out to deal to the seat's primary hand
zone — the same target the built-in deal() uses.
Gotchas#
faceDown defaults to the card's own facing in the deck, not to false. If you care
which way up a dealt card lands, say so explicitly.
See also#
Members#
| Signature | Description | Returns |
|---|---|---|
seat |
Target seat, e.g. "red". |
string |
zoneName |
Authored zone NAME ("CardZone1", "HandZone"). Matched case-insensitively. Omit to deal to the seat's primary hand zone, which is what the built-in deal() does. |
string |
faceDown |
Face the card down. Defaults to the card's own facing in the deck. | boolean |
stack |
Add the card to whatever is already in the zone instead of laying it beside it, so a face-up row can be stacked on a face-down one. Defaults to false. |
boolean |
dealtooptions.seat#
seat: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Target seat, e.g. "red".
Which seat to deal to — "red", "blue", and so on. The only required field.
How, why and when to use it
Take it from world.getPlayers() so you only deal to seats
someone is actually sitting in.
Gotchas
Dealing to an unoccupied seat is not an error — the card is placed and simply sits there. Filter first.
See also
dealtooptions.zoneName#
zoneName?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Authored zone NAME ("CardZone1", "HandZone"). Matched case-insensitively.
Omit to deal to the seat's primary hand zone, which is what the built-in
deal() does.
The AUTHORED zone name to deal into, e.g. "CardZone1".
How, why and when to use it
Omit it to deal into the seat's hand — the same destination, layout and ownership the
built-in deal() produces. Name a zone when you are laying out a tableau in front of a
player rather than filling their hand.
Matching ignores case and separators, so "CardZone1", "Card Zone 1" and "card-zone-1"
all find the same zone. The zone's authored id works too.
Gotchas
A name no zone matches deals nothing and writes a line to the event log naming the zones the seat actually has. It used to fail in silence, which meant a typo looked like "the deal partly worked".
See also
world.getSeatZones()— what names exist.
dealtooptions.faceDown#
faceDown?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Face the card down. Defaults to the card's own facing in the deck.
Which way up the dealt card lands.
How, why and when to use it
Set it explicitly whenever the facing carries meaning — a face-down row that players may not look at, or a face-up row that everyone can read.
Gotchas
It defaults to the card's own facing in the deck, not to false. A deck sitting face down deals face-down
cards unless you say otherwise.
See also
dealtooptions.stack#
stack?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Add the card to whatever is already in the zone instead of laying it beside it,
so a face-up row can be stacked on a face-down one. Defaults to false.
A pile is ONE object on the table, so stacking onto an occupied zone folds the two together and resolves with the pile, not with a loose card; the dealt card ends up on top and every card keeps its own face. Stacking into an EMPTY zone just places the card, and resolves with it as usual.
Add the card to whatever is already in the zone instead of laying it beside it.
How, why and when to use it
This is what makes a face-up row sit on a face-down one in the same zone. A pile is a single object on this table, so stacking onto an occupied zone folds the two together: the dealt card goes on top, every card keeps its own face, and the zone ends up holding one pile rather than two overlapping cards.
That is deliberate, not a shortcut. A card is 0.2 mm of sheet inside a 6 mm collider — the floor Bullet needs to solve a shape at this world scale — so two loose cards resting on each other always leave a visible gap you can read the lower card through. Folding them is also exactly what a player gets by picking a card up and dropping it on the pile by hand.
Gotchas
With stack: true, the call resolves with the PILE, not with a loose card, whenever the
zone already held something. Its cardId is the card on top. Stacking into an empty zone has
nothing to fold into, so it simply places the card and resolves with it as usual — a script
that deals a face-down row and then a face-up row over it therefore gets cards back from the
first pass and piles from the second.
The pile is built from what is physically inside the zone's footprint, so moving cards in or out by hand between deals changes what the next one lands on.
See also
DiceRollSummary#
Surface A — table script · interface · 9 members
One finished batch roll — what globalEvents.onDiceRollResult hands you.
value is null for a die that settled COCKED (resting past the tilt the face reader
accepts) or that carries no face table at all. Those dice add nothing to total and are
counted in cocked — the table never invents a number it could not read.
One finished batch roll, as a plain read-only record: who threw, where the dice landed, the notation, every die,
the total, and how many dice could not be read. It is the only argument
globalEvents.onDiceRollResult passes, and it is a
copy assembled by the host at the moment the last die stopped — not a live view of the dice, which go on
being ordinary entities you can move, keep or clear afterwards.
How, why and when to use it#
This is the record that makes "score the roll" a three-line handler instead of a state machine. Everything a
throw means is on it at once: total for scoring, notation for printing, dice for per-die detail,
actorPeerId/seat for attribution, and cocked for the case where the answer is "we could not read it". The
alternative — accumulating onDiceRolled events until you decide the batch is done — cannot tell two
simultaneous rolls apart and has no way to know how many dice to wait for.
Treat rollId as the key when you store anything about a roll, and objectId on each die as the way back to the
entity if you want to highlight, move or delete it.
Gotchas#
Nothing here is a handle. dice[i].objectId is a string; reach the entity with
world.getObjectById when you need to act on a die. By the time
you do, a player may already have picked it up.
It is not in the snapshot. The summary is emitted once and is not stored on the table, so a script that starts later, or a peer that joins later, never sees a roll that already happened. Keep your own record if you need history; the durable half of a roll is the ordinary event-log entry the host appends.
total is not dice.length sums. Cocked dice are in dice with value: null, are counted in cocked, and
are excluded from total.
See also#
globalEvents.onDiceRollResult— the only thing that hands you one.globalEvents.onDiceRolled— the per-die event that fires first.manifest.dice— which dice a game offers the roller.
Members#
| Signature | Description | Returns |
|---|---|---|
rollId |
Correlates with the roll request that produced it. | string |
actorPeerId |
string |
|
actorName |
string |
|
seat |
The roller's seat, or null when they hold none. |
string | null |
target |
"tray" (the roller's own bounded region) or "table". |
"tray" | "table" |
notation |
Dice notation, e.g. "3d6+1d20". |
string |
dice |
ReadonlyArray<{ readonly objectId: string; readonly preset: string; readonly sides: number; readonly value: number | null; }> |
|
total |
Sum of the dice that reported a face. | number |
cocked |
How many dice could not be read. | number |
dicerollsummary.rollId#
readonly rollId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Correlates with the roll request that produced it.
The id of the roll this summary describes — the same id the roll request carried, and the same id every die in the batch is stamped with while it is still the roller's.
Returns
string. Always present and never empty. Unique per roll for the life of the session; it is not derived from
anything and carries no meaning beyond identity.
How, why and when to use it
Key anything you remember about a roll on this rather than on the roller or the time: a player may roll twice in
a second, and two players may roll at once. It is also what lets you match a summary to the dice still sitting on
the table — a die kept in the roller's custody carries the same rollId, so "highlight the dice from that roll"
is a comparison, not a search through positions.
Gotchas
It is not a table object id. rollId names the throw; dice[i].objectId names a die. Passing one where the
other is expected resolves to nothing.
It does not survive the session. Nothing persists a roll id, so it is useless as a saved-data key across loads.
See also
DiceRollSummary.dice— the dice this roll produced.globalEvents.onDiceRollResult— where the summary arrives.
dicerollsummary.actorPeerId#
readonly actorPeerId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The peer id of the player who rolled. This is the identity half of attribution — always present, always stable for the length of that player's connection.
Returns
string. Never null, on every roll, including one thrown by a spectating host who holds no seat.
How, why and when to use it
Key per-player state on this, not on seat (which is null for a spectator and can change hands) and not on
actorName (which is a display string and is not unique). Turn enforcement — "it is not your roll" — compares
this against
world.getTurn's activePeerId.
Gotchas
It is a peer id, not a player record. Turn it into something human with
world.getPlayers, or just print actorName.
A peer id dies with the connection. The same person rejoining is a different peer. Anything that must outlive a disconnect belongs on the seat.
See also
DiceRollSummary.actorName— the printable half.DiceRollSummary.seat— the seat, when they hold one.PlayerInfo.peerId— the same identity on the roster.
dicerollsummary.actorName#
readonly actorName: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The roller's display name, captured at the moment the roll was summarised. It is what the chat line and the on-screen summary print, and it is here so a script does not have to resolve a peer id to say who rolled.
Returns
string. Always present. A player who has set no name gets whatever the table shows for them, so this is never
empty — but it is not unique, and two players may share it.
How, why and when to use it
Use it for anything a person reads and nothing a script decides. It is a snapshot of the name at emit time, which is exactly right for a chat line ("Ada rolled 3d6") and exactly wrong for a scoreboard that should follow a rename.
Gotchas
Never key on it. Two players may pick the same name, and one player may change theirs mid-session. Use
actorPeerId.
It is frozen at emit. A rename after the roll does not rewrite a summary you kept.
See also
DiceRollSummary.actorPeerId— the identity to key on.PlayerInfo.displayName— the same name on the roster.
dicerollsummary.seat#
readonly seat: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The roller's seat, or null when they hold none.
The seat the roller was sitting in, or null when they hold none. It is the label half of attribution — handy
for scoring a seat rather than a person, and for saying whose end of the table the dice are at.
Returns
string | null. A seat identifier such as north or south when the roller is seated; null means they hold
no seat — a spectator, or a host who never sat down. null is an ordinary outcome, not an error: a seatless
player can still roll.
How, why and when to use it
Score on the seat when the score belongs to the position rather than the person — seats outlive the peers that
occupy them, so a running total kept per seat survives someone dropping out and reconnecting. Score on
actorPeerId when it belongs to the player. Handle
null explicitly either way, or a seatless roll will quietly land in a bucket named "null".
Gotchas
Not the same thing as "where the dice are". A roll can be made into the roller's own region or straight onto
the table; that is target, not this.
A seat can change hands mid-session. A summary you stored records who sat there then.
See also
DiceRollSummary.actorPeerId— the identity that is always present.PlayerInfo.seat— the same seat on the roster.globalEvents.onSeatChanged— when it changes.
dicerollsummary.target#
readonly target: "tray" | "table";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
"tray" (the roller's own bounded region) or "table".
Where the dice were thrown: "tray" for the roller's own bounded region, or "table" for a throw made onto the
table itself.
Returns
"tray" | "table". Exactly one of the two, always present, and always what the roller asked for — it is not a
description of where the dice ended up.
How, why and when to use it
The two modes mean different things to a game. A "tray" roll is a roll the roller is still holding — the dice
sit in a bounded region in front of their seat, cannot shove anything else on the table, and are removed when
they clear. A "table" roll puts real dice into the shared space to be picked up and kept. A script that deals
out dice as tokens cares about the difference; a script that only scores totals does not, and can ignore this
field.
Gotchas
"tray" is not "private". Dice are public: everyone sees the result either way. The tray bounds a roll, it
does not hide it.
Custody, not location, is what a later clear reads. A die that is picked up and placed stops being the roller's and survives every subsequent clear, whichever target it was rolled to.
See also
DiceRollSummary.seat— whose region a"tray"roll used.tableObjectDefinitionSchema.diceTray— the custody field on a die that is still the roller's.
dicerollsummary.notation#
readonly notation: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Dice notation, e.g. "3d6+1d20".
The roll in dice notation — "3d6+1d20" — built from the dice that were actually thrown and grouped by face
count, smallest die first.
Returns
string. Never empty; a single die reads "1d6". Groups are joined with + and are always <count>d<sides>
— there is no modifier term, because the table rolls dice and does not add bonuses for you.
How, why and when to use it
Print it. It is the one field written for a human to read at a glance, and it is what the chat line and the summary panel show. It exists so that every surface describing a roll describes it the same way instead of each one inventing a format.
Gotchas
Grouped by sides, not by preset. Two visually different six-siders read as 2d6. A player who rolled a
wooden d6 and a white d6 expects 2d6, not 1d6+1d6 — but it does mean the string cannot tell you which models
were involved. Read dice[i].preset for that.
Do not parse it. Everything the string encodes is already structured on dice; re-deriving it from text is
work that can only go wrong.
See also
DiceRollSummary.dice— the structured form of the same throw.manifest.dice— which dice a game offers in the first place.
dicerollsummary.dice#
readonly dice: ReadonlyArray<{
readonly objectId: string;
readonly preset: string;
readonly sides: number;
readonly value: number | null;
}>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Every die in the throw, in the order the host settled them, each as a small record: which entity it is, which preset it was spawned from, how many faces it has, and what it landed on.
Returns
ReadonlyArray<{ objectId: string; preset: string; sides: number; value: number | null }>. Never empty for a
roll that happened. The four fields are:
| Field | Type | Notes |
|---|---|---|
objectId |
string |
The die on the table. Resolve it with world.getObjectById if you want to act on it. |
preset |
string |
The standard preset the die came from, e.g. die-d6. |
sides |
number |
Face count, from the preset. This is what notation groups on. |
value |
number | null |
The printed face, verbatim — or null when there was none to read. |
How, why and when to use it
Use it whenever the individual faces matter rather than the total: counting successes over a threshold, looking
for a matching set, scoring highest-die, or highlighting the dice that produced a result. Filter out the nulls
first — that single line is the difference between "count the 6s" working and silently counting a cocked die as
not-a-6 when nobody knows what it was.
Each objectId is a live entity for as long as the die is on the table, which is how a script can move, colour
or delete the dice a roll produced.
Gotchas
value is null for a cocked die or a die with no face table. Those dice are still in this array and still
in dice.length; they are excluded from
total and counted in
cocked. Never coerce null to 0 — a die that landed
unreadable is not a die that landed on zero.
The dice may already be gone. By the time an async handler resolves an objectId, the roller may have
cleared the roll or another script may have deleted the die.
world.getObjectById resolves null then.
preset is a plain string, not a narrowed union. It is whatever preset the die was spawned from; compare it,
do not switch exhaustively on it.
See also
DiceRollSummary.total— the sum of the readable faces.DiceRollSummary.cocked— how many of these reported nothing.tableObjectDefinitionSchema.faceValue— the same number as it rides in the snapshot.
dicerollsummary.total#
readonly total: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Sum of the dice that reported a face.
The sum of every die that reported a face. Dice that could not be read contribute nothing, so this is the answer
to the roll only when cocked is 0.
Returns
number. Always present, and 0 for a roll in which no die was readable — which is why 0 is not, on its own,
a meaningful result.
How, why and when to use it
It is the field most scripts want and the reason this event exists: "the active player rolls and scores the
total" is one line here and a state machine over
onDiceRolled. Read cocked first, and if it is non-zero
either ask for a re-roll or score the readable dice deliberately — do not do it by accident.
Gotchas
It is a plain sum of faces. No modifiers, no multipliers, no dropping the lowest. Anything a game does beyond
adding the numbers up is yours to do from
dice.
0 is ambiguous without cocked. An all-cocked roll and a roll of dice that genuinely print zero both total
0. cocked is what tells them apart.
See also
DiceRollSummary.cocked— read this before trusting the total.DiceRollSummary.dice— the faces the total came from.
dicerollsummary.cocked#
readonly cocked: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
How many dice could not be read.
How many dice in the throw failed to report a face. 0 on a clean roll — and anything above 0 means the total
is missing that many dice.
Returns
number. Always present, between 0 and dice.length. A die counts here when it settled past the tilt the face
reader accepts (leaning on a piece, wedged against a wall, resting between two faces), or when it is a custom die
model that carries no face table at all.
How, why and when to use it
This field is the table refusing to guess. The face reader will not name a face it cannot see squarely, so rather
than rounding to the nearest plausible number the die reports null and is counted here. Branch on it: ask for a
re-roll, tell the player which dice to nudge, or score the readable dice — but make it a decision your script
took, not one it drifted into by summing nulls away.
Gotchas
A cocked roll is not an error. Nothing failed; the dice landed awkwardly, exactly as real dice do. There is no exception and no retry.
"No face table" is permanent, not transient. A die imported as a custom model reports null every time, so a
re-roll never fixes it. If every roll of a particular die is cocked, that is the cause.
It counts dice, not readings. cocked === 1 on a five-die roll means total is the sum of four dice.
See also
DiceRollSummary.total— what the readable dice came to.DiceRollSummary.dice— which die reportednull.globalEvents.onDiceRolled— the same honesty rule, per die.
PlayerInfo#
Surface A — table script · interface · 5 members
One connected peer, as a table script sees it: five read-only fields, no methods. You get PlayerInfo from
world.getPlayers() and from the onPlayerJoined and onPlayerLeft delegates. It is a copy, assembled by
the app from the room's peer list and seat assignments and pushed into the sandbox whenever any of it changes —
not a live view of a player.
How, why and when to use it#
You want to say "Ada moved out of turn" rather than "peer-8f2a moved out of turn", or to know how many people are
seated before starting. PlayerInfo is the only place a script can turn a peer id into anything human, and the
only place seats and teams are readable. The alternative is keeping your own map from the seat and team events,
which you will end up doing anyway for anything that has to survive a departure — the roster drops a peer as soon
as they disconnect. Read getPlayers() for the current picture; keep your own record for anything historical.
Example#
// content/scripting-api/examples/playerinfo.ts
// Scene script: PlayerInfo is a snapshot of the roster the host pushed into
// the sandbox. Read it fresh each time - the array world.getPlayers() returns
// is a copy, so a stored one goes stale as people join, sit and leave.
function describe(player: PlayerInfo): string {
const name = player.displayName ?? player.peerId;
const seat = player.seat ?? "standing";
const team = player.team ?? "no team";
return `${name} [${seat}, ${team}]${player.isHost ? " (host)" : ""}`;
}
function reportRoster(): void {
const players = world.getPlayers();
if (players.length === 0) {
world.log("Nobody is connected yet.");
return;
}
const seated = players.filter((player) => player.seat !== null);
world.log(`${players.length} connected, ${seated.length} seated.`);
for (const player of players) {
world.log(describe(player));
}
}
globalEvents.onPlayerJoined.add((player) => {
world.broadcast(`Welcome, ${describe(player)}.`);
reportRoster();
});
globalEvents.onSeatChanged.add(() => reportRoster());
reportRoster();
At a two-player table the script console prints 2 connected, 2 seated. followed by
Ada [north, no team] and Bo [south, no team].
Gotchas#
The list includes the peer running the script, and it is the one with isHost: true. The signaling layer's
peer list deliberately excludes the peer reading it (apps/web/src/net/signaling/signalState.ts,
filterPresencePeers), so the app adds the local participant back before pushing the roster into the sandbox
(apps/web/src/ui/scriptPlayers.ts, buildScriptPlayers). A script that means "everyone else" has to filter on
isHost. A solo table therefore returns one entry, not none.
Every field is a snapshot taken when the context was last pushed. A stored PlayerInfo does not update. Call
world.getPlayers() again rather than holding one.
In Edit Mode there is exactly one, and it is synthetic. ▶ Play Scripts pushes a single entry —
{ peerId: "editor", displayName: "Editor", seat: null, team: null, isHost: true }
(apps/web/src/ui/TableEditModeShell.tsx, EDITOR_SCRIPT_PLAYER). A script that branches on the roster behaves
differently under test than at a table; check peerId === "editor" if that matters.
onPlayerLeft hands you a partly filled one. That event carries only a peer id and a display name, and the
sandbox fills the other three from the roster if the peer is still in it — which it is not once they have
disconnected. See
onPlayerLeft.
See also#
world.getPlayers— the call that returns these.globalEvents.onPlayerJoined— the arrival, with aPlayerInfopayload.globalEvents.onSeatChanged— how to keep a seat map that outlives the roster.TurnInfo— whose turn it is, which is a separate question.- Host authority — why the script sees the table from the host's side.
Members#
| Signature | Description | Returns |
|---|---|---|
peerId |
string |
|
displayName |
string | null |
|
seat |
string | null |
|
team |
string | null |
|
isHost |
boolean |
playerinfo.peerId#
readonly peerId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The peer's id: the only value that identifies a player to the rest of the API. It is what
EventContext.actor carries for a player-caused event, what
onTurnStarted and onTurnEnded report, and what TurnInfo.activePeerId
holds.
Returns
string. Never null and never empty. It is assigned by the signaling server when the peer joins and does not
change while they stay connected.
How, why and when to use it
Key everything on peerId — scores, per-player state, "has moved this turn". The alternative that looks
tempting is displayName, which is what you want to print and is a terrible key: it is optional, it is not
unique, and for the host's own chat line it is the literal "You". Use peerId for identity and displayName
for display, and look one up from the other with world.getPlayers().
Gotchas
Treat it as valid for one connection only. Nothing in the scripting API guarantees a returning player the
same id, and onPlayerLeft followed by onPlayerJoined is what a reconnection looks like from a script. Store
anything that must outlive a disconnect against a seat, and write it with world.setSavedData.
Comparing it to context.actor fails for three values. "Script", "Host" and "You" are not peer ids.
Filter those out before treating an actor as one.
The host's own id never appears in world.getPlayers(). The roster excludes the peer the script is running
on, so a lookup for the host's id always returns undefined.
See also
EventContext.actor— where a peer id arrives from an event.PlayerInfo.displayName— the value to print instead.TurnInfo.activePeerId— the same id, in the turn state.world.setSavedData— persisting per-player state that must outlive a connection.
playerinfo.displayName#
readonly displayName: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The name the player chose, for showing to people. It is the only human-readable string a table script can get
about a participant, and it is optional — the app passes the peer's displayName straight through, or null
when they have not set one.
Returns
string | null. null means the peer has no display name at all, which is the case for anyone who has not set
one; the app does not substitute a fallback here. Write player.displayName ?? player.peerId wherever a name has
to appear.
How, why and when to use it
Every message your script broadcasts to the table should name a person, not a peer id — Ada took 34s reads as a
game, peer-8f2a took 34s reads as a bug report. Use this field in world.broadcast and world.log output.
Never use it to decide anything: it is optional, it is not unique, and two players can choose the same name.
PlayerInfo.peerId is the identity.
Gotchas
Not the same as an entity's displayName. Entities have three names — id addresses, label is the slug,
displayName is the human name — and a table script cannot read an entity's displayName at all. This field is
about a person, and it is unrelated. See
A table script cannot read displayName.
onChatMessage carries a different string under the same name. The chat event's displayName is already
resolved for display and is the literal "You" for a message typed at the host machine. This field is the raw
peer name.
In Edit Mode it is always "Editor". ▶ Play Scripts pushes one synthetic player.
See also
PlayerInfo.peerId— the identity to key on.world.broadcast— where a name belongs.globalEvents.onChatMessage— the otherdisplayName, and why it differs.- IDs, names and tags — the three names an entity has, which are a separate story.
playerinfo.seat#
readonly seat: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Which seat the player is sitting in. The app reads it from the room's assignment map at the moment the roster is pushed into the sandbox, so it is the same value the seat UI shows and the same one the hidden-hand system uses to decide who may see a hand.
Returns
string | null. A seat identifier such as north or south when the player is seated; null means they hold
no seat — a spectator, or someone who has joined but not sat down yet. null is the ordinary state for a peer
that has just arrived, not an error.
How, why and when to use it
"Start when all the seats are full" and "deal a card to every seated player" both need this field, and both go
wrong if you use the connected-peer count instead — spectators are connected and are not playing. Read it
alongside world.getPlayers() when you need the current picture, and keep your own peer-to-seat map from
onSeatChanged when you need to know where somebody
was after they have gone.
Gotchas
onPlayerJoined reports null here on a first join. A peer arrives before they take a seat, so the join
event reads an empty assignment. Use onSeatChanged for the moment they sit down.
onPlayerLeft reports null here in practice. The departure event carries no seat, and the sandbox's
fallback fills null once the peer is out of the roster.
A seat is not a team. They are separate assignments with separate events; a player can hold either, both or
neither. See PlayerInfo.team.
A seat outlives a player. Seats belong to the table, not to the peer, so a piece owned by a seat stays attached to that seat when its occupant leaves. Key persistent per-player state on the seat rather than on the peer id.
See also
globalEvents.onSeatChanged— the event that changes this value.PlayerInfo.team— the other assignment.world.getPlayers— the current roster.ObjectHandle.deal— the action that sends one item to every authored seat.
playerinfo.team#
readonly team: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Which team the player is on. Like the seat, it comes from the room's assignment map at the moment the roster was
pushed into the sandbox. Teams are optional at every table — a game that does not use them leaves this null for
everyone, forever.
Returns
string | null. A team identifier such as A or B when the player has been assigned one; null means no
team, which is both the default and a legitimate ongoing state.
How, why and when to use it
Team games need to know the sides before a round starts — even sides, per-team scoring, a shared hand. This is
where that lives. The reason not to use it for anything about visibility is that team-based reveals are the
host's job: the reveal-team-a and reveal-team-b actions exist in the engine and are deliberately outside the
script vocabulary, so a script that tries to show a card to one team has to model it as its own state instead.
Read team for game logic, and leave card visibility to the host.
Gotchas
Unavailable on a join. onPlayerJoined reports null here unless the sandbox's roster already knew the
peer, which on a first join it does not. Read it from world.getPlayers() a moment later, or subscribe to
onTeamChanged.
A team is not a seat. They change independently and raise separate events.
Scripts cannot set it. There is no table-scripting call that assigns a seat or a team; both are the host's UI. A script reacts to assignments, it does not make them.
See also
globalEvents.onTeamChanged— the event that changes this value.PlayerInfo.seat— the other assignment.world.getPlayers— the current roster.- Action vocabularies — why
reveal-team-aandreveal-team-bare not scriptable.
playerinfo.isHost#
readonly isHost: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Whether this peer is the room's host. The app computes it by comparing the peer's id against the room's
hostPeerId as it builds each roster entry (apps/web/src/ui/scriptPlayers.ts, buildScriptPlayers); on a solo
or offline table with no room record, the local peer is the authority and gets true.
Returns
boolean. Exactly one entry in the roster has it set — the roster includes the local participant, and a
table script only ever runs on the host, so that entry is you. In Edit Mode the single synthetic player reports
true for the same reason.
How, why and when to use it
The useful thing it gives you is not "am I the host" — you are, or your script would not be executing — but
which roster entry is you. world.getPlayers().find(p => p.isHost) is how a script separates itself from the
other participants without needing to know its own peer id, which the table-scripting surface does not otherwise
expose. Use it to exclude yourself from a deal, to attribute a broadcast, or to pick the seat that does not get
dealt into.
Gotchas
It identifies the machine, not a seat. The host may be a spectator, or seatless. Check seat before treating
the isHost entry as a player in the game.
Every other entry is false, and that is not a gap. There is one host per room by construction.
No delegate reports a change to it. There is no host-changed event among them, so a script cannot react to
hosting moving during a migration. Call world.getPlayers() again if you need the current answer — the roster is
refreshed whenever it changes.
In Edit Mode the entry is synthetic. ▶ Play Scripts pushes one player with peerId: "editor" and
isHost: true (apps/web/src/ui/TableEditModeShell.tsx, EDITOR_SCRIPT_PLAYER), so anything keyed on a real
peer id behaves differently under test than at a table.
See also
PlayerInfo.peerId— the identity the comparison is made against.world.getPlayers— the roster, and who is in it.- Host authority — why a script is always on the host already.
TurnInfo#
Surface A — table script · interface · 2 members
The table's turn state, as two read-only fields: whether turn order is running at all, and whose turn it is. You
get it from world.getTurn(), which answers from context the host
pushes into the sandbox — so it is a copy that is refreshed whenever the turn changes, not a live read.
How, why and when to use it#
You need to know whose turn it is inside some other handler — a drop, a chat command, an action — to decide
whether to allow it. world.getTurn() is the right tool there because it answers immediately, with no round-trip
and no await. The alternative, subscribing to onTurnStarted and keeping the active peer in a variable, is what
you want when the transition is the event you care about; it is redundant when you only need the current
answer. Use getTurn() for a check, onTurnStarted for upkeep.
Example#
// content/scripting-api/examples/turninfo.ts
// Scene script: gate an action on whose turn it is. TurnInfo has two fields
// and they move together - when turn order is off, enabled is false and
// activePeerId is null, so check enabled before trusting the peer id.
function activePlayerName(): string {
const turn: TurnInfo = world.getTurn();
if (!turn.enabled) {
return "turn order is off";
}
if (turn.activePeerId === null) {
return "turn order is on with nobody active";
}
const player = world.getPlayers().find((entry) => entry.peerId === turn.activePeerId);
return player === undefined ? turn.activePeerId : (player.displayName ?? turn.activePeerId);
}
globalEvents.onObjectDropped.add((entity, context) => {
const turn = world.getTurn();
if (!turn.enabled || turn.activePeerId === null) {
world.log(`${entity.id} moved with no turn order in force.`);
return;
}
if (context.actor !== turn.activePeerId) {
world.broadcast(`It is ${activePlayerName()}'s turn, not ${context.actor}'s.`);
return;
}
world.log(`${context.actor} moved ${entity.name ?? entity.kind} on their own turn.`);
});
globalEvents.onTurnStarted.add(() => world.log(`Active: ${activePlayerName()}.`));
world.log(`Turn gate is running. Active: ${activePlayerName()}.`);
With turn order off the script console prints Turn gate is running. Active: turn order is off.; with it on, a
drop by the wrong player puts It is Ada's turn, not peer-3c11's. into the table chat.
Gotchas#
A fresh object every call. world.getTurn() builds a new record from the sandbox's cached context each time,
so two calls are never the same object and holding one gives you a stale answer.
The turn order itself is not readable. There is no field for the sequence of players, the turn number, or the
per-turn action limit. Track those from onTurnStarted and onTurnEnded if you need them.
context.actor is not always comparable to activePeerId. A grab on the host machine reports the actor
"You", not a peer id, so a strict comparison treats it as the wrong player. Handle the three fixed actor
strings explicitly. See EventContext.actor.
In Edit Mode turn order is always off. ▶ Play Scripts pushes { enabled: false, activePeerId: null }, so a
turn-gated rule never triggers under test.
See also#
world.getTurn— the call that returns this.globalEvents.onTurnStarted— the transition, with the seat and team.globalEvents.onTurnEnded— the other half of the transition.PlayerInfo— turningactivePeerIdinto a name.EventContext.actor— why an actor is not always a peer id.
Members#
| Signature | Description | Returns |
|---|---|---|
enabled |
boolean |
|
activePeerId |
string | null |
turninfo.enabled#
readonly enabled: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Whether turn order is switched on for this table. It mirrors the host's turn-order setting, and the sandbox
coerces anything that is not the boolean true to false, so a missing or malformed context reads as "off"
rather than throwing.
Returns
boolean. false on a table where nobody has turned turn order on — which is the default, and the state of most
casual tables.
How, why and when to use it
Check this before you read activePeerId. A rule that enforces
turns has to do nothing at all on a table where turns are not in use, and the distinction between "turn order is
off" and "turn order is on and nobody is active" is a real one your messages should not blur. The alternative —
testing activePeerId !== null alone — happens to work today and reads as if the two fields were independent,
which is exactly the assumption that breaks when a game switches turn order on mid-session.
Gotchas
No delegate reports it changing. Switching turn order on raises onTurnStarted for the first player, and
switching it off raises nothing at all. A script that only listens never learns that turns stopped; poll
world.getTurn() where it matters.
In Edit Mode it is always false. ▶ Play Scripts pushes { enabled: false, activePeerId: null }, so turn
logic cannot be exercised there.
It says nothing about action limits. The table's per-turn action limit is enforced by the app before an intent reaches the runtime and is not exposed to table scripting at all.
See also
TurnInfo.activePeerId— the field to read once this istrue.world.getTurn— the call that returns both.globalEvents.onTurnStarted— the only event raised when turns begin.
turninfo.activePeerId#
readonly activePeerId: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
The peer whose turn it is. The sandbox passes the host's value through, normalizing undefined to null, so the
field is either a real peer id or null and never anything in between.
Returns
string | null. null has two causes, and they are not the same situation: turn order is switched off, or
turn order is on but no player is active. Read
TurnInfo.enabled to tell them apart.
How, why and when to use it
This is the id you compare an actor against to answer "was that their move?". Pair it with a lookup in
world.getPlayers() when you want to name the player in a message, because the raw id means nothing to anybody
at the table. The alternative to reading it here is capturing turn.peerId from
onTurnStarted into a variable, which is equivalent and
one more thing to keep in sync — read it from world.getTurn() at the point of the check instead.
Gotchas
Comparing it to context.actor fails for a host-side grab. A pickup or drop made at the host machine
reports the actor "You", not a peer id, so a strict comparison marks it as the wrong player. Handle "You",
"Script" and "Host" before falling through to the comparison. See
EventContext.actor.
The id is not always in the roster. world.getPlayers() excludes the peer the script is running on and drops
anyone who has disconnected, so a lookup can return undefined for a perfectly valid active peer — the host's
own turn, for instance. Fall back to printing the raw id.
It changes without your script being told, in one case. Switching turn order off leaves it null and raises
no event.
See also
TurnInfo.enabled— the field that disambiguatesnull.PlayerInfo.peerId— the same identity, on the roster side.globalEvents.onTurnStarted— the event that changes this value.EventContext.actor— why a comparison against it needs care.
ScriptVariableSpec#
Surface A — table script · type
One declared variable — a typed slot the script needs filled in, and the filters the inspector enforces when somebody fills it.
label is the row's caption (it falls back to the property name), and
description is its hint. kind and tag narrow which entities may be
dropped on an object/objectList row; min/max bound the numeric
editor; default is the value a scalar reads when nothing is bound.
declare type ScriptVariableSpec =
| { type: "object"; kind?: ObjectKind; tag?: string; label?: string; description?: string }
| { type: "objectList"; kind?: ObjectKind; tag?: string; label?: string; description?: string }
| { type: "spawnable"; label?: string; description?: string }
| { type: "zone"; label?: string; description?: string }
| { type: "number"; label?: string; description?: string; default?: number; min?: number; max?: number }
| { type: "string"; label?: string; description?: string; default?: string }
| { type: "boolean"; label?: string; description?: string; default?: boolean }
| { type: "vector3"; label?: string; description?: string; default?: Vec3 }
| { type: "color"; label?: string; description?: string; default?: string }
| { type: "seat"; label?: string; description?: string; default?: string };
One declared slot, as written inside declareVariables: what the variable is, and which
things the inspector will let somebody put in it. It is a declaration, never a value — nothing you write here
is evaluated, and none of it decides what the variable reads back. That is
ScriptVariableValue's job.
The ten types#
type |
What can be bound to it | Extra fields it accepts |
|---|---|---|
"object" |
one entity on the table | kind, tag |
"objectList" |
several entities on the table | kind, tag |
"spawnable" |
a standard-library preset, or a prefab authored in this project | — |
"zone" |
one seat zone | — |
"number" |
a number | default, min, max |
"string" |
text | default |
"boolean" |
true or false |
default |
"vector3" |
an [x, y, z] triple |
default (a Vec3) |
"color" |
a #rrggbb colour |
default (a #rrggbb string) |
"seat" |
a seat id | default |
label and description are accepted on every one of them: label is the row's caption and falls back to the
property name you declared it under, description is the row's hint.
How, why and when to use it#
Write the narrowest declaration that is still true. kind and tag are not decoration — they are what the
inspector uses to grey out the entities that cannot go in the slot, and they travel into the sandbox and are
re-checked on every read, so a wrongly-typed binding reads null instead of handing your script a card
where it expected a deck — and starts resolving if a matching entity later takes that id. kind also pays for itself at authoring time: declaring { type: "object", kind: "deck" } makes the
variable read as a DeckObject, so drawCard() is offered and a typo is caught in the editor.
Prefer a spawnable over a hard-coded presetId string whenever a script creates pieces. Both end up in the
same spawn intent, but the presetId string is a name only you know and it breaks silently when the project's
assets are re-authored; the spawnable is a row somebody can re-point without opening the script.
The scalar types exist for the numbers a script would otherwise carry as constants at the head of the file — how many cards to deal, how long to wait, which colour to tint something. Anything a table owner might reasonably want to change belongs in a declaration; anything that would break the rules of the game if it changed belongs in the code.
Gotchas#
A filter on the wrong type is a save-time diagnostic, not an ignored field. kind and tag are accepted
only on object and objectList, and min/max only on number. Writing { type: "string", max: 20 }
rejects the declaration, and the script keeps the declarations it last saved with.
default must match the declared type, and only the scalars have one. A default on an object,
objectList, spawnable or zone is meaningless — those read null (or []) when nothing is bound, and
there is nothing to substitute. A color default must be a full #rrggbb string; #fff does not parse.
kind is stricter here than ObjectKind looks. ObjectKind accepts any string so that a
future kind still type-checks, but a declaration's kind is validated against the nine real kinds
(card, deck, die, token, board, bag, custom, card-holder, button). A misspelled kind is a
diagnostic, not a filter that never matches.
min and max bound the editor, not the read. They constrain the inspector's numeric field. They are not
re-applied when the value reaches your script, so clamp anything you are about to loop over — the example on
declareVariables does.
Thirty-two declarations per script, and the whole literal is rejected past that. See
declareVariables for why the cap exists.
See also#
declareVariables— the call these specs go into.ScriptVariableValue— what eachtypereads back as.ObjectKind— the kinds akindfilter may name.SeatZoneInfo— what azonevariable resolves to.
ScriptVariableValue#
Surface A — table script · type
What one declared variable READS AS — the resolved value, not the binding.
Reference types resolve lazily, on every read: an object whose bound entity
is gone (deleted, never loaded, still inside a container) reads null rather
than handing back a stale handle or throwing, and an objectList simply
omits the entries that are not on the table right now. Declaring a kind
narrows the handle type too, so { type: "object", kind: "deck" } reads as
DeckObject | null and offers a deck's API.
declare type ScriptVariableValue<S> =
S extends { type: "object"; kind: infer K } ? (K extends ObjectKind ? ObjectHandleForKind<K> | null : ObjectHandle | null) :
S extends { type: "object" } ? ObjectHandle | null :
S extends { type: "objectList"; kind: infer K } ? (K extends ObjectKind ? ObjectHandleForKind<K>[] : ObjectHandle[]) :
S extends { type: "objectList" } ? ObjectHandle[] :
S extends { type: "spawnable" } ? SpawnObjectOptions | null :
S extends { type: "zone" } ? SeatZoneInfo | null :
S extends { type: "number" } ? number :
S extends { type: "boolean" } ? boolean :
S extends { type: "vector3" } ? Vec3 :
S extends { type: "string" | "color" } ? string :
S extends { type: "seat" } ? string | null :
never;
What one declared slot reads back as — the resolved value, not the binding. You never write this type; it is
how declareVariables turns each ScriptVariableSpec into the
property type your script actually sees, so { type: "object", kind: "deck" } reads as DeckObject | null and
offers a deck's API rather than a bare ObjectHandle's.
What each type reads as#
Declared type |
Reads as | When nothing usable is bound |
|---|---|---|
"object" |
ObjectHandle | null, narrowed to the declared kind |
null |
"objectList" |
ObjectHandle[], narrowed to the declared kind |
[], and entries that are not on the table are simply absent |
"spawnable" |
SpawnObjectOptions | null |
null |
"zone" |
SeatZoneInfo | null |
null |
"number" |
number |
the declared default, else 0 |
"string" |
string |
the declared default, else "" |
"boolean" |
boolean |
the declared default, else false |
"vector3" |
Vec3 |
the declared default, else [0, 0, 0] |
"color" |
string |
the declared default, else "#ffffff" |
"seat" |
string | null |
the declared default, else null |
How, why and when to use it#
The point of the table above is the right-hand column: a reference variable's normal, expected value is
null. Bound entities get deleted, drawn into a deck, put in a bag, or belong to a save that has not finished
loading. Rather than throw, or hand back a handle that addresses nothing, the read returns null — so a script
that checks is a script that keeps running, and a script that does not is one that fails at the worst moment.
Treat if (!vars.x) return; as the first line of anything that touches a reference variable, exactly as you
would treat the null from world.getObjectById.
The scalars have the opposite property: they never read null (except seat, which has no sensible empty
number to fall back on), so a number variable is safe to use in arithmetic without a guard. That asymmetry is
deliberate — it means an unconfigured script still runs with sensible values rather than dying on line one.
Gotchas#
The reads are synchronous, and they are live. Every property is a getter. Reading vars.landingBox twice
either side of an await can legitimately give you a handle and then null, because the entity left the table
in between. Read it once into a local when you need a stable answer for one operation, and re-read it on the
next.
A bound entity that no longer matches the declared filter reads null too. The kind/tag filter travels
to the sandbox beside the bound id and is re-checked on the same read as liveness, so re-tagging a piece can
empty a slot that the inspector still shows as bound — and re-tagging it back fills the slot again.
An objectList never contains holes. Missing entries are omitted, not left as null, so its length is the
number of entities you actually got — not the number somebody bound. If the count matters, compare it against
what you expect rather than assuming.
spawnable and zone read a fresh copy every time. Mutating what you read back changes nothing; spread it
into the call instead (world.spawnObject({ ...vars.dieTemplate, position })). A zone is likewise a snapshot
copy, with the same caveats as SeatZoneInfo.
A binding is never resolved once and frozen — it is lazy in both directions. The host passes the authored id
straight through, unchecked, with the declaration's kind/tag filter beside it; the sandbox checks liveness
and the filter on every read. So a bound entity that is not on the table when the script starts reads null
and then simply begins resolving the moment it arrives — a save finishing its load, a scripted spawn — with no
message from the host and no re-attach. One that leaves stops resolving just as quietly. An entity that arrives
under the bound id as the wrong kind still reads null, and the slot fills if a matching entity later takes
that id. Binding a piece a later save is expected to bring in is therefore fine; what is not fine is reading the
value once at load and caching it.
See also#
declareVariables— where these values come from.ScriptVariableSpec— the declaration each of these is derived from.ObjectHandle— what anobjectvariable hands you.SeatZoneInfo— what azonevariable hands you.- Async and snapshots — why a handle can go stale across an
await.
declareVariables#
Surface A — table script · const
Declare the typed slots this script needs filled in, and read back the resolved values.
Call it ONCE, at the top level, with an object literal — that literal is lifted out of the source when the script is saved, which is what lets the entity inspector render a row per variable without running anything. The values themselves come from the host, resolved from what somebody wired up in that inspector; the literal you pass is never evaluated for them.
Bindings belong to the ATTACHMENT, not to the script, so the same script on two entities reads two different sets of values.
declare const declareVariables: <T extends Record<string, ScriptVariableSpec>>(
declarations: T
) => { readonly [K in keyof T]: ScriptVariableValue<T[K]> };
Declares the typed slots this script needs filled in, and hands back an object whose properties read those slots' resolved values. The literal you pass is lifted out of the source when the script is saved, which is what lets the entity inspector render a row per variable without running anything; the values come from the host, resolved from whatever somebody wired up in that inspector.
Parameters#
| Name | Type | Required | Notes |
|---|---|---|---|
declarations |
Record<string, ScriptVariableSpec> |
yes | An object literal of object literals. Each key is a variable name (and the inspector row's fallback caption); each value is a ScriptVariableSpec naming its type and its filters. At most 32 entries. |
The argument is read by a literal parser, not evaluated — so a variable, a spread, a computed key, a template
with a substitution, a function call, or a second declareVariables() call anywhere in the file is a
save-time diagnostic rather than a run-time surprise. On a diagnostic the script keeps the declarations it
saved with last time, so a transient typo does not wipe out the bindings authored against them.
Returns#
An object with one readonly property per key you declared, typed by
ScriptVariableValue — { type: "object", kind: "deck" } reads as DeckObject | null,
{ type: "number" } as number, and so on.
Every property is a getter, and the read is synchronous — unusual on this surface, where nearly everything
that touches the table returns a Promise. Nothing is awaited because nothing is fetched: the host handed this
attachment's bindings to the sandbox before the script body ran, and the getter only decides whether what the
binding names is on the table — and still matches the declared kind/tag — at the moment you ask.
How, why and when to use it#
Reach for this the moment a script would otherwise hard-code a name. Without declared variables the only way to
find a specific piece is to search for it — world.getAllObjects({ tag: "landing-box" }), and hope nobody
renames or re-tags anything — and the only way to configure a script is to edit a constant at the head of the
file. Both work; both make the script the private property of whoever wrote it. A declared variable turns the
same script into something a non-programmer can wire up from the inspector: drag an entity onto the row, or
click it and pick from the entity tree.
The decisive property is that bindings belong to the attachment, not to the script. An object script's
bindings live on the attaching entity's metadata; a scene script's live in the scene document. Attach one
dice-roller script to three different buttons and each one throws its own dice into its own box, with no
copy-pasted script variants and no per-button if ladder. That is the thing a tag search cannot give you.
Keep using a world query when the set you want is genuinely dynamic — every card currently in play, every die
a player just spawned. Declare a variable when the answer is a decision somebody makes while authoring the
table, not something the script can work out at run time.
Example#
// content/scripting-api/examples/declarevariables.ts
// Roll a bound number of dice, from a bound die template, into a bound box.
// Nothing here names a piece: every reference is a slot somebody filled in from
// the entity inspector, so the same script on two entities can throw different
// dice into different boxes.
const vars = declareVariables({
dieTemplate: { type: "spawnable", label: "Die to spawn" },
landingBox: { type: "object", kind: "custom", label: "Landing box" },
count: { type: "number", label: "How many", default: 5, min: 1, max: 20 }
});
async function rollIntoBox(): Promise<void> {
// Both reference variables read null when nothing is bound yet, and again
// whenever the bound entity is not on the table - deleted, not loaded yet, or
// tucked inside a container. Re-read and re-check them on every run.
const template = vars.dieTemplate;
if (!template) {
world.log("No die bound yet - drop one on the 'Die to spawn' row in the inspector.");
return;
}
const box = vars.landingBox;
if (!box) {
world.log("The landing box is not on the table right now - skipping the roll.");
return;
}
const centre = box.position;
const howMany = Math.max(1, Math.round(vars.count));
for (let index = 0; index < howMany; index += 1) {
const angle = (index / howMany) * Math.PI * 2;
const die = await world.spawnObject({
...template,
position: [centre[0] + Math.cos(angle) * 0.4, centre[1] + 2, centre[2] + Math.sin(angle) * 0.4]
});
if (!die) {
world.log("spawnObject refused the bound template - check it still exists in the project.");
return;
}
die.roll();
}
world.log(`Threw ${howMany} dice into ${box.name ?? box.id}.`);
}
globalEvents.onTurnStarted.add(() => {
void rollIntoBox();
});
{ ...vars.dieTemplate, position } is the intended shape of a spawnable read: the template is exactly a
SpawnObjectOptions without the placement fields, so you spread it and add where the
piece should land.
Gotchas#
The same script on two entities reads two different sets of values. Bindings are per attachment. Do not
cache a resolved handle in a script-level variable and reuse it across attachments — read vars.x where you
need it, every time.
A script that declares variables must survive having none of them bound. Nothing forces an author to fill a
row in. Until somebody does, a reference variable reads null and a scalar reads its declared default, or the
type's empty value (0, "", false, #ffffff, [0, 0, 0]; seat reads null). Write the if (!x) return;
branch first, as the example does.
The literal is never evaluated for values. Editing a default in the source and not saving changes nothing
at run time, and neither does passing a different literal than the one the script was saved with — the sandbox's
declareVariables ignores its argument entirely and returns what the host resolved. That is deliberate: it is
what stops a script smuggling a value past resolution.
Only one call, and only a literal. The extractor accepts exactly one declareVariables( in the file and
refuses anything it cannot read without running it. Build the object inline; do not assemble it from constants,
however tempting ...COMMON_VARS looks.
Thirty-two is the ceiling, and it is all-or-nothing. A thirty-third declaration is a diagnostic that rejects
the whole extraction, not just the extra row. Bindings ride the entity's metadata, which replicates in every
snapshot delta — the cap exists so one script cannot inflate the table's wire traffic.
No new reach. A variable is an index, not an access grant: an object binding resolves to a handle the same
script could have obtained from a world query, and a spawnable feeds the ordinary spawn intent, still
validated host-side. Nothing here is a way around the sandbox.
See also#
ScriptVariableSpec— the ten declaration shapes and the filters each one accepts.ScriptVariableValue— what each declared type reads back as, and when it readsnull.world.spawnObject— where aspawnabletemplate goes.SpawnObjectOptions— the shape aspawnablereads as.- Host authority — why the values are resolved on one peer.
