Dicey Table

World

world is the table itself, as a table script sees it. It is one of three globals injected into every table script — alongside globalEvents and, in an object script, refObject — and it is the only one that can reach entities your script did not receive from an event.

Ten methods, in four groups:

  • Find entities. getObjectById when you have an address, getAllObjects when you have a category.
  • Create entities. spawnObject, the only way a script adds a piece to the table mid-game.
  • Read the room. getPlayers and getTurn, both answered from state the host pushes into the sandbox.
  • Say things, remember things, pace things. log, broadcast, getSavedData, setSavedData, wait.

Two facts shape every entry on this page. A table script runs on the host peer only, so nothing here executes on a player's client and everything it changes reaches other people through the host's next snapshot. And the sandbox is a separate frame with no access to the table's memory, so every read is a request: the methods that return a Promise are asking the host a question and waiting for the answer.

world does not exist in a mod's runtime. Mod scripting has its own object, api, with a different shape and a capability model — see api.

Members below is the full signature table, with a linked entry for every method. It is generated from the declarations, so it cannot drift from them.

Where to start#

If you have never written a table script, spawnObject and log are enough to see something happen: spawn a die, log its id, press ▶ Play Scripts. From there, getAllObjects is what turns a script from "does one thing" into "knows what is on the table".

Two things world deliberately does not do. It cannot cancel anything a player did — there are no veto hooks, so every event is a notification of something that has already happened. And it cannot change an entity's tags, metadata or names after spawn; spawnObject is the only write for those.

See also#

World#

Surface A — table script · interface · 17 members

The table world.

Reached from a script as the injected global world.

declare const world: World;

The table world singleton.

world is the injected handle to the table. The sandbox builds exactly one World object when the frame boots, freezes it, and passes it as a function parameter to every compiled script it runs — not as a property of a global object. That is why it is in scope everywhere in your file, including at the top level before any handler has fired, and why every scene script and object script in a scene is holding the same object.

A table script is evaluated on the host peer and nowhere else, so a player's client never runs a line of it and every change it makes travels outward as the host's next snapshot. A mod cannot obtain a World at all: the two surfaces share no objects and no member names. Mod scripting reaches the table through api, which arrives as an argument to exports.setup and gates each call on a capability — a model World has no counterpart for.

See also#

  • ObjectHandle — the entity handle every world query hands back.
  • globalEvents — the second injected global, and the eighteen delegates on it.
  • api — the other surface's entry object.
  • Choosing a surface — which of the two your game belongs in.
  • Host authority — why one peer evaluates the script.

Members#

