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.
getObjectByIdwhen you have an address,getAllObjectswhen you have a category. - Create entities.
spawnObject, the only way a script adds a piece to the table mid-game. - Read the room.
getPlayersandgetTurn, 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#
ObjectHandle— what you can do with an entity once you have one.- Events —
globalEvents, and the delegates that hand you handles. - Types —
Vec3,ObjectKind,SpawnObjectOptions,PlayerInfo,TurnInfo. - Host authority — why a script runs on one peer.
- Async and snapshots — what an
awaitcosts and invalidates. - Action vocabularies — the three object-action lists and why they differ.
- Script Editor — authoring, compiling and running a script in Edit Mode.
- Known limitations — every documented gap, in one list.
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 everyworldquery 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
ObjectHandle— everything you can do with the handle you get back.world.getAllObjects— the lookup for a category rather than an address.- IDs, names and tags — why
idis the only thing that resolves an entity. - Async and snapshots — what "resolves" means when the answer comes from another peer's state.
- Host authority — why a read is a request and not a memory access.
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
tagand matches it exactly. Mod scripting'sapi.listObjectsacceptstag,tagsandmatch, 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.getObjectById— the lookup when you have an id.world.spawnObject— the only place a script can write a tag.ObjectData.tags— what a handle's tag list contains.- Tags and groups — authoring tags in Edit Mode so a script can find them.
- IDs, names and tags — the
dt:namespace and why it is unforgeable. - Known limitations — the full list of documented gaps.
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
displayNameat all.ObjectData.namereads backlabel(apps/web/src/scripting/sandbox/tableScriptSandbox.html,stateToData), andSpawnObjectOptionshas nodisplayNamefield, 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
ObjectHandle— what you can do with the handle you get back.- Host authority — why scripts run on one peer and what replicates.
- Async and snapshots — what "resolves" means when the answer arrives by broadcast.
- Action vocabularies — which actions a spawned entity will accept.
- Known limitations — the full list of documented gaps.
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
SeatZoneInfoSeatZoneInfo— what each entry looks like.
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— the point one position is on.SnapPointInfo— each entry.
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.getSnapPoints— the whole list.SnapPointInfo— what comes back.
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()— move it.world.getSeatZones()— the zones a variable can own.
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.getSeatVariable()— read it back.world.getPlayers()— who is actually seated.
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— the other half of the table context, pushed the same way.world.broadcast— how to say something to the people this returns.- Host authority — why the sandbox is told about peers rather than asking.
- Known limitations — the full list of documented gaps.
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.getPlayers— the roster the same context push carries.- Host authority — why a script cannot veto an out-of-turn action.
- Known limitations — the full list of documented gaps.
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
globalEvents.onObjectMenuItem— the click.refObject.onMenuItem— the same click, scoped to one entity.world.removeObjectMenuItem— take one away.
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.addObjectMenuItem— register one.globalEvents.onObjectMenuItem— the click it stops delivering.
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— the same idea for text a player should read.- Script Editor — where the console strip lives while you are authoring.
- Host authority — why only one peer runs the script that logged.
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.log— the same idea for text only you should read.world.getPlayers— who is going to receive this.- Host authority — why one peer speaks for the table.
- Known limitations — the full list of documented gaps.
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— the write half.ObjectHandle.getSavedData— the same idea scoped to one entity.- Async and snapshots — what a host round trip costs you.
- Known limitations — the full list of documented gaps.
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.getSavedData— the read half.ObjectHandle.setSavedData— the same idea scoped to one entity.- Async and snapshots — when an acknowledged write becomes visible elsewhere.
- Known limitations — the full list of documented gaps.
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
world.broadcast— the usual companion when you are pacing something the table is watching.ObjectHandle.refresh— how to re-read state after a wait.- Async and snapshots — why an
awaitinvalidates what you read before it.
