Dicey Table

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#

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#

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#

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, split and combine. 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). delete looks like the same problem and is not: it is reachable, as destroy(). Until a method exists, model tapping as your own state — a tag, or saved data — and read it back with handle.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#

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#

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" — the delete action was applied (a menu, a peer, or a script's destroy()).
  • "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 own onObjectCreated.
  • "absorbed" — a combine folded the entity into a stack. context.containerId names 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#

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#

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

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

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

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#

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

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

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#

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

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#

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

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 writes label, and ObjectData.name reads label back (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 in metadata if 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

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

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

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

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#

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

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

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

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

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#

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#

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

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

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

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#

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

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

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#

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#

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

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#

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

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

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

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

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#

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#

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

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#

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#

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#