Signature Description Returns
getObjectById(id: string) Get a live handle for an object by id (null if it doesn't exist). Promise<ObjectHandle | null>
getAllObjects<K extends ObjectKind>(filter: { kind: K; tag?: string }) List objects, optionally filtered by kind and/or tag. Filtering on a single kind narrows the result to that kind's handle type, so getAllObjects({ kind: "deck" }) resolves DeckObject[]. Promise<ObjectHandleForKind<K>[]>
spawnObject(options: SpawnObjectOptions) Spawn a new object onto the table. Resolves with its handle. Promise<ObjectHandle | null>
getSeatZones(seat?: string) The table's seat zones, in world space — optionally just one seat's. Promise<SeatZoneInfo[]>
getSnapPoints() Every scene snap point on the table, with the labels authors gave them. Returns a snapshot copy; an empty array when the scene has none. Promise<SnapPointInfo[]>
getSnapPointAt(position: Vec3) The snap point a position belongs to — or null when it is inside no point's radius. Promise<SnapPointInfo | null>
getSeatVariable(name: string) The seat a declared SEAT VARIABLE currently holds, or null when it is unset. Promise<string | null>
setSeatVariable(name: string, seat: string | null) Set a declared seat variable, or clear it with null. Promise<void>
getPlayers() Players currently at the table. PlayerInfo[]
getTurn() Current turn state. TurnInfo
addObjectMenuItem(item: ObjectMenuItemRegistration) Add (or replace) a context-menu entry on entities this script cares about. void
removeObjectMenuItem(id: string) Remove one of this script's menu entries by id. Unknown ids are ignored. void
log(message: string) Write a line to the script console / event log. void
broadcast(message: string) Broadcast a chat message to all players (shown as coming from the table). void
getSavedData(key?: string) Table-scoped persisted script data (survives save/load). Promise<string | null>
setSavedData(value: string, key?: string) Promise<void>
wait(seconds: number) Wait, then resolve. Prefer this over setTimeout for game pacing. Promise<void>

world.getObjectById#

getObjectById(id: string): Promise<ObjectHandle | null>;
Badge Value
Authority host-only
Timing async
Capability none
Availability both

Get a live handle for an object by id (null if it doesn't exist).

Asks the host for one entity by its id and resolves a live handle to it. The sandbox requests the host's current snapshot, finds the entry whose id matches, and hands back the handle it keeps for that id — refreshed with the state the host just reported.

Parameters

Name Type Required Notes
id string yes The entity's address. Compared with === against each object's id after String(id) coercion, so the match is exact and case-sensitive. Whitespace is not trimmed: " abc" finds nothing.

Applicability: every object kind. kind plays no part in the lookup.

Returns

Promise<ObjectHandle | null>.

null means the host's current snapshot has no object with that id. Three things produce it: the entity was destroyed, the id never existed (a typo, or an id from a different table), or you are holding an id from a world.spawnObject call the host rejected. There is no other path to null — a valid id always resolves.

A non-null result is the same handle object every time for a given id. Call it twice for one id and you get one JavaScript object back both times, with its properties freshened. That is why keeping a handle in a variable and calling refresh() is equivalent to looking it up again, and cheaper to read.

How, why and when to use it

You stored an entity's id somewhere durable — in another entity's metadata, in a module-level variable, in the payload of an event you handled earlier — and now you need to act on that entity. world.getObjectById is the lookup for that case. The alternative most authors reach for is world.getAllObjects() and a find over the result, which costs the same round trip but makes you re-derive which entity you meant from tags or kind, and silently picks the wrong one when two entities match. Use getObjectById when you have the id; use getAllObjects when what you have is a category — "every deck", "everything tagged discard". If you already hold the handle, use handle.refresh() instead: same round trip, and it tells you directly whether the entity still exists.

Example

// content/scripting-api/examples/world.getObjectById.ts

// Scene script: remember one entity by id and act on it later. The id is the
// only thing that resolves an entity, so it is the thing worth holding on to.

let markerId: string | null = null;

globalEvents.onObjectCreated.add((object) => {
  if (object.tags.indexOf("turn-marker") >= 0) {
    markerId = object.id;
    world.log(`Tracking turn marker ${markerId}.`);
  }
});

async function moveMarker(peerId: string): Promise<void> {
  if (markerId === null) {
    world.log("No turn marker on the table yet.");
    return;
  }

  const marker = await world.getObjectById(markerId);
  if (marker === null) {
    world.log(`Turn marker ${markerId} no longer exists - clearing the id.`);
    markerId = null;
    return;
  }

  const seatIndex = world.getPlayers().findIndex((player) => player.peerId === peerId);
  const slot = Math.max(seatIndex, 0);
  marker.setPosition([slot * 2 - 3, 0.6, 4]);
  world.log(`Moved turn marker ${marker.id} to slot ${slot}.`);
}

globalEvents.onTurnStarted.add((turn) => {
  void moveMarker(turn.peerId);
});

With a turn-marker-tagged token on the table, the script console prints Tracking turn marker script-… once, then one Moved turn marker … to slot 0. line per turn.

Gotchas

Resolves after a round-trip to the host, so the value is the host's state at the moment it answered — not necessarily still true when your handler continues. Another player can pick the entity up, or delete it, between the await and the next line.

The whole snapshot crosses the sandbox boundary for one lookup. Every call serializes the host's full object list into the frame. Looking up ten ids is ten snapshots; await world.getAllObjects() once and filter locally when you need several.

An id you generated yourself is not proof of existence. world.spawnObject gives you a handle addressed by an id the sandbox invented, before the host has validated the spawn. If the host rejects it, this method resolves null for that id forever.

See also

world.getAllObjects#

getAllObjects<K extends ObjectKind>(filter: { kind: K; tag?: string }): Promise<ObjectHandleForKind<K>[]>;
getAllObjects(filter?: { kind?: ObjectKind; tag?: string }): Promise<ObjectHandle[]>;
Badge Value
Authority host-only
Timing async
Capability none
Availability both

List objects, optionally filtered by kind and/or tag. Filtering on a single kind narrows the result to that kind's handle type, so getAllObjects({ kind: "deck" }) resolves DeckObject[].

Asks the host for its current snapshot and resolves a handle for every entity that matches your filter. With no filter you get every entity on the table, in the host's snapshot order.

Parameters

Name Type Required Notes
filter { kind?: ObjectKind; tag?: string } no Omit it — or pass {} — to match everything.
filter.kind ObjectKind no Exact, case-sensitive === against the entity's kind. Anything that is not a string is ignored, so { kind: undefined } behaves as no kind filter. An unknown kind string is accepted and matches nothing.
filter.tag string no One tag. Matched with indexOf against the entity's tag list, so the comparison is exact and case-sensitive with no normalization: "Blue" does not find "blue", because stored tags are always lowercase. Anything that is not a string is ignored.

Both filters are ANDed. { kind: "die", tag: "scoring" } matches dice that carry scoring, and nothing else.

Applicability: every object kind is returned. The filter narrows the result; it never changes what a handle is.

Returns

Promise<ObjectHandle[]> — or, when you pass a single kind, Promise<ObjectHandleForKind<K>[]>.

A kind filter narrows the result type. getAllObjects({ kind: "deck" }) resolves DeckObject[], so deck.cards and deck.onDepleted are available without a cast; getAllObjects({ tag: "scoring" }) resolves plain ObjectHandle[], because no kind was named. The narrowing is the filter's own guarantee — the call already discards every other kind — and it is the way a scene script reaches kind-specific members. See Object Types.

Never null and never rejected — an unmatched filter resolves an empty array. Each element is the handle the sandbox keeps for that id, so calling this twice returns the same handle objects with freshened properties, not copies.

Order is the host's snapshot order, which is creation order for a table nothing has been removed from. It is not sorted and not stable across a delete, so never index into the result to identify a particular entity — filter by tag, or hold the id.

How, why and when to use it

You are writing a round-end scoring pass and need every die a player could have scored with: they were spawned at different times by different players, so there is no single id to hold. Tag them at spawn and ask for the tag. The alternative is world.getObjectById per entity, which needs you to have collected every id as it appeared and to keep that list correct through deletes — real bookkeeping for something the host already knows. Use getAllObjects when the thing you know is a category: a kind, or a tag you control. Use getObjectById when you know exactly which entity you mean, because it says directly whether that one still exists.

Example

// content/scripting-api/examples/world.getAllObjects.ts

// Scene script: report what is on the table each turn. One query with no
// filter, one by kind, one by tag - the only three shapes this method has.

const SCORING_TAG = "scoring-die";

async function reportTable(): Promise<void> {
  const everything = await world.getAllObjects();
  const decks = await world.getAllObjects({ kind: "deck" });
  const scoring = await world.getAllObjects({ tag: SCORING_TAG });

  world.log(`${everything.length} entities on the table, ${decks.length} of them decks.`);

  if (scoring.length === 0) {
    world.log(`Nothing carries the "${SCORING_TAG}" tag. Stored tags are lowercase and the match is exact.`);
    return;
  }

  const lockedCount = scoring.filter((handle) => handle.locked).length;
  world.log(`${scoring.length} scoring dice, ${lockedCount} of them locked.`);

  for (const handle of scoring) {
    const tagList = handle.tags.length > 0 ? handle.tags.join(", ") : "none";
    world.log(`  ${handle.id} (${handle.kind}) tags: ${tagList}`);
  }
}

globalEvents.onTurnStarted.add(() => {
  void reportTable();
});

void reportTable();

On a table with a deck and two tagged dice, the script console prints 3 entities on the table, 1 of them decks. followed by 2 scoring dice, 0 of them locked. and one line per die.

Gotchas

Resolves after a round-trip to the host, so the value is the host's state at the moment it answered — not necessarily still true when your handler continues. The array is a point-in-time answer; an entity in it can be gone by the time you act on it.

The tag match is raw, not normalized. Stored tags are lowercased [a-z0-9_-] strings. The filter compares your needle to them literally, so any capital letter, space or colon in the needle matches nothing. Write the needle exactly as the tag is stored, and prefer a const shared with the world.spawnObject call that set it.

Known gap. The table-script filter accepts one tag and matches it exactly. Mod scripting's api.listObjects accepts tag, tags and match, so a mod can ask for "all of these tags" or "any of these" and gets its needles normalized first (packages/shared/src/objectTags.ts, objectTagsMatch). Table scripting has no equivalent. The single-tag filter itself works correctly — this is a missing feature on one surface, not a broken one. Filter on one tag here and narrow the rest in your own code. See Known limitations.

Platform dt: tags never appear in handle.tags. They live on the engine entity, which no script can reach; ObjectData.tags carries the author tag set only. A tag filter can therefore never see or match one, and you do not need to skip them when you enumerate a handle's tags.

See also

world.spawnObject#

spawnObject(options: SpawnObjectOptions): Promise<ObjectHandle | null>;
Badge Value
Authority host-authoritative
Timing async
Capability none
Availability both

Spawn a new object onto the table. Resolves with its handle.

Adds a new entity to the table and hands you a live handle to it. The call builds a table object definition from your options, posts a spawn intent to the host, and returns a handle addressed by the id it just generated — so you can hold on to the entity and act on it without looking it up.

Parameters

One SpawnObjectOptions object. It is required; passing nothing resolves null.

Name Type Required Notes
kind ObjectKind yes Must be a non-empty string or the whole call resolves null. Accepted without change: card, deck, die, token, board, bag, custom, card-holder. Any other string is silently replaced with "custom" — no error, no log line.
name string no Becomes the entity's label — its slug and machine key — truncated to 80 characters. Defaults to the kind string, so an unnamed die gets the label die. It does not set displayName.
position Vec3 no Table-space [x, y, z] in feet. Defaults to [0, 1, 0], one foot above the table origin, so the entity drops onto the surface. A value that is not an array of at least three entries falls back to that default; individual non-finite numbers become 0.
rotation Vec3 no Euler angles in degrees. Omit it and the entity spawns unrotated. Same coercion rules as position, except that a malformed value is dropped rather than defaulted.
presetId string no A standard-library preset id such as die-d6 or deck-standard. Stored as metadata.standardPresetId. The runtime uses it to attach the preset's 3D model, and for kind: "die" its convex-hull collision shape. It does not copy the preset's other authored fields — color and scale still come from the kind defaults.
metadata Record<string, unknown> no Shallow-copied onto the definition. presetId is written into this bag last, so it wins over a standardPresetId you set here yourself.
tags string[] no Entries that do not match [a-z0-9_-]+ are dropped before the intent is sent, and the list is capped at 100. Platform dt: tags can never be written from a script — the pattern forbids :.

Applicability: every kind in the list above can be spawned. kind selects the entity's platform kind and nothing else about the call changes with it.

Returns

Promise<ObjectHandle | null>.

null means exactly one thing: the options were unusable — you passed no object, or kind was missing or not a non-empty string. Nothing was sent to the host. There is no other path to null.

Anything else resolves with a handle. The handle is built inside the sandbox from the definition that was just posted, carrying the id the sandbox generated, and the host honors that id when it creates the entity. So the handle is addressed correctly from the first tick — but it resolves before the host has validated or applied the spawn. A non-null handle means the request was well-formed, not the entity exists. If the host rejects the definition, a diagnostic appears in the script console and the handle goes on addressing nothing. await handle.refresh() resolves null in that case and is the only way to be sure.

How, why and when to use it

You are writing a scoring game, and each round needs a fresh die on the table: the entities a mod's setup.json places are the ones that exist at load, and after that only players can add anything. world.spawnObject is how a script adds a piece mid-game. The alternative most authors reach for first is to put every die the game could ever need into setup.json and hide the unused ones — that works, but it costs you an entity in every snapshot for the whole session and gives you nothing to hold a handle to. Spawn when the number of pieces depends on something you only learn at runtime: how many players sat down, which card was drawn, how many rounds are left. Pre-place in setup.json when the count is fixed and known while you are authoring, because pre-placed entities load with the scene and cost nothing to create.

Example

// content/scripting-api/examples/world.spawnObject.ts

// Scene script: give every seated player one scoring die, and keep the set in
// step as more players arrive. Spawning is how a script adds a piece mid-game.

const SCORING_DIE_TAG = "scoring-die";

async function dealScoringDice(): Promise<void> {
  const players = world.getPlayers();
  if (players.length === 0) {
    world.log("No players seated yet - holding off on scoring dice.");
    return;
  }

  const existing = await world.getAllObjects({ tag: SCORING_DIE_TAG });
  for (let index = existing.length; index < players.length; index += 1) {
    const player = players[index];
    const die = await world.spawnObject({
      kind: "die",
      name: `scoring-die-${index + 1}`,
      presetId: "die-d6",
      position: [index * 1.5 - 3, 1, 0],
      tags: [SCORING_DIE_TAG]
    });

    if (!die) {
      world.log("spawnObject refused the request: kind must be a non-empty string.");
      return;
    }

    // The handle is optimistic, so confirm the host really created the entity.
    const created = await die.refresh();
    world.log(created
      ? `Scoring die ${created.id} is ready for ${player.displayName ?? player.peerId}.`
      : `Scoring die ${die.id} was rejected by the host - see the script console.`);
  }
}

globalEvents.onPlayerJoined.add((player) => {
  world.log(`${player.displayName ?? player.peerId} sat down.`);
  void dealScoringDice();
});

void dealScoringDice();

With one player seated, the script console prints one line per die, for example Scoring die script-1f2e3d4c5b6a7089 is ready for Ada.

Gotchas

Resolves before the host has validated or applied the request. A non-null handle only tells you the options were well-formed. Await refresh() when it matters — as the example does — and treat a null from refresh() as "the host rejected this spawn, go read the script console."

name is the slug, not the display name. SpawnObjectOptions.name writes label, the machine key that identifies the entity to scripts, mods and — for cards — the hidden-information system. It is not the human name shown in the Hierarchy.

Known gap. A script cannot see or set displayName at all. ObjectData.name reads back label (apps/web/src/scripting/sandbox/tableScriptSandbox.html, stateToData), and SpawnObjectOptions has no displayName field, so an entity a script spawns has no human name until someone sets one in the editor. Spawning, labelling and reading the label all work correctly — only the second name is missing. See Known limitations.

An unrecognized kind becomes "custom" silently. There is no error and no console line, so a typo ("dice" instead of "die") produces a plain custom entity that never rolls. Read handle.kind back if the kind is coming from data rather than a literal.

The sandbox's tag filter is looser than the host's schema. A tag matching [a-z0-9_-]+ passes the in-frame filter at any length, but the host rejects any tag longer than 32 characters — and it rejects the whole spawn, not only the offending tag. Keep tags short, and if a spawn silently fails to appear, check the tags first.

The entity reaches other players with the next snapshot, not when the promise resolves. Scripts run on the host, so everything here is happening on one peer; every other player sees the new entity after the host's next broadcast. Never build a countdown or a reveal on the assumption that a spawn is visible everywhere the instant you get the handle. See Host authority.

See also

world.getSeatZones#

getSeatZones(seat?: string): Promise<SeatZoneInfo[]>;
Badge Value
Authority host-only
Timing async
Capability none
Availability both

The table's seat zones, in world space — optionally just one seat's.

This is the only way a script can find out WHERE a seat's zones are, which is what makes dealing to a specific zone possible at all. Returns a snapshot copy; zones do not change during a game unless the scene is re-authored.

The table's seat zones in world space, optionally narrowed to one seat.

How, why and when to use it

Seat zones are authored in the scene document and never cross the wire, so this is the only way a script can discover them — and therefore the only way to deal into a specific zone. Call it once at the start of a routine and match zones by name.

It is also how you tell which seats this scene actually supports: a seat with no zones has nowhere to put anything.

Gotchas

Returns zones for authored seats, occupied or not. Cross-check with getPlayers() before dealing.

Results are copies. Zones do not move during play, so caching for the length of a routine is fine; caching across a scene reload is not.

Example

// content/scripting-api/examples/world.getSeatZones.ts

// Scene script. Report which seats this scene actually supports.

globalEvents.onChatMessage.add((message) => {
  if (message.text.trim() === "!seats") {
    void reportSeats();
  }
});

async function reportSeats(): Promise<void> {
  const zones = await world.getSeatZones();
  const seats: string[] = [];
  for (const zone of zones) {
    // Skip TABLE zones — they are shared layout (a deck slot, a discard pile), not a seat.
    if (zone.seat !== null && seats.indexOf(zone.seat) === -1) {
      seats.push(zone.seat);
    }
  }
  world.log(`Scene supports ${seats.length} seat(s): ${seats.join(", ")}`);
}

See also

world.getSnapPoints#

getSnapPoints(): Promise<SnapPointInfo[]>;
Badge Value
Authority host-only
Timing async
Capability none
Availability both

Every scene snap point on the table, with the labels authors gave them. Returns a snapshot copy; an empty array when the scene has none.

Every scene snap point on the table, with the labels authors gave them.

Returns

Promise<SnapPointInfo[]> — a snapshot copy of each SnapPointInfo. An empty array when the scene has none.

How, why and when to use it

For anything that needs the board's layout as a whole — checking it is fully labelled, building a lookup from label to position so a script can place a piece on "e4", or counting how many squares a region has.

To ask which point one particular position is on, use world.getSnapPointAt instead: it applies the table's exact snap rule, which a script re-implementing it over this list would have to keep in step by hand.

Example

// content/scripting-api/examples/world.getSnapPoints.ts

// Scene script: check when the table loads that every snap point has been
// given a name, so a move log never falls back to printing "Snap point".
void checkLabels();

async function checkLabels(): Promise<void> {
  const points = await world.getSnapPoints();
  if (points.length === 0) {
    world.log("This scene has no snap points.");
    return;
  }
  // "Snap point" is the label the editor gives a point nobody has named yet.
  const unnamed = points.filter((point) => point.label === "" || point.label === "Snap point");
  if (unnamed.length > 0) {
    world.log(`${unnamed.length} of ${points.length} snap points still need a label.`);
    return;
  }
  world.log(`All ${points.length} snap points are labelled.`);
}

The log reports how many snap points still carry the editor's default name.

Gotchas

It is a copy. Moving a snap point in the editor does not update an array you already hold; call it again.

Only scene snap points are listed — the ones placed with the editor's snap-point tool. A board defining its squares through metadata.snapPoints or snapGrid contributes nothing here.

See also

world.getSnapPointAt#

getSnapPointAt(position: Vec3): Promise<SnapPointInfo | null>;
Badge Value
Authority host-only
Timing async
Capability none
Availability both

The snap point a position belongs to — or null when it is inside no point's radius.

This is the SAME rule the table uses to decide where a dropped piece lands: the nearest point whose snapRadius contains the position, measured in plan view (height is ignored). So asking about a piece's position after onObjectDropped names exactly the square it was snapped to.

The scene snap point a position belongs to — the square a piece is on — or null when the position is inside no point's radius. This is how a script turns a pair of coordinates into a name a player can read.

Parameters

Parameter Type Notes
position Vec3 World-space [x, y, z]. Only x and z are used.

Returns

Promise<SnapPointInfo | null> — a SnapPointInfo copy, or null.

How, why and when to use it

Use it wherever a rule or a log needs to name a place rather than a coordinate: "from e2 to e4", "played to the discard", "moved onto Boardwalk". Pass it an entity's position from any event.

It answers with the table's own snap rule — the nearest point whose snapRadius contains the position, in plan view — which is the same function that decided where a dropped piece landed. So after onObjectDropped, the point you get back is the one the piece was pulled to, not a near neighbour of it.

Example

// content/scripting-api/examples/world.getSnapPointAt.ts

// Scene script: a move log that reads like a scoresheet - "Matt moved White
// Pawn from e2 to e4" - using the labels authors gave the board's snap points.
//
// getSnapPointAt answers with the SAME rule the table snaps a dropped piece
// with, so the square named here is the square the piece actually landed on.

// Where each piece was when it was lifted. The position is copied at pickup
// and only RESOLVED on drop, so no lookup can still be in flight when the
// piece lands.
const liftedFrom = new Map<string, Vec3>();

globalEvents.onObjectPickedUp.add((object) => {
  liftedFrom.set(object.id, object.position);
});

globalEvents.onObjectDropped.add((object, context) => {
  // A script moving a piece (a capture sweeping it off the board) is not a
  // player's move; "Script" is the documented actor for those.
  if (context.actor === "Script") {
    return;
  }
  void logMove(object, context);
});

async function logMove(object: ObjectHandle, context: EventContext): Promise<void> {
  const start = liftedFrom.get(object.id);
  liftedFrom.delete(object.id);
  const from = start === undefined ? null : await world.getSnapPointAt(start);
  const to = await world.getSnapPointAt(object.position);

  // Picked up and set back down on the same square is not a move.
  if (from !== null && to !== null && from.id === to.id) {
    return;
  }

  // context.actor is a peer id; turn it into the name players actually see.
  const player = world.getPlayers().find((entry) => entry.peerId === context.actor);
  const who = player?.displayName ?? context.actor;
  const piece = object.name ?? object.kind;
  world.log(`${who} moved ${piece} from ${from?.label ?? "off the board"} to ${to?.label ?? "off the board"}`);
}

Each move appears in the log as a line like "Matt moved White Pawn from e2 to e4".

Gotchas

Height is ignored. Snap points are a plan-view idea, and a piece resting on a board sits well above a point authored at the table surface — comparing Y would make every square miss.

null means "on no snap point", not "off the table". A piece dropped between two squares, or anywhere on a table with no snap points, resolves to null.

Capture the position at pickup, resolve it later. object.position on a handle is a copy taken when the event fired, so storing it and resolving on drop is race-free. Starting a lookup at pickup and reading its result on drop is not.

Only scene snap points count. A board that defines squares through its own metadata.snapPoints or snapGrid has no labels to report, and those squares are not considered here.

See also

world.getSeatVariable#

getSeatVariable(name: string): Promise<string | null>;
Badge Value
Authority all-peers
Timing async
Capability none
Availability both

The seat a declared SEAT VARIABLE currently holds, or null when it is unset.

A seat variable is the scene's named "which player is this?" slot — "activePlayer", "dealer", "startingPlayer". A TABLE zone can bind its owner to one, so moving the variable moves the zone's ownership: what it conceals, who may act inside it, and what colour it draws.

Reading an UNDECLARED name returns null rather than throwing — the same answer as "declared but unset", because a script should degrade rather than crash when a scene is re-authored underneath it.

The seat a declared seat variable currently holds, or null when it is unset.

How, why and when to use it

A seat variable is the scene's named "which player is this?" slot — activePlayer, dealer, startingPlayer. Authors declare them in Edit Mode, and a table zone can bind its owner to one, so moving the variable moves that zone's ownership: what it conceals, who may act inside it, and the colour it draws.

Read it when a rule depends on whose turn it is but you do not want to duplicate the turn state in setSavedData. The variable is the one copy both your script and the table's zones agree on.

const active = await world.getSeatVariable("activePlayer");
if (active === null) {
  world.log("No active player yet.");
}

Gotchas

An undeclared name returns null rather than throwing, and so does a declared variable that has never been set — the two are indistinguishable here on purpose, so a script degrades instead of crashing when a scene is re-authored underneath it. If you need to know a variable exists, declaring it is the author's job, not something to probe for at runtime.

null is a real answer, not an error. A table zone bound to an unset variable is unowned, which for a hidden zone means it conceals from everyone.

Example

// content/scripting-api/examples/world.getSeatVariable.ts

// Scene script. Refuse a chat command unless the sender holds the active seat.

globalEvents.onChatMessage.add((message) => {
  if (message.text.trim() === "!end-turn") {
    void endTurn(message.peerId);
  }
});

async function endTurn(peerId: string): Promise<void> {
  const active = await world.getSeatVariable("activePlayer");
  if (active === null) {
    world.log("The round has not started yet.");
    return;
  }
  const players = world.getPlayers();
  const sender = players.filter((player) => player.peerId === peerId)[0];
  if (!sender || sender.seat !== active) {
    world.log("Only the active player may end the turn.");
    return;
  }
  world.log(`${sender.displayName ?? sender.peerId} ended their turn.`);
}

See also

world.setSeatVariable#

setSeatVariable(name: string, seat: string | null): Promise<void>;
Badge Value
Authority host-only
Timing async
Capability none
Availability both

Set a declared seat variable, or clear it with null.

Host-authoritative and refused on a peer, like every other mutating call. An UNDECLARED name is refused too — declare the variable in the scene's Seat Variables panel first, so the zone Inspector can offer it as an owner.

Clearing is meaningful: a zone bound to a cleared variable is UNOWNED, not still owned by whoever held it last. An unowned hidden zone conceals from everyone.

Set a declared seat variable, or clear it by passing null.

How, why and when to use it

This is how a script passes the turn. A table zone whose owner is bound to the variable changes hands the moment this resolves: its tint follows the new owner, an owner-seat-only interaction rule starts admitting them instead, and a hidden zone starts concealing from everyone but them.

await world.setSeatVariable("activePlayer", "blue");

Because the ownership lives on the variable rather than on the zone, one authored zone covers every player in the rotation — you do not author a staging area per seat and hide seven of them.

Gotchas

Host-only. Like every mutating call it is refused on a peer, silently, so a demoted host's in-flight handler degrades instead of throwing.

The name must be declared. An undeclared name is refused, not created: the declarations are what Edit Mode's owner dropdown is built from, so an ad-hoc variable would own zones that no authoring surface can show or fix.

Clearing is meaningful. null makes a bound zone unowned — not "still owned by whoever held it last". An unowned hidden zone conceals its contents from every player including the one who just released it, which is usually what you want between rounds and occasionally a surprise.

Re-setting the value a variable already holds is a no-op and costs no snapshot, so a script may assert the current holder freely.

Example

// content/scripting-api/examples/world.setSeatVariable.ts

// Scene script. Rotate `activePlayer` through the seated players on `!next`.

globalEvents.onChatMessage.add((message) => {
  if (message.text.trim() === "!next") {
    void passTurn();
  }
});

async function passTurn(): Promise<void> {
  const seats: string[] = [];
  const players = world.getPlayers();
  for (const player of players) {
    if (player.seat && seats.indexOf(player.seat) === -1) {
      seats.push(player.seat);
    }
  }
  if (seats.length === 0) {
    world.log("Nobody is seated.");
    return;
  }
  const current = await world.getSeatVariable("activePlayer");
  const index = current === null ? -1 : seats.indexOf(current);
  const next = seats[(index + 1) % seats.length];
  await world.setSeatVariable("activePlayer", next);
  world.log(`It is now ${next}'s turn.`);
}

See also

world.getPlayers#

getPlayers(): PlayerInfo[];
Badge Value
Authority host-only
Timing sync
Capability none
Availability both

Players currently at the table.

Returns the peer roster the host last pushed into the script sandbox. The sandbox keeps that list in memory and hands back a fresh array copy, so the call is a plain memory read — no request, no wait.

Returns

PlayerInfo[]. A new array each call; the PlayerInfo entries inside it are the objects the host sent and are not copied.

Each entry carries peerId, displayName (null when the peer never set one), seat (null when they are not seated), team (null when they are not on one) and isHost.

The roster includes the local participant — the machine the scripts are running on — so at a real table it is never empty and exactly one entry has isHost: true. It is seeded from the host's callbacks when the script host starts (apps/web/src/scripting/TableScriptHost.ts, start), so it is already populated on the first line your script runs, and refreshed whenever the roster changes.

An empty array means there is no local peer id yet — a table still connecting.

How, why and when to use it

You are dealing an opening hand and need to know how many people are actually sitting down, because dealing five cards each to four seats is a different script from dealing to two. world.getPlayers is the roster read, and it is synchronous, so you can branch on it in the middle of an event handler — or at the top of your script, before anything has happened — without an await. The alternative is to count globalEvents.onPlayerJoined and onPlayerLeft yourself into a local array, which works but starts empty for everyone who was already at the table when your script loaded and drifts the first time you miss an event. Track joins and leaves when you need to react to a change; call getPlayers when you need the current answer.

It is also how you turn an EventContext.actor peer id into a name worth putting in a broadcast.

Example

// content/scripting-api/examples/world.getPlayers.ts

// Scene script: keep a seat report in the script console. getPlayers reads the
// roster the host pushed into the sandbox - including the local participant -
// so there is nothing to await and it is populated before the first line runs.

function describe(player: PlayerInfo): string {
  const name = player.displayName ?? player.peerId.slice(0, 8);
  const seat = player.seat ?? "no seat";
  const team = player.team ?? "no team";
  return `${name} (${seat}, ${team})`;
}

function reportRoster(reason: string): void {
  const players = world.getPlayers();
  if (players.length === 0) {
    world.log(`${reason}: the roster is empty - this table has no local peer id yet.`);
    return;
  }

  world.log(`${reason}: ${players.length} peer(s) in the roster.`);
  for (const player of players) {
    world.log(`  ${describe(player)}`);
  }
}

globalEvents.onPlayerJoined.add((player) => {
  reportRoster(`${player.peerId.slice(0, 8)} joined`);
});

globalEvents.onPlayerLeft.add((player) => {
  reportRoster(`${player.peerId.slice(0, 8)} left`);
});

globalEvents.onSeatChanged.add((change) => {
  reportRoster(`${change.peerId.slice(0, 8)} changed seat`);
});

reportRoster("Script loaded");

At load on a solo table the script console prints Script loaded: 1 peer(s) in the roster. and one indented line for the local participant. When someone joins it prints a1b2c3d4 joined: 2 peer(s) in the roster.

Gotchas

The array is a copy; the entries are not. Mutating the array you get back is safe and pointless. The PlayerInfo objects inside it are shared with the sandbox's stored roster, and writing to one changes nothing on the table.

You are in the list. The roster is assembled by the app from the room's peer list plus the local participant (apps/web/src/ui/scriptPlayers.ts, buildScriptPlayers), because the signaling layer's peer list deliberately excludes the peer reading it. A script counting "other players" has to filter your own peer id out — or filter on isHost, since a table script always runs on the host.

In Edit Mode the roster is a single synthetic entry. Pressing ▶ Play Scripts pushes exactly one player — peerId "editor", displayName "Editor", no seat, no team, isHost: true — so seat-dependent logic cannot be exercised there. Test seating at a real table.

"Player" here means connected peer, not seated player. Spectators and seatless participants are in the array with seat: null. Filter on seat when you mean the people actually playing.

See also

world.getTurn#

getTurn(): TurnInfo;
Badge Value
Authority host-only
Timing sync
Capability none
Availability both

Current turn state.

Returns the turn state the host last pushed into the script sandbox: whether turn order is switched on at all, and which peer's turn it currently is. Like the roster, this is a memory read inside the frame — no request, no wait.

Returns

TurnInfo, a fresh object built on every call.

Field Type Meaning
enabled boolean Whether the table is running turn order. false means nobody is "on turn" and the whole notion is off — not that it is somebody's turn and you don't know whose.
activePeerId string | null The peer whose turn it is. null whenever no peer holds the turn, including whenever enabled is false.

The two are independent reads, so check enabled first: { enabled: false, activePeerId: null } and { enabled: true, activePeerId: null } mean different things and deserve different handling.

How, why and when to use it

A player picks up a piece and you want to say something if it is not their turn. world.getTurn answers that in one synchronous call inside the onObjectPickedUp handler, which is where the decision has to happen. The alternative is to track the turn yourself from globalEvents.onTurnStarted and onTurnEnded — that works, and you need those events anyway to react to the change, but a script that loads mid-game has missed the onTurnStarted that set the current turn and will be wrong until the next one. Use the events for "the turn just changed"; use getTurn for "whose turn is it right now".

Nothing a script can do stops an out-of-turn action — there is no veto hook — so this is for reporting and for driving your own state, not for enforcement. The host's own participant gate is what actually blocks a player who is not on turn.

Example

// content/scripting-api/examples/world.getTurn.ts

// Scene script: call out anyone who moves a piece out of turn. getTurn reads
// the turn state the host last pushed into the sandbox - no await, no request.

function turnDescription(): string {
  const turn = world.getTurn();
  if (!turn.enabled) {
    return "turn order is off";
  }
  return turn.activePeerId === null
    ? "turn order is on but nobody is active"
    : `it is ${turn.activePeerId.slice(0, 8)}'s turn`;
}

globalEvents.onObjectPickedUp.add((handle, context) => {
  const turn = world.getTurn();
  const actor = context.actor.slice(0, 8);

  if (!turn.enabled || turn.activePeerId === null || context.actor === turn.activePeerId) {
    world.log(`${actor} picked up ${handle.id} (${turnDescription()}).`);
    return;
  }

  world.log(`Out of turn: ${actor} moved ${handle.id} while ${turnDescription()}.`);
  world.broadcast(`${actor}, it is not your turn.`);
});

globalEvents.onTurnStarted.add((turn) => {
  world.log(`Turn started for ${turn.peerId.slice(0, 8)} - ${turnDescription()}.`);
});

world.log(`Script loaded - ${turnDescription()}.`);

On a table with turn order off, the script console prints Script loaded - turn order is off. and one … picked up … line per grab.

Gotchas

It is correct on the first line your script runs. The script host reads its getTurn callback while starting, rather than waiting for the embedder's first context push (apps/web/src/scripting/TableScriptHost.ts, start), so a script that loads into a game already in progress gets the real turn state at load and not { enabled: false, activePeerId: null }. After that it is refreshed by the host's context push whenever the seats, roster or turn change.

It is a copy, refreshed on change — not a live read. Between pushes the value cannot go stale in any way that matters (nothing but a turn change alters it), but the object you get back is rebuilt per call, so holding one tells you nothing later.

In Edit Mode this is hard-coded off. ▶ Play Scripts pushes { enabled: false, activePeerId: null } and never changes it, so turn-order branches are unreachable there. Test them at a real table.

activePeerId is a peer id, not a seat. Compare it against context.actor from an event or peerId from world.getPlayers(); there is no seat in TurnInfo. globalEvents.onTurnStarted carries the seat and team if you need them.

See also

world.addObjectMenuItem#

addObjectMenuItem(item: ObjectMenuItemRegistration): void;
Badge Value
Authority host-only
Timing sync
Capability none
Availability both

Add (or replace) a context-menu entry on entities this script cares about.

Replacing is by id: registering the same id again overwrites the previous entry, so a relabel is one more call rather than a remove-then-add.

Host-authoritative like every other write, and refused on a peer — but the ENTRY it creates is replicated, so every player sees it and any of them can click it. The handler always runs on the host.

Adds an entry to the right-click context menu of the entities you name, and calls you back through globalEvents.onObjectMenuItem when a player clicks it. This is how a script gives a game its own verbs — Promote, Rally, Reveal to owner — instead of inventing a floating panel for them.

Registering the same id twice replaces the entry, so relabelling one is a second call rather than a remove-then-add.

Parameters

One ObjectMenuItemRegistration:

Field Type Notes
id string Your id, unique within this script. Handed back to the handler; the key removeObjectMenuItem takes.
label string The caption on the button.
match object (optional) Which entities it appears on. Omit for every entity.
match.objectIds string[] Only these entities.
match.kinds ObjectKind[] Only these kinds.
match.tags string[] Only entities carrying these tags.
match.tagMatch "all" | "any" How tags is read. "all" is the default.
danger boolean Draw it as destructive, like Delete.
order number Sort order among script items. Built-in actions always come first.

match is AND across the fields you set, OR within objectIds and kinds, and tagMatch decides within tags. So { kinds: ["token"], tags: ["pawn", "white"] } means a token carrying both of those tags.

How, why and when to use it

Reach for this instead of spawning button entities when the verb belongs to a piece rather than to a place on the table. A menu entry costs nothing until someone opens the menu, it cannot be knocked over or dragged off the table, and it appears on every matching entity at once — including entities spawned long after you registered it, because match is evaluated when the menu opens, not when you call this.

Example

// content/scripting-api/examples/world.addObjectMenuItem.ts

// Scene script: add a "Promote to Queen" entry to the context menu of every pawn,
// and act on it when a player clicks.
//
// `match` is DATA, not a callback. Table scripts run on the host alone, but the
// context menu is drawn on every peer - so the peer deciding whether to show your
// item is not running your script. The table evaluates this filter for you, on
// every peer, against replicated state.
world.addObjectMenuItem({
  id: "promote-queen",
  label: "Promote to Queen",
  match: { tags: ["pawn"] }
});

// A second entry on the SAME entities. Items are ordered among themselves by
// `order`, and always drawn below the built-in actions - a script cannot displace
// Flip or Delete.
world.addObjectMenuItem({
  id: "promote-knight",
  label: "Promote to Knight",
  match: { tags: ["pawn"] },
  order: 1
});

// One handler for every entry this script registered; the second argument is the
// id you chose. Fires on the host wherever the click happened, and only for an
// entity the item genuinely matches - the host re-checks before dispatching.
globalEvents.onObjectMenuItem.add((object, itemId, context) => {
  if (itemId !== "promote-queen" && itemId !== "promote-knight") {
    return;
  }
  // `match` cannot express "only on the back rank" - it is a static filter, not a
  // predicate over game state. Conditions like this belong in the handler.
  if (object.position[2] < 0.8) {
    world.log(`${context.actor} tried to promote off the back rank.`);
    return;
  }
  const piece = itemId === "promote-queen" ? "queen" : "knight";
  world.broadcast(`${object.name ?? "A pawn"} is promoted to a ${piece}.`);
});

// Registrations are replicated table state, so they outlive nothing but the
// script that owns them: drop one when its rule stops applying.
globalEvents.onTurnEnded.add(() => {
  world.removeObjectMenuItem("promote-knight");
});

Right-clicking any pawn now shows both promotion entries under the built-in actions, on every player's screen.

Gotchas

match is a filter, not a predicate. There is deliberately no "show this when this function returns true", and that is not an oversight. Table scripts run on the host alone, while the context menu is drawn on every peer — so the peer that must decide whether to show your item is not running your script, and a function could not cross the sandbox boundary to reach it anyway. Conditions that depend on game state (only on the back rank, only on your turn) belong in the handler, which does run on the host and can see everything.

Registering is a host-authoritative write. Called from a demoted peer it is refused silently, like every other mutation. The entry it creates is replicated, though — that is the whole point, and it is why every player sees it and any of them can click it.

Script entries are always drawn below the built-in actions, and order sorts only among your own. A script cannot move, hide or replace Flip, Lock or Delete.

There is a cap of 100 entries per table, across every script. Re-registering an existing id still works at the cap; a new one is dropped.

An invalid registration is dropped, not thrown. An empty id or label raises an author diagnostic in the Script Errors tab; one that fails schema validation (an over-long label, an unknown kind) is discarded silently rather than being allowed to poison the replicated snapshot for every peer at the table.

See also

world.removeObjectMenuItem#

removeObjectMenuItem(id: string): void;
Badge Value
Authority host-only
Timing sync
Capability none
Availability both

Remove one of this script's menu entries by id. Unknown ids are ignored.

Removes one of this script's context-menu entries by the id you gave it. Unknown ids are ignored, so calling this for an entry you never registered — or registered and already removed — is safe.

Parameters

Parameter Type Notes
id string The id you passed to addObjectMenuItem.

How, why and when to use it

Use it when a verb stops applying to the game rather than to one entity: a phase ends, a variant is switched off, a one-shot action has been taken.

To stop offering an entry on particular entities, change what match selects instead — re-register the same id with a narrower filter, or maintain the tag it matches on. Removing and re-adding as entities come and go is the slow way round, and it churns the replicated snapshot each time.

Example

// content/scripting-api/examples/world.removeObjectMenuItem.ts

// Scene script: an entry that exists only while a phase does.
//
// Removing is by the id you registered, and an unknown id is ignored - so this is
// safe to call whether or not the entry is currently registered.
globalEvents.onTurnStarted.add(() => {
  world.addObjectMenuItem({
    id: "end-phase-discard",
    label: "Discard for the turn",
    match: { kinds: ["card"] },
    danger: true
  });
});

globalEvents.onTurnEnded.add(() => {
  // The verb stopped applying to the GAME, so the entry goes. To stop offering it
  // on particular entities instead, narrow `match` rather than removing it.
  world.removeObjectMenuItem("end-phase-discard");
});

globalEvents.onObjectMenuItem.add((object, itemId) => {
  if (itemId === "end-phase-discard") {
    object.destroy();
  }
});

See world.addObjectMenuItem for the registration side of the same pair.

Gotchas

You can only remove your own. An entry is owned by the script realm that registered it, and that scope is stamped by the host rather than taken from your call — so naming another script's id removes nothing.

Host-authoritative, and refused silently on a demoted peer, like every other write.

Stopping the script removes its entries. You do not have to clean up on teardown; this is for entries that should go away while the script keeps running.

See also

world.log#

log(message: string): void;
Badge Value
Authority host-only
Timing sync
Capability none
Availability both

Write a line to the script console / event log.

Writes one line to the script console. This is the only output a table script has: there is no console binding in the sandbox, so world.log is how you see what your script did.

Parameters

Name Type Required Notes
message string yes Coerced with String() before it leaves the sandbox, so any value is accepted and none of them throw. null becomes "null", undefined becomes "undefined", and a plain object becomes "[object Object]" — call JSON.stringify yourself when you want to see inside one. There is no length cap and no truncation.

Applicability: the message is plain text everywhere it lands. There is no formatting, no severity level and no per-kind behavior.

How, why and when to use it

You are working out why a deck never shuffles, and you want to see the branch your handler actually took. world.log is the debugging line for that — it goes to the console strip under the Edit Mode viewport while you are authoring, and to the host's Activity Log at a real table. The alternative is world.broadcast, which is what most authors reach for first because it is visible without opening a panel — but it puts your debugging text in every player's chat, and you will ship it by accident. Use log for anything written for you; use broadcast only for text a player at the table is meant to read.

Example

// content/scripting-api/examples/world.log.ts

// Scene script: a running trace of what the table is doing, written to the
// script console. Only this peer sees these lines - they are not replicated.

let actionCount = 0;

world.log("Trace script loaded.");

globalEvents.onObjectAction.add((handle, action, context) => {
  actionCount += 1;
  world.log(`#${actionCount} ${action} on ${handle.kind} ${handle.id} by ${context.actor}`);
});

globalEvents.onObjectCreated.add((handle, context) => {
  world.log(`created ${handle.kind} with label "${handle.name ?? "(none)"}" by ${context.actor}`);
});

globalEvents.onObjectDestroyed.add((objectId, context) => {
  world.log(`destroyed ${objectId} by ${context.actor}`);
});

globalEvents.onChatMessage.add((message) => {
  world.log(`chat from ${message.displayName ?? message.peerId}: ${message.text}`);
});

globalEvents.onTurnEnded.add((turn) => {
  world.log(`turn ended for ${turn.peerId}; ${actionCount} action(s) seen so far`);
});

Loading the script prints Trace script loaded.; flipping a card then prints #1 flip on card script-1f2e3d4c5b6a7089 by a1b2c3d4.

Gotchas

Nothing you log reaches another player. Scripts run on the host, and the line is written to that browser's local state and nowhere else — it is not replicated, not persisted, and not in any save. A player watching the same table sees no trace of it. If a player needs to know something, world.broadcast is the method.

The two places it lands look different, and both are capped. In Edit Mode the line appears in the script console strip under the viewport, tagged as a log line, keeping the most recent 200 entries. At a live table it appears in the Activity Log's Events tab, attributed to System and prefixed [script], keeping the most recent 60 entries — newest first, and shared with every other table event. A chatty onTick handler will push everything else out of that list within seconds.

Logging is not free at tick rate. Each call is a message across the sandbox boundary and a React state update on the host. One line per user action is fine; one line per tick is not.

See also

world.broadcast#

broadcast(message: string): void;
Badge Value
Authority host-authoritative
Timing sync
Capability none
Availability both

Broadcast a chat message to all players (shown as coming from the table).

Posts a chat message to everyone at the table, attributed to Table on every peer. The host adds the line to its own chat panel and sends it over the data channel marked via: "script", and every receiver renders it under that name rather than under the host player's — provided the sender really is the room host, which is the only place table scripts run (apps/web/src/ui/chatAttribution.ts, resolveChatAuthorLabel).

This is the one thing in world that reaches other peers without going through the table snapshot: it emits no intent and changes nothing about the table, but the text lands on every client.

Parameters

Name Type Required Notes
message string yes Coerced with String() before it leaves the sandbox, so nothing throws. null becomes "null" and an object becomes "[object Object]". No length cap, no truncation, and no formatting or markup — the text arrives verbatim.

Applicability: every peer connected to the room receives it. There is no way to address one player, one seat or one team — a broadcast is a broadcast.

How, why and when to use it

Your script just dealt the opening hands and the table needs to know it happened, because nothing about a card appearing in a hand explains why it appeared. world.broadcast is how a script speaks to the table. The alternative is world.log, which most authors try first and which is the right call for anything you are writing for yourself — but it never leaves the host's browser, so a player will never see it. The dividing line is simple: if a person at the table is supposed to read it, broadcast it; if you are the only intended reader, log it. For anything that must survive a reload or drive later logic, neither is right — chat is not state.

Example

// content/scripting-api/examples/world.broadcast.ts

// Scene script: tell the table what just happened. broadcast reaches every
// player's chat panel; world.log only reaches this peer's script console.

let round = 0;

function startRound(): void {
  round += 1;
  world.log(`Round ${round} starting.`);
  world.broadcast(`Round ${round} - everyone draw one card.`);
}

globalEvents.onTurnStarted.add((turn) => {
  world.broadcast(`It is ${turn.seat ?? turn.peerId.slice(0, 8)}'s turn.`);
});

globalEvents.onDiceRolled.add((handle, value, context) => {
  const who = context.actor.slice(0, 8);
  world.broadcast(value === null
    ? `${who} rolled ${handle.name ?? handle.kind} and it landed cocked.`
    : `${who} rolled a ${value}.`);
});

globalEvents.onPlayerJoined.add((player) => {
  world.broadcast(`Welcome, ${player.displayName ?? "player"}.`);
  world.log(`Greeted ${player.peerId}.`);
});

startRound();

At load, every player's chat shows Round 1 - everyone draw one card. and the host's script console additionally shows Round 1 starting.

Gotchas

A broadcast is not table state. It reaches every peer, but nothing about it is stored in the snapshot, saved with the table, or replayed to someone who joins afterwards. A player who arrives one line later has no way to read it. Anything a late joiner needs must live on the table — a text label, a tag, an entity — not in chat.

The Table attribution is only honoured from the host. The via: "script" flag rides the wire message, and a receiver applies it only when the sender is the room's host — anyone else's via: "script" renders as ordinary chat from that peer. That is deliberate: scripts run host-only, so a via: "script" from a player would be a forged table announcement.

Your own broadcast does not come back to you as a chat event. globalEvents.onChatMessage fires for messages arriving from other peers. A script that broadcasts inside its own onChatMessage handler therefore cannot loop on itself — but it also cannot observe its own output.

In Edit Mode nothing is sent. ▶ Play Scripts has no peers and no chat panel, so the text is written to the script console strip, marked as a broadcast, and goes no further. Verify wording there; verify delivery at a real table.

Chat keeps the most recent 120 lines. A script that broadcasts on every tick will push the players' conversation out of the panel.

See also

world.getSavedData#

getSavedData(key?: string): Promise<string | null>;
Badge Value
Authority host-only
Timing async
Capability none
Availability both

Table-scoped persisted script data (survives save/load).

Reads one table-scoped string your scripts previously stored. The sandbox asks the host for the value under world:<key>; the host looks it up in the table's saved-data store and answers.

Parameters

Name Type Required Notes
key string no Names one slot. Anything that is not a string — including omitting it — becomes "", so getSavedData() and getSavedData("") read the same unnamed slot. The key is not trimmed, lowercased or otherwise normalized, so "Score" and "score" are different slots.

Applicability: the value is scoped to the whole table, not to any entity. Use ObjectHandle.getSavedData for a value that belongs to one entity.

Returns

Promise<string | null>.

null means no value is stored under that key. Values are always strings — store numbers and structures with String() or JSON.stringify and parse them back yourself, checking the result, because a string written by an older version of your script is exactly as likely as a well-formed one.

The promise rejects if the host refuses the request.

How, why and when to use it

Your game has a round counter, and a table that is saved and reopened next week should carry on at round seven rather than round one. A module-level variable does not survive that — the sandbox is torn down and rebuilt with the table — so the counter has to be read back from somewhere at load. world.getSavedData is that read. The alternative authors reach for is stashing the value in some entity's metadata, which does replicate and does persist — but a script cannot write metadata after spawn, so it is a one-shot at best. Use saved data for script bookkeeping that has to outlive the session; use an entity and its properties for anything a player should be able to see or move.

Example

// content/scripting-api/examples/world.getSavedData.ts

// Scene script: restore the table's round counter at load, and report it when
// a player asks in chat. Every read has to cope with a null answer.

const ROUND_KEY = "round";

async function readRound(): Promise<number> {
  const stored = await world.getSavedData(ROUND_KEY);

  if (stored === null) {
    world.log(`Nothing stored under "${ROUND_KEY}" - treating this as round 1.`);
    return 1;
  }

  const parsed = Number(stored);
  if (!Number.isFinite(parsed)) {
    world.log(`Stored value "${stored}" is not a number - treating this as round 1.`);
    return 1;
  }

  world.log(`Restored round ${parsed}.`);
  return parsed;
}

globalEvents.onChatMessage.add((message) => {
  if (message.text.trim() !== "!round") {
    return;
  }
  void readRound().then((round) => {
    world.broadcast(`We are on round ${round}.`);
  });
});

void readRound();

On a table where nothing has been stored yet this prints Nothing stored under "round" - treating this as round 1.; once a value has been written it prints Restored round 3. and keeps doing so across a reload.

Gotchas

Resolves after a round-trip to the host, so the value is the host's state at the moment it answered — not necessarily still true when your handler continues. Two handlers that read, modify and write the same key will lose one of the two updates; keep the authoritative copy in a module variable and treat saved data as the place you persist it, not the place you compute with it.

Your key is not the storage key. The host composes the runtime key as scripts.<scope>.<encoded namespaced key>.v1 (apps/web/src/playcanvas/savedDataKeys.ts, tableScriptSavedDataKey), escaping every character the store's key pattern does not allow. That is invisible from a script — read back what you wrote and you get it — but it means the value is not addressable from anywhere else, including a mod: mod saved data is keyed by mod id and lives in the same store under a different key space.

Edit Mode's store is session-only. ▶ Play Scripts keeps saved data in memory and clears it every time you stop or restart the scripts, so the editor cannot show you whether a value truly persists. At a real table the value rides the snapshot and survives a reload, a host migration and a save.

See also

world.setSavedData#

setSavedData(value: string, key?: string): Promise<void>;
Badge Value
Authority host-only
Timing async
Capability none
Availability both

Stores one table-scoped string for your scripts to read back later. The sandbox sends the value to the host under world:<key>; the host writes it into the table's saved-data store and acknowledges.

Parameters

Name Type Required Notes
value string yes Coerced with String() before it leaves the sandbox, and null or undefined become "". A plain object becomes "[object Object]" — serialize with JSON.stringify yourself. Nothing throws on a bad value; you get a useless string.
key string no Names the slot. Anything that is not a string — including omitting it — becomes "", so setSavedData(v) writes the one unnamed slot. Not normalized: "Score" and "score" are different slots.

Applicability: the value is scoped to the whole table. Use ObjectHandle.setSavedData for a value that belongs to one entity.

Returns

Promise<void>.

It resolves with no value once the host has accepted the write, and rejects with an Error when the host refuses. Await it inside try/catch (as the example does) so a refusal shows up instead of surfacing as an unhandled rejection.

How, why and when to use it

The round counter your script keeps in a module variable disappears the moment the table is closed, and a table reopened from a save should not restart at round one. world.setSavedData is the write that makes it survive. The alternative is to encode the state into the table itself — a text label showing the round, a tag on a token — which has the real advantage that players can see it and it always travels with the snapshot; the cost is that every write is an intent and a visible change. Persist with saved data when the value is bookkeeping the game needs and a player does not; put it on the table when it is part of what people are looking at.

Example

// content/scripting-api/examples/world.setSavedData.ts

// Scene script: persist the round counter so a reloaded table picks up where it
// left off. Every write is awaited so a refusal is visible instead of silent.

const ROUND_KEY = "round";
let round = 1;

async function persistRound(): Promise<void> {
  try {
    await world.setSavedData(String(round), ROUND_KEY);
    world.log(`Persisted round ${round}.`);
  } catch (error) {
    const detail = error instanceof Error ? error.message : String(error);
    world.log(`Round ${round} was NOT persisted: ${detail}`);
  }
}

globalEvents.onTurnStarted.add(() => {
  round += 1;
  void persistRound();
});

globalEvents.onChatMessage.add((message) => {
  if (message.text.trim() === "!reset") {
    round = 1;
    world.broadcast("Round counter reset to 1.");
    void persistRound();
  }
});

void persistRound();

The script console prints Persisted round 1. and a line for every turn after that.

Gotchas

Resolves once the host has accepted the write. The value reaches other peers with the next snapshot, not when this resolves. Saved data travels in the table snapshot, so a peer sees it on the host's next broadcast.

Your key is not the storage key. The host composes the runtime key as scripts.<scope>.<encoded namespaced key>.v1 (apps/web/src/playcanvas/savedDataKeys.ts, tableScriptSavedDataKey), escaping every character the store's key pattern does not allow. Nothing about that is visible from a script — but it does mean the value is not addressable from anywhere else, including a mod.

Wrap the write anyway. A rejection is an Error with the host's message, and an unawaited rejected promise is silent in the sandbox. Keeping the live value in a module variable means a refusal costs you persistence rather than the running game.

Nothing validates the shape of what you stored. The store holds strings. A value written by last month's version of your script comes back exactly as it went in, so parse defensively on read and version your format if it is anything more than a number.

See also

world.wait#

wait(seconds: number): Promise<void>;
Badge Value
Authority host-only
Timing async
Capability none
Availability both

Wait, then resolve. Prefer this over setTimeout for game pacing.

Pauses an async function for a number of seconds and then continues. The delay is a plain timer inside the script sandbox — nothing is asked of the host and nothing about the table changes while you wait.

Parameters

Name Type Required Notes
seconds number yes Converted with Number(), then clamped to a minimum of 0. A value that is not a finite number — NaN, Infinity, a string that will not parse — becomes 0 and the promise resolves on the next turn of the event loop instead of throwing. Fractions are honored: 0.25 waits 250 ms.

Applicability: nothing about the wait depends on the table. It is the same delay whatever is on it.

Returns

Promise<void>, resolving with no value once the delay has elapsed.

How, why and when to use it

You are dealing five cards to each seat and want the table to see five separate deals rather than one instant pile: a short pause between passes turns a state change into something a person can follow. world.wait is the pacing primitive for that. The alternative is setTimeout, which exists at runtime but is not in the scripting type declarations, so it will not compile in the editor — and reaching for setInterval instead is worse, because the static scanner rejects any script containing that word outright. Use wait for every delay. Reach for globalEvents.onTick instead when you want something to happen repeatedly on a clock rather than once after a pause.

Example

// content/scripting-api/examples/world.wait.ts

// Scene script: a three-second countdown before the deck is shuffled, so
// players can see it coming. wait is the pacing primitive for table scripts.

async function countdownAndShuffle(): Promise<void> {
  const decks = await world.getAllObjects({ kind: "deck" });
  if (decks.length === 0) {
    world.log("No deck on the table - nothing to shuffle.");
    return;
  }

  const deckId = decks[0].id;
  for (let remaining = 3; remaining > 0; remaining -= 1) {
    world.broadcast(`Shuffling in ${remaining}...`);
    await world.wait(1);
  }

  // The table kept moving while we waited, so read it again before acting.
  const deck = await world.getObjectById(deckId);
  if (deck === null) {
    world.log(`Deck ${deckId} was removed during the countdown.`);
    return;
  }

  deck.shuffle();
  world.log(`Shuffled ${deck.id}.`);
  await world.wait(0.5);
  world.broadcast("Shuffled. Draw when ready.");
}

globalEvents.onChatMessage.add((message) => {
  if (message.text.trim() === "!shuffle") {
    void countdownAndShuffle();
  }
});

Typing !shuffle in chat broadcasts three countdown lines a second apart, then prints Shuffled script-… to the script console and broadcasts Shuffled. Draw when ready.

Gotchas

Resolves after the delay elapses on the host. Nothing about the table is guaranteed to have changed. The converse also holds and matters more: everything about the table is free to change while you wait. Players keep playing, entities are moved and deleted, and any handle or ObjectData you captured before the await is stale after it. Re-read what you are about to act on, as the example does.

Nothing is queued behind a wait. Events keep firing and your other handlers keep running while one is paused. Two overlapping calls to the same waiting function will interleave — guard with a flag if the sequence must not run twice at once.

Stopping the scripts kills a pending wait. Disposing the sandbox — pressing ■ in Edit Mode, leaving the table, restarting the scripts — tears down the frame, so a wait in flight never resolves and everything after that await never runs. Do not treat the code after a wait as guaranteed cleanup.

It is a browser timer, not a game clock. The delay is measured on the host's timer, so it is not synchronized with the physics step, and a backgrounded host tab can overshoot a long wait. Do not build precise timing on it.

See also