The mod api object
api is a mod's entire reach into the table. It is injected into your script as a function parameter — not
a global on self — so it is in scope everywhere in the file, including at the top level before your setup
function runs. Every method on it is gated on a capability your manifest has to declare.
A mod script is one JavaScript file, named by entry.script in the manifest, which must end .js. It
is never transpiled: it runs verbatim inside the mod sandbox iframe. world, globalEvents and refObject
do not exist here — those belong to Table Scripting, which is a separate
surface with a different vocabulary. See
Choosing a surface if you are deciding between the two.
The entry point#
The sandbox runs your file, then looks for an entry point in exactly this order and takes the first match:
module.exports.defaultexports.defaultexports.setup
If that value is a function, the sandbox calls it once as setup(api, manifest) and awaits it, so an
async setup is fine. The conventional form is:
exports.setup = async function setup(api, manifest) {
api.log(manifest.name + " is running.");
};
⚠ module.exports = { setup } silently does nothing#
This is the single most common way a mod fails, and it fails without an error message.
Known gap. The sandbox resolves the entry point as
module.exports.default || exports.default || exports.setup, evaluated against the objects it created before running your file (apps/web/src/mods/sandbox/modSandbox.html). Reassigningmodule.exportsreplaces the object the first path reads from, so none of the three expressions resolve and the mod loads with no setup having run — and because loading succeeded, you still get the mod's… loaded.line in the event feed and no diagnostic anywhere. All three supported forms work correctly. Writeexports.setup = function setup(api, manifest) { … };, orexports.default = …, and assign ontomodule.exportsrather than replacing it. See Known limitations.
If your mod loads, logs … loaded. and then does nothing at all, check this first.
What setup receives#
manifest is not your whole manifest. The host builds the second argument as exactly four fields:
| Field | Type | Notes |
|---|---|---|
id |
string |
Your mod id. Use it to namespace UI element ids and to filter ownerModId. |
name |
string |
Your mod's display name, for log lines. |
capabilities |
{ version: "1"; allowed: ModCapability[] } |
What you were actually granted. Reading it lets a script degrade rather than throw. |
soundSets |
ModSoundSet[] |
The custom sounds you declared, or undefined when you declared none. |
entry, assets, description, compatibility and everything else are unreachable from a script.
By design. A script has no use for its own file paths or its store listing, and handing them over would widen the surface for nothing. This is not expected to change. Put anything else your script needs in the script, as a constant. See Known limitations.
Capabilities gate every call#
Every one of these methods begins with a capability check, and calling one whose capability is not in
manifest.capabilities.allowed throws Missing mod capability: <capability> synchronously — including
for the methods that return a promise, so .catch() will not see it. A manifest with no capabilities
block is granted log and nothing else.
The capability-to-method matrix, the detector regexes and the exact rejection message live on Mod capabilities. The badge on each entry below names the one slug that method needs.
By design. The throw runs inside the same untrusted frame as your script, so it is the error you meet while developing rather than the boundary. For ten of the twelve capabilities the host re-validates the gated message against your mod's grants before acting on it (
apps/web/src/mods/SandboxedModRunner.ts), and that check is the authoritative one: it drops a fire-and-forget call with a diagnostic and rejects a read withMissing mod capability: <capability>. The two exceptions areread-contextandsubscribe-events, which gate no message at all —getMySeat,getMyTeam,getTurnandonare answered inside the frame from context and hook payloads the host pushes to every frame unconditionally, so for those two the check is frame-local and a declaration is disclosure rather than enforcement. Read a capability list as least-privilege disclosure throughout — it says what your script was scanned as calling, not what it intends. See Known limitations.
The api methods#
| Signature | Capability | What it does | Returns |
|---|---|---|---|
createObject(object) |
spawn-object |
Ask the table to add an entity. Fire-and-forget; nothing comes back. | void |
objectAction(objectId, action) |
object-action |
Apply one of ten built-in actions to an entity. | void |
getSnapshot() |
read-world |
The whole replicated table, including zones, joints and the event log — redacted. | Promise<TableSnapshot | null> |
getObject(objectId) |
read-world |
One entity by id, redacted. | Promise<TableObjectState | null> |
listObjects(filter?) |
read-world |
Entities filtered by kind and tags, redacted. | Promise<TableObjectState[]> |
getContainerContents(objectId) |
read-world |
A deck's or bag's publicly-visible front card. At most one entry. | Promise<TableContainerContentEntry[]> |
getHandObjects(seat?) |
read-world |
Entities grouped by the seat that owns them, redacted — sizes, not faces. | Promise<TableHandState[]> |
getZoneObjects(seat, zoneId) |
read-world |
Entities standing inside one authored seat zone, redacted. | Promise<TableObjectState[]> |
getUnredactedSnapshot() |
read-hidden-information |
The table with nothing withheld. The one read that returns hidden information. | Promise<TableSnapshot | null> |
getMySeat() |
read-context |
The seat of the client running this mod. | string | null |
getMyTeam() |
read-context |
The team of the client running this mod. | string | null |
getTurn() |
read-context |
Whether turns are on, who is up, and whether that is you. | ModTurnInfo |
getSavedData(scope?) |
saved-data |
Read this mod's persisted string, table-wide or per entity. | Promise<string | null> |
setSavedData(data, scope?) |
saved-data |
Persist this mod's string. Host only; rejects elsewhere. | Promise<boolean> |
getUiState() |
ui |
The whole table UI tree plus its revision number. | Promise<TableUiState | null> |
listUiElements() |
ui |
Every live UI element, from every mod. | Promise<TableUiElementState[]> |
setUiElement(element) |
ui |
Create or update one element you own. Host only; rejects elsewhere. | Promise<TableUiElementState | null> |
deleteUiElement(elementId) |
ui |
Remove one element you own. Host only; rejects elsewhere. | Promise<boolean> |
playSound(params) |
play-sound |
Play a one-shot spatial sound. Ephemeral, never persisted. | void |
setObjectSound(objectId, action, ref) |
play-sound |
Change what an entity sounds like for one action, permanently. | void |
on(eventName, handler) |
subscribe-events |
Register a hook handler. Append-only; no unsubscribe. | void |
log(message) |
log |
Write one line to the running client's event feed. | void |
registerAction(action) |
register-action |
Declare a named action. Writes a log line and nothing else. | void |
listPlugins() |
plugin-call |
The plugins your manifest declared that are installed here. | Promise<ModPluginSummary[]> |
callPlugin(pluginId, functionName, params?) |
plugin-call |
Call one declared function on one declared plugin. Not network access. | Promise<ModPluginCallResult> |
Shapes to know before you start#
The six read-world reads are least-privileged. Since 2026-08-14 getSnapshot, getObject,
listObjects, getContainerContents, getHandObjects and getZoneObjects answer as a spectator with no
seat and no team, on every peer including the host. Card faces you are not entitled to come back as
label: "Card" with metadata.__redacted set, a container reports at most its face-up front card,
secretMetadata is gone, and an entity a hidden seat zone conceals is missing entirely. A mod running on
the host is granted nothing by living where the secrets are kept. If your rules engine genuinely has to see
the real table, declare read-hidden-information and call
getUnredactedSnapshot — a separate method, so a call
site says what it does without anyone cross-referencing a manifest.
Reads are asynchronous; writes are not. Every method that answers a question about the table crosses
the sandbox boundary and returns a promise. Every method that changes something returns void immediately
and the change arrives back with a later snapshot — so the entity you just created is not in
getSnapshot() on the next line. Async and snapshots
explains what that costs and what an await invalidates.
Four methods answer without leaving the frame. getMySeat, getMyTeam, getTurn and on read a
context object the host pushes in, or write a local handler list, so they are synchronous and can be one
update stale. They also describe the peer running the mod, not the table.
Three globals that are not on api#
The mod sandbox is a real iframe document, so the standard ES globals are present. Three of them are
declared in the mod scripting library, because the docs promise them and a script that uses one has to
typecheck against lib: ["es2020"] with no DOM and no @types:
| Global | What it is | Capability |
|---|---|---|
setTimeout |
One deferred callback, returning a handle. Re-arm it for anything periodic. | none — it is not an api method |
clearTimeout |
Cancel a pending callback. | none |
crypto |
randomUUID() and getRandomValues(array), and nothing else. |
none |
setInterval and requestAnimationFrame are deliberately not declared: the static scanner's timer-loop
rule rejects any script whose text contains either word, so declaring them would advertise a call that cannot
ship. Neither of the three grants anything a capability would otherwise withhold — a call your manifest does not
allow is refused just as hard inside a timer as outside one.
Where to start#
Give a mod log and nothing else, write exports.setup, and confirm you see your line in the event feed
before adding a capability. From there, listObjects plus on is enough for most game logic: find your
pieces, react to what players do with them. setUiElement is how you give players a control —
registerAction looks like it should be and is not.
See also#
- Mod hooks and capabilities — the fourteen hooks
api.oncan subscribe to. - Mod capabilities — the capability-to-method matrix and how a declaration is checked.
- Calling a plugin from a mod — the
pluginsdeclarationlistPluginsandcallPluginrequire. - Anatomy of a mod — where
entry.scriptsits in a repository. - Sandbox limits — the language subset and what the frame removes.
- Script safety — the five patterns that reject a script before it ever loads.
- Action vocabularies — why a mod gets ten object actions and a table script gets thirteen.
world— the other surface, for scripts authored in the editor.- Known limitations — every documented gap, in one list.
ModApi#
Surface B — mod script · interface · 30 members
The mod's entire reach into the table.
Every method throws Missing mod capability: <capability> SYNCHRONOUSLY when
the named capability is absent from manifest.capabilities.allowed — including
the async ones, so .catch() will not see it. The publish-time validator
detects the capability each method implies, so an undeclared call is caught
before a player ever loads the mod.
Reached from a script as the injected global api.
declare const api: ModApi;
The table API. Injected as a function parameter, so it is in scope for the whole
script — including at top level, before setup runs.
ModApi is the type of the object a mod script is handed. There is one per running mod, built inside that mod's
own sandbox frame, and its twenty-two methods are the complete set of things a mod can do — a frame carries no
other channel to the table. Seventeen of them post a message to the host and get an answer or an effect back; four
(getMySeat, getMyTeam, getTurn, on) are answered inside the frame from state the host pushed in, and never
leave it.
Each method opens with a check against the capability list the host sent with your script, so the same api
object behaves differently for two mods depending on what their manifests declared. A table script has no api:
world, globalEvents and refObject are a separate surface with a separate vocabulary, and no member appears
on both.
How, why and when to use it#
You have written exports.setup = function setup(api, manifest) { … } and you are deciding what your game logic
can be built out of. Read the twenty-two methods as three groups and design against them: the reads
(getSnapshot, getObject, listObjects, getContainerContents, getHandObjects) tell you what is on the
table, on tells you when it changed, and the writes (createObject, objectAction, setUiElement,
playSound) are the whole of what you can change. The alternative most authors assume exists is a way to reach
the scene graph or an entity's engine components — there is none, deliberately, and a rule that needs one has to
be rewritten against the replicated snapshot instead.
Gotchas#
One api per mod, not per table. Two mods running side by side get two frames, two capability sets and two
saved-data namespaces, and neither can see the other's. The only place they meet is
api.listUiElements, which reports every mod's elements.
A capability failure is thrown, not rejected. The check runs before the promise is created, so .catch() on
an awaited read never sees it. Wrap the call in try/catch if you branch on a capability you might not have.
api is in scope before setup runs. It is a parameter of the wrapper the sandbox compiles your file into,
so a top-level api.log(…) executes at load. That is useful for a boot line and a trap for anything that assumes
the table is ready — no snapshot has arrived at that point.
See also#
ModSetupFunction— the signatureapiis delivered through.ModSetupManifest— the second argument, and the four fields it has.- Mod capabilities — the capability-to-method matrix.
- Mod hooks and capabilities — the fourteen hooks
api.onaccepts. world— the other surface's entry object.
Members#
| Signature | Description | Returns |
|---|---|---|
createObject(object: TableObjectDefinition) |
Spawn a new object. Fire-and-forget: it dispatches a spawn intent and returns immediately, so the object is NOT in getSnapshot() on the next line. Await a later snapshot (or an onTableEvent) before looking for it. |
void |
objectAction(objectId: string, action: ModObjectAction) |
Apply one built-in action to an object. Fire-and-forget and host-authoritative — the effect reaches you back through a snapshot. | void |
getSnapshot() |
The whole replicated table, REDACTED to the least-privileged view, or null before the first snapshot arrives (or when the redaction inputs cannot be computed — a missing table runtime resolves null rather than leaking). | Promise<TableSnapshot | null> |
getObject(objectId: string) |
One object by id, REDACTED to the least-privileged view — or null when it does not exist, when the id is empty, or when the object sits in a hidden seat zone that conceals it entirely (an object you may not see does not report its existence, position or count). |
Promise<TableObjectState | null> |
listObjects(filter?: ModObjectFilter) |
Objects matching a filter, or all objects when the filter is omitted, each REDACTED to the least-privileged view. This filters the replicated SNAPSHOT, never the scene graph. | Promise<TableObjectState[]> |
getContainerContents(objectId: string) |
The publicly-visible contents of a deck or bag. Empty for any other kind, for a missing object, and for an empty id. | Promise<TableContainerContentEntry[]> |
getHandObjects(seat?: string) |
Hand contents for one seat, or for every OCCUPIED seat when seat is omitted or empty, each object REDACTED to the least-privileged view. |
Promise<TableHandState[]> |
getZoneObjects(seat: string, zoneId: string) |
The entities standing inside ONE authored seat zone, right now. | Promise<TableObjectState[]> |
listSeatZones(seat?: string) |
Every live seat zone's GEOMETRY — the answer to "where is the deck area?". | Promise<ModSeatZone[]> |
getUnredactedSnapshot() |
The host's table state with NO redaction: every face-down card's identity, every deck's and bag's ordered metadata.cards, every secretMetadata, every hidden-zone occupant. |
Promise<TableSnapshot | null> |
getMySeat() |
This peer's seat, or null when unseated. Synchronous — read from context the host pushes in; it can be stale by one update. | string | null |
getMyTeam() |
This peer's team, or null. Synchronous. Capability: read-context. |
string | null |
getTurn() |
Current turn state for THIS peer. Synchronous. Capability: read-context. |
ModTurnInfo |
getSavedData(scope?: ModSavedDataScope) |
This mod's saved data — table-wide, or for one object with { objectId }. Resolves null when nothing has been stored. |
Promise<string | null> |
setSavedData(data: string, scope?: ModSavedDataScope) |
Persist this mod's saved data. Namespaced to the mod; another mod's slot is unreachable. Non-string values are coerced with String(...). |
Promise<boolean> |
getUiState() |
The whole UI tree (all mods). Resolves { revision, elements } whenever the table runtime exists — an empty elements array means no UI, NOT null. Null only when there is no runtime at all (e.g. before the table mounts). |
Promise<TableUiState | null> |
listUiElements() |
Every live UI element (all mods). Capability: ui. |
Promise<TableUiElementState[]> |
setUiElement(element: TableUiElementDefinition) |
Create or update ONE UI element owned by this mod. Resolves with the stored element, or null when the payload was not an object. | Promise<TableUiElementState | null> |
deleteUiElement(elementId: string) |
Delete one UI element owned by this mod. Resolves false when the id was empty or no such element exists. Host-only, like setUiElement. |
Promise<boolean> |
playSound(params: PlaySoundParams) |
Play a one-shot spatial sound. Ephemeral and never persisted. Semantic only — a mod names a (material, action), an object, or its OWN declared sound; it can never name a first-party clip. Invalid params are dropped with a diagnostic. | void |
setObjectSound(objectId: string, action: SoundAction, ref: SoundRef | null) |
Replace (or clear, with null) an object's sound for one action. Persisted and replicated. ref must be a semantic builtin or a sound THIS mod declared; anything else is dropped with a diagnostic. |
void |
on<K extends ModHookEventName>(eventName: K, handler: (payload: ModHookEventMap[K]) => void) |
Register a hook handler. Append-only: registering twice runs the handler twice and there is no way to unsubscribe. A handler that throws is reported as a diagnostic and does not stop the others. | void |
log(message: string) |
Write a line to the table's event log, attributed to "Mod". Capability: log (the only capability granted by default). |
void |
registerAction(action: ModActionRegistration) |
Declare a named action. | void |
listPlugins() |
List the plugins installed on this table that this mod may call. | Promise<ModPluginSummary[]> |
callPlugin(pluginId: string, functionName: string, params?: Record<string, string | number | boolean>) |
Call one declared function on one declared plugin. | Promise<ModPluginCallResult> |
resolveCards(cardIds: readonly string[]) |
Resolve card ids to the data your own game shipped for them. | Promise<ResolvedCard[]> |
listDecks(query?: ModDeckQuery) |
The saved DiceyTable decks for THIS GAME — the caller's own, or everyone's public ones. | Promise<ModDeckSummary[]> |
getDeck(deckId: string) |
One saved deck WITH its decklist, or null when there is no such deck you may read. |
Promise<ModDeckRecord | null> |
sendToHost(name: string, data?: unknown) |
Send a named message to the HOST'S copy of this mod. The only call that runs upwards. | Promise<void> |
api.createObject#
createObject(object: TableObjectDefinition): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Spawn a new object. Fire-and-forget: it dispatches a spawn intent and returns
immediately, so the object is NOT in getSnapshot() on the next line. Await a
later snapshot (or an onTableEvent) before looking for it.
The definition is validated host-side against the shared object schema; an
invalid one — including a soundSetOverrides entry naming a sound this mod did
not declare — is dropped with a diagnostic, never thrown back to you.
Capability: spawn-object.
Asks the table to add a new entity. The frame posts your definition straight to the host, which turns it
into a spawn intent and applies it. Nothing comes back — not the entity, not its id, not an error — so a
mod that needs to act on what it created has to find it again by the label or tag it chose.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
object |
TableObjectDefinition |
yes | Posted verbatim by the frame, then parsed host-side against tableObjectDefinitionSchema before anything touches the table (apps/web/src/mods/SandboxedModRunner.ts) — the same schema that guards the table-scripting spawn and setup.json. A definition that fails is dropped with a mod-console diagnostic and nothing is thrown back into your script. Unknown top-level keys are stripped by the parse; keys inside metadata are kept. |
Inside that definition only three fields are required:
| Field | Type | Notes |
|---|---|---|
kind |
TableObjectKind |
One of the eight engine kinds. An unrecognized string fails the host's parse, so the entity never appears. |
label |
string |
The slug — the machine key, and for kind: "card" the card's identity, which is what hidden-information redaction keys on. It is not the human name. |
position |
Vector3 |
An { x, y, z } object in feet. Give y some height (1 is one foot up) so the entity drops onto the surface rather than starting inside it. |
id |
string |
Optional. Omit it and the host mints one, which is the usual choice — a mod that supplies its own id owns keeping it unique. |
displayName |
string |
Optional, ≤80 characters. The human name shown in the editor's hierarchy. A mod can set this; a table script cannot. |
tags |
string[] |
Optional. Each entry must match /^[a-z0-9_-]+$/i and be ≤32 characters. A : is rejected, so the platform's dt: namespace is unwritable from a mod. |
stackCount |
number |
Optional, 1–1000. Deck depth. |
secretMetadata |
Record<string, unknown> |
Optional, ≤2 KB of JSON. Author data the host withholds from any peer not entitled to the entity's identity — the right home for "which card is this really". See TableObjectDefinition.secretMetadata. |
| other accepted fields | — | color, ownerSeat, faceDown, locked, containerMode, capacityLimit, components, parentId, material, soundSetOverrides and metadata; each falls back to the per-kind default when absent. |
physics |
— | Optional. A per-object body/collider override the spawn carries — it is in tableObjectDefinitionSchema and is honoured, which is how a preset ships its own collider. Absent means the runtime's per-kind default. |
tapped |
— | Not accepted on a spawn, despite appearing on the TableObjectDefinition type. It is not in tableObjectDefinitionSchema, so it is dropped by the parse — and it was always inert here: only the tap/untap actions write it. |
Applies to: every object kind. kind picks which per-kind defaults the runtime applies (mass, friction,
collision shape, default scale and color) and which of the optional fields mean anything — stackCount and
containerMode matter to a deck or bag and are inert on a die.
How, why and when to use it
You are writing a game where the number of pieces depends on how many people sat down — one scoring token
per seat, one marker per team. The alternative is setup.json, which pre-places entities when the mod
loads and is what most authors reach for first. Pre-place when the count is fixed while you are authoring:
those entities load with the scene, cost nothing to create, and survive a save without your script doing
anything. Use createObject when the count is only knowable at run time, or when a piece has to appear in
response to something a player did. The one thing createObject will not do is hand you the entity back,
so pick a label or tag you can search for and plan on a listObjects call to find it again.
Example
// content/scripting-api/examples/api.createObject.js
// Mod script: put one scoring token in front of every seat that owns something,
// then report what the host actually created once a snapshot comes back.
// manifest capabilities.allowed: ["log", "spawn-object", "read-world", "subscribe-events"]
const SEAT_SPOTS = [
{ seat: "red", x: -2, z: 2 },
{ seat: "blue", x: 2, z: 2 },
{ seat: "green", x: -2, z: -2 },
{ seat: "yellow", x: 2, z: -2 }
];
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const hands = await api.getHandObjects();
const seated = new Set(hands.map((hand) => hand.seat));
for (const spot of SEAT_SPOTS) {
if (!seated.has(spot.seat)) {
continue;
}
api.createObject({
kind: "token",
label: "score-" + spot.seat,
position: { x: spot.x, y: 1, z: spot.z },
color: "#f0c419",
ownerSeat: spot.seat,
tags: ["score-token"],
displayName: "Score marker"
});
}
api.log(manifest.name + ": requested " + seated.size + " scoring tokens.");
// createObject is fire-and-forget, so count them once the table has changed.
api.on("onTableEvent", async () => {
const tokens = await api.listObjects({ tag: "score-token" });
api.log(manifest.name + ": " + tokens.length + " scoring tokens exist.");
});
};
With two players seated, the event log shows requested 2 scoring tokens. and then, on the next table
event, 2 scoring tokens exist.
Gotchas
This returns immediately. The entity is not in getSnapshot() on the next line — the call posts a
message and the host applies it on its own schedule, so nothing you read straight afterwards includes it.
Wait for a snapshot (an onTableEvent handler is the simplest trigger) and then look the entity up by tag.
A rejected definition is silent at the call site. The host parses it, but the result is not reported back
into the frame, so a bad kind, an over-long tag or a malformed position produces no exception and no
return value. The only signal is a diagnostic line in the mod console (Invalid createObject definition from sandbox: …). If a spawn never appears, read the console before changing the script.
soundSetOverrides obeys the same ownership rule as api.setObjectSound. A definition may name a
semantic builtin or one of your manifest's declared soundSets; naming another mod's sound, or a name you
did not declare, drops the entire spawn with the diagnostic A mod may only set overrides to its own declared sounds. The rule is enforced identically on both paths.
label is the slug, not the display name. For a card the label is the card's identity and it drives
which players are allowed to see it, so renaming a card for readability changes which card it is. Put the
human-readable string in displayName — which is itself redacted along with a hidden card — and put
anything that must stay secret in secretMetadata.
See also
api.listObjects— how to find the entity you asked for.api.objectAction— what you can do to it once you have its id.- Object kinds — the eight values
kindaccepts. - Object state — every field a mod can read back off an entity, and
secretMetadata's redaction rule. - Host authority — why the effect arrives by broadcast.
- Mod capabilities — declaring
spawn-objectin the manifest.
api.objectAction#
objectAction(objectId: string, action: ModObjectAction): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | object-action |
| Availability | mod |
Apply one built-in action to an object. Fire-and-forget and host-authoritative — the effect reaches you back through a snapshot.
Throws Unsupported object action from sandbox: <action> for anything outside
ModObjectAction.
Capability: object-action.
Applies one of ten built-in actions to one entity. The frame checks the action name against its own allowlist and throws immediately on anything else; the host checks the same ten names again before it dispatches the intent, so a mod that bypasses the frame gains nothing.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
objectId |
string |
yes | Coerced with String(objectId ?? "") in the frame. An empty or unknown id is posted anyway and the host silently does nothing with it. |
action |
ModObjectAction |
yes | Coerced with String(action ?? ""), then matched against flip, rotate, lock, unlock, shuffle, draw, deal, split, combine, roll. Anything else throws Unsupported object action from sandbox: <action> synchronously. |
Applies to: every action reaches every kind, because a mod's request comes through the host and bypasses the per-kind gate that restricts players. What each action does varies by kind:
drawanddealdo nothing unless the target is adeckorbag.splitdoes nothing unless the target is adeckholding more than one card.shuffleshuffles adeckand is refused outright on every other kind — the host returns before it changes anything, plays no sound and moves nothing. That refusal covers a mod too, because it is enforced in the runtime rather than only in the per-kind gate. Abagdraws at random, so it has no order to shuffle.flip,rotate,lock,unlock,combineandrolldo not self-guard:rollon a card throws the card, andflipon a die turns it over and flips itsfaceDownflag.
A locked entity refuses every action except unlock.
How, why and when to use it
You want the draw pile shuffled at the start of each turn without a player having to remember. The
alternative is to leave it to the players and watch for a shuffle event, which works but means the rule
is only enforced when someone follows it. objectAction is how a mod performs a table action itself, and
it is the only mutation a mod has besides createObject. What it will not do is remove anything: delete
is not on the mod list, so a game that has to take pieces off the table belongs in a table script. Check
locked on the entity first — the host drops the action for a locked entity and tells you nothing.
Example
// content/scripting-api/examples/api.objectAction.js
// Mod script: shuffle the draw pile at the start of every turn, and refuse to
// shuffle anything that is locked (the host would drop the action anyway).
// manifest capabilities.allowed: ["log", "object-action", "read-world", "subscribe-events"]
const DRAW_PILE_TAG = "draw-pile";
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
api.on("onTurnStart", async (payload) => {
const piles = await api.listObjects({ kind: "deck", tag: DRAW_PILE_TAG });
if (piles.length === 0) {
api.log(manifest.name + ": no deck tagged " + DRAW_PILE_TAG + " to shuffle.");
return;
}
for (const pile of piles) {
if (pile.locked) {
api.log(manifest.name + ": " + pile.label + " is locked, leaving it alone.");
continue;
}
api.objectAction(pile.id, "shuffle");
api.log(manifest.name + ": shuffled " + pile.label
+ " for " + (payload.seat || "the next player") + ".");
}
});
api.log(manifest.name + ": will shuffle the draw pile each turn.");
};
At load the console shows will shuffle the draw pile each turn., then one shuffled <label> line per
tagged deck every time a turn begins.
Gotchas
This returns immediately. The change is not visible in any entity you already read — including one you
fetched a line earlier — until the next snapshot. Re-read with api.getObject when you need the result.
The action name is checked at three points, and the capability is not the gate. object-action lets you
call the method; the ten-name allowlist decides what you may ask for, and it is re-checked twice on the host
— once when the message arrives and once at dispatch — regardless of what the manifest declares. tap,
untap, delete, lift, flick and the three reveal-* actions are unreachable from a mod under any
capability. See
Action vocabularies.
A bad id fails silently; a bad action throws. An unknown objectId produces no error anywhere, while an
action outside the ten throws synchronously inside your handler. Wrap the call if the action name comes
from data rather than a literal.
See also
- Action vocabularies — the 19/13/10 split and why a mod gets ten.
- Sandbox-safe object actions — the ten names, in one table.
- Object actions — what each action does per kind.
api.getObject— reading the result back after a snapshot.- Async and snapshots — why the effect lags the call.
api.getSnapshot#
getSnapshot(): Promise<TableSnapshot | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | read-world |
| Availability | mod |
The whole replicated table, REDACTED to the least-privileged view, or null before the first snapshot arrives (or when the redaction inputs cannot be computed — a missing table runtime resolves null rather than leaking).
Capability: read-world.
Returns the replicated table as one structurally-cloned object: every entity, plus snap points,
vector lines, decals, text labels, joints, the UI tree, the event log and the per-mod saved-data
maps. This is a mod's entire world — the scene graph, pc.Entity and engine guids are not reachable from
here at all.
Hidden information is not in it. Since 2026-08-14 this returns the least-privileged view of the
table — what a spectator with no seat and no team is entitled to see — on every peer, the host included.
See the first gotcha below, and api.getUnredactedSnapshot
for the migration if your mod needs the real thing.
Parameters
None.
Returns
Promise<TableSnapshot | null>.
null means this client has no table state it can give you — the mod started before the first snapshot
arrived, there is no table runtime attached, or the host could not work out what to conceal. It never means
"the table is empty"; an empty table resolves an object whose objects array has length zero.
That last case is deliberate rather than defensive. Deciding what a hidden seat zone conceals needs live
geometry; when that cannot be computed there is no safe default, and withholding the table is the safe
answer while publishing it is not.
The object is a deep clone taken at the moment the host answered, so mutating it changes nothing and it does not update as the table moves.
How, why and when to use it
You are opening a saved game and need to rebuild your own bookkeeping — which pieces exist, where they
sit, what the event log already says — in one pass. The alternative is
api.listObjects, which is the right call when you only want
entities and want them filtered; going through the whole snapshot to find three dice wastes a full clone
of the table on every call. Reach for getSnapshot when you need something listObjects cannot give you:
snap points, joints, the UI tree, or the event log. Take it once at setup, keep what you need, and use the
narrower reads afterwards.
Example
// content/scripting-api/examples/api.getSnapshot.js
// Mod script: describe the whole table in one read - object count, snap points
// and how much of the event log this peer can see.
// manifest capabilities.allowed: ["log", "read-world"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const snapshot = await api.getSnapshot();
if (!snapshot) {
api.log(manifest.name + ": no snapshot yet - the table has not sent one.");
return;
}
const kinds = new Map();
for (const object of snapshot.objects) {
kinds.set(object.kind, (kinds.get(object.kind) || 0) + 1);
}
const summary = [...kinds.entries()]
.map((entry) => entry[1] + " " + entry[0])
.sort()
.join(", ");
api.log(manifest.name + ": tick " + snapshot.tick + ", " + summary + ".");
api.log(manifest.name + ": "
+ (snapshot.snapPoints ? snapshot.snapPoints.length : 0) + " snap points, "
+ snapshot.eventLog.length + " visible log lines.");
};
On a table with a deck and four dice the console reads tick 812, 1 deck, 4 die. followed by the
snap-point and event-log counts.
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. Anything you await in between can change the table.
It is redacted on every peer, including the host. A card identity you are not entitled to is stripped —
label rewritten to Card, metadata.cardId and displayName deleted, metadata.__redacted set — along
with a deck's or bag's ordered contents, identity-bearing eventLog lines, and secretMetadata on every
kind. An entity a hidden seat zone conceals is missing from objects altogether, so objects.length is
not the entity count on a table that uses them.
"You" here is not the peer your mod happens to be running on. The entitlement used is the narrowest one the
model can express — a spectator with no seat and no team — and it is the same on all four roles. A mod
running on the host is granted nothing by living where the secrets are kept. This changed on 2026-08-14;
before that, read-world on the host returned every player's hand and the full deck order to any mod that
declared one capability.
The corollary: a rule that reads secretMetadata or a card's real label through this method now evaluates
as "absent" everywhere, host included. If your mod genuinely needs the real table, declare
read-hidden-information and call
api.getUnredactedSnapshot — a separate method, so the
line of code says what it is doing, and a manifest entry a player can read.
The whole table is cloned on every call. On a busy table that is a real cost, and calling it from
onTableEvent clones the table once per logged action. Prefer the narrow reads inside hot handlers.
See also
api.listObjects— entities only, filtered, and much cheaper.api.getObject— one entity by id.api.getUnredactedSnapshot— the elevated read, for a mod that needs the real table.TableSnapshot— every collection the snapshot carries.- Host authority — who owns the state you are reading.
- Async and snapshots — what an
awaitinvalidates.
api.getObject#
getObject(objectId: string): Promise<TableObjectState | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | read-world |
| Availability | mod |
One object by id, REDACTED to the least-privileged view — or null when it does
not exist, when the id is empty, or when the object sits in a hidden seat
zone that conceals it entirely (an object you may not see does not report its
existence, position or count).
Capability: read-world.
Resolves one entity id to a copy of that entity's replicated state, least-privileged. This is the read
you make when a hook payload, a saved-data record or an earlier listObjects gave you an id and you need
the entity as it stands right now — as long as "right now" does not have to include anything hidden.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
objectId |
string |
yes | Coerced with String(objectId ?? "") in the frame. An empty string short-circuits on the host side and resolves null without searching. |
Returns
Promise<TableObjectState | null>.
null means the id is not in this client's current snapshot: the entity was deleted, it was consumed (a
one-card deck that became its last card), it never existed, the id was empty, or a hidden seat zone
conceals it. There is no separate "unknown id" error and no separate "concealed" answer — every one of
those cases is the same null, deliberately, because a distinguishable refusal is an oracle.
A non-null result is a structured clone, redacted to the least-privileged view before it reaches you
(see the first gotcha). Writing to it changes nothing at the table; use
api.objectAction for physical state and
api.setSavedData for your own game state.
How, why and when to use it
A hook has told you something happened and handed you an id — payload.event.objectId — and you need
to know what kind of thing it was and where it ended up. onTableEvent already resolves the subject into
payload.object, so for that one case you do not need this call; getObject is what you use for every
other id you are holding, including ones you stored in saved data across a reload. The alternative,
listObjects, is the right tool when you are looking for a set — do not fetch every entity and filter to
one when you already know the id.
Example
// content/scripting-api/examples/api.getObject.js
// Mod script: every table event names the entity it happened to, but only by id.
// Resolve that id to the live entity and report where it ended up.
// manifest capabilities.allowed: ["log", "read-world", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
api.on("onObjectDropped", async (payload) => {
const objectId = payload.event.objectId;
if (!objectId) {
api.log(manifest.name + ": a move was logged with no entity id.");
return;
}
const object = await api.getObject(objectId);
if (!object) {
api.log(manifest.name + ": entity " + objectId + " is gone from the table.");
return;
}
const name = object.displayName || object.label;
api.log(manifest.name + ": " + name + " (" + object.kind + ") rests at "
+ object.position.x.toFixed(2) + ", "
+ object.position.z.toFixed(2) + " feet"
+ (object.ownerSeat ? " in the " + object.ownerSeat + " hand." : "."));
});
api.log(manifest.name + ": watching for entities that get moved.");
};
Drag a token across the table and the console prints something like
Red Knight (token) rests at 1.42, -0.87 feet.
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. An entity can be moved or destroyed between your
await and the next line.
The result is redacted on every peer, the host included. read-world does not give you the host's view
of the table and never gives it to you on the host either. The answer is passed through the
least-privileged redactors — the view a spectator with no seat and no team would get — before it leaves the
host process, so a face-down card arrives with label rewritten to Card, no displayName, no
metadata.cardId, metadata.__redacted === true and no secretMetadata, and a deck arrives with its
ordered metadata.cards stripped. That is true whether your mod is running on the host or on a player's
client. If your game genuinely has to adjudicate a secret, declare read-hidden-information and call
api.getUnredactedSnapshot instead of trying to reach
it through this call.
A concealed entity is null, not a redacted object. An entity inside a hidden seat zone is erased
rather than neutralized, so getObject resolves null for it — indistinguishable from a deleted id.
Do not write if (await api.getObject(id)) … as a proof that an id is stale.
Everything else about the entity is still here. parentId, components, physics, tapped,
material, soundSetOverrides and the rest of the public shape are untouched by redaction; only identity
and secrets are. The table-scripting ObjectData shape is much narrower and cannot read any of these
fields — do not carry that limitation across.
See Known limitations.
position, rotation and scale are world-absolute even when parentId is set. A parented token
reports where it actually is, not an offset from its ancestor.
See also
api.listObjects— when you want a set rather than one id.api.getUnredactedSnapshot— the elevated read, when a rule has to see a hidden face.TableObjectState— every field on the value you get back.- Object state — the same fields with per-kind meaning.
- Hooks and capabilities — the payloads that hand you ids.
api.listObjects#
listObjects(filter?: ModObjectFilter): Promise<TableObjectState[]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | read-world |
| Availability | mod |
Objects matching a filter, or all objects when the filter is omitted, each REDACTED to the least-privileged view. This filters the replicated SNAPSHOT, never the scene graph.
⚠ Objects concealed entirely by a hidden seat zone are DROPPED, so the
length of this array is not a reliable object count.
Capability: read-world.
Filters the replicated snapshot and returns the entities that match. This is the workhorse read for a mod: it is how you find the pieces your game owns without walking the whole table, and it filters the snapshot, never the scene graph.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
filter |
ModObjectFilter |
no | Omit it (or pass a non-object) and every entity comes back. |
The filter's four fields, with the coercion each one gets in the frame and again on the host:
| Field | Type | Notes |
|---|---|---|
kind |
TableObjectKind |
Used only when it is a string; anything else is dropped and the kind is not filtered. An unknown kind string matches nothing rather than erroring. |
tag |
string |
The older single-tag form. It behaves as a one-element tags, and it combines with tags rather than replacing it. |
tags |
string[] |
Non-string entries are dropped, then the list is silently truncated to the first 32. Truncation happens in the frame and independently on the host, so a longer list never reaches the matcher. |
match |
"any" | "all" |
Exactly "all" intersects; every other value, including undefined and a typo, is coerced to "any". There is no error for a bad value. |
With no tag and no tags, tag matching is skipped entirely and only kind narrows the result.
Applies to: every object kind. The filter reads kind and tags off each entity and cares about
nothing else.
Returns
Promise<TableObjectState[]>.
Always an array, never null. An empty array means "nothing matched", "this client has no snapshot yet",
or "everything that matched is concealed" — the three are indistinguishable by design. Entries are
structured clones in snapshot order, redacted to the least-privileged view: a matching entity a
hidden seat zone conceals is dropped from the array entirely, and a matching face-down card is present
but neutralized. So length counts what you are entitled to see, not what is on the table.
How, why and when to use it
Your game has to act on its own pieces and only its own — the four scoring dice you spawned, not the
handful a player dropped on the table for fun. Tag everything you create and query the tag. The
alternative is getSnapshot().objects and a filter you write yourself, which works, but it clones the
whole table (zones, event log and all) on every call and gives you nothing listObjects does not. Use
match: "all" when membership means carrying several tags at once; leave it alone when any one tag is
enough.
Do not assume the table-scripting filter matches this one. world.getAllObjects accepts { kind, tag }
and nothing else — no tags, no match.
See Known limitations.
Example
// content/scripting-api/examples/api.listObjects.js
// Mod script: find the dice this game owns. `match: "all"` needs an entity to
// carry BOTH tags; the same call with "any" would also match loose scenery dice.
// manifest capabilities.allowed: ["log", "read-world"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const gameDice = await api.listObjects({
kind: "die",
tags: ["mine", "scoring"],
match: "all"
});
if (gameDice.length === 0) {
const anyDice = await api.listObjects({ kind: "die" });
api.log(manifest.name + ": no scoring dice; " + anyDice.length + " dice on the table.");
return;
}
const locked = gameDice.filter((die) => die.locked).length;
api.log(manifest.name + ": " + gameDice.length + " scoring dice, " + locked + " locked.");
for (const die of gameDice) {
api.log(manifest.name + ": " + die.label + " at height "
+ die.position.y.toFixed(2) + " feet.");
}
};
With two matching dice the console prints 2 scoring dice, 0 locked. and then one height 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 list is least-privileged on every peer, the host included. read-world is not a view of the host's
table. Every entry is passed through the same redactors a spectator with no seat gets, on whichever peer
your mod happens to be running: face-down cards keep their id, kind, position and tags but lose
displayName, metadata.cardId and secretMetadata and report label: "Card" with
metadata.__redacted === true; decks and bags lose their ordered metadata.cards; entities concealed by a
hidden seat zone are missing from the array altogether. Filtering, counting and moving pieces all work
unchanged — identifying one does not. A rule that must know a hidden card's face needs
read-hidden-information and
api.getUnredactedSnapshot.
Do not use length as a census. Because concealed entities are dropped rather than neutralized,
(await api.listObjects({ kind: "card" })).length under-reports a table with a hidden zone on it, and
under-reports differently as pieces move in and out of that zone. Track counts you care about in your own
saved data rather than deriving them from this call.
A bad match value is not an error. match: "ALL" and match: "every" both become "any", which
quietly widens your query instead of failing it. Write the literal "all" or leave the field out.
Tags beyond the 32nd are dropped without a word. Both the frame and the host truncate, so a 40-tag filter matches on the first 32 and reports nothing about the rest.
See also
ModObjectFilter— the filter type itself.api.getObject— when you already have the id.api.getSnapshot— zones, joints and the event log.api.getUnredactedSnapshot— the elevated read, when identities matter.- Object kinds — the eight values
kindaccepts. world.getAllObjects— the table-scripting read, which is not the same shape.
api.getContainerContents#
getContainerContents(objectId: string): Promise<TableContainerContentEntry[]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | read-world |
| Availability | mod |
The publicly-visible contents of a deck or bag. Empty for any other kind, for a missing object, and for an empty id.
What comes back depends on which lane the container is in, and the two are redacted quite differently:
A pile of CARDS resolves to AT MOST ONE entry — the container's face-up top card,
when there is one — and [] otherwise. A pile's ORDER is host-only information; a
face-up discard-pile top is the only part of it anyone at the table legitimately sees.
It resolved to the full order until 2026-08-14; a mod that needs the real order must
declare read-hidden-information and read metadata.cards off the container in
getUnredactedSnapshot().
A bag of PIECES resolves to every run it holds (kind: "object"), because a piece
bag's contents are unseen rather than secret: everybody watched each piece go in, and
which piece the next draw yields is decided by the host at draw time, not derivable from
the list. The one exception is a bag whose author turned secretContents on — those
runs are withheld from every peer including this call, and reach a mod only through
getUnredactedSnapshot() with read-hidden-information. There is no partial answer:
a secret bag resolves [].
A holder (an open bowl or tray) resolves [] at all times — its pieces are ordinary
entities on the table, so listObjects is where they are.
Read stackCount on the object for the pile HEIGHT or the piece COUNT, which is public
either way and is never redacted.
Capability: read-world.
The publicly visible contents of a deck or a bag. What that amounts to depends on which lane the container is in, and the two are redacted quite differently:
- A pile of CARDS resolves to at most ONE entry — the face-up card the pile is showing — and
[]for a face-down pile. A container's card order is host-only information, so this read cannot report it on any peer. - A bag of PIECES resolves to every run it holds (
kind: "object"), because a piece bag's contents are unseen rather than secret: everybody watched each piece go in, and which piece the next draw yields is decided by the host at draw time rather than derived from the list. The one exception is a bag whose author turnedsecretContentson — those runs are withheld from every peer including this call, with no partial answer.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
objectId |
string |
yes | Coerced with String(objectId ?? ""). An empty string resolves [] without a lookup. |
Applies to: deck, and bag in either lane. Every other kind resolves an empty array — not an error,
and not distinguishable from an empty container. So does a bag in the holder form: an open bowl or tray
stores nothing, its pieces are ordinary entities, and
api.listObjects is where they are.
Returns
Promise<TableContainerContentEntry[]>.
Always an array, never null. Each entry is a copy; mutating it changes nothing. Its length depends on the
lane:
- Card lane — never longer than one entry. That entry, when present, is the container's face-up first
card:
kind: "card", a zero-basedindex, acardId, alabel(the same string ascardId) andfaceDown: false. - Piece lane — one entry per RUN, in the bag's stored order:
kind: "object", anindex, anentryKey, anobjectKind, acount, and alabelthat is the run's display name.
It resolves [] when the id is empty, when no entity has that id, when the entity is not a deck or
bag, when the container is empty, when a card pile's first card is face down, when the bag is a
holder, when the bag's author turned secretContents on, and when a hidden seat zone conceals the
container. Those are indistinguishable from each other on purpose — a distinguishable refusal would tell
you something you are not entitled to know.
How, why and when to use it
For a card pile, you want to react to what it is showing: the face-up card on a discard pile, the revealed card on a market row. That is what a player at the table can see, and it is what this returns.
For a piece bag it is the whole inventory — every sort of piece and how many of each — which is enough to drive a supply rule or a "what can I draw" panel with no elevated read at all.
For container size, read stackCount on the container entity itself — you already have it from
api.listObjects, it is public and it is cheaper than this call
(though for a bag it is clamped to 1000, and it never drops below 1). For a card pile's real order, for
the identity of a face-down card, or for a secret bag's runs, this read is the wrong tool on every peer,
host included: declare read-hidden-information and read the container in
api.getUnredactedSnapshot.
Example
// content/scripting-api/examples/api.getContainerContents.js
// Mod script: report what each container is showing. The two lanes answer
// differently - a pile of CARDS resolves at most ONE entry, the face-up front
// card, while a bag of PIECES resolves every run it holds. Branch on `kind`.
// Height comes from stackCount on the container, which is public and is never
// redacted.
// manifest capabilities.allowed: ["log", "read-world"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const decks = await api.listObjects({ kind: "deck" });
const bags = await api.listObjects({ kind: "bag" });
const containers = decks.concat(bags);
if (containers.length === 0) {
api.log(manifest.name + ": no deck or bag to inspect.");
return;
}
for (const container of containers) {
const entries = await api.getContainerContents(container.id);
const first = entries[0];
if (!first) {
// Face down, an open holder, a secretContents bag, concealed by a hidden
// zone, or genuinely empty - all of them answer the same way, on purpose.
api.log(manifest.name + ": " + container.label + " shows nothing ("
+ container.stackCount + ").");
continue;
}
if (first.kind === "object") {
const runs = entries.map((entry) => entry.label + " x" + entry.count);
api.log(manifest.name + ": " + container.label + " holds " + runs.join(", ") + ".");
continue;
}
api.log(manifest.name + ": " + container.label + " shows " + first.cardId
+ " (" + container.stackCount + ").");
}
};
For a face-up discard pile the console prints discard shows 7♥ (12).; for the face-down draw pile beside
it, main-deck shows nothing (40).; and for a bag of go stones, bowl holds Black Stone x180, White Stone x181.
Gotchas
It answers the same way on the host. read-world is not a view of the host's table. The card lane's
single-entry rule, and a secret bag's [], are applied on every peer — so a rules engine that must know
what is in a pile cannot get it here by running on the host. Declare read-hidden-information and read the
container from api.getUnredactedSnapshot.
Narrow on kind before reading an entry's fields. cardId/faceDown are card-only and
entryKey/objectKind/count are object-only, all declared optional — so reading the wrong one gives
undefined rather than an error, and a rule that silently never matches.
This used to return the whole pile, in order. Until 2026-08-14 a read-world mod got every card and
every index. If you are updating a mod written against that behaviour, the fix is not to loop over a
shorter array — it is to decide whether the mod needs stackCount (public, use it) or the real order
(elevated, declare the capability).
Use stackCount for the count, never length. In the card lane entries.length is 0 or 1 and says
nothing about the pile's height. In the piece lane it is the number of sorts of piece, not the number of
pieces — sum count across the runs for that, and note that it can exceed the container's clamped
stackCount.
A deck with no authored card entries still behaves as a standard 52-card deck. When a deck's metadata
carries no contents list the platform fills one in — A♠ through K♣ — so a face-up deck with no
authored contents reports A♠ here. That is genuinely the card the runtime would deal, not a placeholder.
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. A flip between the await and your next line changes
the answer.
An empty array is ambiguous, and more ambiguous than it used to be. "Wrong kind", "no such entity",
"face down", "an open holder", "a secret bag", "concealed" and "genuinely empty" all look identical. Check
the entity's kind and stackCount with api.getObject if the
difference matters.
See also
api.getObject— the container entity itself, includingstackCount.api.getUnredactedSnapshot— the elevated read, and the only way to a pile's order.api.objectAction—draw,deal,shuffleandsplit.- Object kinds — which kinds are containers.
api.getHandObjects— what players are holding instead.
api.getHandObjects#
getHandObjects(seat?: string): Promise<TableHandState[]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | read-world |
| Availability | mod |
Hand contents for one seat, or for every OCCUPIED seat when seat is
omitted or empty, each object REDACTED to the least-privileged view.
⚠ Since a face-down card in a seat's hand is exactly what redaction conceals,
the objects you get back are typically identity-neutralized: same ids, same
positions, no card faces. Use it to know THAT a seat holds five cards, not
WHICH five. A rules engine that must know the faces needs
read-hidden-information and getUnredactedSnapshot().
⚠ Three things this does not do. A seat whose entries are all concealed is
DROPPED rather than returned empty, so "this hand is entirely hidden from you"
is indistinguishable from "this seat holds nothing" — including for the
single-seat form, which therefore resolves [] rather than one empty entry.
Omitting seat returns an entry only for seats that currently hold something.
And "hand contents" means every entity with a non-empty ownerSeat, not only
cards, so a token dropped in an owned zone appears here too; filter on kind.
Capability: read-world.
Groups the entities that belong to a seat, least-privileged. "Hand" here means owned by a seat —
every entity in the snapshot whose ownerSeat is a non-empty string, grouped by that seat. Cards are the
usual case, but an owned token or die is in the list too. Use it to know that a seat holds five cards,
never which five.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
seat |
string |
no | Omit it, or pass an empty string or null, and every seat that owns something comes back. Pass a seat name and you get exactly one entry for that seat. |
Returns
Promise<TableHandState[]>.
Always an array, never null. Each entry is { seat, objectCount, objects }, where objects holds one
TableObjectState per owned entity you are entitled to see
and objectCount is that array's length.
Redaction happens before the grouping, and the grouping then drops empty seats — which is what makes the edges surprising:
- With a
seat: at most one entry. A seat that holds nothing, and a seat every one of whose entities ahiddenzone conceals, both resolve[]— not[{ seat, objectCount: 0, objects: [] }]. "This hand is entirely hidden from you" and "this seat holds nothing" are deliberately the same answer. - Without a
seat: one entry per seat that owns at least one entity you may see. A seated player holding nothing, or holding only concealed entities, does not appear at all. The array length is neither a player count nor a count of non-empty hands.
How, why and when to use it
You are enforcing a hand limit, or driving a "3 cards" badge over each seat, and you need sizes. The
alternative is listObjects plus your own grouping on ownerSeat, which gives the same entities but
leaves you to write the grouping and to remember that an empty string is not a seat. Pass a seat when you
care about one player — usually the one from api.getMySeat — and
omit it when you are surveying the table.
What this read will not do is score a hidden hand. A card in a seat's hand is exactly what redaction
conceals, and it is concealed here on every peer including the host, so a scoring pass that reads
object.metadata.cardId off these entries reads undefined everywhere. A rules engine that has to
adjudicate hands declares read-hidden-information and calls
api.getUnredactedSnapshot once.
Example
// content/scripting-api/examples/api.getHandObjects.js
// Mod script: announce hand SIZES whenever a card is drawn, and call out any
// seat that has gone over the limit this game sets. Sizes survive redaction;
// card faces do not, so nothing here reads an identity off a hand entry.
// manifest capabilities.allowed: ["log", "read-world", "subscribe-events"]
const HAND_LIMIT = 7;
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
api.on("onCardDrawn", async () => {
const hands = await api.getHandObjects();
if (hands.length === 0) {
api.log(manifest.name + ": nobody is holding anything.");
return;
}
for (const hand of hands) {
api.log(manifest.name + ": " + hand.seat + " holds " + hand.objectCount + ".");
if (hand.objectCount > HAND_LIMIT) {
api.log(manifest.name + ": " + hand.seat + " is over the "
+ HAND_LIMIT + " card limit.");
}
}
});
api.log(manifest.name + ": watching hand sizes, limit " + HAND_LIMIT + ".");
};
After a deal the console shows one red holds 7. line per seat, plus an over-limit line for anyone above
seven.
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.
Every hand is redacted, on every peer, the host included. read-world is not a view of the host's
table and running on the host does not change what this returns. A card in any seat's hand — including
your own player's — comes back with label: "Card", metadata.__redacted === true, and no displayName,
metadata.cardId or secretMetadata. Counting a hand works everywhere; reading what is in it works
nowhere. That is the anti-cheat boundary rather than a gap, and the sanctioned way past it is
read-hidden-information.
objectCount is a count of what survived redaction. It equals the true hand size for an ordinary hand,
because a face-down card is neutralized rather than removed — but an entity a hidden seat zone conceals
is dropped, so a hand partly inside one under-reports and a hand entirely inside one disappears. Do not
use objectCount as a hand-limit check when your table has hidden zones on it.
An empty result does not mean an empty table. Seats holding nothing, and seats holding only concealed
entities, are both absent from the array, so hands.length is not a player count. Use
api.getSnapshot or the peer hooks if you need the roster.
See also
api.getMySeat— the seat name to pass in.api.getUnredactedSnapshot— the elevated read, when hand contents decide a rule.api.getContainerContents— decks and bags instead of hands.onSeatChanged— when the seat map moves.- Object state —
ownerSeatand the other per-entity fields.
api.getZoneObjects#
getZoneObjects(seat: string, zoneId: string): Promise<TableObjectState[]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | read-world |
| Availability | mod |
The entities standing inside ONE authored seat zone, right now.
zoneId is the authored SeatZoneBox.id and is unique only WITHIN its seat, which
is why both arguments are required. The zone's tagFilter is applied, so this
returns exactly the set onZoneEnter / onZoneLeave fire for.
Resolves [] for an unknown seat, an unknown zone id, and a zone that is not
currently live — at the table a seat's zones exist only while the seat is CLAIMED.
Containment is 2D: a zone is a footprint on the table plane with no height, so an entity held high above one still counts as inside it.
Entities are REDACTED to the least-privileged view, and any entity concealed
entirely by a hidden seat zone is dropped.
Capability: read-world.
Resolves the entities standing inside one authored seat zone, right now. It is the pull half of the zone
surface: onZoneEnter and
onZoneLeave tell you when the set
changed; this tells you what it holds.
Parameters
| Parameter | Type | Notes |
|---|---|---|
seat |
string |
The seat that owns the zone, e.g. "red". Required. |
zoneId |
string |
The authored zone id. Required — an id is unique only within its seat. |
Returns
Promise<TableObjectState[]> — the same
TableObjectState shape
api.listObjects returns and redacted the same way, in no
guaranteed order.
Resolves [] — never rejects — for an unknown seat, an unknown zone id, an empty argument, and a zone that is
not currently live. At a live table a seat's zones exist only while the seat is claimed, so an unoccupied
seat always reads empty.
Every entity is passed through the least-privileged redactors first: a face-down card in the zone is
present but neutralized, and an entity a hidden seat zone conceals is dropped from the array entirely.
How, why and when to use it
Two jobs. The first is answering a question on demand — a button that reports what each player is holding, a
scoring pass at the end of a round, a check that a required area is not empty before letting a turn end.
The second is recovering after a
onZoneLeave: the entity that left
may be gone from the table entirely, so asking the zone what remains is more robust than trying to resolve the
id you were handed.
The zone's tagFilter is applied, so this returns exactly the set the hooks fire for — the push and the pull
can never disagree. Prefer it to api.listObjects plus your own
coordinate maths for the same reason: the containment test here is the engine's, not a re-implementation.
Example
// content/scripting-api/examples/api.getZoneObjects.js
// Mod script: count what each seat is holding, on demand. getZoneObjects is
// the pull half of the zone surface - onZoneEnter says WHEN the set changed,
// this says what it holds right now, with the zone's tagFilter already applied.
// manifest capabilities.allowed: ["log", "read-world", "subscribe-events", "ui"]
const SEATS = ["red", "blue", "green", "yellow"];
/**
* The authored id of a seat's first zone. Scenes written in Edit Mode name
* their boxes "seat-zone-<seat>-<index>"; read the id out of the scene rather
* than assuming it when you did not author the scene yourself.
* @param {string} seat
*/
function firstZoneId(seat) {
return "seat-zone-" + seat + "-0";
}
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
await api.setUiElement({
id: "zone-census",
type: "button",
presentation: { mode: "screen", anchor: "bottom-center", offsetX: 0, offsetY: -40 },
props: { text: "Count zones", onClick: "countZones" }
});
api.on("countZones", async () => {
for (const seat of SEATS) {
// An unclaimed seat has no live zones at the table, so its count is 0.
const held = await api.getZoneObjects(seat, firstZoneId(seat));
api.log(manifest.name + ": " + seat + " holds " + held.length + ".");
}
});
};
Clicking the button writes one line per seat to the event log.
Gotchas
Both arguments are required, and an empty one resolves []. A zone id is unique only within its seat, so
there is no "any seat" form. Passing an empty string for either half returns nothing rather than falling back
to a broader query.
An unclaimed seat reads empty even though the scene defines its zones. Zones are only live for claimed seats at a live table. In Edit Mode every seat's zones are live, so the same call can answer differently in the two contexts — which is correct, and worth knowing before you debug it.
Containment is two-dimensional. A zone is a footprint on the table plane with no height, so an entity held high above one is still counted as inside it.
Nothing is filtered out for you except what redaction removes. Locked furniture, boards and card
holders standing inside the zone are returned like anything else — filter on kind yourself, or have the
author give the zone a tagFilter. What you will not see is identity: this read is least-privileged on
every peer, the host included, so a face-down card in the zone reports label: "Card" with
metadata.__redacted === true and no metadata.cardId or secretMetadata wherever your mod runs. A rule
that has to know which card is standing in a zone needs read-hidden-information and
api.getUnredactedSnapshot.
A hidden zone's occupants are missing, not neutralized. If the author put a hidden seat zone over
the same footprint, entities it conceals are absent from this array, so held.length is a count of what
you may see rather than a count of what is standing there. The zone hooks still fire for those entities and
still name their ids — see Mod hooks and capabilities — so
push and pull can disagree on exactly that set.
It is a snapshot of this instant, not a subscription. Polling it in a hot handler is the wrong shape; subscribe to the hooks and call this when you need the whole set.
See also
onZoneEnter— the push half.onZoneLeave— where this is most useful.api.getHandObjects— by seat ownership rather than by zone.api.listObjects— the whole table, filtered by kind or tag.api.getUnredactedSnapshot— the elevated read, when a zone's occupants must be identified.
api.listSeatZones#
listSeatZones(seat?: string): Promise<ModSeatZone[]>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
Every live seat zone's GEOMETRY — the answer to "where is the deck area?".
Pass a seat to get only that seat's zones; omit it for every seat's. Resolves [] for an
unknown seat, and for a seat that is not currently CLAIMED — at the table a seat's zones
exist only while somebody is sitting in it.
Zones are matched by name or templateZoneId, never by position: both survive a seat
being moved, rotated or rescaled, and id is a derived string whose shape is not a
contract. To place a pile so it faces the seat, spawn it at the zone's position with
rotation: { x: 0, y: zone.rotationY, z: 0 }.
Contents are NOT here — use getZoneObjects(zone.seat, zone.id), which redacts.
Capability: read-world.
Resolves the geometry of the live seat zones — where each zone is, how big it is and which way it
faces. It is the question api.getZoneObjects does not
answer: that one tells you what is standing in a zone, this one tells you where the zone is, which is
what you need to put something there.
Parameters
| Parameter | Type | Notes |
|---|---|---|
seat |
string |
Optional. One seat's zones, e.g. "red". Omit for every seat's. |
Returns
Promise<ModSeatZone[]> — one entry per live zone, in no guaranteed order.
Resolves [] — never rejects — for an unknown seat, and for a seat that is not claimed. At a live table
a seat's zones exist only while somebody is sitting in it, so an empty seat has no zones to report.
Each entry carries seat, id, templateZoneId, name, type, position, size, rotationY and
tagFilter. There is nothing about the zone's contents here, by design.
How, why and when to use it
Placement. A pile, a marker or a board that belongs in a named area needs that area's world position, and before this call the only way to get one was to hard-code coordinates — which a seat being moved, rotated or rescaled in Edit Mode silently invalidated, with no error and no visible cause.
Spawn at the zone's position and rotate by its rotationY, and the object lands in the area facing the
same way the seat does:
api.createObject({
kind: "deck",
label: "main",
position: { x: zone.position.x, y: zone.position.y + 0.2, z: zone.position.z },
rotation: { x: 0, y: zone.rotationY, z: 0 }
});
Match on name or templateZoneId, not on id. A zone materialized from a seat template has a derived
id (seat-zone-<seat>-<templateZoneId>) whose shape is not a contract, while the authored name and the
template id are the same on every seat linked to that template. That is what lets one line of code mean "the
deck area, on whichever seat is asking".
The id you get back is the one api.getZoneObjects and the
zone hooks use, so the two reads compose without any string surgery.
Example
// content/scripting-api/examples/api.listSeatZones.js
// Mod script: put a marker in the seat's own "Deck Area", wherever that is.
// listSeatZones answers WHERE a zone is; getZoneObjects answers what is standing
// in one. Matching on the authored name means the code survives a seat being
// moved, rotated or rescaled in Edit Mode.
// manifest capabilities.allowed: ["log", "read-context", "read-world", "spawn-object"]
/**
* The seat's zone with this authored name, or null.
*
* Match on `name` (or `templateZoneId`), never on `id`: an id materialized from a
* seat template is a derived string whose shape is not a contract.
* @param {ModSeatZone[]} zones
* @param {string} name
*/
function zoneNamed(zones, name) {
const wanted = name.toLowerCase();
for (const zone of zones) {
if ((zone.name || "").toLowerCase() === wanted) {
return zone;
}
}
return null;
}
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const seat = api.getMySeat();
if (!seat) {
// A spectator has no seat, so it has no zones. Nothing to place.
api.log(manifest.name + ": not seated, skipping placement.");
return;
}
const zones = await api.listSeatZones(seat);
const deckArea = zoneNamed(zones, "Deck Area");
if (!deckArea) {
// Say which zone is missing. A silent no-op here reads as a broken mod.
api.log(manifest.name + ': seat "' + seat + '" has no zone named "Deck Area".');
return;
}
api.createObject({
kind: "token",
label: "deck-marker",
displayName: "Deck goes here",
position: { x: deckArea.position.x, y: deckArea.position.y + 0.1, z: deckArea.position.z },
// The zone's own yaw, so the marker faces the seat rather than the table origin.
rotation: { x: 0, y: deckArea.rotationY, z: 0 }
});
const standing = await api.getZoneObjects(seat, deckArea.id);
api.log(manifest.name + ": deck area holds " + standing.length + " entities.");
};
The marker lands in the middle of that seat's deck area, however the seat is placed.
Gotchas
An unclaimed seat has no zones. The scene defines them, but they are only live for a claimed seat at a live table. In Edit Mode every seat's zones are live, so the same call answers differently in the two contexts — correct, and worth knowing before you debug it.
name is the box's name, not the seat's. A zone the author never named reports name: null; the
Hierarchy shows such a row as Zone N, which is a label rather than a name and is not returned here. If
your mod depends on finding a zone by name, say so in your setup instructions — you cannot author the
player's scene for them.
size is a footprint, not a box. A zone has x and z and no height: containment is two-dimensional,
so an object held high above a zone is still inside it. This is the same rule
api.getZoneObjects applies.
position.y is the play surface under the zone, not the box you authored. A zone box is drawn a hair below the table top so its outline does not fight the surface, and that authored plane stays exactly where it was authored if the table is later swapped for a taller one. Reporting it would mean a mod that spawned at position.y + drop created its piles inside the new table, physics ejected them, and they came to rest on the floor — a failure with no visible cause. So this call probes the column instead and answers with the height a piece would rest on. Spawn at position.y, plus a small drop if you want it to settle. Containment still ignores y entirely.
rotationY is degrees, and it is the whole reason to use it. Spawning at a zone's position without its
rotation puts the object in the right place facing the wrong way, which looks like a card-orientation bug
and is not one.
This tells you nothing about occupancy, deliberately. A hidden zone's box is reported like any other —
its outline is drawn on the table — but what stands inside it is not derivable from this call. Ask
api.getZoneObjects, which redacts to the least-privileged
view and drops what you may not see.
It is a snapshot of this instant. Zones change when a seat is claimed, released or re-linked to a template. Read it when you are about to place something rather than caching it at setup.
See also
api.getZoneObjects— what is standing in a zone.onZoneEnter— when the set changes.api.createObject— placing something at the position you found.api.getMySeat— whose zones to ask for.
api.getUnredactedSnapshot#
getUnredactedSnapshot(): Promise<TableSnapshot | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | read-hidden-information |
| Availability | mod |
The host's table state with NO redaction: every face-down card's identity,
every deck's and bag's ordered metadata.cards, every secretMetadata, every
hidden-zone occupant.
This is the migration path for a mod whose logic needs information the six
read-world reads stopped returning on 2026-08-14. It is a separate METHOD,
not a mode, so a reviewer reading your script can see the one line where your
mod asks for secrets.
⚠ A capability cannot grant what the peer does not have. On a player or spectator peer this resolves that peer's own received snapshot — already redacted for it at the wire boundary, and nothing here un-redacts it. Only on the HOST is the answer the full authoritative table. Write logic that adjudicates secrets so it runs on the host, and treat what a non-host peer sees as a bonus.
Resolves null before the first snapshot arrives.
Capability: read-hidden-information — declared in the manifest, never granted
by default, and re-checked HOST-side (unlike most capability gates, forging the
postMessage does not get you past this one).
The table with nothing withheld: real card faces, a deck's and a bag's ordered metadata.cards, every
seat's hand, secretMetadata on every kind, and the entities a hidden seat zone conceals. It is the one
read on this surface that can return hidden information, and the only reason it exists is that
api.getSnapshot and its five siblings stopped returning it.
Parameters
None.
Returns
Promise<TableSnapshot | null>.
null means this client has no table state to give you yet — the mod started before the first snapshot
arrived, or there is no table runtime attached. It never means "the table is empty".
The object is a deep clone taken at the moment the host answered. Mutating it changes nothing.
How, why and when to use it
You are writing a rules engine that has to decide something a player is not allowed to know: whether a
face-down hand beats another, whether the card about to be drawn ends the game, whether a bag's next token
is legal. Every other read on this surface now answers those questions with "Card" and
metadata.__redacted, on purpose. Declare read-hidden-information and call this instead.
Do it once, keep the derived answer, and go back to the narrow reads. This clones the entire table and
carries the whole of the table's secrets into your frame; it is not a call to make inside onTableEvent.
Example
// content/scripting-api/examples/api.getUnredactedSnapshot.js
// Mod script: adjudicate hands a rules engine has to actually see. The
// read-world reads return the least-privileged view, so a mod that needs real
// card faces asks for them by name - and its manifest says so, where a reviewer
// and a player can both read it.
// manifest capabilities.allowed: ["log", "read-hidden-information"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const snapshot = await api.getUnredactedSnapshot();
if (!snapshot) {
api.log(manifest.name + ": no snapshot yet - the table has not sent one.");
return;
}
// On a player or spectator peer this resolves that peer's OWN snapshot, which
// was already redacted for it before it arrived. A capability cannot hand back
// what this client was never sent, so only on the host is this the real table.
const bySeat = new Map();
for (const object of snapshot.objects) {
if (object.kind !== "card" || !object.ownerSeat) {
continue;
}
const identity = object.metadata && object.metadata.cardId
? String(object.metadata.cardId)
: object.label;
const held = bySeat.get(object.ownerSeat) || [];
held.push(identity);
bySeat.set(object.ownerSeat, held);
}
for (const entry of bySeat.entries()) {
api.log(manifest.name + ": seat " + entry[0] + " holds " + entry[1].sort().join(", "));
}
};
On the host of a four-player game the console names four seats and the real cards in each. On a player's client it names one seat — theirs — because that is all their client was ever sent.
Gotchas
A capability cannot grant what the peer does not hold. On a player or spectator peer this resolves that peer's own received snapshot, which the host already redacted at the wire boundary before broadcasting it. Nothing here un-redacts it, and nothing could: the secret was never in that process. Only on the host is this the full authoritative table. Write anything that adjudicates secrets so that it runs on the host, and treat a richer answer elsewhere as a bonus rather than a guarantee.
It is refused, not silently emptied, without the capability. A frame that calls it without
read-hidden-information in manifest.capabilities.allowed gets Missing mod capability: read-hidden-information. Unlike most capability checks on this surface, that refusal is re-made
host-side — forging the underlying message from mod code does not get past it.
The publish scanner sees this call. detectScriptCapabilities matches api.getUnredactedSnapshot( by
name, so a mod that calls it without declaring the capability is rejected at publish with
undeclared-capability. That is deliberate: "this mod reads hidden information" is a fact a reviewer
should be able to establish by reading the script, not only the manifest.
The six read-world reads do not change behaviour because you hold this. api.getSnapshot() returns
the least-privileged view whether or not your manifest also declares read-hidden-information. One call
site, one meaning — you never have to cross-reference a manifest to know what a line of code returns.
Everything you learn here stays in your frame. Putting a hidden card's identity into a UI element, an event-log line or saved data republishes it to every peer. The redaction you just stepped around exists to stop exactly that, and nothing downstream will stop it for you.
See also
api.getSnapshot— the least-privileged read, and the default.api.getContainerContents— why a pile no longer reports its order.api.getHandObjects— hand sizes without hand contents.- Capabilities — what each one grants, and what a player sees.
- Host authority — why the host is the only peer that holds this.
api.getMySeat#
getMySeat(): string | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | read-context |
| Availability | mod |
This peer's seat, or null when unseated. Synchronous — read from context the host pushes in; it can be stale by one update.
Capability: read-context.
Returns the seat the client running this mod is sitting in. It is answered from a small context object the host pushes into the sandbox frame whenever seats, teams or turns change — no message goes out, so the answer is immediate.
Parameters
None.
Returns
string | null.
null means this client is not seated: it is a spectator, it is a player who has not claimed a seat, or —
before the host's first context push — the mod started earlier than the context did. There is no separate
value for "not seated yet" versus "never seated".
A non-null result is the seat name as the table uses it ("red", "blue", …) — the same string that
appears as ownerSeat on entities and as seat in
getHandObjects.
How, why and when to use it
You want the mod to do something for the person at this screen — highlight their pieces, show them a
private prompt, count only their cards. The alternative is
api.getSnapshot and a search for entities owned by a seat, but
the snapshot never says which seat is yours; that fact only exists in the local context. Read it inside a
handler rather than caching it at setup, because a player can change seats mid-game and your cached value
would then be someone else's.
Example
// content/scripting-api/examples/api.getMySeat.js
// Mod script: run seat-specific setup for whoever is sitting at THIS client,
// and keep it correct when they move seats later.
// manifest capabilities.allowed: ["log", "read-context", "read-world", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const describeSeat = async () => {
const seat = api.getMySeat();
if (!seat) {
api.log(manifest.name + ": this client has no seat - spectating.");
return;
}
const hands = await api.getHandObjects(seat);
const mine = hands[0];
api.log(manifest.name + ": you are in the " + seat + " seat holding "
+ (mine ? mine.objectCount : 0) + ".");
};
await describeSeat();
api.on("onSeatChanged", async (payload) => {
// The hook fires for every peer, so only re-read when the seat map moved.
if (payload.seat === api.getMySeat() || payload.previousSeat === api.getMySeat()) {
await describeSeat();
}
});
};
Sitting in the red seat with three cards, the console prints
you are in the red seat holding 3.
Gotchas
The value can be one update stale. It is a cached copy of what the host last pushed, not a question
asked at call time, so it lags a seat change by the length of one context update. Re-read it in an
onSeatChanged handler rather
than trusting a value captured at setup.
It answers for the peer running the mod, not for the host. In a room where the mod script runs on the
host, that is the host's own seat — which is not necessarily the seat whose turn it is. Use
api.getTurn for that.
See also
api.getMyTeam— the same idea for teams.api.getTurn— whose turn it is, and whether it is yours.api.getHandObjects— what that seat is holding.onSeatChanged— when to re-read it.
api.getMyTeam#
getMyTeam(): string | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | read-context |
| Availability | mod |
This peer's team, or null. Synchronous. Capability: read-context.
Returns the team the client running this mod belongs to. Like the seat, it comes out of the context object the host pushes into the sandbox frame, so it costs nothing and answers instantly.
Parameters
None.
Returns
string | null.
null means this client is on no team — which is the normal state for a table that does not use teams at
all, for a spectator, and for a player who has taken a seat but not a side. It is also what you get before
the host's first context push.
A non-null result is the team name the table uses, the same string that appears as team in the
onTeamChanged and
onSeatChanged payloads.
How, why and when to use it
You are writing a two-sided game and each client should only be told about its own side's pieces —
scores, remaining units, whose reinforcements are due. The alternative is to key everything on the seat
from api.getMySeat, which works right up until two seats share a
side and you find yourself maintaining a seat-to-team map the table already has. Use the team when the rule
is about the side; use the seat when the rule is about the person.
Teams are not a permission boundary. A mod on a rival client can read its own team the same way; what stops it seeing your cards is snapshot redaction, not this call.
Example
// content/scripting-api/examples/api.getMyTeam.js
// Mod script: a team game where each side only reports its own pieces. The team
// is read locally, so every client logs its own side and nobody else's.
// manifest capabilities.allowed: ["log", "read-context", "read-world", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const reportMyPieces = async () => {
const team = api.getMyTeam();
if (!team) {
api.log(manifest.name + ": this client is on no team yet.");
return;
}
const pieces = await api.listObjects({ kind: "token", tag: "team-" + team });
api.log(manifest.name + ": team " + team + " has " + pieces.length + " pieces.");
};
await reportMyPieces();
api.on("onTeamChanged", async (payload) => {
api.log(manifest.name + ": " + payload.peerId + " moved to team "
+ (payload.team || "none") + ".");
await reportMyPieces();
});
};
On a client assigned to team A with six tagged tokens, the console prints
team A has 6 pieces.
Gotchas
The value can be one update stale, for the same reason getMySeat can: it is the last context the host
pushed in, not a live question. Re-read it inside an onTeamChanged handler.
A team change does not imply a seat change, and the reverse is not true either. The two are tracked
separately and raise separate hooks. A mod that only listens for onSeatChanged will miss a player
switching sides in place.
See also
api.getMySeat— the per-person identity instead of the per-side one.onTeamChanged— when to re-read it.api.listObjects— finding the pieces a side owns.- Host authority — what a non-host client is allowed to see.
api.getTurn#
getTurn(): ModTurnInfo;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | read-context |
| Availability | mod |
Current turn state for THIS peer. Synchronous. Capability: read-context.
Reports whether turn order is running, whose turn it is, and whether that is the client running this mod. It is read out of the pushed context object, so it is a plain synchronous read with no round-trip.
Parameters
None.
Returns
ModTurnInfo — always an object, never null.
| Field | Type | Meaning |
|---|---|---|
enabled |
boolean |
true while the host has turn order switched on. Defaults to false before the first context push. |
activePeerId |
string | null |
The peer whose turn it is, or null when turn order is off or the order is empty. |
isMyTurn |
boolean |
Resolved for this client. Defaults to false before the first context push. |
When enabled is false, treat activePeerId and isMyTurn as meaningless rather than as "nobody's
turn" — free play has no active player.
How, why and when to use it
Your game only lets a player act on their own turn, and you want the mod to check that before it does
anything on their behalf. The alternative is the
onTurnStart hook, which tells
you the moment a turn begins — that is the right tool for "do this when the turn changes". getTurn is
the right tool for "is it my turn right now", which is the question you have inside a button handler or a
draw handler, long after the turn began. Use both: the hook to react, this call to gate.
Example
// content/scripting-api/examples/api.getTurn.js
// Mod script: only nudge the player whose turn it actually is. `isMyTurn` is
// resolved for THIS client, so each peer answers for itself.
// manifest capabilities.allowed: ["log", "read-context", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
/** @param {string} reason */
const announce = (reason) => {
const turn = api.getTurn();
if (!turn.enabled) {
api.log(manifest.name + ": turn order is off (" + reason + ").");
return;
}
api.log(manifest.name + ": " + reason + " - "
+ (turn.isMyTurn
? "it is your turn."
: "waiting on " + (turn.activePeerId || "nobody") + "."));
};
announce("startup");
api.on("onTurnChanged", () => { announce("turn changed"); });
api.on("onCardDrawn", () => { announce("card drawn"); });
};
With turn order off, every line reads turn order is off (startup).; switch it on and the same handler
prints turn changed - it is your turn. on the active player's client only.
Gotchas
The value can be one update stale. It reflects the last context the host pushed, so immediately after a
turn advances the answer may still describe the previous turn. In an
onTurnChanged handler, prefer
the payload's own activePeerId over calling this.
isMyTurn is per client, and every other field is not. enabled and activePeerId describe the
table; isMyTurn describes the peer that asked. Two clients calling this at the same moment get the same
first two fields and different third ones.
Nothing here can stop a player acting out of turn. A mod has no veto: by the time you read this, any action that happened has already been applied and broadcast. Turn enforcement belongs to the host's own turn settings. See Known limitations.
See also
onTurnStart— the moment a turn begins.onTurnChanged— the turn state changing at all.api.getMySeat— who this client is.ModTurnInfo— the returned shape.
api.getSavedData#
getSavedData(scope?: ModSavedDataScope): Promise<string | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | saved-data |
| Availability | mod |
This mod's saved data — table-wide, or for one object with { objectId }.
Resolves null when nothing has been stored.
The value is an opaque string (JSON.stringify your own shape). It rides
EVERY snapshot broadcast, so keep it small.
Capability: saved-data.
Reads back a string this mod stored earlier — either the mod's one table-wide slot, or a slot attached to a
single entity. The mod's own id is injected by the host, so a mod can only ever address its own storage;
scope never names a mod.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
scope |
ModSavedDataScope |
no | Omit it for the table-wide slot. Pass { objectId } for the per-entity slot. The frame only forwards objectId when it is a string, so { objectId: 42 } silently reads the table-wide slot instead. |
Returns
Promise<string | null>.
null means "nothing is stored there", and it covers four distinct situations that are not distinguishable
from the outside:
- the mod has never written to that slot;
- a previous write stored an empty string, which clears the slot rather than storing it;
{ objectId }names an entity that is not on the table right now;{ objectId }(or the mod id) does not match/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/i.
A non-null result is exactly the string that was stored. It is opaque to the platform — JSON.stringify
your own shape on the way in and parse it here, defensively.
How, why and when to use it
Your game has state the table itself has no concept of: a score, a round number, which player has already
used a once-per-game ability. Entity properties cannot carry it, and a variable in your script is gone the
moment the mod restarts. Saved data rides the snapshot, so it survives a reload, a rejoin and a host
migration — read it at setup and treat null as "new game" rather than as an error. The per-entity scope
is the right choice when the value belongs to one piece (a token's charge count); the table-wide slot is
right for anything about the game as a whole.
Example
// content/scripting-api/examples/api.getSavedData.js
// Mod script: restore a running score that survived a save, a reload or a host
// migration. Nothing is stored yet on a brand new table, so plan for null.
// manifest capabilities.allowed: ["log", "saved-data"]
const EMPTY_SCORE = { round: 1, points: /** @type {Record<string, number>} */ ({}) };
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const stored = await api.getSavedData();
if (stored === null) {
api.log(manifest.name + ": no saved score - starting at round 1.");
return;
}
let score = EMPTY_SCORE;
try {
score = JSON.parse(stored);
} catch (error) {
api.log(manifest.name + ": saved score is not valid JSON - starting over.");
return;
}
const seats = Object.keys(score.points || {});
api.log(manifest.name + ": resumed round " + score.round
+ " with " + seats.length + " scored seats.");
for (const seat of seats) {
api.log(manifest.name + ": " + seat + " has " + score.points[seat] + " points.");
}
};
On a fresh table the console prints no saved score - starting at round 1.; after a reload of a game in
progress it prints resumed round 4 with 2 scored seats. and one line per seat.
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 stored string is not validated for you. It is whatever was written, possibly by an older version of
your own mod. Parse inside a try/catch and have a defined answer for a value you cannot read.
Saved data rides every snapshot broadcast. The whole map is part of the replicated table state, so a large value is re-sent to every peer on every broadcast. Keep it to the smallest thing that works — a 16 KiB write is the hard ceiling, and a value anywhere near it is a bandwidth problem long before it is a storage one.
See also
api.setSavedData— writing it, and what rejects a write.ModSavedDataScope— the scope object.TableSnapshot—modSavedDataandmodObjectSavedDataas they ride the wire.- Mod capabilities — what
saved-datagrants, and its per-mod namespacing.
api.setSavedData#
setSavedData(data: string, scope?: ModSavedDataScope): Promise<boolean>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | saved-data |
| Availability | mod |
Persist this mod's saved data. Namespaced to the mod; another mod's slot is
unreachable. Non-string values are coerced with String(...).
HOST ONLY: on a player or spectator peer the promise REJECTS with
"Only the host can persist mod saved data." — the same rule as
setUiElement. It also rejects for an object scope whose id is not on the
table.
Resolves true on success and never false; failure always arrives as a
rejection, so use try/catch rather than testing the result.
Capability: saved-data.
Stores one string for this mod, either table-wide or against a single entity. The host injects the mod's
own id, so the write always lands in this mod's namespace and no scope can reach another mod's slot. The
value becomes part of the table's replicated state and is persisted with it.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
data |
string |
yes | Coerced with String(data ?? "") in the frame, so a number or object is stringified rather than rejected — {} becomes "[object Object]". Serialize deliberately. Writing "" clears the slot instead of storing an empty value. |
scope |
ModSavedDataScope |
no | Omit for the table-wide slot; { objectId } for a per-entity slot. Only forwarded when objectId is a string. |
Returns
Promise<boolean>.
It resolves true on every accepted write — the host posts a literal true back after storing. It never
resolves false, so do not read the value as a success flag.
Failure arrives as a rejection, not as false. The promise rejects with the thrown message when:
- the client running the mod is a
playerorspectator—Only the host can persist mod saved data. - the value is over 16 KiB (16384 bytes, measured as UTF-8) —
Saved data exceeds 16384 bytes. { objectId }names an entity that is not on the table —Cannot persist saved data for unknown object.- the mod id or object id does not match
/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/i—Invalid object id for saved data key.
Wrap the call in try/catch if your mod can run anywhere but the host.
How, why and when to use it
You need a score, a round counter or a "this ability has been used" flag to survive a reload and a host
migration. The alternative is a variable in your script, which is correct and free right up until the tab
reloads or the host changes and every value resets with no warning. Write on change, not on a timer, and
write the smallest representation you can — the whole value is re-broadcast to every peer with the next
snapshot. For anything belonging to one piece, prefer the { objectId } scope so the value dies with the
piece rather than accumulating in a table-wide blob.
Example
// content/scripting-api/examples/api.setSavedData.js
// Mod script: count the cards drawn this game and persist the count, so a
// reload or a host migration does not reset it.
// manifest capabilities.allowed: ["log", "saved-data", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const stored = await api.getSavedData();
let draws = 0;
if (stored !== null) {
const parsed = Number.parseInt(stored, 10);
draws = Number.isFinite(parsed) ? parsed : 0;
}
api.log(manifest.name + ": resuming at " + draws + " draws.");
api.on("onCardDrawn", async () => {
draws += 1;
try {
const accepted = await api.setSavedData(String(draws));
api.log(manifest.name + ": draw " + draws
+ (accepted ? " saved." : " was refused."));
} catch (error) {
// Rejects on a player or spectator peer, and on anything over 16 KiB.
api.log(manifest.name + ": could not save - " + String(error));
}
});
};
On the host the console prints resuming at 0 draws., then one draw 1 saved. line per card drawn. On a
player peer the same script prints
could not save - Error: Only the host can persist mod saved data.
Gotchas
Resolves once the host has accepted the write. The value reaches other peers with the next snapshot, not when this resolves. A second peer reading immediately after your promise settles can still see the old value.
The empty string is a delete, not a value. setSavedData("") removes the slot, and a following
getSavedData() resolves null. If empty is meaningful to your game, store a sentinel such as "{}".
Non-strings are coerced, not rejected. String(...) runs in the frame, so passing an object stores
"[object Object]" and no error is raised anywhere. Call JSON.stringify yourself.
See also
api.getSavedData— reading it back, and whatnullmeans.ModSavedDataScope— the scope object.- Host authority — why a player peer is refused.
- Async and snapshots — acknowledged writes versus visible state.
api.getUiState#
getUiState(): Promise<TableUiState | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | ui |
| Availability | mod |
The whole UI tree (all mods). Resolves { revision, elements } whenever the
table runtime exists — an empty elements array means no UI, NOT null.
Null only when there is no runtime at all (e.g. before the table mounts).
Capability: ui.
Returns the table's whole UI tree in one read — a revision number and every live element, from every mod
that has one. It is the UI counterpart of
api.getSnapshot: a single structured read of everything, which
you then filter yourself.
Parameters
None.
Returns
Promise<TableUiState | null>.
null means there is no table runtime attached to this client — the mod is running somewhere with no
table to describe. It does not mean "no elements": a table with an empty UI resolves
{ revision, elements: [] }.
revision increments as the UI tree changes, which makes it a cheap way to notice that something moved
without diffing the elements. elements is ordered by ancestry and then by each element's order field,
so a parent precedes its children.
Every element carries an ownerModId that the host sets and a mod cannot spoof, so filtering to
ownerModId === manifest.id is how you find your own.
How, why and when to use it
Your mod restarts — a reload, a rejoin, a host migration — and you need to know whether the panel you built
last time is still there before you build it again. The alternative is
api.listUiElements, which returns the same element array
without the revision number and is the better call when the array is all you want. Use getUiState when
you care about revision: storing it and comparing later tells you the tree changed without you having to
compare every element, which is worth it inside a frequently-fired handler.
Example
// content/scripting-api/examples/api.getUiState.js
// Mod script: report the table UI as one tree - how many elements exist, how
// many belong to this mod, and which revision they are at.
// manifest capabilities.allowed: ["log", "ui", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
/** @param {string} reason */
const describeUi = async (reason) => {
const state = await api.getUiState();
if (!state) {
api.log(manifest.name + ": no table UI is running (" + reason + ").");
return;
}
const mine = state.elements.filter((element) => element.ownerModId === manifest.id);
const screen = mine.filter((element) => element.presentation.mode === "screen");
api.log(manifest.name + ": revision " + state.revision + ", "
+ state.elements.length + " elements, " + mine.length + " mine ("
+ screen.length + " screen-anchored) - " + reason + ".");
};
await describeUi("startup");
api.on("onUiEvent", async (payload) => {
await describeUi("after " + payload.interaction + " on " + payload.elementId);
});
};
With one button of your own on a table that also has another mod's panel, the console reads
revision 12, 2 elements, 1 mine (1 screen-anchored) - startup.
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.
You can read every mod's elements and write only your own. getUiState deliberately returns the whole
tree so a mod can position itself around what is already there;
api.setUiElement and
api.deleteUiElement are scoped to your ownerModId by the
host. Trying to update another mod's element throws UI element <id> is owned by another mod.
Unlike the two write methods, this one works on every peer. Reading the UI is not host-gated, so a player's client can inspect the tree it has been sent even though it cannot change it.
See also
api.listUiElements— the same elements without the revision.api.setUiElement— creating and updating one.TableUiState— the returned shape.- Table UI widget types — the eight element types and what each does.
api.listUiElements#
listUiElements(): Promise<TableUiElementState[]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | ui |
| Availability | mod |
Every live UI element (all mods). Capability: ui.
Returns every live table UI element, from every mod, as a flat ordered array. It is the read you make before you write: find out what already exists, and update it instead of creating a duplicate.
Parameters
None.
Returns
Promise<TableUiElementState[]>.
Always an array, never null. It is empty when the table has no UI elements and also when this client has
no table runtime attached — the two are indistinguishable here, so use
api.getUiState if you need to tell them apart.
The order is ancestry first, then each element's order field, so a container precedes the elements
nested inside it. Every entry carries ownerModId, which the host sets from the calling mod's id and which
a mod cannot forge — filter on ownerModId === manifest.id to get your own.
How, why and when to use it
Your mod's setup runs again — after a reload, a rejoin or a host migration — and the panel it built the
first time is still on the table. Rebuilding blind would either duplicate it or, if you reuse the same
id, quietly overwrite whatever state the players had put into it. Listing first lets you adopt what is
there. The alternative is to always write with a fixed id and let the upsert take care of it, which is
simpler and is the right call for a static label; list first when the element carries state you would
rather not reset, or when you need to know how many you already have.
Example
// content/scripting-api/examples/api.listUiElements.js
// Mod script: after a reload, find the elements this mod already put on the
// table instead of creating a second copy of each one.
// manifest capabilities.allowed: ["log", "ui"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const elements = await api.listUiElements();
const mine = elements.filter((element) => element.ownerModId === manifest.id);
const others = elements.length - mine.length;
if (mine.length === 0) {
api.log(manifest.name + ": no elements of mine yet ("
+ others + " belong to other mods).");
await api.setUiElement({
id: manifest.id + "-scoreboard",
type: "text",
// "upper-right" is the mod-safe spelling; it is stored as the canonical
// value, which is what a read-back element reports. See Gotchas.
presentation: { mode: "screen", anchor: "upper-right", offsetX: -16, offsetY: 0 },
props: { text: "Score: 0" }
});
api.log(manifest.name + ": created the scoreboard.");
return;
}
for (const element of mine) {
api.log(manifest.name + ": reusing " + element.type + " " + element.id
+ " at order " + element.order + ".");
}
};
First load: no elements of mine yet (0 belong to other mods). then created the scoreboard. After a
reload the same script prints reusing text my-mod-scoreboard at order 0.
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.
An element you created with an upper-* anchor reads back with the canonical spelling. The three
top-row anchors have a mod-safe input alias — upper-left, upper-center, upper-right — because the
canonical spellings contain the whole word top, which the static scanner rejects on sight, in a string as
readily as in code.
By design. The scanner runs five whole-word regular expressions over raw script text with no lexing, and
topis one of the tokens on the DOM rule (packages/shared/src/modManifest.ts,bannedScriptPatterns). It cannot tell apresentation.anchorstring from a reference to the enclosing frame, and matching inside strings is what makes the rule worth having; the trade is stated in the scanner's own note: false positives are acceptable, false negatives are boundary risks. So the rule was left alone and the schema grew the aliases instead (packages/shared/src/tableObjects.ts,TABLE_UI_SCREEN_ANCHOR_ALIASES). They are normalized away at every parse site, so this method — andgetUiState, and every snapshot — only ever reports the canonical nine. Do not compare a read-back anchor against an alias; it will never match. Per-rule workarounds are on Script safety.
This is a read of every mod's UI, not only yours. Nothing stops you enumerating another mod's elements; what stops you changing them is the ownership check on write.
See also
api.getUiState— the same elements plus a revision number.api.setUiElement— creating or updating one of your own.api.deleteUiElement— removing one.- What gets rejected — the scanner rule behind the anchor limitation.
api.setUiElement#
setUiElement(element: TableUiElementDefinition): Promise<TableUiElementState | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | ui |
| Availability | mod |
Create or update ONE UI element owned by this mod. Resolves with the stored element, or null when the payload was not an object.
HOST ONLY: on a player or spectator peer the promise REJECTS with "Only the host can update mod UI state."
Capability: ui.
Creates or updates one table UI element owned by this mod. It is an upsert keyed on id: supply an id
that already exists and the element is updated in place, omit it and the host mints one and creates a new
element. This — not api.registerAction — is how a mod puts a
control in front of players.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
element |
TableUiElementDefinition |
yes | The frame forwards {} when the argument is not an object, and the host then resolves null rather than throwing. |
The fields that decide the outcome:
| Field | Type | Notes |
|---|---|---|
id |
string |
Trimmed. Empty or absent means create; the host mints a UUID. An id owned by another mod throws UI element <id> is owned by another mod. |
type |
TableUiWidgetType |
Any value outside the eight becomes panel, silently. On an update, an absent type keeps the existing one. |
parentId |
string | null |
Trimmed. An id that does not exist throws UI parent element not found: <id>. An element naming itself is reset to a root element instead of erroring. |
order |
number |
Used only when it is an integer ≥ 0; otherwise the existing order is kept, or the next free slot under that ancestor is used. |
presentation |
TableUiPresentationInput |
{ mode: "world" } by default. { mode: "screen", anchor, offsetX, offsetY } pins it to the viewport. For the top row write upper-left / upper-center / upper-right — see Gotchas. An unrecognized anchor becomes top-left; an unrecognized mode becomes { mode: "world" }. |
visibility |
TableUiVisibilityTarget |
{ scope: "all" } by default; seat, team and players scopes narrow who renders it. |
props |
Record<string, unknown> |
Per type. button reads text, disabled, onClick, hook; checkbox reads text, checked, onChange, hook; input reads value, placeholder, onChange, hook; text reads text. |
ownerModId |
string |
Ignored on input — the host sets it from the calling mod and it cannot be spoofed. |
Applies to: all eight widget types, but only button, checkbox, input and select ever dispatch an
interaction. See Gotchas.
Returns
Promise<TableUiElementState | null>.
A non-null result is the stored element as the table now holds it, with the host's id, ownerModId
and every defaulted field filled in.
null means the write was dropped, for one of three reasons and with no way to tell them apart: the
payload was not an object, the table already holds 2000 UI elements, or the table has already applied 120
UI mutations this tick. The last two write a warning to the mod console.
The promise rejects rather than resolving null when the client is a player or spectator —
Only the host can update mod UI state. — and for the ownership and ancestor errors above.
How, why and when to use it
You want players to be able to press something: end the round, draw a card, raise a bid. registerAction
looks like the method for that and is not — it writes a log line and nothing calls back. The working
mechanism is this one: create a button, give its props an onClick hook name, and subscribe to that name
with api.on. The payload you get back names the element, the actor's
peer id and their role, which is everything a rule needs. Use a fixed id so a restart updates your
control instead of adding a second one.
Example
// content/scripting-api/examples/api.setUiElement.js
// Mod script: the working way to give players a button. Create a `button` with
// an `onClick` hook name, then subscribe to that name.
// manifest capabilities.allowed: ["log", "ui", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
let rolls = 0;
const buttonId = manifest.id + "-roll-button";
try {
const element = await api.setUiElement({
id: buttonId,
type: "button",
presentation: { mode: "screen", anchor: "bottom-center", offsetX: 0, offsetY: 24 },
visibility: { scope: "all" },
props: { text: "Roll for initiative", onClick: "rollForInitiative" }
});
api.log(element
? manifest.name + ": button " + element.id + " is live."
: manifest.name + ": the table refused the button definition.");
} catch (error) {
// Rejects on a player or spectator peer: only the host owns mod UI state.
api.log(manifest.name + ": no button here - " + String(error));
return;
}
api.on("rollForInitiative", async (payload) => {
rolls += 1;
api.log(manifest.name + ": " + (payload.actorPeerId || "someone")
+ " rolled (" + rolls + " so far).");
await api.setUiElement({ id: buttonId, type: "button", props: { text: "Rolled " + rolls } });
});
};
On the host the console prints button my-mod-roll-button is live., and each press adds
peer-3f2a rolled (1 so far). while the button's own label counts up.
Gotchas
Resolves once the host has accepted the write. The element reaches other peers with the next snapshot, not when this resolves.
A hook on the wrong widget type never fires.
Known gap. Only
button,checkboxandinputread an interaction hook from a widget's props — abuttonfromonClickfalling back tohook, acheckboxand aninputfromonChangefalling back tohook(apps/web/src/ui/App.tsx, the mod UI element renderer).text,panel,canvasandlayoutaccept the prop, store it and replicate it, and never dispatch anything, because none of them has an interaction to dispatch from. Every widget type renders and nests correctly, and the three interactive types fire reliably. Put the hook on thebutton,checkboxorinputinside the container rather than on the container. See Known limitations.
Write the top row as upper-left / upper-center / upper-right. The canonical spellings contain the
whole word top, which the static scanner rejects on sight — inside a string as readily as in code. The
three upper-* aliases are accepted here and normalized to the canonical value before anything is stored,
so the element you get back reports top-center even though you sent upper-center. Compare a read-back
anchor against the canonical value, never against the alias. The other six anchors have one spelling each.
See Table UI widget
types and
Known limitations.
An unrecognized type becomes panel, not an error. A typo such as "buton" produces an empty
container that renders nothing and dispatches nothing, with no diagnostic anywhere.
See also
api.deleteUiElement— taking it away again.api.on— subscribing to the hook name you gaveonClick.onUiEvent— the payload every interaction sends.- Table UI widget types — the eight types and which ones dispatch.
api.registerAction— the method that looks like this one and is not.
api.deleteUiElement#
deleteUiElement(elementId: string): Promise<boolean>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | ui |
| Availability | mod |
Delete one UI element owned by this mod. Resolves false when the id was empty
or no such element exists. Host-only, like setUiElement.
Capability: ui.
Removes one table UI element this mod owns. It is the counterpart to
api.setUiElement, and it is scoped the same way: the host
matches the element's ownerModId against the calling mod, so a mod can only delete its own.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
elementId |
string |
yes | Coerced with String(elementId ?? "") in the frame, then trimmed on the host. An empty result short-circuits and resolves false without touching the tree. |
Returns
Promise<boolean>.
true means an element was removed, along with every descendant of it that this mod also owns. A
descendant belonging to a different mod is left in place.
false means nothing was removed: the id was empty, no element has that id, the element belongs to another
mod, or the table has already applied 120 UI mutations this tick. Those four are not distinguishable, and
none of them is an error — deleting something that is already gone is safe and idempotent.
The promise rejects with Only the host can update mod UI state. when the client running the mod is a
player or a spectator, exactly as setUiElement does.
How, why and when to use it
A control should only exist while it is usable — a "next turn" button while turn order is on, a bidding
panel during the bidding phase. The alternative is to leave the element in place and set
props.disabled = true with setUiElement, which keeps its position and any state it holds and is the
better choice when the control comes back a moment later. Delete when the element is finished with, or
when leaving it would take up screen space during the rest of the game — a deleted element is gone from
every peer's snapshot, so it also stops costing anything to replicate.
Example
// content/scripting-api/examples/api.deleteUiElement.js
// Mod script: show a "your turn" banner only while turn order is on, and take
// it away again the moment it stops.
// manifest capabilities.allowed: ["log", "ui", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
const bannerId = manifest.id + "-turn-banner";
api.on("onTurnChanged", async (payload) => {
try {
if (!payload.enabled) {
const removed = await api.deleteUiElement(bannerId);
api.log(removed
? manifest.name + ": banner removed."
: manifest.name + ": no banner was there to remove.");
return;
}
await api.setUiElement({
id: bannerId,
type: "text",
presentation: { mode: "screen", anchor: "middle-center", offsetX: 0, offsetY: 12 },
props: { text: "Active: " + (payload.activePeerId || "nobody") }
});
api.log(manifest.name + ": banner shows " + (payload.activePeerId || "nobody") + ".");
} catch (error) {
api.log(manifest.name + ": UI is host-only here - " + String(error));
}
});
api.log(manifest.name + ": turn banner wired up.");
};
Starting turns prints banner shows peer-3f2a.; stopping them prints banner removed., and stopping them
a second time prints no banner was there to remove.
Gotchas
Resolves once the host has accepted the delete. The removal reaches other peers with the next snapshot, not when this resolves.
false is not an error and is often the correct answer. Calling this on a table where your element was
never created — a fresh room, or a peer that reloaded — resolves false and does nothing. Treat it as
"already absent" rather than as a failure to retry.
Deleting a container takes your own descendants with it, and nothing you were tracking. Every descendant owned by this mod goes too, so a whole panel is one call — but anything you were holding by id in your script needs clearing yourself.
See also
api.setUiElement— creating, updating, andprops.disabledas the alternative.api.listUiElements— checking what exists before you delete.onTurnChanged— the hook this example is driven by.- Host authority — why a player peer is refused.
api.playSound#
playSound(params: PlaySoundParams): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | play-sound |
| Availability | mod |
Play a one-shot spatial sound. Ephemeral and never persisted. Semantic only — a mod names a (material, action), an object, or its OWN declared sound; it can never name a first-party clip. Invalid params are dropped with a diagnostic.
Capability: play-sound.
Plays a one-shot spatial sound. It is ephemeral: nothing about it is stored in the table state, nothing is persisted with a save, and it does not appear in any snapshot. A mod names a sound semantically — a material and an action, an entity, or one of its own declared sounds — and never a clip id, a file path or a URL.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
params |
PlaySoundParams |
yes | Passed through the frame untouched, then parsed against the shared schema on the host. A parse failure drops the sound and writes a diagnostic. |
Three mutually exclusive forms:
| Form | Shape | What it plays |
|---|---|---|
| Semantic | { event: { material?, action }, position?, volume?, loop? } |
A first-party sound resolved from the material and action. material defaults to generic. |
| Entity-relative | { objectId, action, volume?, loop? } |
That entity's resolved set, honoring its own soundSetOverrides. Nothing plays if the id is not on the table. |
| Mod-declared | { modSound, position?, volume?, loop? } |
A sound this mod declared in its manifest soundSets. Another mod's name resolves nothing. |
| Shared field | Type | Notes |
|---|---|---|
position |
[number, number, number] |
⚠ A tuple, not the { x, y, z } object every other position on this surface uses. World units are feet. Available on the semantic and mod-declared forms only — the entity-relative form takes its position from the entity. |
volume |
number |
0–1 gain for this one play. Outside that range the schema rejects the whole call. |
loop |
boolean |
Starts a looping sound rather than a one-shot. |
Applies to: every object kind in the entity-relative form; the action has to be one the kind's sound map binds, or nothing plays.
How, why and when to use it
You want a flourish the table would never make on its own — a fanfare when a player wins the bid, a knock
when your game accepts a play. The table already plays a sound for physical events (a card landing, dice
rolling), so do not use this to duplicate those; you will get two. The alternative for a permanent change
is api.setObjectSound, which replaces what an entity sounds
like from then on and is replicated and persisted. Use playSound for a moment, setObjectSound for a
property.
Example
// content/scripting-api/examples/api.playSound.js
// Mod script: a one-shot flourish when a card is drawn, plus a positional
// wooden knock over the deck it came from.
// manifest capabilities.allowed: ["log", "play-sound", "read-world", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
api.on("onCardDrawn", async (payload) => {
// Form 1: a semantic first-party sound, placed in the world.
const objectId = payload.event.objectId;
const source = objectId ? await api.getObject(objectId) : null;
if (source) {
api.playSound({
event: { material: "wood", action: "place" },
position: [source.position.x, source.position.y, source.position.z],
volume: 0.6
});
api.log(manifest.name + ": knock played over " + source.label + ".");
return;
}
// Form 2: let the entity resolve its own set, honoring its overrides.
if (objectId) {
api.playSound({ objectId: objectId, action: "withdraw", volume: 0.8 });
api.log(manifest.name + ": played the entity's own withdraw sound.");
return;
}
api.log(manifest.name + ": a draw with no entity to sound.");
});
api.log(manifest.name + ": draw sounds armed.");
};
Each draw prints knock played over main-deck. and plays a wooden knock at the deck's position.
Gotchas
This returns immediately and reports nothing. There is no return value and no promise, so a dropped
sound — bad params, an unknown objectId, a modSound this mod never declared — is invisible at the call
site. The only signal is a diagnostic in the mod console.
position is a tuple here and an object everywhere else on this surface. { x, y, z } is what
createObject takes and what every entity reports; playSound takes [x, y, z]. Passing the wrong one
fails the schema and drops the sound silently.
Nothing about a sound is persisted or replayed. It is sent on the unreliable channel as an ephemeral event, so it can be lost under packet loss and it never appears in a saved table. Do not use a sound as the signal that something happened — log it or store it as well.
See also
api.setObjectSound— changing what an entity sounds like permanently.PlaySoundParams— the three forms as declared.- Sound materials and sound actions — the two semantic axes.
- Runtime sound events — the moments the table makes a sound on its own.
api.setObjectSound#
setObjectSound(objectId: string, action: SoundAction, ref: SoundRef | null): void;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | play-sound |
| Availability | mod |
Replace (or clear, with null) an object's sound for one action. Persisted and
replicated. ref must be a semantic builtin or a sound THIS mod declared;
anything else is dropped with a diagnostic.
Capability: play-sound.
Changes what one entity sounds like for one action, permanently. Unlike
api.playSound, this is table state: it travels as a
set-object-sound intent, lands in the entity's soundSetOverrides, replicates to every peer and is
saved with the table.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
objectId |
string |
yes | Coerced with String(objectId ?? ""). An empty id is dropped host-side with a diagnostic. |
action |
SoundAction |
yes | Coerced to a string, then parsed against the 17-value sound-action enum. Anything else is dropped with a diagnostic. |
ref |
SoundRef | null |
yes | null clears the override for that action, so the entity falls back to its own material. undefined is treated as null. |
A ref is one of two shapes, and only two:
| Shape | Meaning |
|---|---|
{ kind: "builtin", material } |
A first-party sound, named semantically by material. The engine picks the clip. |
{ kind: "mod", modId, name } |
A sound this mod declared in its manifest soundSets. modId must equal your own mod id and name must be one you declared, or the host drops the call with A mod may only set overrides to its own declared sounds. |
Applies to: every object kind. The override is stored per action, so an entity can have a metal
place and its default pickup at the same time.
How, why and when to use it
Your game ships wooden tiles that should sound like wood, or metal coins that should not sound like the
plastic tokens they are built from. The alternative is to set material on each entity when you spawn it,
which is simpler and is the right answer when the whole entity is made of one thing — the material drives
every action's sound at once. Reach for setObjectSound when one action should differ from the rest: a
chest that opens with a creak but is placed like wood, a coin that only sounds metallic when it lands.
Pass null to undo it.
Example
// content/scripting-api/examples/api.setObjectSound.js
// Mod script: make this game's metal tokens sound like metal when they land,
// and clear the override again if a token stops belonging to the game.
// manifest capabilities.allowed: ["log", "play-sound", "read-world"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const coins = await api.listObjects({ kind: "token", tag: "coin" });
if (coins.length === 0) {
api.log(manifest.name + ": no coins to re-voice.");
return;
}
for (const coin of coins) {
api.setObjectSound(coin.id, "place", { kind: "builtin", material: "metal" });
api.setObjectSound(coin.id, "pickup", { kind: "builtin", material: "metal" });
}
api.log(manifest.name + ": " + coins.length + " coins now land like metal.");
const scenery = await api.listObjects({ kind: "token", tag: "scenery" });
for (const piece of scenery) {
// null clears the override; the entity falls back to its own material.
api.setObjectSound(piece.id, "place", null);
}
api.log(manifest.name + ": cleared overrides on " + scenery.length + " scenery pieces.");
};
With eight tagged coins the console prints 8 coins now land like metal. and every one of them changes
sound for every player at the table.
Gotchas
This returns immediately. The override is not visible in any entity you already read — including one you
fetched a line earlier — until the next snapshot. Re-read with
api.getObject and check soundSetOverrides if you need to
confirm it landed.
A rejected call is silent at the call site. The host validates the action, the ref shape and the ref's ownership, and drops the whole call with a mod-console diagnostic if any of them fails. There is no return value and no throw, so a typo in an action name produces nothing but a console line.
A mod can only point at first-party sounds semantically or at its own declared ones. A { kind: "mod" }
ref naming another mod, or a name this mod never declared in its manifest, is refused. That is a
licensing and ownership boundary rather than a gap: first-party clips are never addressable by id from a
mod, and they are not expected to become so. Declare what you need in soundSets and reference it by name.
See also
api.playSound— a one-shot that changes nothing.- Sound actions — the 17 values
actionaccepts. - Sound materials — the 8 values a
builtinref names. - Runtime sound events — which moments map to which action, per kind.
- Manifest reference — declaring your own
soundSets.
api.on#
on<K extends ModHookEventName>(eventName: K, handler: (payload: ModHookEventMap[K]) => void): void;
on(eventName: string, handler: (payload: ModUiEventPayload) => void): void;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | subscribe-events |
| Availability | mod |
Register a hook handler. Append-only: registering twice runs the handler twice and there is no way to unsubscribe. A handler that throws is reported as a diagnostic and does not stop the others.
The event name may also be a custom UI hook you named in a widget's
onClick / onChange / hook prop, in which case the payload is a
ModUiEventPayload.
Capability: subscribe-events.
Registers a handler for one hook name. It is the only way a mod learns that anything happened — there is no polling loop and no tick. The registration is entirely local to the sandbox frame: the call posts no message, and the host pushes hook events in whether or not anything is listening.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
eventName |
ModHookEventName | string |
yes | One of the ten declared hooks, or any custom name you gave a widget's onClick, onChange or hook prop. Used verbatim as an object key, so it is case-sensitive and a typo silently registers a handler nothing will ever dispatch to. |
handler |
(payload) => void |
yes | Pushed onto the list for that name. It is not type-checked or validated at registration; a non-function is stored and throws when the hook fires. |
The declared overload types handler's payload from
ModHookEventMap; the string overload types
it as ModUiEventPayload, which is what
every custom UI hook carries.
How, why and when to use it
Everything a mod does after setup returns starts here: a card was drawn, a turn began, a player pressed
your button. There is no alternative on this surface — no addEventListener, no delegate object, no
polling primitive — so the question is not whether to use api.on but which hook to attach to.
onTableEvent fires for every logged action and is the tempting first choice; prefer the narrow hook when
one exists, because the narrow ones have already done the filtering you would otherwise write, and
onTableEvent will run your handler on every unrelated move at the table.
Example
// content/scripting-api/examples/api.on.js
// Mod script: a scoreboard driven entirely by hooks. `onTableEvent` fires for
// every log line, so the narrower hooks come first and it stays last.
// manifest capabilities.allowed: ["log", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
let draws = 0;
let moves = 0;
api.on("onCardDrawn", () => {
draws += 1;
api.log(manifest.name + ": draw number " + draws + ".");
});
api.on("onObjectDropped", (payload) => {
moves += 1;
api.log(manifest.name + ": " + payload.event.actor + " moved something ("
+ moves + " moves).");
});
api.on("onTurnStart", (payload) => {
api.log(manifest.name + ": " + (payload.seat || "an unseated player")
+ " starts with a limit of "
+ (payload.actionLimit === null ? "no" : payload.actionLimit) + " actions.");
});
api.on("onPeerLeft", (payload) => {
api.log(manifest.name + ": " + payload.displayName + " left.");
});
api.log(manifest.name + ": four handlers registered.");
};
At load the console prints four handlers registered., then one line per matching event for the rest of
the session.
Gotchas
Registration is append-only and there is no unsubscribe. Calling api.on("onCardDrawn", fn) twice runs
fn twice per draw, and nothing removes a handler once it is registered. The only way to stop a handler is
to make it return early on a flag you control. Register in setup and register once.
A handler that throws is reported and does not stop the others. The frame catches it, posts a hook
phase diagnostic naming the hook, and carries on with the remaining handlers for that event. Your handler
failing is therefore invisible unless you read the mod console.
Handlers run in registration order, synchronously, for one event at a time. An async handler returns
a promise the frame does not await, so two async handlers on the same hook interleave after their first
await. Keep ordering-sensitive work before the first await, or in one handler.
You are not the only subscriber. Every mod a room selected runs in its own sandbox frame and every one
of them receives the same hook events (apps/web/src/mods/SandboxedModRunner.ts, dispatchEvent over the
frames map). A second mod can be reacting to the same turn or draw you are, in the order the frames were
created. Namespace anything you own — element ids, saved data, entity labels — with your mod id, and do not
write a handler whose correctness depends on being alone.
See also
- Hooks and capabilities — the fourteen hooks, their payloads and when each fires.
ModHookEventMap— the name-to-payload map.api.setUiElement— where custom hook names come from.- Events and delegates — ordering and cancellation across both surfaces.
api.log#
log(message: string): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | log |
| Availability | mod |
Write a line to the table's event log, attributed to "Mod".
Capability: log (the only capability granted by default).
Writes one line to the event feed on the client running the mod, attributed to Mod. It is the only output
a mod has — there is no console binding in the sandbox frame — and it is what you read when you are
working out why a script did not do what you expected.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
message |
string |
yes | Coerced with String(message) in the frame, so a number, an object or null is stringified rather than rejected. An object becomes "[object Object]"; call JSON.stringify yourself. |
How, why and when to use it
Something in your mod is not happening and you need to know how far it got. api.log is the answer,
because nothing else in the sandbox can produce output: console.log has no binding, and the scanner would
reject the script if you tried to reach one. The other reason to call it is deliberate narration — telling
players at the table that the round advanced or a rule fired — but be sparing there, because the feed keeps
only the most recent 60 lines and a chatty mod pushes the table's own events out of it.
The related surface is api.registerAction, which also writes
one line to the same feed. If all you want is a message, use this one — registerAction implies a control
that does not exist.
Example
// content/scripting-api/examples/api.log.js
// Mod script: the smallest useful thing a mod can do - say what it loaded with,
// then narrate what it sees. `log` is the only capability granted by default.
// manifest capabilities.allowed: ["log", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
const granted = manifest.capabilities.allowed;
api.log(manifest.name + " (" + manifest.id + ") started.");
api.log("Capabilities: " + granted.join(", ") + ".");
api.log("Declared sound sets: " + (manifest.soundSets ? manifest.soundSets.length : 0) + ".");
if (granted.indexOf("read-world") === -1) {
api.log("No read-world capability, so this mod reports events only.");
}
api.on("onTableEvent", (payload) => {
// One line per table event. Anything longer is truncated by the log itself.
api.log(payload.event.actor + ": " + payload.event.message);
});
};
The console shows My Game (my-game) started., Capabilities: log, subscribe-events.,
Declared sound sets: 0., and then one line per table event for the rest of the session.
Gotchas
The line is local and is never replicated. It lands in the running client's own event feed as React state; no other peer sees it, and it is not part of the table snapshot, so it does not survive a save or a reload. Two players will not see the same log unless the mod runs on both.
The feed keeps 60 lines. Table events and mod lines share the same list, so a handler that logs on
every onTableEvent erases the history you were trying to read within a minute of play. Log the decision,
not the loop.
One more line appears that you did not write. When a mod finishes loading the sandbox posts
<mod name> loaded. through the same channel, which is how you tell "the script ran" from "the script
never started". A mod whose setup silently never ran still prints it — see
Known limitations.
See also
api.registerAction— the other method that writes to this feed.- Mod capabilities — why
logis the one capability granted by default. - Sandbox limits — what else the frame does and does not give you.
- Fixing a rejection — when the problem is the scanner, not the script.
api.registerAction#
registerAction(action: ModActionRegistration): void;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | register-action |
| Availability | mod |
Declare a named action.
⚠ Today this only writes "registered action <label>" to the event log. No
button is rendered and NOTHING calls back into the mod. For a real control,
create a button UI element with an onClick hook and subscribe to it.
Capability: register-action.
Declares a named action. Today it appends one line — registered action <label> — to the running client's
event feed and does nothing else. No control is rendered, and nothing ever calls back into the mod. If you
are looking for the way to give players a button, it is
api.setUiElement.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
action |
ModActionRegistration |
yes | Posted verbatim. The host reads only label, for the log line; id is accepted and currently unused. A non-object is posted anyway and produces registered action undefined. |
How, why and when to use it
The honest answer is: for a log line that says your mod offers an action, and nothing more. The method a
reader is usually looking for when they find this one is
api.setUiElement — create a button, give its props an
onClick hook name, and subscribe to that name with api.on. That path
is wired end to end: the button renders for whoever the element's visibility allows, and the payload
names the element, the actor's peer id and their role. Use registerAction only if you want the
declaration to appear in the feed alongside the button, and expect nothing else from it.
Example
// content/scripting-api/examples/api.registerAction.js
// Mod script: registerAction announces an action in the event log and nothing
// calls back, so the button that actually works is built right after it.
// manifest capabilities.allowed: ["log", "register-action", "ui", "subscribe-events"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
// Writes "registered action End Round" to the event log. That is all it does.
api.registerAction({ id: "end-round", label: "End Round" });
api.log(manifest.name + ": declared End Round in the event log.");
// The mechanism players can actually use: a button plus its onClick hook.
try {
await api.setUiElement({
id: manifest.id + "-end-round",
type: "button",
presentation: { mode: "screen", anchor: "bottom-right", offsetX: -16, offsetY: -16 },
props: { text: "End Round", onClick: "endRound" }
});
api.log(manifest.name + ": End Round button is live.");
} catch (error) {
api.log(manifest.name + ": button needs the host - " + String(error));
return;
}
api.on("endRound", (payload) => {
api.log(manifest.name + ": End Round pressed by "
+ (payload.actorPeerId || "the host") + " at " + payload.at + ".");
});
};
The feed shows registered action End Round, then End Round button is live. Pressing the button — the
button, not the registration — adds End Round pressed by peer-3f2a at …, with the ISO-8601 instant from payload.at.
Gotchas
No button is rendered and nothing calls back.
Known gap. The host's handler appends one line to the event feed —
registered action <label>— and does nothing else (apps/web/src/ui/App.tsx, theregisterActioncallback passed to the mod runner). No UI is rendered and there is no callback channel, so the capability grants the ability to write a log line. The mechanism that does work is a table UI element: create abuttonwithapi.setUiElement, give its props anonClickhook name, and subscribe to that name withapi.on. That path is wired end to end and carries the actor's peer id and role in its payload. See Known limitations.
The line is local, like every other mod log line. It lands in the running client's event feed only, is not replicated, and is not part of the table snapshot.
id is accepted and ignored. Supplying one costs nothing and buys nothing today; do not build a
dispatch table keyed on it.
See also
api.setUiElement— the control that actually works.api.on— subscribing to the button's hook name.api.log— the same feed, without implying a control.- Mod capabilities — what declaring
register-actiontells a reviewer.
api.listPlugins#
listPlugins(): Promise<ModPluginSummary[]>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | plugin-call |
| Availability | mod |
List the plugins installed on this table that this mod may call.
Only plugins your manifest's plugins array declares appear here, so the
list is your own declaration intersected with what the table actually has.
Capability: plugin-call.
The plugins installed on this table that your manifest declared, with the functions each one exposes.
This is your own plugins declaration intersected with what the table actually has, so it never tells you
about a plugin you did not name. A table's full plugin list is not something a mod can enumerate.
Parameters
None.
Returns
Promise<ModPluginSummary[]>, possibly empty.
Empty means one of: your manifest declares no plugins, none of the ones you declared are installed here, or this client has no plugin host attached. The three are deliberately indistinguishable.
How, why and when to use it
Call it once in setup and branch on the result. A mod that needs card data from a provider should degrade
into a playable-but-plainer mode when the plugin is absent, rather than failing to load; a table that has
the plugin then gets the richer path.
The attribution on each summary is not decoration. Most card providers' terms require it, and the plugin
author accepted those terms on their own behalf — display it wherever you show that plugin's data.
Example
// content/scripting-api/examples/api.listPlugins.js
// Mod script: discover which of the plugins this mod declared are actually
// installed here, and degrade cleanly when none are. Most tables have none.
// manifest capabilities.allowed: ["log", "plugin-call"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const plugins = await api.listPlugins();
if (plugins.length === 0) {
api.log(manifest.name + ": no declared plugin is installed here - using built-in cards.");
return;
}
for (const plugin of plugins) {
// Show this wherever you render the plugin's data. The plugin's author agreed
// to the provider's terms on their own behalf, and attribution is one of them.
api.log(manifest.name + ": " + plugin.name + " v" + plugin.version + " - " + plugin.attribution);
// A function that receives table-derived data cannot be called from a mod, so
// it is not part of what this mod can plan around.
const callable = plugin.functions.filter((fn) => !fn.acceptsTableData);
api.log(manifest.name + ": " + callable.length + " callable function(s) on " + plugin.id);
}
};
On a table with no plugins this logs one line and the mod carries on. On a table that has the declared plugin, it names the provider, its attribution, and how many of its functions this mod can actually call.
Gotchas
An empty list is the normal case. Most tables have no plugins. Write the no-plugin path first.
It is not a capability check. A plugin appearing here means it is installed and you declared it — not that a given call will succeed. Quota, the provider being down, and the platform's circuit breaker are all call-time outcomes.
A function marked acceptsTableData cannot be called. It is listed so you can see it and skip it; calling
one is refused.
See also
api.callPlugin— calling one of the functions listed here.- Capabilities — what
plugin-callgrants, and what it does not.
api.callPlugin#
callPlugin(pluginId: string, functionName: string, params?: Record<string, string | number | boolean>): Promise<ModPluginCallResult>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | plugin-call |
| Availability | mod |
Call one declared function on one declared plugin.
⚠ This is not network access. You name a function; the platform performs the request from its own servers, to an origin the PLUGIN declared, under that plugin's quota and circuit breaker. There is no way to name a URL, an endpoint, or a payload the plugin did not declare a schema for.
Both pluginId and functionName must be plain string literals in your
source — the publish scanner reads them out of the script text and rejects
any pair your manifest does not declare, or any call site it cannot read.
params is validated against the plugin's declared parameter schema before
anything leaves the machine, and the response is validated against its
declared return schema before it reaches you. Either failing is
{ ok: false, reason: "refused" }.
⚠ A function whose endpoint produces card pages is also
{ ok: false, reason: "refused" }, always. Card data reaches the table
through the deck builder's own prefetch, which fetches a whole set up front;
routing it through per-call script code would make the request pattern depend
on which cards are in play. Use a deck source, not callPlugin, for cards.
Capability: plugin-call.
Call one declared function on one declared plugin, and get back that function's declared return shape.
⚠ This is not network access, and it is not a way to get some. You name a function. The platform maps
that name to the endpoint the plugin's manifest declared, composes the URL from the plugin's declared
origin, and performs the request from its own servers under that plugin's quota and circuit breaker. There
is no form of this call that names a URL, a host, a header or a body. Mod scripts still cannot use fetch,
XMLHttpRequest, WebSocket or EventSource, and the publish scanner still rejects a script that mentions
any of them.
Parameters
pluginId— the plugin's id. Must be a plain string literal in your source.functionName— the function's name on that plugin. Must be a plain string literal in your source.params— optional bag of values, validated against the plugin's declared parameter schema.
The publish scanner reads those two literals out of your script text. A call site it cannot read — a
variable, a template placeholder, a computed name — is rejected with dynamic-plugin-call, and a pair your
manifest's plugins array does not declare is rejected with undeclared-plugin-call. Branch between two
literal call sites instead.
Returns
Promise<ModPluginCallResult>. Check ok first.
On success you get { ok: true, data } — two fields, and that is the whole surface. data has already been
validated against the function's declared return schema.
There is deliberately no cache state on this result: no stale flag and no age. The platform does cache
plugin responses, but that cache is shared by every room and every user, so telling your script whether a
query was served from it would tell your script whether anyone else had recently run the same query.
Freshness belongs to the person, in the deck UI, not to the script.
How, why and when to use it
Look a card up, pull a set list, resolve an id a player typed. Do it in response to something, cache the answer in your own state, and do not put one of these inside a high-frequency hook — every call spends the plugin's shared budget, and a plugin that exhausts its budget stops working for everyone at the table.
Example
// content/scripting-api/examples/api.callPlugin.js
// Mod script: look a card up through a declared plugin function. Both the plugin
// id and the function name are plain string literals, so the publish scanner can
// see exactly what this mod reaches - and so can a reviewer.
// manifest capabilities.allowed: ["log", "plugin-call"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const result = await api.callPlugin("org.example.cards", "searchCards", { q: "lightning" });
if (!result.ok) {
// Four reasons, no detail, and nothing worth branching on beyond "try later"
// versus "give up". The real cause is in the mod diagnostics panel.
api.log(manifest.name + ": card lookup unavailable (" + result.reason + ").");
return;
}
// A success is `{ ok: true, data }` and nothing else. The platform caches plugin
// responses, but your script is not told whether it was served one: that cache is
// shared by every room and every user, so "was this exact query run recently" is a
// question about other people's tables. Cache state is shown to the PERSON, in the
// deck UI, and never to the script.
// `data` already matched the plugin function's DECLARED return schema, so this
// shape is the plugin's contract rather than whatever the provider sent today.
api.log(manifest.name + ": lookup returned " + JSON.stringify(result.data).length + " bytes.");
};
The mod's manifest must declare { "id": "org.example.cards", "functions": ["searchCards"] } under
plugins, or this exact script is rejected at publish with undeclared-plugin-call.
Gotchas
A failure is a value, not an exception. You get { ok: false, reason } with one of four reasons and
nothing else — no message, no status, no hostname. That is deliberate: a richer failure would let a script
probe the provider and the plugin's remaining budget. The real cause is written to the mod diagnostics
panel, where you can read it and your script cannot.
not-found collapses several causes. Plugin not installed, plugin not declared by you, function not
declared by you, function not exposed by the plugin — all one answer, so a script cannot enumerate what it
was not told about.
refused includes your own arguments being wrong. Params are validated against the plugin's declared
schema before anything leaves the machine, and the response is validated against its declared return schema
before it reaches you. Both compile to strict objects, so an undeclared key on either side is a refusal.
rate-limited and unavailable are indistinguishable in timing as well as content. Do not try to tell
them apart; treat both as "no answer right now".
A function declaring acceptsTableData is always refused. That flag marks the plugin's declared
table-to-provider channel, which carries its own warning and consent model and is not routed through the
mod API.
A function whose endpoint produces card pages is always refused too. Card data reaches a table through
the deck builder's own prefetch, which pulls a whole set up front. Routing it through per-call script code
would make the pattern of requests depend on which cards are actually in play, which is the thing prefetching
exists to avoid. Build a deck from a plugin card source instead; callPlugin is for the plugin's other
declared functions. You will get { ok: false, reason: "refused" } — and, unlike a transient
"unavailable", retrying will never help.
See also
api.listPlugins— what you can call.ModPluginCallResult— the result shape.- Capabilities — what
plugin-callgrants.
api.resolveCards#
resolveCards(cardIds: readonly string[]): Promise<ResolvedCard[]>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-cards |
| Availability | mod |
Resolve card ids to the data your own game shipped for them.
A catalogue-sourced pile carries card ids and nothing else — card data is bulk asset data
and never travels in a snapshot. This reads the catalogue your data/cardSchema.json names and
returns the row for each id you ask about, so a script can get a card's name, cost or type
without the table ever having carried them.
const [card] = await api.resolveCards(["SO001_Anakin_Skywalker_T_v3"]);
if (card) api.log(card.data.name + " costs " + card.data.cost);
Ids are matched on the schema's key role, and a physical copy suffix (bolt#3) resolves to
its definition entry. An id with no catalogue row is omitted rather than returned empty, so
check the length rather than assuming a one-to-one result.
⚠ This grants no new reach. It reads your own repo's shipped file — no network, no other mod's data, no table state, and nothing a player could not read out of your public repo. It also tells you nothing about which cards are in play: pass ids you already hold, and ids you are not entitled to see are ids you never got.
Capability: read-cards.
Resolve card ids to the card data your own game shipped.
A pile loaded from a card catalogue carries card ids and nothing else — card data is bulk asset
data and never travels in a table snapshot, so a script reading the table sees
"SO001_Anakin_Skywalker_T_v3" where it wanted a name. This reads the catalogue your
data/cardSchema.json names and returns the row for each id you ask about.
Each lookup resolves by the catalogue's key field (its card_id) first, then falls back to a
name match against the catalogue's title field (trimmed and case-insensitive). That fallback is
what lets a script that only has card names — for example a decklist fetched from a plugin that
returns names and counts, never ids — still reach its own catalogue rows, including each card's art
URL. A name shared by two printings resolves to the earlier catalogue row.
Parameters
cardIds— the card ids (or, via the name fallback, card names) to look up. At most 2000 per call; anything beyond that is ignored.
Returns
An array of { cardId, data }, where data is keyed by the field keys your deck schema declares.
Ids that resolve to nothing are omitted, so the result may be shorter than the input and is
not positionally aligned with it — match on cardId, never by index. A physical-copy suffix
(bolt#3) resolves to its definition entry, so several copies of one card collapse to one result.
How, why and when to use it
Use it whenever a script needs to reason about what a card is rather than where it is: scoring a hand, validating a play against a card's type, or writing a readable log line instead of an opaque id.
It grants no new reach. The catalogue is your own repository's shipped file — no network, no other
mod's data, no table state, and nothing a player could not read out of your public repo. That is why
it has its own read-cards capability rather than riding on read-world: a script that only wants a
card's name should not have to ask for the table.
Example
// content/scripting-api/examples/api.resolveCards.js
// Mod script: turn the card ids sitting in a pile into the card data this game
// shipped. The table carries ids only - card data is bulk asset data and never
// travels in a snapshot - so this is how a script learns what a card actually is.
// manifest capabilities.allowed: ["log", "read-world", "read-cards"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const objects = await api.listObjects({ kind: "deck" });
const deck = objects[0];
if (!deck) {
return;
}
const held = await api.getContainerContents(deck.id);
// A deck's entries are always the CARD lane, but the reply type also covers a piece
// bag's runs, where there is no cardId at all. Narrow on `kind` before reading it.
const cardEntries = held.filter((entry) => entry.kind === "card" && entry.cardId);
const cards = await api.resolveCards(cardEntries.map((entry) => String(entry.cardId)));
// Unknown ids produce NO entry, so the result can be shorter than what was asked
// for and is not positionally aligned with it. Key a Map on cardId and look up
// through that; zipping the two arrays by index quietly mismatches every row
// after the first id that had no catalogue row.
const byId = new Map(cards.map((card) => [card.cardId, card.data]));
let known = 0;
for (const entry of cardEntries) {
const data = byId.get(String(entry.cardId));
if (!data) {
continue;
}
known += 1;
// The keys are the ones THIS game declared in data/cardSchema.json. A blank
// field can be null, so guard before doing arithmetic on it.
const cost = typeof data.cost === "number" ? data.cost : 0;
api.log(manifest.name + ": " + String(data.name) + " (cost " + cost + ")");
}
api.log(manifest.name + ": resolved " + known + " of " + cardEntries.length + " cards.");
};
Gotchas
- The result is not one-to-one with the input. Unknown ids are dropped. Build a
Mapkeyed bycardIdrather than zipping the two arrays together. - It tells you nothing about which cards are in play. You pass ids you already hold, and an id you were not entitled to see is an id you never received — a face-down deck's contents are redacted before your script sees them.
- An empty array is a normal answer. A game that ships no catalogue, or whose card source is a
plugin rather than a
staticdocument, resolves to[]. Handle it exactly as you handle an unknown id. - Values are whatever the catalogue holds, including
nullfor a field a card leaves blank. Check before doing arithmetic on a number field.
See also
api.getContainerContents— one source of the ids to resolve.ResolvedCard— the shape this returns.
api.listDecks#
listDecks(query?: ModDeckQuery): Promise<ModDeckSummary[]>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
The saved DiceyTable decks for THIS GAME — the caller's own, or everyone's public ones.
const mine = await api.listDecks({ scope: "mine", limit: 20 });
const shared = await api.listDecks({ scope: "public", search: "vong" });
Summaries only; the list arrives from api.getDeck. That split is not a detail — a page of
24 decks would otherwise ship 24 decklists to render 24 names.
⚠ The card source is the HOST'S to decide, not yours. It is derived from the calling mod, so this answers with decks for your own game and there is no parameter that would widen it to somebody's library for another. Nor does it reach past what the person can already see: another player's private deck is not in any scope.
Resolves [] when nobody is signed in, when this game declares no card schema, and when the
query simply matches nothing — those are deliberately the same answer.
Capability: read-decks.
Resolves the saved DiceyTable decks for your own game — the signed-in player's own, or everyone's public ones. This is the deck library behind the game page's Decks tab, made readable to a mod so a script can offer a picker instead of asking the player to leave the table.
Summaries only. The decklist itself comes from api.getDeck.
Parameters
| Parameter | Type | Notes |
|---|---|---|
query |
ModDeckQuery |
Optional. Omit for the caller's own decks, newest first. |
query.scope |
"mine" | "public" |
"mine" (default) is every deck the caller owns, any visibility. "public" is public decks by anyone, the caller's included. |
query.search |
string |
Free text over name and description. |
query.formatId |
string |
A format id from your game's cardSchema.json. |
query.limit |
number |
1–100. Defaults to 24. |
query.offset |
number |
For paging. |
Returns
Promise<ModDeckSummary[]> — never rejects.
Each entry carries id, name, description, formatId, visibility, username, totals,
thumbnailCardId and updatedAt. totals.cards is the physical count, so a playset of four counts four;
totals.distinctCards is how many distinct ids.
How, why and when to use it
Building a deck picker at the table. A player who has to alt-tab to the game page, load a deck and come back has left the table to do it; this is what lets the mod ask them in place.
The card source is not yours to choose. The platform derives it from your mod — its id and its card
schema's breakingVersion — so this answers with decks for your game and there is no parameter that would
widen it to somebody's library for another game. That is the whole shape of the capability: a mod reads the
deck pool it owns, and nothing else.
Nor does it reach past what the person can already see. "mine" is owner-scoped by the server and
"public" is public-only; another player's private deck is in neither.
Example
// content/scripting-api/examples/api.listDecks.js
// Mod script: offer the player their own saved decks for this game.
// listDecks returns SUMMARIES only - names, counts, an author - which is what a
// picker needs. The list itself arrives from api.getDeck once one is chosen.
// manifest capabilities.allowed: ["log", "read-decks", "subscribe-events", "ui"]
/** How many rows the picker draws. Keep one rebuild inside the UI mutation budget. */
const PAGE_SIZE = 10;
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
// Scoped to THIS game by the host - there is no parameter that would widen it.
const mine = await api.listDecks({ scope: "mine", limit: PAGE_SIZE });
if (mine.length === 0) {
// Empty covers three cases on purpose: no decks, nobody signed in, and a
// failed read. Offer the way forward rather than guessing which it was.
await api.setUiElement({
id: "deck-picker",
type: "text",
presentation: { mode: "modal", title: "Choose a deck", size: "medium", dismissible: true },
props: { text: "No saved decks yet. Build one on this game's page, then reopen this." }
});
return;
}
for (let index = 0; index < mine.length; index += 1) {
const deck = mine[index];
await api.setUiElement({
id: "deck-row-" + index,
type: "button",
presentation: { mode: "screen", anchor: "middle-left", offsetX: 16, offsetY: index * 36 },
props: {
// `totals.cards` is the physical count, so a playset of 4 counts 4.
text: deck.name + " (" + deck.totals.cards + ")",
onClick: "pickDeck"
}
});
}
api.on("pickDeck", async (payload) => {
const row = Number((payload.elementId || "").replace("deck-row-", ""));
const chosen = mine[row];
if (!chosen) {
return;
}
const deck = await api.getDeck(chosen.id);
// `readable` is not the same question as "has entries" - check it first.
if (!deck || !deck.readable) {
api.log(manifest.name + ": that deck could not be read.");
return;
}
api.log(manifest.name + ": " + deck.name + " has " + deck.entries.length + " lines.");
});
};
Gotchas
Empty means four different things and says none of them. No saved decks, nobody signed in, this game
ships no data/cardSchema.json, or the read failed — all resolve []. A distinguishable failure would be
an oracle, so the array is the whole answer. Write the empty state as an invitation ("build one on the game
page") rather than as an error.
Your mod must ship a card schema. The deck pool is keyed to the card source, so a mod with no
data/cardSchema.json has no pool to read and always resolves [].
"public" includes the caller's own public decks. It is a visibility filter, not an "other people"
filter. Deduplicate against "mine" if you show both lists at once.
Bumping breakingVersion forks the pool. The deck source is (kind, id, breakingVersion), so raising
that number in your cardSchema.json means this call stops seeing every deck saved under the old one. That
is the intended behaviour when your card model changes incompatibly, and a disaster when it was a typo.
Ungated by role. Unlike a write, this read runs on every peer — which is the point, since the player
choosing a deck is usually not the host. What they may then do with it is still gated: a player's
api.createObject is refused, so spawn on the host or send the choice there.
See also
api.getDeck— one deck, with its list.api.resolveCards— turning the card ids in a decklist into names and art.api.createObject— putting the result on the table.
api.getDeck#
getDeck(deckId: string): Promise<ModDeckRecord | null>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
One saved deck WITH its decklist, or null when there is no such deck you may read.
const deck = await api.getDeck(summary.id);
if (deck && deck.readable) spawnPiles(deck.entries);
⚠ null and "private, not yours" are the same answer, on purpose — a distinguishable
refusal would confirm that a deck exists. Check readable before you act on entries: a row
whose stored list could not be parsed also arrives with entries: [], and treating that as
an empty deck is how a good deck gets replaced by an empty pile.
A deck belonging to a DIFFERENT card source resolves null even when it is public — the ids
in it are not your game's.
Capability: read-decks.
Resolves one saved deck with its decklist, or null when there is no such deck you may read. It is the
second half of the deck library: api.listDecks finds a deck, this
one opens it.
Parameters
| Parameter | Type | Notes |
|---|---|---|
deckId |
string |
A deck id from an api.listDecks summary. Required. |
Returns
Promise<ModDeckRecord \| null> — never rejects.
Everything a summary carries, plus entries and readable. Each entry is
{ cardId, count, partitionId }, where count is how many copies and partitionId is the partition from
your game's cardSchema.json (or null for the default one).
null covers "no such deck", "private and not yours", "belongs to a different card source" and "the read
failed" — deliberately one answer, because a distinguishable refusal would confirm that a deck exists.
How, why and when to use it
Turning a chosen deck into piles. entries is a decklist, not a physical stack: a count: 4 line means
four cards in the pile, so expand it before you build metadata.cards, and give each copy a distinct id
so anything addressing a single card later is unambiguous.
The ids in entries are card ids from your own catalogue, so
api.resolveCards turns them into names, art and any other
field your schema declares.
Example
// content/scripting-api/examples/api.getDeck.js
// Mod script: turn one saved deck into piles on the table, one per partition.
// getDeck is the only call that returns a decklist; everything else about the
// deck library is summaries.
// manifest capabilities.allowed: ["log", "read-decks", "spawn-object"]
/** Deck depth is capped at 1000 by the object schema; refuse past it rather than truncate silently. */
const MAX_STACK = 1000;
/**
* Flatten a decklist into one entry per PHYSICAL card, grouped by partition.
*
* A `count: 4` line is four cards in the pile, not one card with a count, because
* `metadata.cards` is the ordered physical stack. Ids are made unique per copy so
* that anything addressing a single card later is unambiguous.
* @param {readonly ModDeckEntry[]} entries
*/
function pilesByPartition(entries) {
/** @type {Record<string, { cardId: string; faceDown: boolean }[]>} */
const piles = {};
for (const entry of entries) {
// A null partition means the schema's default one.
const key = entry.partitionId || "main";
const pile = piles[key] || (piles[key] = []);
for (let copy = 0; copy < entry.count && pile.length < MAX_STACK; copy += 1) {
pile.push({ cardId: entry.cardId + "#" + (copy + 1), faceDown: true });
}
}
return piles;
}
/** @param {ModApi} api @param {ModSetupManifest} manifest @param {string} deckId */
exports.setup = async function setup(api, manifest, deckId) {
const deck = await api.getDeck(deckId);
if (!deck) {
// null is "no such deck you may read" - a private deck that is not yours
// answers identically to one that never existed, on purpose.
api.log(manifest.name + ": no readable deck with that id.");
return;
}
if (!deck.readable) {
// The row exists and its list could not be parsed. Spawning `entries` here
// would put an empty pile on the table over someone's real deck.
api.log(manifest.name + ': "' + deck.name + '" could not be read; nothing spawned.');
return;
}
const piles = pilesByPartition(deck.entries);
let column = 0;
for (const partitionId of Object.keys(piles)) {
const cards = piles[partitionId];
api.createObject({
kind: "deck",
label: deck.name + " - " + partitionId,
displayName: deck.name + " - " + partitionId,
position: { x: column * 1.6, y: 1.2, z: -2.5 },
faceDown: true,
stackCount: cards.length,
metadata: { cards: cards }
});
column += 1;
}
api.log(manifest.name + ": spawned " + column + " pile(s) from " + deck.name + ".");
};
Gotchas
Check readable before you act on entries. A deck whose stored list could not be parsed comes back
with entries: [] and readable: false. Without the check, "this deck could not be read" and "this deck is
empty" are the same value — and spawning the first as if it were the second replaces someone's real deck
with an empty pile, which is exactly what the flag exists to prevent.
A deck from another game resolves null, even when it is public. The card ids in it mean nothing under
your schema, so it is refused rather than handed over to be spawned as unresolvable cards.
count is copies, not a card. Expanding it is your job. Forgetting to is how a 60-card deck becomes a
20-card pile.
partitionId: null means the default partition, not "no partition". Resolve it against your schema's
default: true entry rather than dropping those lines.
A pile is capped at 1000 cards by the object schema. Refuse past the cap and say so, rather than truncating silently — a deck that quietly loses its tail is worse than one that will not load.
Ungated by role, like every read. The player choosing a deck is usually not the host, so this resolves
for them — but api.createObject will not. Spawn on the host.
See also
api.listDecks— finding the deck to open.api.resolveCards— names and art for the ids inentries.api.createObject— building the piles.
api.sendToHost#
sendToHost(name: string, data?: unknown): Promise<void>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | host-message |
| Availability | mod |
Send a named message to the HOST'S copy of this mod. The only call that runs upwards.
// On a player: read your own library locally, then hand the host the result.
const deck = await api.getDeck(chosenId);
api.sendToHost("load-deck", { name: deck.name, entries: deck.entries });
// On the host: act on it, keyed on WHO sent it.
api.on("onHostMessage", (msg) => {
if (msg.name !== "load-deck") return;
const seat = seatOf(msg.actorPeerId); // never trust a seat inside the payload
if (seat) spawnDeckFor(seat, msg.data);
});
Why this exists#
Everything that MUTATES the table is host-only: createObject, objectAction,
setUiElement and setSavedData are all refused on a player. Everything that reads a
PERSON — listDecks, getDeck — necessarily runs on that person's own peer, because it
reads with their credentials. Those two facts used to have no bridge, so a mod could not do
the obvious thing: let a player choose from their own library and have the host act on the
choice. This is that bridge, and it is deliberately the narrowest one that closes it.
What it is not#
It is not a message bus. There is no host-to-player direction, no peer-to-peer direction and no broadcast: messages travel to the host and stop. A mod that wants to tell everyone something writes replicated state — a UI element, an object, saved data — from the host, which every peer already sees.
It is not a way around authority. The host's handler decides what to do; a message is a request, and a host that does not implement a name simply ignores it.
Delivery#
Fire-and-forget: it resolves once the message has been handed to the transport, which is not
a promise that anything happened. There is no reply, no acknowledgement and no error — a host
that has not registered onHostMessage, or that ignores your name, is indistinguishable from
one that acted. Design the flow so the CONSEQUENCE is visible in replicated state (the deck
appears, the label changes) rather than in a response you do not get.
On the host itself the message is delivered to its own onHostMessage by the same path, so a
handler never needs to know whether the sender was remote.
data must be JSON-serialisable, and the whole message is capped at 64 KB — enough for a
decklist, which is the case this was built for. Oversize messages are dropped, and so are
messages beyond about 20 per second per peer; both are reported to you in the mod diagnostics
panel and to the script not at all.
Capability: host-message.
Sends a named message to the host's copy of your mod. It is the only call that runs upwards, and it exists to join two facts that had no bridge: everything that changes the table is host-only, and everything that reads a person runs on that person's own peer.
Parameters#
| Parameter | Type | Notes |
|---|---|---|
name |
string |
Your own message name. At most 64 characters. Required. |
data |
unknown |
Anything JSON-serialisable. Optional. |
Returns#
Promise<void> — resolved once the message is handed to the transport, which is not a
promise that anything happened. There is no reply and no acknowledgement.
It arrives on the host as onHostMessage, carrying a ModHostMessagePayload.
How, why and when to use it#
The shape is always the same: read locally, decide locally, ask the host to act.
The deck picker is the case it was built for. api.listDecks reads with the signed-in person's credentials, so only their peer can see their private decks; api.createObject is refused on a player, so only the host can put cards on the table. The player's copy of the script reads the deck and sends the LIST — not the id, because the host cannot fetch somebody else's deck — and the host spawns it.
The same shape covers anything a player knows and the host does not: a local preference, a choice made in a dialog, the result of a read only that peer can make.
A player's script hears its own UI clicks, which is what gives this a trigger: an interaction is dispatched to the actor's own peer as well as to the host, so a handler can react locally and then send.
Example#
// content/scripting-api/examples/api.sendToHost.js
// Mod script: a player picks from THEIR OWN deck library and the host places it.
// The two halves of this file run on different peers. Reading a person's decks
// only works on that person's own peer; putting cards on the table only works on
// the host. sendToHost is the bridge, and it runs one way.
// manifest capabilities.allowed: ["host-message", "log", "read-decks", "spawn-object", "subscribe-events", "ui"]
/**
* Seats, by peer id — built from the seat hook, never from a message payload.
* @type {Record<string, string>}
*/
const seatByPeer = {};
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
api.on("onSeatChanged", (payload) => {
if (payload.seat) {
seatByPeer[payload.peerId] = payload.seat;
} else {
delete seatByPeer[payload.peerId];
}
});
/* ---- on EVERY peer: the person's own library ---- */
api.on("chooseDeck", async () => {
// Runs on the peer whose user clicked, because that is the only peer whose
// credentials can read their library. A private deck of theirs is readable
// here and nowhere else.
const [first] = await api.listDecks({ scope: "mine", limit: 1 });
if (!first) {
return;
}
const deck = await api.getDeck(first.id);
if (!deck || !deck.readable) {
return;
}
// The LIST travels, not the id: a deck of yours is not readable by the host,
// so whoever asked for it has to supply it.
await api.sendToHost("load-deck", { name: deck.name, entries: deck.entries });
});
/* ---- on the HOST: act on it ---- */
api.on("onHostMessage", (message) => {
if (message.name !== "load-deck") {
return;
}
// The seat comes from actorPeerId, which the host stamped from the channel.
// A seat named inside the payload would be the sender's claim about itself.
const seat = seatByPeer[message.actorPeerId];
const data = /** @type {{ name?: string; entries?: ModDeckEntry[] }} */ (message.data ?? {});
if (!seat || !Array.isArray(data.entries)) {
api.log(manifest.name + ": ignoring a malformed load-deck.");
return;
}
const cards = [];
for (const entry of data.entries) {
for (let copy = 0; copy < entry.count; copy += 1) {
cards.push({ cardId: entry.cardId + "#" + (copy + 1), faceDown: true });
}
}
api.createObject({
kind: "deck",
label: String(data.name || "Deck"),
position: { x: 0, y: 1.2, z: 0 },
faceDown: true,
stackCount: cards.length,
metadata: { cards: cards, ownerSeat: seat }
});
});
await api.setUiElement({
id: "choose-deck",
type: "button",
presentation: { mode: "screen", anchor: "upper-right", offsetX: 16, offsetY: 16 },
props: { text: "Load my deck", onClick: "chooseDeck" }
});
};
The button is drawn by the host and clicked by a player. Two peers run two halves of the same handler, and only one message crosses between them.
Gotchas#
One direction, and no reply. There is no host-to-player message and no peer-to-peer message. A host that has not registered onHostMessage, or that ignores your name, is indistinguishable from one that acted. Design so the CONSEQUENCE is visible in replicated state — the deck appears, the label changes — rather than in a response you will not get.
Send the data, not a reference to it. The host cannot read another person's private deck, another person's preferences, or anything else scoped to the sender. An id that only the sender can resolve is useless on the other end.
64 KB, and about 20 messages a second per peer. Both are the host's to cap because the sender chooses both. Over either limit the message is dropped, and you are told in the mod diagnostics panel — the script is told nothing, deliberately.
data must survive JSON. It is serialised on the way out and re-parsed on the way in, so a Map, a Set, a Date or a function does not arrive as itself. A payload that cannot be serialised is dropped with a diagnostic.
On the host it is a local call. A host calling this delivers to its own onHostMessage by the same path, so a handler never has to know whether the sender was remote. That is deliberate — write one handler, not two.
It is a request, not a command. Nothing here grants authority. The host's handler decides; a mod that assumes its message was obeyed is a mod that will disagree with the table.
See also#
onHostMessage— the inbound half, on the host.ModHostMessagePayload— what the host receives.api.listDecks— the read that has to happen on the player's own peer.api.createObject— the write that has to happen on the host.
Vector3#
Surface B — mod script · interface · 3 members
A position / rotation / scale triple. Positions are world units (feet).
The three-number triple every spatial value on the mod surface uses: { x, y, z }, plain numbers, no
methods. It is the wire shape vector3TupleSchema validates (packages/shared/src/tableObjects.ts), which
requires all three components to be present and finite — NaN and Infinity are rejected at the host
boundary rather than stored. What the three numbers mean depends on which field holds them, and the units
are never mixed within one field.
How, why and when to use it#
You are placing a spawn, measuring the gap between two entities, or deciding whether a piece has come to
rest, and every one of those reads or writes a Vector3. The thing to get right is which units you are in:
position is feet, rotation is degrees, scale is a multiplier, velocity is feet per second. The
mistake authors make is carrying a habit over from an engine where positions are metres or rotations are
radians and then wondering why a 1-unit offset moved a card a foot; read the field's own entry before you do
arithmetic on it. There is no vector maths helper on this surface — x, y and z are numbers, so distance
and offsets are ordinary arithmetic you write yourself.
Gotchas#
It has no methods. Vector3 is a data shape, not the engine's pc.Vec3. There is no add, length,
clone or copy on it, and the engine's vector type is not reachable from a mod at all.
Y is up. The table surface lies in the X/Z plane and y is height, so a "flat" distance between two
entities is computed from x and z alone.
The triples you read are frozen copies. Every Vector3 on a TableObjectState arrives as
Readonly<Vector3> inside a structured clone, so assigning to state.position.x changes a value in your own
frame and nothing at the table.
See also#
TableObjectState.position— the feet case.TableObjectState.rotation— the degrees case.TableObjectState.velocity— the feet-per-second case.TableObjectDefinition— where you write one, at spawn.- Object state — every field that carries a triple.
Members#
| Signature | Description | Returns |
|---|---|---|
x |
number |
|
y |
number |
|
z |
number |
vector3.x#
x: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The first component of the triple, and the table's left-right axis. On a position it is feet from the
table origin; on a rotation it is pitch in degrees; on a scale it is the width multiplier; on velocity
it is feet per second sideways. vector3TupleSchema requires it to be a finite number — the host rejects
NaN and Infinity rather than storing them.
How, why and when to use it
You want to know whether a token has been dropped on the left or the right half of the board, or you are
spawning a row of pieces a fixed distance apart. x and z are the two axes that matter for anything laid
out flat on the table, so a "same square" or "within reach" test compares those two and ignores y. The
alternative — comparing the whole triple, or comparing position objects with === — does not work: every
read is a fresh structured clone, so two identical positions are never the same object.
Gotchas
Positive x is one specific direction and the docs do not fix which seat sees it as "right". Seats sit
around the table facing inward, so a player's left and right depend on where they are sitting. Anchor layout
maths on the table origin or on another entity's position, never on a player's point of view.
Floating-point equality does not hold. A physics-settled entity reports a position that is near, not equal to, where it was placed. Compare with a tolerance.
See also
Vector3— the shape, and the units per field.Vector3.z— the other flat-plane axis.TableObjectState.position— feet from the table origin.- Object state — every field that carries a triple.
vector3.y#
y: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The second component, and the up axis. On a position it is height in feet above the table origin; on a
rotation it is the heading — the spin around the vertical, which is the rotation that matters for a card or
a board; on a scale it is the thickness multiplier; on velocity it is feet per second of rise or fall.
vector3TupleSchema requires a finite number here as it does for the other two.
How, why and when to use it
You want to know whether a die is still in the air, or you are spawning a piece that has to land on top of a
board rather than inside it. Height is the component authors get wrong most often, because a spawn y below
the surface drops the entity into the table and physics shoves it out sideways. Spawn a little above the rest
height and let it fall; the alternative — computing the exact resting height from the entity's scale and
the board's — is fragile, because the collider the runtime fits depends on the kind and on the model. On a
rotation, y is the one you set to face a piece at a seat.
Gotchas
A deck's y scale is not authored, it is derived. The runtime sets a deck's thickness from its
stackCount, so a deck that has been drawn from is thinner than the one you spawned and its scale.y
changes without anyone having edited it.
Rotation y is degrees, not radians, and it is not normalized to 0–360. Compare headings with a
modulo, not with equality.
See also
Vector3— the shape, and the units per field.TableObjectState.scale— where the thickness multiplier lives.TableObjectState.stackCount— what drives a deck's height.- Object kinds — per-kind default sizes and colliders.
vector3.z#
z: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The third component, and the table's near-far axis. On a position it is feet from the table origin toward
or away from the far edge; on a rotation it is roll in degrees; on a scale it is the depth multiplier; on
velocity it is feet per second along that axis. Like the other two, vector3TupleSchema accepts only a
finite number.
How, why and when to use it
You are laying out a hand rail, a row of scoring tracks, or a grid of squares, and you need the second flat
coordinate to go with x. Together they describe everything on the table surface, so a proximity test — "is
this card inside the discard area?" — is a comparison on x and z with a tolerance. The alternative is to
place snap points in the editor and let the runtime pull dropped pieces into alignment; prefer that when the
positions are fixed at authoring time, and compute from x/z when the layout depends on something you only
learn at runtime, such as how many players sat down.
Gotchas
Flat distance is the X/Z distance. Including y in the comparison makes a card lying on a board look
farther from a card on the table than it is.
Roll is rarely the rotation you want. Facing a piece at a seat is a rotation.y change; rotation.z
tips it onto its side, which for a card means it is standing on edge.
See also
Vector3— the shape, and the units per field.Vector3.x— the other flat-plane axis.TableObjectState.position— feet from the table origin.- Object state — zones and snap points, which are the authored alternative.
TableObjectKind#
Surface B — mod script · type
The eight object kinds the engine can spawn.
declare type TableObjectKind =
| "card" | "deck" | "die" | "token" | "board" | "bag" | "custom" | "card-holder";
The nine kinds an entity can be, fixed at spawn and never changed afterwards. The kind decides which physics
defaults the runtime applies, which collision geometry it builds, which object actions the host will accept, and
which of the optional state fields carry meaning. The nine values are "card", "deck", "die", "token",
"board", "bag", "custom", "card-holder" and "button" — the same nine, in the same order, as the
engine's own TABLE_OBJECT_KINDS (packages/shared/src/tableObjects/kinds/index.ts).
This union is closed: it has no escape hatch, so a kind that is not one of the nine is a type error where the editor is checking your file and a schema failure on the host where it is not.
How, why and when to use it#
You are writing the filter that finds the game's draw pile — api.listObjects({ kind: "deck" }) — or the
switch that decides what to do with an entity a hook handed you. Reaching for label or a tag instead works
until a mod spawns a second thing with the same slug; kind is the one classification the engine itself enforces
and the one that predicts behavior, because roll is refused on anything that is not a die and flip on
anything that is not a card or a deck. Choose the kind at
api.createObject time by what you want the physics and the action
gate to do, not by what the piece looks like — a hex tile with a face is a token, and a mat that pieces sit on
is a board.
Gotchas#
A bad kind loses the whole spawn. The host parses a TableObjectDefinition against the shared schema, and a
kind outside the nine fails that parse — the entity never appears and the only trace is a line in the mod
console. Nothing is coerced and nothing partial is created.
Both surfaces now name all nine; only the escape hatch differs. The table-scripting ObjectKind union
names the same nine values but adds an open (string & {}) fallback, so on that surface a typo compiles.
TableObjectKind is closed and matches the engine exactly. See
ObjectKind vs TableObjectKind.
See also#
- ObjectKind vs TableObjectKind — the value table and the two-union comparison.
- Object kinds — per-kind physics defaults, scale and color.
- Object actions — the kind-by-action matrix the host enforces.
api.createObject— where you pick a kind.api.listObjects— filtering by kind.
ContainerMode#
Surface B — mod script · type
How a container hands out its contents.
declare type ContainerMode = "random" | "stack" | "queue";
Which end of a container a draw takes from. Three values, and each one names a different Array operation the
host performs on the contents list:
"stack"— takes entry0, the card the pile renders on its visible front face. A draw pile, a discard pile, anything where the piece a player can see is the piece they get."queue"— takes the last entry, the far end of the list. A cycling deck: what you put back comes out again only after everything else has."random"— takes an entry the host picks with a private seed, then closes the gap. A tile bag, a chit pull, a blind draw.
Applies to: deck and bag. Every other kind ignores it, because no other kind has contents to hand out.
The default when nothing sets it is "random" for a bag and "stack" for everything else
(resolveContainerConfig, packages/shared/src/tableContainers.ts, which the runtime's
containerDrawModeFor delegates to — so the host and every peer resolve the same answer).
How, why and when to use it#
Your game has a bag of tiles and a deck of cards, and the two want opposite behavior — the bag must be blind,
the deck must not be. The alternative is to leave every container on its default and shuffle before each pull,
which produces something close to "random" but costs a shuffle action, a snapshot broadcast and a visible
spin on every client. Setting the mode once is the cheaper and more honest description of what the container is.
Pick "queue" for the narrow case that neither of the other two covers: a rotation where a card returned to the
container has to wait its turn.
Gotchas#
A "random" draw is seeded on the host, not in your script. The seed is host-private, so the outcome is
neither predictable from the contents list nor reproducible by a client — which is what makes a blind pull
blind. You learn what came out by reading the entity the draw created.
The field is the control; metadata.containerMode is legacy. The precedence is first-class
containerMode, then the metadata key, then the per-kind default. The key is still honoured for an object
that has not been through a load — a setup.json parsed straight into a spawn, a live api.createObject —
but migrateTableSnapshot moves it onto the real field and deletes it from metadata on every load, so
there is exactly one spelling of the draw order afterwards. Write the field.
Standard presets declare it and mean it. bowl-standard, go-bowl-black and go-bowl-white are bag
kinds that set "stack" (packages/shared/src/standardObjects.ts), so they draw off the front rather than
at the bag default of "random" — worth knowing if your game expects a Go bowl to be blind.
See also#
TableContainerContentEntry— the list a mode reads from.TableContainerContentEntry.index— which end is which.api.objectAction— thedrawanddealactions themselves.- Object kinds —
deckandbagin full. - Standard presets — the containers the platform ships.
ModObjectAction#
Surface B — mod script · type
The actions a mod may pass to api.objectAction. Enforced twice: in the frame
(throws Unsupported object action from sandbox: <action>) and again on the
host, so a direct postMessage cannot widen it.
The engine's own TableObjectAction union is larger (19). lift, flick, the
three reveal-* actions and peek are engine-internal; tap, untap and
delete are withheld from mods.
declare type ModObjectAction =
| "flip" | "rotate" | "lock" | "unlock" | "shuffle"
| "draw" | "deal" | "split" | "combine" | "roll";
The ten action names api.objectAction accepts: flip,
rotate, lock, unlock, shuffle, draw, deal, split, combine and roll. The declaration is
the authoring convenience; the enforcement is three independent copies of the same ten names —
SAFE_OBJECT_ACTIONS inside the sandbox frame (apps/web/src/mods/sandbox/modSandbox.html),
isSandboxSafeObjectAction where the message reaches the host (apps/web/src/mods/SandboxedModRunner.ts),
and the same predicate once more in the host's own objectAction implementation before it dispatches an
intent. A mod that posts to the host directly, skipping the frame, still meets the other two.
Applies to: every object kind. A mod's request arrives through the host, which bypasses
isObjectActionAllowedForTarget — the per-kind gate that restricts players and spectators — so nothing
here refuses an action for being nonsensical on its target. What each name does per kind is
Object actions.
How, why and when to use it#
You are writing a deck-builder and want the discard pile shuffled and dealt back out the moment the draw
pile empties, without a player having to remember. These ten names are the entire vocabulary you have for
changing an entity that already exists. The alternative a reader reaches for first is the engine's own
23-action list — the one behind the right-click menu, catalogued in
Action vocabularies — and thirteen of those are unreachable from a mod;
annotate the variable you build an action name in with ModObjectAction and the compiler tells you which
thirteen before you publish. Reach for api.createObject when you want a new entity rather than a change to
an existing one. There is no third option, because a mod cannot destroy one.
Gotchas#
By design.
delete,tapanduntapare in the table-script vocabulary and withheld here, andlift,flick,press,reveal-all,reveal-team-aandreveal-team-bare engine-internal on both surfaces. A mod is untrusted code fetched from a GitHub repository; a table script is written by whoever built the scene, so the two get different budgets. A mod that can delete entities can quietly dismantle a table it did not build, and the threereveal-*actions decide who sees a face-down card, which is the one thing the host must own outright.pressis raised by a player clicking a button, never requested. This is not expected to change. Take pieces off the table with a table script, and model a reveal as your own state — a tag, or saved data.
split and combine are reachable here and not from a table script. Both are in both vocabularies,
but ObjectHandle exposes no method for either, and a mod passes the name as a plain string — so this is
the one place the mod surface is the wider of the two.
See Known limitations.
See also#
api.objectAction— the call, its coercions, and why a bad id fails silently.- Sandbox-safe object actions — the ten names as a value table.
- Action vocabularies — the 19 / 13 / 10 split, and why each gap exists.
- Object actions — the kind × action matrix.
TableObjectState.locked— the flag that makes every one of these exceptunlocka no-op.
ModPluginFunctionSummary#
Surface B — mod script · interface · 3 members
One function an installed plugin exposes.
You learn that a function exists and what it is for — never where it goes. A plugin's endpoints, origins and auth are not part of this surface, by design.
One callable function on an installed plugin: its name, what it is for, and whether it is a table-data sink.
There is no parameter or return shape on this type, and no endpoint. The plugin declares both schemas in its own manifest, and the platform validates against them on your behalf at both ends of the call — so what you need at authoring time is the plugin's own documentation, not a shape echoed through here.
Members#
| Signature | Description | Returns |
|---|---|---|
name |
string |
|
summary |
string |
|
acceptsTableData |
True when the plugin declared this function as one that receives table-derived data. ⚠ Calling such a function is currently REFUSED — the table-to-provider flow has its own consent model and is not routed through the mod API. | boolean |
modpluginfunctionsummary.name#
readonly name: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | plugin-call |
| Availability | mod |
The function's name. This is the exact string you pass as api.callPlugin's second argument — as a plain
string literal, so the publish scanner can read it — and the exact string your manifest's plugins entry
lists under functions.
How, why and when to use it
Use it to check a function is present before you rely on it, and write the same string as a literal at the call site.
Gotchas
A literal at the call site, always. api.callPlugin(id, fn.name) is rejected at publish with dynamic-plugin-call — the scanner cannot read a variable, and a reach it cannot read is a reach nobody can review.
See also
api.callPlugin— where the literal goes.
modpluginfunctionsummary.summary#
readonly summary: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | plugin-call |
| Availability | mod |
The plugin author's one-line description of what this function does. Text for people, from a party the platform does not vet beyond schema validity — render it, do not parse it.
How, why and when to use it
Show it in any UI where a player or a table owner picks between functions, so the choice is legible without reading the plugin's repository.
Gotchas
It is untrusted text. Render it as text; never as markup, and never as a value your logic depends on.
See also
ModPluginFunctionSummary.name— the part that is an identity.
modpluginfunctionsummary.acceptsTableData#
readonly acceptsTableData: boolean;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | plugin-call |
| Availability | mod |
True when the plugin declared this function as one that receives table-derived data. ⚠ Calling such a function is currently REFUSED — the table-to-provider flow has its own consent model and is not routed through the mod API.
True when the plugin declared this function as one that receives table-derived data.
⚠ Calling such a function is refused. That flag marks the plugin's declared table-to-provider channel,
which exists only for a plugin holding the combined capability and carries a platform-owned warning and a
consent flow — because the person who accepts a plugin (the host) is not the person whose hidden information
is at risk (everyone else at the table). Routing it through the mod API would step around all of that.
It is surfaced here so you can filter these out and degrade cleanly, rather than discovering it as a
refused.
How, why and when to use it
Filter these out when you build the list of functions your mod will actually call, and say so in your own UI if a player might otherwise expect the feature.
Gotchas
Calling one is always refused, on every peer. It is not a capability you can acquire by declaring something.
It does not mean the plugin is malicious. It means the plugin declared a channel that carries table-derived data to its own servers, and that channel has a consent model the mod API is not part of.
See also
api.callPlugin— what happens if you try.ModPluginCallFailureReason— therefusedyou would get.
ModPluginSummary#
Surface B — mod script · interface · 6 members
An installed, callable plugin.
One installed plugin, as a mod sees it: who it is, what version, what it credits, and which functions it exposes.
Note what is absent. There is no endpoint, no origin, no path and no auth shape on this type. A mod learns that a function exists and what it is for, never where it goes — otherwise choosing a function would be a way of choosing a destination, and every endpoint a plugin added later would silently widen what mods could reach.
See also#
api.listPlugins— how you get these.ModPluginFunctionSummary— one entry infunctions.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
name |
string |
|
version |
string |
|
breakingVersion |
number |
|
attribution |
Attribution text you must display wherever you show this plugin's data. | string |
functions |
readonly ModPluginFunctionSummary[] |
modpluginsummary.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | plugin-call |
| Availability | mod |
The plugin's id, in reverse-DNS form (org.example.cards). This is the exact string you pass as
api.callPlugin's first argument, and the exact string your manifest's plugins entry declares.
How, why and when to use it
Match on it. This is the identity the manifest, the scanner and the runtime all key on, so any branch that means "do we have the provider plugin?" compares this string.
Gotchas
It is not the display name. Two plugins may share a name; ids are unique.
It must be a literal at the call site. Passing this value through a variable into api.callPlugin makes the call site unreadable to the publish scanner, which rejects it.
See also
api.callPlugin— where this id is named again, as a literal.manifest.plugins— where you declared it.
modpluginsummary.name#
readonly name: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | plugin-call |
| Availability | mod |
The plugin's display name. Author-supplied text meant for people — use it in UI, never as an identity. Match
on id instead.
How, why and when to use it
Put it in a label, a log line, or a settings panel so a player can see which provider a piece of data came from.
Gotchas
Never branch on it. It is author-supplied prose and may change on any release; id is the identity.
See also
ModPluginSummary.id— the identity to compare.
modpluginsummary.version#
readonly version: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | plugin-call |
| Availability | mod |
The plugin's release version (semver), bumped on every publish. Fine for a diagnostic line; it is not the
value that tells you whether the plugin's data model changed — that is
breakingVersion.
How, why and when to use it
Log it once at setup. When a table misbehaves, knowing which release of a plugin it was running is usually the first useful fact.
Gotchas
It moves on every publish. Comparing it to decide whether your stored data is still valid will make you re-fetch constantly; compare breakingVersion instead.
See also
ModPluginSummary.breakingVersion— the one that means compatibility.
modpluginsummary.breakingVersion#
readonly breakingVersion: number;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | plugin-call |
| Availability | mod |
The author-bumped breaking version of the plugin's card model. It changes only when a genuinely incompatible change lands — a renamed identity field, a different id space — so a patch or a feature release leaves it alone.
If you persist anything keyed to a plugin's ids in your saved data, store this next to it. When it changes, your stored ids may no longer resolve.
How, why and when to use it
Store it beside anything you persist that is keyed to this plugin's ids. On load, compare; if it moved, treat your stored ids as unresolved rather than silently wrong.
Gotchas
A bump strands saved data on purpose. The platform's floor is that an unmigrated deck stays visible, marked and exportable — never silently lost. Hold your own persisted state to the same standard: mark it, do not delete it.
See also
api.setSavedData— where to keep it.
modpluginsummary.attribution#
readonly attribution: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | plugin-call |
| Availability | mod |
Attribution text you must display wherever you show this plugin's data.
Attribution text that must be displayed wherever this plugin's data appears.
This is not optional politeness. Most card providers' terms require it, and the plugin's author accepted those terms on their own behalf. The platform renders it mechanically wherever it owns the surface — a mod that renders provider data in its own UI owns that surface.
How, why and when to use it
Render it verbatim anywhere you show this plugin's data in your own UI — a footer line on the panel, a caption under a card list. The platform does the same wherever it owns the surface.
Gotchas
Verbatim means verbatim. Do not truncate, reword, or fold it into a tooltip nobody opens. It is a term the plugin's author accepted on your table's behalf.
See also
api.setUiElement— where a mod's own UI is created.
modpluginsummary.functions#
readonly functions: readonly ModPluginFunctionSummary[];
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | plugin-call |
| Availability | mod |
The functions this plugin exposes, filtered to the ones your manifest declared.
A function listed here is one you may name in api.callPlugin —
except one marked acceptsTableData, which is listed so you can
see it and skip it.
How, why and when to use it
Filter it once at setup and keep the result. Checking that the function you are about to call is present turns a not-found at call time into a clean degraded mode at load time.
Gotchas
It is already filtered to your declaration. A function the plugin exposes but your manifest does not name will not appear, which is not a bug.
Order is the plugin's, not yours. Do not index into it.
See also
ModPluginFunctionSummary— one entry.api.callPlugin— calling one.
ModPluginCallFailureReason#
Surface B — mod script · type
Everything a mod may learn about a failed plugin call, and nothing more.
There is no message, no status code and no upstream detail — those would let a script probe the provider, its budget, or other tables' activity. The real cause is written to the mod diagnostics panel, where you can read it and the script cannot.
rate-limited and unavailable are deliberately indistinguishable, in
timing as well as content.
declare type ModPluginCallFailureReason =
| "unavailable" | "rate-limited" | "not-found" | "refused";
Everything a mod may learn about a failed plugin call. Four values, and there is no fifth:
unavailable— no answer. Upstream failed, timed out, or something went wrong on our side.rate-limited— the plugin's request budget is spent.not-found— the plugin is not installed here, you did not declare it, or you did not declare this function. All of those collapse into this one answer.refused— rejected before or after the wire: arguments that do not satisfy the plugin's declared parameter schema, a response that does not satisfy its declared return schema, or a function you are not permitted to call.
There is no message, no status code, no hostname and no upstream body, and there will not be. A richer
refusal is an oracle: it would let a script probe the provider, measure the plugin's remaining budget, and —
because the circuit breaker is shared per origin — infer other tables' activity. rate-limited and
unavailable are additionally indistinguishable in timing, for the same reason.
The real cause is written to the mod diagnostics panel, where the author can read it and the script cannot.
ModPluginCallResult#
Surface B — mod script · type
The result of api.callPlugin. Check ok before reading data.
A success carries data and nothing else. The platform caches plugin responses, but a
script is deliberately not told whether it was served one or how old it was: that cache is
shared by every room and every user, so "was this exact query run recently" is a question
about other people's tables. Cache state is shown to the PERSON, in the first-party card
UI — never to the script.
declare type ModPluginCallResult =
| {
readonly ok: true;
/** Validated against the plugin function's DECLARED return schema. */
readonly data: unknown;
}
| { readonly ok: false; readonly reason: ModPluginCallFailureReason };
What api.callPlugin resolves to. Check ok before touching
anything else — a failure carries no data at all, rather than an empty one.
On success:
data— the response, already validated against the plugin function's declared return schema. A provider that starts returning a different shape produces arefused, not a surprise in your code.stale— true when this came from the platform cache after an upstream failure or a quota refusal. Show that ("results from N minutes ago") rather than presenting it as live.ageMs— how old the served data is.0for a live response.
On failure, reason and nothing else.
SoundMaterial#
Surface B — mod script · type
Physical surface of a piece. silent is the no-sound sentinel.
declare type SoundMaterial =
| "wood" | "cardboard" | "metal" | "plastic" | "card" | "tile" | "generic" | "silent";
What a piece is made of, as far as the audio engine is concerned. Eight values: "wood", "cardboard",
"metal", "plastic", "card", "tile", "generic" and "silent". It is half of the material-by-action
pair the engine resolves into a licensed clip — the action says what happened, the material says what it
happened to — and it is the only handle a mod has on which clip plays, because clip ids and file paths are
unreachable from a mod by design.
"silent" is the sentinel rather than a surface: the resolver returns nothing for it before it looks at the
action, so a "silent" entity makes no sound at all.
Applies to: every object kind. An entity with no material falls back to its kind's default — card for
card and deck, plastic for die and token, wood for board and card-holder, tile for bag, and
generic for custom (packages/shared/src/soundSets.ts, DEFAULT_SOUND_MATERIAL_BY_KIND).
How, why and when to use it#
Your game's pieces are heavy wooden meeples and the platform is playing them as plastic, because token
defaults to plastic. Setting material: "wood" on the definition at
api.createObject fixes every sound that entity will ever make in
one field. The alternative is a per-action override through
api.setObjectSound, which is the right tool when one moment needs
to sound different from the rest — a chest that creaks only when opened — and the wrong one for "this piece is
made of wood", because it makes you enumerate every action by hand. Set the material for what a piece is, and
override for what one moment does.
Gotchas#
Not every material has a clip for every action. The resolver tries <material>.<action>, then dice.<action>
for plastic only, then generic.<action> — and the generic catalog covers place, pickup, drop,
box-pickup and box-place and nothing else. So metal plus topple resolves to no sound rather than to a
substitute, and the call is silently a no-op.
Two actions ignore the piece's own material. A roll keys on the struck surface instead, and resolves
wood or cardboard only. A slide keys on the sliding piece's material and falls back to wood, so only
wood and plastic have their own slide loop.
A material is replicated state. It rides the snapshot on the entity, so every client resolves the same clip locally and the sound message on the wire carries no clip id.
See also#
- SoundMaterial — the value table and where a material comes from.
SoundAction— the other half of the pair.SoundRef— how a material is named in an override.- Sound sets —
resolveBuiltinSetIdand the full catalog. api.setObjectSound— per-action overrides.
SoundAction#
Surface B — mod script · type
A logical interaction that can emit a sound.
declare type SoundAction =
| "place" | "pickup" | "drop" | "slide" | "shuffle" | "roll" | "fall" | "topple"
| "withdraw" | "collect" | "return-to-box" | "board-clear" | "box-pickup"
| "box-place" | "bag-rummage" | "counter-land" | "counter-fall";
A logical interaction that can make a noise: "place", "pickup", "drop", "slide", "shuffle", "roll",
"fall", "topple", "withdraw", "collect", "return-to-box", "board-clear", "box-pickup",
"box-place", "bag-rummage", "counter-land" and "counter-fall" — 17 values. It is the other half of the
material-by-action pair the engine resolves into a licensed clip, and it is what
api.playSound names, what
api.setObjectSound keys an override on, and what a
ModSoundSet binds its own files to.
This is not ModObjectAction. The two unions share two spellings,
shuffle and roll, and mean different things by them: a sound action is a noise, an object action is a change
to the table. Passing a sound action to api.objectAction throws inside the frame.
How, why and when to use it#
Your card game ends a round by sweeping the board, and you want the sweep to sound like a sweep rather than like
seventeen separate cards being picked up. api.playSound({ event: { material: "cardboard", action: "board-clear" } })
names the moment directly. The alternative is to let the runtime's own per-kind map choose for you, which it
already does for every ordinary grab, drop, settle, shuffle, roll and draw — and which has no idea that your
seventeen objectAction calls were one gesture. Name an action yourself when the game knows something the
physics does not; leave it alone otherwise, because the automatic mapping is what makes an unmodded table sound
right.
Gotchas#
A material without a matching clip resolves to nothing. The resolver falls back through generic.<action>,
and the generic catalog holds only place, pickup, drop, box-pickup and box-place — so an action that
has no set for the material you named plays silently rather than substituting.
Eleven of the 17 are what the table plays by itself today: place, pickup, drop, shuffle, roll,
fall, topple, withdraw, return-to-box, box-pickup and box-place. Three of those are worth knowing the
trigger for, because they are the ones a mod is most likely to double up on: fall is a landing that descended
faster than 2.5 ft/s — roughly a 6 in drop; topple is a landing that was still spinning over 6 rad/s; and
return-to-box is a card or deck being absorbed into a deck. The first two are the same moment as settle,
resolved once, so a landing never plays two clips.
Three more are bound but never reached — and not for the same reason. collect, board-clear and
bag-rummage all have entries in the per-kind map (packages/shared/src/soundSets.ts,
OBJECT_SOUND_EVENT_MAP), so they preload, and no code in apps/web/src/playcanvas/ raises their moment. The
difference is what "not yet" means:
bag-rummageis one protocol change away. Shaking a held bag is a gesture the runtime already detects, and it currently does nothing with it.bag-rummageis a looping set, and a loop cannot be replicated — the same blocker asslide, below.collectandboard-clearhave no operation behind them at all: nothing gathers a group of dice and nothing sweeps a board. They are bindings for features that do not exist.
Either way they are yours to name today, and collect/board-clear are likely to stay that way.
By design.
slide,counter-landandcounter-fallare a mod-only vocabulary. No runtime moment selects them, so nothing the table does on its own will ever play one. That is not an oversight in either case:slideandcounter-fallare looping sets and the sound protocol cannot express a stop, and thecounter-*clips are catalogued under ids the resolver never probes, so binding them would change nothing. The full reasoning is on Known limitations. Call them throughapi.playSoundfor a moment the engine has no concept of, or bind one to an entity withapi.setObjectSoundso a moment the runtime does detect plays your choice instead — and remember a mod cannot stop a loopingplaySoundonce it starts.
See also#
- SoundAction — the value table and what binds each one.
SoundMaterial— the other half of the pair.- Runtime sound events — the moments that pick an action for you.
api.playSound— naming an action directly.ModObjectAction— the unrelated union with two look-alike names.
SoundRef#
Surface B — mod script · type
A license-safe pointer to a sound: either a SEMANTIC first-party reference (the engine resolves material x action to a licensed clip internally) or a sound this mod declared in its own manifest. A clip id or file path can never be expressed.
declare type SoundRef =
| { kind: "builtin"; material: SoundMaterial }
| { kind: "mod"; modId: string; name: string };
A license-safe pointer to a sound. Two forms, discriminated on kind: { kind: "builtin", material } names a
first-party sound semantically — you give the material, the engine pairs it with the action and resolves a
licensed clip internally — and { kind: "mod", modId, name } names one of your own declared
ModSoundSet entries. There is no third form, and neither one can carry
a clip id, a file path or a URL.
That shape is the licensing boundary made structural: the first-party catalog is not addressable, so a mod can choose which kind of sound without ever being handed the library.
Applies to: every object kind. A SoundRef is the value of an override in soundSetOverrides and the third
argument of api.setObjectSound.
How, why and when to use it#
Your treasure chest is a custom entity that sounds like plastic, and you want its lid to creak with a clip you
shipped in the mod. api.setObjectSound(chestId, "pickup", { kind: "mod", modId: manifest.id, name: "creak" })
binds your file to that one moment and leaves everything else the entity does alone. The alternative is
api.playSound({ modSound: "creak" }) from a hook, which fires the clip once at a position you calculate — use
that for a moment nothing on the table corresponds to, and a SoundRef when a real interaction should sound
different from now on. The builtin form is for the commoner case: a piece the runtime is treating as the wrong
substance for one action.
Gotchas#
A mod can only name its own sounds. The host rejects a { kind: "mod" } ref whose modId is not the
calling mod's, and one whose name the manifest never declared, with a mod-console diagnostic and no change
(apps/web/src/mods/SandboxedModRunner.ts). This is an ownership boundary, not a validation slip.
The ref replicates; the clip does not. setObjectSound routes through an intent, so the ref lands on the
entity's soundSetOverrides and reaches every client with the next snapshot. Each client then resolves it
locally against its own copy of the catalog, which is why a sound event on the wire carries no clip id.
A builtin ref carries no action. It names a material only — the action comes from whatever moment is
playing. A ref that resolves to nothing for that pairing plays nothing rather than falling back to the entity's
normal sound.
See also#
api.setObjectSound— where you pass one.ModSoundSet— what{ kind: "mod" }points at.SoundMaterial— what{ kind: "builtin" }carries.- Sound sets — the resolution order in full.
- Sandbox limits — why the catalog is not addressable.
PlaySoundParams#
Surface B — mod script · type
Params for api.playSound. Three semantic forms; no clip ids, paths or URLs.
declare type PlaySoundParams =
| {
/** Play a first-party sound semantically. */
event: { material?: SoundMaterial; action: SoundAction };
position?: [number, number, number];
/** 0..1 gain multiplier for this one play. */
volume?: number;
loop?: boolean;
}
| {
/** Play that object's RESOLVED set, honouring its per-action overrides. */
objectId: string;
action: SoundAction;
volume?: number;
loop?: boolean;
}
| {
/** Play a sound named in this mod's own `soundSets`. */
modSound: string;
position?: [number, number, number];
volume?: number;
loop?: boolean;
};
What api.playSound takes: a union of three semantic forms, each
choosing a different way to say which sound without ever naming a clip.
{ event: { material?, action } }— a first-party sound, resolved from the pair.materialdefaults to"generic"when you leave it out.{ objectId, action }— that entity's fully resolved set, honoring its own material and its per-action overrides. This is the form that sounds like the piece rather than like a category.{ modSound }— one of the sounds your manifest declared. The action comes from that set's ownactionbinding, or"place"when it declares none.
The first and third forms take an optional position, an [x, y, z] tuple in feet; the objectId form has no
position because it plays where the entity is. All three take volume (a 0–1 gain multiplier for this one
play) and loop.
Applies to: every object kind, for the objectId form. The other two are not tied to an entity at all.
How, why and when to use it#
A player completes a set and you want a flourish the table would never produce on its own. Which form you pick
is the whole decision: use { objectId, action } when the sound should belong to a specific piece and follow
whatever that piece is made of, { event } when the sound belongs to a moment rather than a thing — a round
ending, a board being swept — and { modSound } when the sound is yours and no first-party clip is close. The
alternative to all three is api.setObjectSound, which changes what
an entity plays from now on rather than playing something once; reach for it when you find yourself calling
playSound from every hook that touches the same piece.
Gotchas#
A parameter out of range drops the whole call. volume is validated 0–1 and is not clamped, position
must be three finite numbers, and an unrecognized material or action fails the same parse. Any of those leaves a
line in the mod console and plays nothing — the shape is checked twice, once at the sandbox boundary and again in
the runtime.
Give exactly one of event, objectId and modSound. The union is matched in that order, so an object
carrying two of them is read as the first and the other key is discarded before the runtime ever sees it.
{ objectId } with an id nothing matches is silent. No diagnostic, no fallback — the runtime looks the
entity up, finds nothing and returns.
The sound is ephemeral. It rides the unreliable fast channel, is never written into a snapshot and never survives a save. A client that joins a second later hears nothing of it.
See also#
api.playSound— the call itself, with an example.SoundAction— the 17 actions, and which fire by themselves.SoundMaterial— whatevent.materialselects.ModSoundSet— whatmodSoundnames.api.setObjectSound— changing a sound instead of playing one.
ModSoundSet#
Surface B — mod script · interface · 5 members
A mod-declared custom sound set (manifest soundSets entry).
One custom sound your mod ships, as declared in manifest.soundSets and handed back to you on the manifest
setup receives. A set is a logical name, one to sixteen interchangeable variants (repo-relative paths to
your own audio files), and an optional material / action binding that lets the platform reach for the sound
without you asking. The files ride the same GitHub-and-cache pipeline as your models and textures — nothing is
uploaded to a server.
Applies to: every object kind. A set is not tied to a kind; what ties it to an entity is a
SoundRef override or the optional binding.
How, why and when to use it#
Your game has a bell that rings when a round ends, and no material-and-action pairing in the first-party catalog
sounds like a bell. Declaring it as a set gives you a name you can play with api.playSound({ modSound: "bell" })
and a name you can bind to a piece with api.setObjectSound. The alternative is to pick the nearest first-party
sound through { event: { material, action } }, which costs you nothing to ship and is the better answer more
often than authors expect — the catalog covers eight materials across seventeen actions. Ship your own audio when
the sound is part of the game's identity, and lean on the catalog for the ordinary physical noises, because those
are already tuned to the impact velocities the runtime measures.
Gotchas#
The optional binding needs the entity to name your mod. The default mapping runs only for an entity whose
metadata.modId is a string (apps/web/src/playcanvas/TabletopRuntime.ts, resolvableFromObject), and nothing
sets that for you. Pass metadata: { modId: manifest.id } at
api.createObject time, or skip the binding and use
api.setObjectSound, which needs no marker.
Names are unique per mod, last one wins. Sets are registered into a map keyed by name, so two entries
sharing a name leave only the second one reachable.
A set another mod declared is unreachable. Every path that resolves a { kind: "mod" } reference scopes the
lookup to the calling mod's own registration, and the host rejects an override naming a foreign modId
outright.
See also#
ModSoundSet.variants— the files, and how one is chosen.SoundRef— how a set is pointed at.api.playSound— the{ modSound }form.- Sound sets — the resolution order, first-party and mod together.
- Manifest reference — where
soundSetsis declared.
Members#
| Signature | Description | Returns |
|---|---|---|
name |
string |
|
variants |
Repo-relative asset paths (.mp3/.ogg/.wav); one is chosen at random per play. | string[] |
material |
SoundMaterial |
|
action |
SoundAction |
|
loop |
boolean |
modsoundset.name#
name: string;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | play-sound |
| Availability | mod |
The logical name you give this sound, and the only handle anything else uses to reach it. It is what
api.playSound({ modSound }) takes and what the name of a { kind: "mod" }
SoundRef carries. The manifest schema requires 1 to 80 characters matching
/^[a-z0-9][a-z0-9._-]*$/i — it starts alphanumeric, and after that letters, digits, ., _ and - are
allowed. A name outside that pattern fails manifest validation, so the mod does not load with it.
Applies to: every declared set. It is required and has no default.
How, why and when to use it
Name a set for the moment in your game, not for the file — round-end-bell rather than bell_final_v3.mp3.
The alternative that authors default to is naming it after the asset, which reads fine until the audio is
replaced and every call site is describing a file that no longer exists. The name is the stable contract between
your script and your repository; the paths in
variants are the part that is allowed to churn.
Gotchas
Names are scoped to your mod and collapse on collision. Registration builds a map keyed by this string, so two sets sharing a name leave only the later one reachable — with no warning, because both are individually valid.
A name nothing declared plays nothing. api.playSound({ modSound: "typo" }) passes schema validation, finds
no set, resolves to no clip and returns. The failure is silent rather than an error, so a typo here looks like an
audio problem.
It is not a file name and never becomes one. The platform resolves this string to the set's variants and then to a cached asset; nothing concatenates it into a path.
See also
ModSoundSet— the shape it names.ModSoundSet.variants— the files behind the name.api.playSound— the{ modSound }form.- Manifest reference — where the pattern is enforced.
modsoundset.variants#
variants: string[];
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | play-sound |
| Availability | mod |
Repo-relative asset paths (.mp3/.ogg/.wav); one is chosen at random per play.
The audio files behind the set's name — repo-relative paths to your own .mp3, .ogg or .wav assets, one
of which is chosen each time the set plays. They ride the same GitHub-and-local-cache pipeline as your models
and textures, so the audio lives in your repository and nothing is uploaded to a server.
Returns
string[]. Required, with no default: the manifest schema demands 1 to 16 entries of 1 to 180 characters
each (modSoundSetSchema, packages/shared/src/soundSets.ts), and a seventeenth entry fails manifest
validation, so the mod does not load with it. Applies to: every declared set.
How, why and when to use it
Dice land often, and a game that plays one recording every time starts to sound like a stuck record after about four rolls. Recording three or four takes of the same event and listing them all is what buys you variation for the cost of a few more files. The alternative is a single entry, and it is the right answer for a sound whose whole job is to be recognizable — a round-end fanfare, a buzzer — where hearing the same thing twice is the point rather than the problem.
Gotchas
The choice is seeded, not per-listener random. The host draws one seed, broadcasts it with the sound
event, and every peer runs the same pickVariantIndex(seed, count) over the same array
(apps/web/src/playcanvas/audio/resolveSoundRef.ts), so the table hears one take rather than four at once.
Reordering the array changes which file a given seed lands on, which is cosmetic — do not treat an index here
as stable.
A path that resolves to nothing plays nothing. Resolution asks your mod's cached assets for the file; a typo, or a file you never committed, yields no clip, no sound and no error at the call site.
Paths are repo-relative. sounds/bell-1.mp3, not a URL and not an absolute path. A sound file is subject
to the same allowed-extension rules as every other asset in your repository.
See also
ModSoundSet— the shape these belong to.ModSoundSet.name— the stable handle, while these paths are free to churn.api.playSound— the{ modSound }form that plays one of these.- Manifest reference — where
soundSetsis declared and validated. - Sound sets — first-party and mod sounds in one resolution order.
modsoundset.material#
material?: SoundMaterial;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | play-sound |
| Availability | mod |
Half of the optional binding that lets the platform reach for this set without you asking. Paired with
action, it narrows the binding to entities made of one
substance: "play this set when a wooden piece of mine is placed", rather than for every piece of yours.
Returns
SoundMaterial | undefined. Absent means the binding does not care what the entity is made of — it
matches on the action alone. When it is present, the host compares it with the entity's own
material and skips the set on a mismatch, and it makes
that comparison only when both are set (apps/web/src/playcanvas/TabletopRuntime.ts,
modDefaultSoundName) — so an entity that never declared a material matches a set pinned to metal.
Applies to: every declared set.
How, why and when to use it
Your mod ships one game with wooden tiles and metal coins and a distinct place-sound for each. Two sets, both
bound to action: "place", separated by material, and the right one plays for the right piece with no code
in your script at all. The alternative is one set per piece attached with
api.setObjectSound, which is more work and is the better answer
when the split is per-entity rather than per-substance — and which needs no metadata.modId marker on the
entity, where the binding does.
Gotchas
On its own it does nothing. A set with a material and no action never enters the default mapping; the
mapping compares action first and skips anything that does not match.
An entity with no material is not excluded. The kind's default material is substituted at the last step
of resolution, not in this comparison, so a token that declared nothing matches a set pinned to any
material. Declare the material on your pieces at spawn if you rely on this split.
First declared wins. The host walks your sets in declaration order and takes the first whose binding matches, so two sets bound to the same action and material leave the second unreachable through the binding.
See also
ModSoundSet.action— the required other half of the binding.SoundMaterial— the eight values.TableObjectDefinition.material— setting the material this is compared against.api.setObjectSound— the per-entity alternative to a binding.- Sound sets — where a mod binding sits in the resolution order.
modsoundset.action#
action?: SoundAction;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | play-sound |
| Availability | mod |
Which interaction this set stands in for — one of the seventeen
SoundAction values. It is the required half of the optional binding:
declare it and the platform reaches for this set whenever one of your mod's own entities performs that
action, with no call from your script.
Returns
SoundAction | undefined. Absent means the set has no binding at all and is reachable only by name,
through api.playSound({ modSound }) or a { kind: "mod" }
SoundRef. It has a second, smaller job: when you play the set by name,
the declared action is the one carried on the resulting sound event, falling back to place
(apps/web/src/playcanvas/TabletopRuntime.ts, playModSound). Applies to: every declared set.
How, why and when to use it
Every piece in your game should thunk with your own recording when a player puts it down, and writing that as
seventeen setObjectSound calls per entity is not a plan. One set bound to action: "place" covers every
piece your mod owns, including ones spawned later, because the binding is consulted at play time rather than
stamped onto entities. The alternative,
api.setObjectSound, is the right tool when the pieces that
differ are a subset you can name — the binding is all-or-nothing across your mod's entities.
Gotchas
The binding only reaches entities that name your mod. It runs for an entity whose metadata.modId is a
string (apps/web/src/playcanvas/TabletopRuntime.ts, resolvableFromObject), and nothing sets that for you:
pass metadata: { modId: manifest.id } at
api.createObject time, or skip the binding entirely.
An entity's own override wins. soundSetOverrides
is step one of resolution and a mod binding is step two, so an override on that action replaces this set
rather than layering with it.
Playing by name ignores it. A { kind: "mod" } reference resolves straight to the set's
variants; the action affects which moment the event is
labeled as, not which files play.
See also
SoundAction— the seventeen values.ModSoundSet.material— narrowing the binding to one substance.api.playSound— playing the set by name instead.- Sound sets — which runtime moment maps to which action, per kind.
- Manifest reference — where
soundSetsis declared.
modsoundset.loop#
loop?: boolean;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | play-sound |
| Availability | mod |
Declares that this set is a continuous sound — a hum, a slide, a rummage — rather than a one-shot. The first-party catalog carries the same flag on the sets it treats as continuous, and this is where a mod says the same thing about its own audio.
Returns
boolean | undefined. Absent and false are equivalent. What decides whether a sound actually loops is the
loop you pass to api.playSound, not this — see Gotchas.
Applies to: every declared set.
How, why and when to use it
You are shipping an ambient table hum alongside your click and thunk sounds, and a reader opening your
manifest has no other way to tell which of the three is meant to run continuously. Declaring loop: true
records that intent where the set is defined. The alternative is to leave it out and rely on the loop you
pass at each call site, which is what governs playback today — so treat this field as documentation and pass
loop on the call as well.
Gotchas
Known gap. Nothing reads the declared flag. Playback takes
loopfrom the play options only (apps/web/src/playcanvas/audio/SoundService.ts,play), and the mod path builds those options fromPlaySoundParams.loop(apps/web/src/playcanvas/TabletopRuntime.ts,playModSound) — the set's own flag never enters the call. The declaration itself is validated and delivered intact on the manifestsetupreceives, so this is unfinished wiring rather than a wrong type. Pass it at the call site:api.playSound({ modSound: "table-hum", loop: true }). See Known limitations.
A mod cannot stop a loop it starts. api.playSound returns nothing, and none of the twenty-two methods
takes a sound handle or stops a running sound, so a looping play is a decision the client owns from then on.
Prefer short one-shots triggered by game events over anything you would want to end on cue.
A loop still needs a set to reach. The flag is meaningless on its own; the audio comes from
variants, and a set whose files resolve to nothing plays
nothing whether or not it is marked as looping.
See also
ModSoundSet— the shape this belongs to.api.playSound— theloopthat does take effect.PlaySoundParams— where thatloopsits, on all three forms.- Sound sets — the first-party sets that loop, and why.
ObjectPhysics#
Surface B — mod script · interface · 12 members
Optional per-object physics override. Absent fields use the per-kind default.
How one entity behaves in the simulation, as twelve optional fields in three groups: the rigidbody
(bodyType, mass, friction, restitution, linearDamping, angularDamping), the collider
(collisionShape, collisionSize, collisionOffset), and three switches over the assembly
(rigidbodyEnabled, collisionEnabled, weldChildren). Every field is optional and an absent one means
"use the runtime's per-kind default" rather than zero, so absence is the normal, meaningful answer. Those
runtime defaults come from createRigidbodyConfig and the massForKind / frictionForKind family
(apps/web/src/playcanvas/physics/), and they are tabulated in
Object kinds. Do not read them off
defaultObjectPhysicsForKind (packages/shared/src/tableObjects.ts): that function feeds the editor
Inspector's placeholders, its own comment calls its output "an editable suggestion — never written onto
object state", and it disagrees with the runtime numerically.
A mod reaches this shape through TableObjectState.physics
and nowhere else. It is not a field of
TableObjectDefinition, so api.createObject cannot
carry one: on this surface physics is read, never written.
How, why and when to use it#
You are deciding where "this miniature has to feel heavy" belongs, and the answer is authoring — the
editor's Rigidbody and Collision panels, or the setup.json your mod ships — with this type as the shape
of what you set there and read back. In a script, use it defensively: read it before running a rule that
depends on the collider an author gave a piece, and log what you found instead of guessing. The
alternative a reader reaches for is api.objectAction, and it is worth knowing exactly how far that goes:
lock and unlock are the only two actions on this surface that change how a piece behaves physically,
and they do it by pinning the body static rather than by editing any field here.
Gotchas#
locked outranks bodyType. The runtime settles an effective body type with a fixed precedence —
locked wins and forces static, then a parented entity is held kinematic, and only then does the
authored bodyType apply (apps/web/src/playcanvas/TabletopRuntime.ts, applyEffectiveBodyType). An
entity reporting bodyType: "dynamic" is not necessarily simulating as one; check
locked and
parentId before you believe it.
rigidbodyEnabled and collisionEnabled disable, they do not remove. render, collision and
rigidbody are intrinsic — every entity carries all three for its whole life — so these flags switch the
behavior off while keeping the authored settings, and an absent flag means enabled. That is also why they
live here rather than in
components, which lists only the components an
entity may or may not have.
See also#
TableObjectState.physics— the only path to this shape, with each field's validated range.ObjectComponentState— the optional components, which this is deliberately not part of.- Object kinds — the per-kind defaults an absent field falls back to.
- RIGIDBODY and COLLISION — the same fields, where they are actually authored.
Members#
| Signature | Description | Returns |
|---|---|---|
bodyType |
"static" | "dynamic" | "kinematic" |
|
mass |
Mass in kilograms; only meaningful for dynamic bodies. | number |
friction |
number |
|
restitution |
number |
|
linearDamping |
number |
|
angularDamping |
number |
|
collisionShape |
"auto" | "box" | "sphere" | "capsule" | "cylinder" | "convexHull" | "mesh" |
|
collisionSize |
Vector3 |
|
collisionOffset |
Vector3 |
|
rigidbodyEnabled |
boolean |
|
collisionEnabled |
boolean |
|
weldChildren |
Merge parented descendants into one compound rigidbody. Set on the parent. | boolean |
objectphysics.bodyType#
bodyType?: "static" | "dynamic" | "kinematic";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How the entity takes part in the Ammo simulation. static never moves and has infinite mass; dynamic is fully
simulated and answers to gravity and impacts; kinematic is moved by code and shoves dynamic bodies without ever
being pushed back. This is the value an author wrote, and there are only three places it can have come from: the
editor's RIGIDBODY section, a model's .meta.json sidecar in the mod's repo (modelAssetMetaSchema,
packages/shared/src/modelAssetMeta.ts), or an imported snapshot. TableObjectDefinition carries no physics
field, so nothing a mod passes to api.createObject ever sets it.
Returns
"static" | "dynamic" | "kinematic" | undefined. Absent means the body was built straight from
createRigidbodyConfig (apps/web/src/playcanvas/physics/collisionHelpers.ts), which hands every kind
dynamic unless the entity is locked, in which case it gets static. Applies to: every object kind. No kind
starts out kinematic, and none starts out static on the strength of its kind alone.
How, why and when to use it
You are writing a knock-over rule — a thrown die has to topple a tower — and you want to log a clear warning when
the author has made the tower immovable, instead of letting players discover it by throwing. The alternative most
mods reach for is TableObjectState.locked, and it answers a
different question: locked is "can a player move this", bodyType is "does the physics engine move this". Read
locked when you are gating an interaction, and read bodyType when you are predicting what a collision will do.
Gotchas
The authored type is often not the live type. applyLockedBodyType and applyEffectiveBodyType
(apps/web/src/playcanvas/TabletopRuntime.ts) override it, in this order: a locked entity is held static; an
entity with a parentId is held kinematic for as long as it is parented; an entity being dragged, or sitting in
a seat's hand, is held kinematic; and while Edit Mode's simulation freeze is on, a would-be-dynamic body is
held kinematic. None of those rewrite the field — unlocking or unparenting re-derives from what you read here.
An imported model is static until its GLB lands. A custom model spawns on a placeholder collider pinned
static so 32 overlapping chess pieces cannot blast each other apart, and promoteLoadedCustomModelBody restores
the authored type once the real collider is fitted.
See also
ObjectPhysics— the shape this belongs to, and why it is a read.TableObjectState.parentId— the flag that forceskinematic.ObjectPhysics.mass— inert on anything that is notdynamic.- RIGIDBODY — Body fields — the same field, in the editor.
- Object kinds — what an absent value falls back to.
objectphysics.mass#
mass?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Mass in kilograms; only meaningful for dynamic bodies.
The body's mass in kilograms. applyObjectPhysicsOverride
(apps/web/src/playcanvas/physics/objectPhysicsOverrides.ts) writes it onto the rigidbody only when the effective
body type is dynamic; on a static or kinematic body the number sits in state and changes nothing. The schema
requires it strictly positive and finite, and sets no ceiling — a 900 kg card passes validation.
Returns
number | undefined. Absent means massForKind (apps/web/src/playcanvas/physics/PhysicsEngine.ts) decided it:
board 4, deck 1.6, die 0.35, token 0.18, card 0.05, and 0.8 for every remaining kind (bag, custom,
card-holder).
How, why and when to use it
You are shipping a dexterity game where a flicked disc has to move a stack, and you want your setup check to log
"this stack outweighs the disc 80 to 1" before anyone plays a round rather than after. The alternative is to watch
TableObjectState.velocity after the first throw and infer the
ratio — which costs a play-test and only measures the throw you happened to see. Mass answers at load time. Keep it
diagnostic: a mod that finds an absurd mass can log it and refuse to start, not correct it.
Gotchas
Two different sets of "per-kind defaults" exist and they disagree. The numbers the editor shows as inspector
placeholders come from defaultObjectPhysicsForKind (packages/shared/src/tableObjects.ts) and are documented in
that file as suggestions only — it offers deck a mass of 0.3 against the runtime's 1.6. What an absent field
actually resolves to is the runtime's table, in
Object kinds.
Changing a piece's Surface writes this field. Setting a piece's material in Edit Mode re-derives mass,
friction, restitution, linearDamping and angularDamping from kind × material × scale and commits all five
as explicit overrides (rederivePhysicsFromMaterial, apps/web/src/ui/TableEditModeShell.tsx). A present value
therefore tells you the field is authored; it does not tell you anyone typed the number.
See also
ObjectPhysics.bodyType— the field that decides whether mass applies.TableObjectState.material— the Surface that rewrites this field.ObjectPhysics.friction— the shared range rule for the four 0–1 scalars.- RIGIDBODY — Body fields — where an author sets it.
objectphysics.friction#
friction?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The Coulomb friction coefficient — how hard the body's surface resists sliding against another surface it is
touching. 0 is ice, 1 is the schema's ceiling. It is one of four scalars on this shape that share the same
0–1 bound (friction, restitution, linearDamping, angularDamping), and the bound is enforced by
rejection, not by clamping: objectPhysicsSchema (packages/shared/src/tableObjects.ts) uses min(0).max(1), so
a payload carrying 1.4 fails validation outright and the value never lands in state. Everything you read on these
four fields is therefore already in range.
Returns
number | undefined. Absent means frictionForKind (apps/web/src/playcanvas/physics/PhysicsEngine.ts) decided
it: card 0.72, deck 0.76, die 0.82, token 0.78, board 0.92, bag 0.8, custom 0.82, card-holder 0.8.
How, why and when to use it
Your game asks players to flick a token down a lane, and a token that stops dead two inches in makes the whole
mechanic feel broken. Reading friction before the session lets your mod name the culprit in a log line. The
alternative most authors reach for is
restitution, because "the piece behaves wrong on contact"
sounds like one problem — it is two. Friction governs a body sliding along something; restitution governs a body
rebounding off something. A piece that stops too soon is friction or damping; a piece that skitters off the board
is restitution.
Gotchas
die carrying the highest friction of any kind is deliberate. At 0.82 it converts a thrown die's slide into a
roll and tumble instead of letting it skate flat across the table, and it is paired with the highest restitution of
any kind so the die actually bounces (frictionForKind's own comment says so). A validator that flags high
friction as an authoring mistake flags every die on the table.
Setting a piece's material in Edit Mode writes this field, along with four others — the mechanism is on
ObjectPhysics.mass.
See also
ObjectPhysics.restitution— the contact behavior friction is not.ObjectPhysics.linearDamping— velocity bleed that acts with no contact at all.TableObjectState.material— the Surface behind many authored values.- Object kinds — every per-kind default in one table.
objectphysics.restitution#
restitution?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Bounciness: the fraction of impact speed the body keeps after a collision. 0 lands dead on the table, 1 returns
everything it arrived with. It acts only at the instant of contact, which is what separates it from the two damping
fields — those bleed velocity continuously whether or not anything is being touched. It shares the 0–1 bound
described on ObjectPhysics.friction.
Returns
number | undefined. Absent means restitutionForKind (apps/web/src/playcanvas/physics/PhysicsEngine.ts)
decided it, and its table is short: die 0.52, token 0.14, and 0.1 for every other kind — card, deck,
board, bag, custom and card-holder all share the same default.
How, why and when to use it
You have built a dice tray and players keep reporting that a hard throw puts a die on the floor. Reading
restitution on the die and on the tray tells you whether the bounce is authored or whether the throw impulse is the
thing that needs tuning. The alternative that looks right first is
linearDamping, because both fields make a piece "settle
sooner" — but damping slows a die that is already travelling, and does nothing about the energy the die keeps at
the moment it hits the tray wall. Reach for restitution when the complaint is about the rebound, damping when it is
about the piece never coming to rest.
Gotchas
A default die keeps 0.52 of its impact speed; everything but a token keeps 0.1. That gap is a deliberate difference, not drift — a die that does not bounce reads as a dropped brick. Comparing a die's restitution against a card's is comparing two intentionally different regimes, so a per-table average across kinds tells you nothing.
board and card-holder are not exceptions. Both fall through restitutionForKind's default case to 0.1,
so a piece dropped on an unauthored board rebounds rather than landing dead. A dead surface is an authored 0, and
its absence is a real signal rather than a formality.
See also
ObjectPhysics.friction— the shared0–1rule, and the sliding half of contact behavior.ObjectPhysics.angularDamping— what actually stops a die tumbling.- RIGIDBODY — Body fields — where an author sets it.
- Object kinds — the full per-kind table.
objectphysics.linearDamping#
linearDamping?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How fast the body sheds straight-line velocity. 0 coasts; higher values bring a moving piece to rest sooner,
whether or not it is touching anything. That last clause is the whole distinction from
friction, which needs a contact surface to act at all. It shares
the 0–1 bound described there.
Returns
number | undefined. Absent means linearDampingForKind (apps/web/src/playcanvas/physics/PhysicsEngine.ts)
decided it: die 0.02, token 0.04, card 0.07, and 0.05 for every other kind (deck, board, bag, custom,
card-holder).
How, why and when to use it
You are running a shuffleboard-style mini-game where a shot has to travel a repeatable distance, and one player's
puck keeps sailing off the far edge while another's stops halfway. Reading linearDamping across the pucks tells
you in one pass whether they were authored identically — a mismatch here is invisible in the editor's hierarchy and
invisible in a screenshot. The alternative is to compare each piece's friction, which is the natural first guess
and the wrong one when the pucks travel most of their distance without much surface contact.
Gotchas
die has the lowest default of any kind, and that is why it rolls. 0.02, against 0.07 on a card and 0.05 on
most kinds, is what lets a thrown die keep travelling instead of stalling where it lands. A rule that treats a low
damping value as an authoring error will treat every die as an error.
Damping and mass are independent inputs to "when does it stop". A heavy piece with low damping and a light
piece with high damping both settle, for different reasons and over different distances, so reading one field
without the other answers half the question. See ObjectPhysics.mass,
which is also the field that documents why five numbers on this shape tend to be present together.
See also
ObjectPhysics.angularDamping— the same bleed, applied to spin.ObjectPhysics.friction— contact-only resistance, and the shared range rule.TableObjectState.velocity— what damping is acting on, sampled live.- Object kinds — the full per-kind table.
objectphysics.angularDamping#
angularDamping?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The same continuous velocity bleed as linearDamping,
applied to rotation instead of travel: how quickly a spinning or tumbling piece stops spinning. It shares the
0–1 bound described on ObjectPhysics.friction.
Returns
number | undefined. Absent means angularDampingForKind (apps/web/src/playcanvas/physics/PhysicsEngine.ts)
decided it, and the spread is narrow: die 0.015, token 0.06, and 0.08 for every other kind (card, deck,
board, bag, custom, card-holder).
How, why and when to use it
You are timing a spinner — a disc a player flicks, which has to keep turning long enough for everyone to watch it
slow down before it points at someone. Angular damping is the single number that decides that, and reading it lets
your mod pick a sensible timeout instead of hard-coding one that breaks the first time an author retunes the disc.
The alternative is sampling
TableObjectState.angularVelocity in a hook until it
falls near zero, which is the right tool for "is it still spinning at this instant" and the wrong one for "how long
will spinning take", because it cannot answer until the spin is nearly over.
Gotchas
die sits far below every other kind at 0.015, against 0.08 for most kinds. A die is authored to keep tumbling
so it lands on a face rather than stalling on an edge. Any threshold your mod picks for "settled" has to be
per-kind, not global.
Do not wait out the tumble to read a die. The host already does it: onDiceRolled fires when the die comes to
rest and hands you the printed face. Sampling damping-driven spin-down to guess at "settled" is strictly worse and
cannot tell you the number. See
globalEvents.onDiceRolled.
See also
ObjectPhysics.linearDamping— the travel half of the pair.TableObjectState.angularVelocity— the live spin this bleeds.ObjectPhysics.mass— the editor action that writes these five numbers together.- RIGIDBODY — Body fields — where an author sets it.
objectphysics.collisionShape#
collisionShape?: "auto" | "box" | "sphere" | "capsule" | "cylinder" | "convexHull" | "mesh";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The collision geometry the body is built from — auto, box, sphere, capsule, cylinder, convexHull or
mesh. The four primitives are cheap approximations fitted to the model's measured bounds; convexHull wraps the
mesh in an accurate-ish hull at a real cost; mesh is the exact triangle mesh, and the schema describes it as the
most accurate and the slowest of the seven. auto is not a shape at all — applyObjectPhysicsOverride
(apps/web/src/playcanvas/physics/objectPhysicsOverrides.ts) skips the shape step entirely for it and leaves
whatever the kind already configured in place.
Returns
"auto" | "box" | "sphere" | "capsule" | "cylinder" | "convexHull" | "mesh" | undefined. Absent behaves exactly as
auto: createCollisionConfig (apps/web/src/playcanvas/physics/collisionHelpers.ts) gives every kind a box
fitted to its scale and a per-kind half-height (collisionHalfHeightForObject). Preset dice are the one exception
— applyDieConvexHullShape swaps a convex hull onto the Ammo body afterwards, and only for a die whose
metadata.standardPresetId names a hull the runtime ships.
How, why and when to use it
You are writing a pre-session validator for a mod other people contribute pieces to, and you want to catch the
contributor who shipped a full mesh collider on a piece players are going to throw. Collision shape is the
largest per-entity physics cost there is and it is readable before anything moves. The alternative is watching the
frame time drop once eight of those pieces are on the table, by which point you are guessing which one did it.
Applies to: every object kind — but the value only differs from the default on a piece an author has
deliberately touched, so a non-undefined read is itself the signal.
Gotchas
A mesh request on a dynamic body silently becomes a convex hull. Ammo's triangle-mesh shape is invalid for a
dynamic body, so the runtime substitutes convexHull and reports the substitution to its caller. It never writes
the substitution back, so this field tells you what was asked for, not what Ammo built. Cross-check
bodyType before you trust a mesh reading.
convexHull and mesh need geometry that has finished loading. If no render mesh with at least four vertices
is reachable when the override is applied, the runtime leaves the existing collider alone and retries after the
model lands. A piece whose GLB never resolves keeps the box it spawned with, while this field still reads
convexHull.
See also
ObjectPhysics.collisionSize— the dimensions, honored for box colliders only.ObjectPhysics.bodyType— what decides whethermeshsurvives.- COLLISION — Shape — the same seven values, in the editor.
- Object kinds — the collider each kind is built with.
objectphysics.collisionSize#
collisionSize?: Vector3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The collider box's full dimensions — not half-extents — in feet, measured with the entity's scale at 1. The
runtime multiplies the entity's own scale back in when it builds the collider
(applyCustomModelCollisionBounds, apps/web/src/playcanvas/TabletopRuntime.ts), so a collisionSize of
{ x: 2, y: 0.1, z: 2 } on an entity scaled 3 produces a collider six feet across. All three components must be
strictly positive; positiveVector3TupleSchema rejects a zero or a negative rather than repairing it.
Returns
Vector3 | undefined. Absent means auto-fit: the runtime measures the
imported model's local bounds and doubles the half-extents into full dimensions. Applies to: any entity carrying
an imported model (metadata.customModelAssetId) whose collider is a box. On anything else —
every standard-preset card, die, token, board and holder —
applyCustomModelCollisionBounds returns before it reads this field, and an authored value is inert.
How, why and when to use it
A player reports that your card holder blocks a card a full hand's width away from where the model ends, and you
want to know whether the collider was hand-widened or whether the model itself is oversized. Comparing
collisionSize against the piece's visible footprint separates the two. The alternative most reach for is
TableObjectState.scale, which is the wrong instrument for
exactly this bug: scale sizes the model, this sizes the collider, and they diverge precisely when someone has
authored an override — which is the case you are chasing.
Gotchas
Full dimensions, not half-extents. The runtime halves the value before handing it to Ammo. Reading it as a half-extent doubles every number you derive from it, and the mistake is invisible until a collider looks twice its real size in your logs.
A present value replaces the measurement outright. Auto-fit stops running once an override exists, so swapping the model behind the entity does not re-fit the collider — an author has to clear the override with the Inspector's Reset to fit. That is the mechanism behind an entity whose collider and art disagree long after both looked correct.
See also
ObjectPhysics.collisionOffset— where the box sits; the paired field.ObjectPhysics.collisionShape— onlyboxhonors these two.TableObjectState.scale— the multiplier baked in at realize time.- COLLISION — the collision box editor an author uses to set it.
objectphysics.collisionOffset#
collisionOffset?: Vector3;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Where the collider box's center sits relative to the entity's origin, in feet, measured with the entity's scale at
1 — the companion to collisionSize. { x: 0, y: 0, z: 0 }
centers the box on the origin. Unlike the size, the components are unconstrained: negative values and zeros are all
legal, because a collider frequently belongs below or behind the point the model is anchored at.
Returns
Vector3 | undefined. Absent means auto-fit — the center of the imported
model's measured local bounds, which sits half the model's height above the origin for a model authored with its
base at y = 0. Applies to: any entity carrying an imported model (metadata.customModelAssetId) whose collider
is a box — the same condition
collisionSize documents. Everywhere else the runtime never
reads it.
How, why and when to use it
A miniature is modeled leaning forward off its base, and players complain that pieces bump into empty air in front
of it while the model's back is walk-through. Reading the offset tells you whether the collider was recentered to
match the pose or left on the origin. The alternative an author reaches for is moving the entity's position,
which drags the visible model along with the collider and therefore fixes nothing — this field is the only thing
that moves the collider alone, which is why a mismatch shows up here and nowhere else.
Gotchas
The offset is scaled by the signed scale; the size is scaled by its magnitude. The runtime multiplies the
offset by scale.x, scale.y and scale.z as given, and the half-extents by Math.abs of the same components.
An entity with a negative scale component therefore mirrors where its collider sits without mirroring how big it
is — the one case where reading the two fields together is not enough and you need the sign of the scale too.
The two fields are independent. An entity can carry collisionOffset with no collisionSize, keeping the
auto-fit dimensions and moving them; the reverse holds too. Do not treat the presence of one as evidence for the
other.
See also
ObjectPhysics.collisionSize— the dimensions this recenters.TableObjectState.scale— including the sign that matters here.Vector3— the record shape.- COLLISION — the draggable box gizmo an author uses instead of typing numbers.
objectphysics.rigidbodyEnabled#
rigidbodyEnabled?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Whether the entity's rigidbody engine component is switched on. It disables rather than removes: the schema's own
note is that disabling it "freezes the body without discarding its authored settings" (objectPhysicsSchema,
packages/shared/src/tableObjects.ts), so the mass, friction, damping and shape stay in the document, unapplied
and intact, ready to resume the moment the flag flips back.
Returns
boolean | undefined. Absent means enabled. applyObjectComponents
(apps/web/src/playcanvas/TabletopRuntime.ts) tests !== false, so only an explicit false switches the body
off, and every entity authored before this flag existed reads undefined and simulates normally. That asymmetry is
deliberate — it is what makes the field backward compatible with every older snapshot.
How, why and when to use it
Your mod runs a table-setup report and you want to distinguish the pieces that are part of the game from the
scenery an author parked in the corner. The alternative reading is
bodyType === "static", which is a different statement: a static
body still occupies space, still blocks, and still stops a rolled die. A disabled rigidbody is not being simulated
at all. Read bodyType for "does it move under its own weight", and read this for "is it in the simulation".
Gotchas
Disabling is not deleting, and deleting is not possible. rigidbody and collision are intrinsic engine
components on every table Entity for its whole life — the components an author can add are light and camera.
A mod that branches on "this piece has no rigidbody" has written a branch that never runs.
It is independent of collisionEnabled. The runtime
realizes the two as separate enable writes against separate components, so reading one tells you nothing about the
other.
See also
ObjectPhysics.collisionEnabled— the paired switch on the other intrinsic component.ObjectPhysics.bodyType— the settings this flag suspends.- RIGIDBODY — The enable checkbox — the control an author ticks.
- Object state — which components are intrinsic and which are addable.
objectphysics.collisionEnabled#
collisionEnabled?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Whether the entity's collision engine component is switched on. The schema's own note for it is that disabling
collision "makes the object non-colliding while staying visible" (objectPhysicsSchema,
packages/shared/src/tableObjects.ts) — the piece keeps its render geometry and stays on the table; it stops
taking part in contacts.
Returns
boolean | undefined. Absent means enabled, tested with the same !== false comparison
applyObjectComponents (apps/web/src/playcanvas/TabletopRuntime.ts) uses for
rigidbodyEnabled.
How, why and when to use it
You place a "current player" arrow above the board each turn, and it has to never deflect a die that someone rolls
under it. The alternative authors try first is shrinking
collisionShape to something tiny — a small collider is
still a collider, and a die can still catch it, so the bug survives every play-test that does not happen to hit it.
Reading this flag is how your mod confirms an overlay marker was authored inert rather than merely made small.
Gotchas
A disabled collider keeps its shape, size and offset. collisionShape, collisionSize and collisionOffset
stay in state untouched and are applied again the moment the flag flips back, so reading them on a non-colliding
piece is still meaningful — they describe the collider it will have, not the one it has.
This flag is realized outside the physics-override path. applyObjectPhysicsOverride
(apps/web/src/playcanvas/physics/objectPhysicsOverrides.ts) never reads it; the enable flags are handled with the
optional light and camera components instead. That is the mechanical reason it is a component switch rather
than a body parameter, and why it changes nothing about mass or friction.
See also
ObjectPhysics.rigidbodyEnabled— the paired switch, and why absent means on.ObjectPhysics.collisionShape— the geometry this suspends.- COLLISION — The enable checkbox — the control an author ticks.
- Object state — intrinsic versus addable components.
objectphysics.weldChildren#
weldChildren?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Merge parented descendants into one compound rigidbody. Set on the parent.
Whether this entity's parented descendants are merged into a single compound rigidbody. It is read on the
ancestor, never on a child. Off, the assembly is in attached mode: every parented child is held kinematic, so
the group shoves loose pieces around and is never pushed back. On, the ancestor's collider becomes compound,
every descendant loses its own rigidbody, and the whole assembly becomes one dynamic body with combined mass and
inertia — a thrown die bounces off it and nudges it (applyWeldState,
apps/web/src/playcanvas/TabletopRuntime.ts).
Returns
boolean | undefined. Absent and false mean the same thing — the runtime tests === true — so undefined on an
entity that has children means attached mode, not "unknown". Reading it on an entity with no children tells you
nothing about behavior, because there is no assembly for it to restructure.
How, why and when to use it
Your mod offers a per-piece action and needs to know whether a peg in a board a player assembled can still be
picked up on its own. A welded child cannot: the grab escalates to the ancestor, so offering the action produces a
button that moves the wrong thing. The alternative is walking
parentId across the snapshot, which tells you the assembly
exists but not how it behaves under a grab or an impact. Read parentId for structure, and read this for behavior.
Gotchas
The runtime deletes the flag when it refuses the weld. Welding an entity that is already a member of another
welded assembly is rejected outright — applyWeldState logs a line naming the entity to the table log and removes
weldChildren from its physics. A true you read once can be gone from the next snapshot, so cache the assembly's
behavior at your own risk.
Rebuilds are debounced to one per frame. The compound shape has to be rebuilt whenever a child is added, removed, moved or rescaled, and the runtime queues those and flushes once per frame rather than once per drag frame. A hook that fires mid-drag can observe the assembly between the change and the rebuild.
See also
TableObjectState.parentId— the edges weld operates on.- Welding — the full weld / attached / joint decision tree.
- Welding — nested weld — why a
truecan disappear. - RIGIDBODY — the checkbox that appears only when the entity has children.
ObjectComponentState#
Surface B — mod script · type
An OPTIONAL engine component. render/collision/rigidbody are intrinsic and never listed here.
declare type ObjectComponentState =
| {
type: "light";
enabled: boolean;
props: {
type: "directional" | "omni" | "spot";
color: string;
intensity: number;
/** Falloff distance in FEET. Ignored for a directional light. */
range: number;
/** How brightness decays over `range`. Ignored for a directional light. */
falloffMode: "linear" | "inverse-squared";
/** Degrees. Spot only. */
innerConeAngle: number;
outerConeAngle: number;
castShadows: boolean;
/** When the shadow map is re-rendered. `once` renders it and freezes. */
shadowUpdateMode: "realtime" | "once" | "none";
shadowResolution: 256 | 512 | 1024 | 2048 | 4096;
/** Directional only. Each cascade is another shadow view. */
numCascades: number;
/** Directional only. Shadow draw distance, in FEET. */
shadowDistance: number;
shadowIntensity: number;
/** The shadow filter. An omni light can only run `pcf1`, `pcf3` or `pcss`. */
shadowType: "pcf1" | "pcf3" | "pcf5" | "vsm16" | "vsm32" | "pcss";
/** `pcss` only: shadow samples per pixel. */
shadowSamples: number;
/** `pcss` only: samples used for contact hardening. 0 turns it off. */
shadowBlockerSamples: number;
/** `pcss` only: the light's apparent size — how wide the penumbra opens. */
penumbraSize: number;
/** `pcss` only: how fast the shadow softens with distance. 1 is linear. */
penumbraFalloff: number;
shadowBias: number;
normalOffsetBias: number;
/** Promises the light never moves. */
isStatic: boolean;
bake: boolean;
bakeDir: boolean;
bakeNumSamples: number;
bakeArea: number;
affectLightmapped: boolean;
affectDynamic: boolean;
/** Directional only. */
affectSpecularity: boolean;
/** Which render layers this light reaches. */
layers: ("scene" | "table" | "game")[];
};
}
| {
type: "camera";
enabled: boolean;
props: {
clearColor: string;
fov: number;
nearClip: number;
farClip: number;
projection: "perspective" | "orthographic";
orthoHeight: number;
priority: number;
};
};
One optional engine component on an entity, as a union discriminated on type with two arms — light and
camera — each shaped { type, enabled, props }. render, collision and rigidbody are intrinsic:
every entity has all three for its whole life, they are configured through
physics and metadata, and they never appear here.
That two-tier split is the reason the type exists — optional components are added and removed, intrinsic
ones are only ever configured and disabled.
Reachable in both directions. Set it on spawn through
TableObjectDefinition.components; read it back
off TableObjectState.components.
How, why and when to use it#
You want a lantern token that actually casts light on the pieces around it, and follows them when a player
drags it. That is what a light component on the entity buys you: it rides the entity's transform, so the
pool of light moves with the piece. The alternative is a scene light placed once in the editor, and it is
the right answer for anything that lights the table as a whole — a scene light costs nothing per entity
and does not multiply when a player spawns ten more lanterns. Put a component on the entity when the
lighting is a property of the piece; light the scene when it is a property of the room.
Gotchas#
At most one per type, at most eight per entity. objectComponentsSchema
(packages/shared/src/objectComponents.ts) rejects a duplicate type with
duplicate component type "<type>": at most one per entity and caps the array at MAX_OBJECT_COMPONENTS.
Two light entries is a validation failure, not last-one-wins, because the runtime applies one engine
component per type and two would have no defined meaning.
A camera arrives disabled and a light arrives enabled. The engine draws through every enabled
camera and the highest priority wins, so enabled defaults to false on the camera arm alone, the
runtime pins an object camera's priority strictly below the table camera, and it refuses to enable a
second one while another is already enabled, saying so in the log
(apps/web/src/playcanvas/TabletopRuntime.ts, applyObjectComponents).
The field is absent, never []. An empty array and an absent field would both reach the snapshot
delta and report a change that did not happen, so upsertObjectComponent and removeObjectComponent
return undefined once nothing is left. Test for the field, not for its length.
An unrecognized type is rejected, not dropped. A component authored by a newer client fails
validation on an older one instead of vanishing — silent dropping would let the older client re-save the
entity and destroy the newer client's authoring for good.
See also#
TableObjectState.components— reading them off an entity.TableObjectDefinition.components— setting them at spawn.- Object state — the per-arm property lists and their caps.
- LIGHT and CAMERA — the same two arms, in the Inspector.
- Add component — the addable set, and why
scriptis not in it.
TableObjectDefinition#
Surface B — mod script · interface · 22 members
What api.createObject accepts. Only kind, label and position are required.
The write-side description of an entity. It is the single argument to
api.createObject and it is also the shape of every literal entry
in a mod's setup.json objects array. Three fields are required — kind, label and position — and
every other field falls back to a default the runtime picks from the kind. You never receive one of these
from the API: reads come back as TableObjectState, which is a
wider shape with different rules.
How, why and when to use it#
You write one of these whenever the table needs an entity that was not in the mod's setup.json — a scoring
token per seat, a marker that appears when a card is drawn, a fresh die for a round. The alternative is
pre-placing everything in setup.json, which is the right choice whenever the count is fixed while you are
authoring: those entities load with the scene, are validated by tableObjectDefinitionSchema
(packages/shared/src/tableObjects.ts) before anything runs, and cost you no script. Build a definition at
run time when the count depends on something you only learn at the table. Keep the definition small — name
the three required fields plus the two or three that actually differ from the kind's defaults, because every
field you set is a field you have to keep true as the kind's defaults improve.
Gotchas#
Known gap.
tappedis declared here and is dropped when the entity is built.TabletopRuntime.ts'screateObjectassembles the runtime definition field by field andtappedis not among them, so it never reaches the snapshot even thoughTableObjectStatedeclares it andobjectStateEq(packages/shared/src/snapshotDelta.ts) compares it. The value is reachable another way — thetapanduntapactions are what write it. Every other field on this interface is honored at spawn,containerModeandcapacityLimitincluded; they were dropped here too until the containers work carried them onto the runtime entity. See Known limitations.
Both paths parse with tableObjectDefinitionSchema now, but they fail differently. On the setup.json
path modSetupSchema parses every literal object, so an over-long label or a bad color fails the mod's
LOAD, with a message naming the field. On the createObject path the host parses the payload it receives
over postMessage (apps/web/src/mods/SandboxedModRunner.ts) and a definition that fails is dropped with
a runtime diagnostic naming your mod — the call simply produces no entity. It is deliberately not thrown
back into your frame, because an error that told you why would turn the schema into an oracle a script
could probe. So: a bad literal stops the load; a bad runtime spawn is a missing entity plus a diagnostic.
position, rotation and scale are world-absolute even when you set parentId. Give the child its
real place on the table, not an offset from the parent.
See also#
api.createObject— the call that takes one.TableObjectState— the wider shape you read back.- setup.json — the same shape, authored instead of scripted.
- Object kinds — what each field means per kind.
- Limits and caps — every schema bound in one table.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
Omit to let the host mint one. | string |
kind |
TableObjectKind |
|
label |
The SLUG / machine identity. For kind: "card" this IS the card id. |
string |
position |
Vector3 |
|
rotation |
Vector3 |
|
scale |
Vector3 |
|
color |
#rrggbb. |
string |
ownerSeat |
string | null |
|
faceDown |
boolean |
|
locked |
boolean |
|
tapped |
boolean |
|
stackCount |
Deck depth, 1..1000. | number |
containerMode |
ContainerMode |
|
capacityLimit |
number |
|
tags |
Author tags match /^[a-z0-9_-]+$/i — a : is rejected, so dt: names are unwritable. |
string[] |
components |
ObjectComponentState[] |
|
displayName |
The HUMAN name ("Chess Board"). Absent = fall back to label. |
string |
parentId |
string | null |
|
material |
SoundMaterial |
|
soundSetOverrides |
Partial<Record<SoundAction, SoundRef>> |
|
metadata |
Record<string, unknown> |
|
secretMetadata |
SECRET data, withheld on the wire from any peer not entitled to this entity's identity. The place to record what a face-down card really is. Max 2 KiB of JSON. | Record<string, unknown> |
tableobjectdefinition.id#
id?: string;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Omit to let the host mint one.
The address you want the new entity to answer to. It is the only field that resolves an entity in a later
call — api.getObject and
api.objectAction both take an id and nothing else. Leave it out
and the host mints one.
Returns
string | undefined. Absent means the host generates a crypto.randomUUID() for the entity
(apps/web/src/playcanvas/TabletopRuntime.ts, createObject), which is what almost every spawn does. On the
setup.json path the schema constrains a supplied id to 1–96 characters
(tableObjectDefinitionSchema, packages/shared/src/tableObjects.ts).
How, why and when to use it
api.createObject returns nothing — no entity, no id — so a mod that has to act on what it just created has
to find it again. Supplying your own id is the one way to know the address before the entity exists: mint it
yourself, spawn with it, and address the entity directly from the next handler. The alternative most authors
reach for is a distinctive label or a tag plus a
api.listObjects search, and that is the better choice when the
entity is one of many interchangeable pieces. Use an explicit id for the small number of singleton entities
your rules name — the score board, the draw pile, the turn marker — where the search would return one row
anyway. Applies to: every object kind.
Gotchas
Nothing checks that the id is free. The host writes the new entity into its object map under the id you
gave (TabletopRuntime.ts, createObject), so reusing the id of a live entity replaces the map entry and
leaves the previous entity in the scene with nothing addressing it. Mint ids that cannot collide, and scope
them to your mod.
An id is not a name. label is the slug and displayName is the human name; neither resolves an entity
and neither is derived from this. Never build an id out of something a player can rename.
See also
api.createObject— which returns nothing, hence this field.TableObjectState.id— the same address, read back.TableObjectDefinition.label— the slug, not the address.- IDs, Names and Tags — the three names and what each is for.
tableobjectdefinition.kind#
kind: TableObjectKind;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
What the entity is, and the single field that decides how the rest of the definition is interpreted. The host reads it first and uses it to pick the entity's default scale, default color, default body type, mass, friction, restitution, damping and collider shape, and which behaviors the entity answers to at all.
Returns
TableObjectKind — one of exactly eight strings: "card", "deck", "die", "token", "board",
"bag", "custom", "card-holder". Required; there is no default and no fallback.
How, why and when to use it
Pick the kind that matches the behavior you want, not the artwork — the model is a separate decision you
make with metadata.standardPresetId or metadata.customModelAssetId. A chess piece is a token with a
custom model, not a custom; a custom entity is the escape hatch for something none of the seven other
kinds describes, and it costs you every kind-specific behavior (a custom cannot be flipped, drawn from,
shuffled or rolled). Reach for deck or bag when you want a container that hands out cards, and for
card-holder when you want a surface that claims cards for a seat. The kinds are not interchangeable after
the fact: nothing converts an entity from one kind to another, so a wrong choice means deleting and
respawning.
Gotchas
Kind picks the per-kind physics defaults, and they differ a lot. card, token, die, deck, bag
and custom spawn dynamic; board and card-holder spawn static and never move under physics
(packages/shared/src/tableObjects.ts, defaultObjectPhysicsForKind). A die gets a convexHull collider
so it tumbles honestly, a custom gets auto, and the rest get a box.
Nothing re-checks the string on the createObject path. The eight names are enforced by
tableObjectKindSchema where setup.json is parsed; a kind your mod computes at run time and posts through
api.createObject is never parsed against that enum, so a typo produces an entity with no per-kind defaults
rather than an error. Write the literal, or check it against the eight names yourself.
The table-scripting ObjectKind names the same eight, so the two surfaces agree on the vocabulary. It
differs only in being open — its (string & {}) arm means a typo is not a type error there either.
See also
TableObjectKind— the union, declared.- Object kinds — what each kind can do, kind by kind.
- ObjectKind — the eight values in one table.
ObjectPhysics— the per-kind defaults this field selects.- Object actions — which actions each kind accepts.
tableobjectdefinition.label#
label: string;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
The SLUG / machine identity. For kind: "card" this IS the card id.
The slug — the machine-facing identity of the entity. For kind: "card" the label is the card, which is
why hidden-information redaction keys on it: a peer that is not entitled to a face-down card's identity
receives the placeholder string "Card" in this field instead of the real one
(packages/shared/src/tableObjects/redaction.ts, REDACTED_CARD_LABEL). It is required, and it is not the
human name.
Returns
string. Required, with no default. On the setup.json path the schema requires 1–80 characters
(tableObjectDefinitionSchema, packages/shared/src/tableObjects.ts).
How, why and when to use it
Choose a label the way you would choose a database key: stable, lowercase, and meaningful to your rules
rather than to a reader. "score-red", "draw-pile", "AS" for the ace of spades. The alternative is to
write the pretty string here — "Red player's score marker" — which is what most authors try first, and it
is wrong twice over: it puts a human-facing string into the value that decides card identity, and it leaves
displayName empty so the editor's hierarchy
shows the slug anyway. Set both: the slug here, the readable name in displayName. Applies to: every object
kind — but on card and deck the label carries identity as well as naming, so treat it as immutable once
players have seen the entity.
Gotchas
Renaming a card changes which card it is. Identity redaction, the container entries returned by
api.getContainerContents and the event-log lines flagged
revealsIdentity all read the card's id off this value. There is no separate card-id field to change
instead.
It is what the host logs and what the editor falls back to. The spawn line reads spawned <label>, and
an entity with no displayName takes its label as its entity name
(apps/web/src/playcanvas/TabletopRuntime.ts, createObject).
Nothing enforces uniqueness. Two entities with the same label are legal and are told apart only by id.
That is fine for interchangeable pieces and a trap for singletons you later look up with
api.listObjects.
See also
TableObjectDefinition.displayName— the human name.TableObjectDefinition.id— the address.TableObjectState.label— the same slug, read back.- IDs, Names and Tags — why the three are separate.
- Object state — the redaction rules in full.
tableobjectdefinition.position#
position: Vector3;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Where the entity appears, in feet, as absolute table coordinates. y is height above the table plane, so
{ x: 0, y: 1, z: 0 } is one foot above the middle of the table. The host places the entity there and then
lets physics take over, so a dynamic entity falls from wherever you put it.
How, why and when to use it
Spawn a little above the surface — around y: 1 — rather than at y: 0. Placing an entity exactly on the
plane starts its collider intersecting the table, and Ammo resolves that overlap by shoving the entity out,
which is the usual cause of a piece that appears and immediately skids away. The alternative is to compute
the exact resting height from the entity's scale, which you can do and which buys you nothing: the drop is a
few frames and it settles the entity against whatever is actually underneath it, including another piece you
did not know was there. Applies to: every object kind — a board or card-holder spawns static and stays
exactly where you put it, so for those two the height you give is the height it keeps.
Gotchas
These are feet, and they are world-absolute even when you set parentId. A token parented to a board
still carries its real place on the table here, not an offset from the board.
A zone can refuse the spawn. Before the entity is built, the host checks the spawn position against the
table's zones (apps/web/src/playcanvas/intent/validators.ts, validateSpawnIntent): a zone with
interaction: "blocked", or an owner-seat-only zone whose seat the actor does not hold, refuses it and
logs cannot spawn in this zone. A mod running on the host is exempt — the host is always allowed.
A mod on a player's client is subject to the host's spawn permission. The same mod running on the host is
not (apps/web/src/ui/App.tsx, isIntentAllowedForParticipant), so a spawn that works while you host can be
silently refused for everybody else at the table.
This is an { x, y, z } object, not a tuple. The table-scripting surface uses [x, y, z] arrays; the two
are not interchangeable.
See also
Vector3— the shape and its units.TableObjectDefinition.rotation— the other half of the pose.TableObjectState.position— where it actually ended up.api.createObject— the call this rides on.- Player zones and seats — the zones that can refuse a spawn.
tableobjectdefinition.rotation#
rotation?: Vector3;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
The entity's starting orientation as Euler angles in degrees, applied as world angles by
entity.setEulerAngles (apps/web/src/playcanvas/TabletopRuntime.ts, createObject). y is the yaw most
authors want: { x: 0, y: 90, z: 0 } turns the entity a quarter turn about the vertical axis.
Returns
Vector3 | undefined. Absent means { x: 0, y: 0, z: 0 } — the host substitutes zero rather than
leaving the entity unrotated-but-undefined, so the value that lands in the snapshot is always a real triple.
How, why and when to use it
Set it when facing is part of the rules: a board that has to line up with the seats, a card-holder angled
toward a player, a token whose model has a front. The alternative is spawning flat and sending a rotate
action afterwards, which turns exactly 90° about y and nothing else — fine for a quarter turn, useless for
the 37° you actually wanted. Give the angle here when you know it, and leave the field out entirely when you
do not care, because a dynamic entity dropped from a foot up will settle into its own resting orientation
regardless of what you asked for. Applies to: every object kind.
Gotchas
Degrees, not radians. Math.PI here is a three-degree tilt, not a half turn.
Physics owns the orientation from the next frame on. A card, token, die, deck, bag or custom
spawns dynamic and starts falling immediately, so a tilted spawn lands flat. Only board and card-holder
are static enough to hold an arbitrary angle indefinitely — and a locked: true entity of any kind, which
spawns static.
It is world-absolute even when you set parentId. The wire format stores world pose for parented
entities too, so give the child the angle you want to see on the table.
See also
Vector3— the shape.TableObjectDefinition.position— the other half of the pose.TableObjectState.rotation— the settled angle, read back.ModObjectAction— includingrotate, the 90° alternative.- Moving, rotating and scaling — the same values in the editor.
tableobjectdefinition.scale#
scale?: Vector3;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
The entity's size, in feet. The built-in primitives are unit-sized boxes and cylinders, so for an entity
with no imported model these three numbers are its actual dimensions: { x: 0.5, y: 0.05, z: 0.5 } is a
six-inch square tile half an inch thick. For an entity carrying a custom GLB the value multiplies the model's
own size instead.
Returns
Vector3 | undefined. Absent means the per-kind default (apps/web/src/playcanvas/physics/PhysicsEngine.ts,
normalizeObjectScale): card 0.2083 × 0.000656 × 0.2917 (a 2.5 × 3.5 inch poker card, one 0.2 mm
sheet thick), die
0.05 × 0.05 × 0.05, token 0.12 × 0.04 × 0.12, board 1.2 × 0.012 × 0.9, bag 0.5 × 0.5 × 0.5,
card-holder 0.6 × 0.04 × 0.36, custom 0.6 × 0.2 × 0.6, and deck a card's footprint with a height of
stackCount × 0.2 mm.
How, why and when to use it
Set it when the piece's real size matters to the rules — a board the seats have to reach across, a token that has to sit inside a board square, a bag big enough to read at a glance. The alternative is scaling the model in your 3D tool before you import it, which is the better choice for a piece whose proportions are fixed: baking the size into the GLB means every spawn of it is right without a script remembering to say so. Use this field for the pieces whose size is a game decision rather than an art decision, and for stretching a primitive into a shape you have no model for.
Gotchas
By design. Applies to:
die,token,board,bag,custom,card-holder. Oncardanddeckthe host overwrites whatever you supply.TabletopRuntime.ts'ssyncStackScaleruns duringcreateObjectand rewrites the scale from the physical card model — poker-card footprint, one 0.2 mm sheet per card — because a deck's visible height is its card count and letting a spawn contradict that would make a deck lie about how many cards it holds. To make a card-like piece of another size, spawn atokenor acustomwith the art you want; to make a deck taller, put more cards in it.
Negative and zero components are not rejected here. The setup.json path parses the value as a finite
Vector3 with no sign or magnitude constraint, and the createObject path parses nothing at all. A zero
component collapses the collider; the runtime floors the fitted half-extents at 0.001 feet rather than
failing, so the entity survives but behaves oddly.
Rescaling after the fact is not a mod action. There is no scale entry in ModObjectAction. Choose the
size at spawn, or author it in the editor.
See also
Vector3— the shape and its units.TableObjectState.scale— the size the host settled on.TableObjectDefinition.stackCount— what sets a deck's height instead.ObjectPhysics.collisionSize— sizing the collider apart from the model.- Importing and optimizing models — baking size into the asset.
tableobjectdefinition.color#
color?: string;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
#rrggbb.
A flat #rrggbb tint for the entity's base material. The host builds the entity's primitive with this color
before anything else is attached, so it is what a token, die, board, bag, custom or card-holder
looks like when it has no model, no texture and no assigned material.
Returns
string | undefined. Absent means the per-kind default
(apps/web/src/playcanvas/TabletopRuntime.ts, DEFAULT_COLOR): card #f6f1df, deck #a23c3c, die
#f4f6f8, token #4c8c6a, board #2c6e6a, bag #7257a6, custom #60758f, card-holder
#3a5f7a. On the setup.json path the schema requires the exact form /^#[0-9a-f]{6}$/i — six hex digits
with a leading #, so red, #fff and rgb(…) all fail there.
How, why and when to use it
Color is how a player tells one seat's pieces apart at a glance, and it costs nothing: a set of tokens that
differ only in color needs no art pipeline, no GLB and no texture. The alternative is a per-seat model or a
texture, which you want when the difference has to survive being seen from across the table or has to carry a
symbol — a color alone reads badly at a distance and is invisible to a colorblind player. Set this for
cheap, functional differentiation, and reach for metadata.customModelAssetId or metadata.materialId when
the piece has to look like something.
Gotchas
Applies to: token, die, board, bag, custom, card-holder. On card and deck it is stored and
replicated but never seen. createObject applies the color and then immediately replaces the entity's
material with the parametric card body (TabletopRuntime.ts, cardEdgeMaterialForObject), because a card's
look comes from its face art and edge, not from a tint.
A board with metadata.boardImageUrl or metadata.boardStyle ignores it too, and so does any entity
with a resolvable metadata.materialId or metadata.customTextureAssetId — those run after the color and
overwrite the material.
The createObject path does not check the format. The hex regex is enforced where setup.json is
parsed. A malformed string posted through api.createObject reaches the material builder unchecked, and the
server refuses to save the resulting snapshot because tableObjectStateSchema does enforce the same regex.
See also
TableObjectState.color— the value read back.TableObjectDefinition.metadata— where models, textures and materials are named.- Assigning and editing materials — the richer alternative.
- Object kinds — what each kind renders as.
tableobjectdefinition.ownerSeat#
ownerSeat?: string | null;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
The seat this entity belongs to. Setting it puts the entity in that seat's hand: it is how
api.getHandObjects groups the table, and — together with
faceDown — it is half of the rule that decides who is allowed to know a card's identity.
Returns
string | null | undefined. Absent and null both mean the entity belongs to the shared table; the host
stores null either way (apps/web/src/playcanvas/TabletopRuntime.ts, createObject). On the setup.json
path the schema caps a seat name at 40 characters.
How, why and when to use it
Deal a starting hand by spawning cards straight into a seat rather than spawning them on the table and then
moving them: a card that arrives with an ownerSeat already set is never briefly visible to the room, which
is exactly the leak a two-step deal creates. The alternative is the deal action on a deck, which is the
right choice when you want the engine's dealing behavior — one card to every authored seat, from a real pile.
Set this field when you are constructing a hand that has no deck behind it: per-seat scoring markers, a
private objective card, a reference tile only one player needs.
Gotchas
Applies to: every object kind for hand grouping; card alone for the physics change. A card with a
non-empty ownerSeat is held kinematic so it does not fall out of the hand
(TabletopRuntime.ts, syncHandCardBody); every other kind keeps its normal body.
It drives hidden-information redaction, but only together with faceDown. A face-up card is public
whatever its owner. A face-down card with an owner is visible to that seat and that seat's team; a face-down
card with no owner is visible to nobody but the host
(packages/shared/src/tableObjects/redaction.ts, isCardIdentityVisibleToViewer).
The host reassigns it from position. When an entity is dropped inside a seat zone the runtime overwrites
this field from where it landed (TabletopRuntime.ts, assignOwnerSeatFromPosition), so a value you set at
spawn is a starting state, not a lock.
A seat outlives its occupant. Seats belong to the table, so an entity keyed to a seat stays with that seat when the player leaves. Key persistent per-player state on the seat rather than on a peer id.
See also
api.getHandObjects— reading the table grouped by seat.TableObjectDefinition.faceDown— the other half of the redaction rule.TableObjectState.ownerSeat— the value read back.TableHandState— one seat's hand.- Player zones and seats — where seat names come from.
tableobjectdefinition.faceDown#
faceDown?: boolean;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Whether the entity starts face down. On a card it decides which side is up and, with ownerSeat, whether
other players are allowed to know what the card is. On a deck it sets the facing of the whole pile and is
stamped onto every card entry the host seeds into it.
Returns
boolean | undefined. Absent means true for a deck and false for every other kind — the host reads
it as definition.faceDown ?? (kind === "deck") (apps/web/src/playcanvas/TabletopRuntime.ts,
createObject), because a deck that arrives face up is a deck that has already shown its first card.
How, why and when to use it
Spawn a card face down when a player is meant to look at it before anybody else does — a dealt hand, a
face-down objective, a card placed for a later reveal. The alternative is spawning face up and sending a
flip action, which is visibly wrong: the card exists face up for at least one snapshot, and everyone at the
table receives its identity in that snapshot. Because redaction is applied at the send boundary rather than
in the renderer, a card that was ever face up and unowned has already leaked. Set the facing at spawn and use
flip only for reveals you actually intend.
Gotchas
Applies to: card, deck. On every other kind the value is stored and replicated and nothing reads it.
flip itself is refused for any other kind (packages/shared/src/tableObjects.ts,
isObjectActionAllowedForTarget).
Face down alone is not private. A face-down card with no ownerSeat is hidden from every peer
including the one who put it there; a face-down card with an ownerSeat is visible to that seat and that
seat's team. Face up is public regardless of owner
(packages/shared/src/tableObjects/redaction.ts, isCardIdentityVisibleToViewer).
On a deck the host can overwrite it. When the deck's metadata.cards already lists entries, the runtime
takes the facing from the first entry instead (TabletopRuntime.ts, setDeckFaceFromTopEntry), so a
pre-populated deck shows what its first card says rather than what you asked for.
See also
TableObjectDefinition.ownerSeat— the other half of the redaction rule.TableObjectState.faceDown— the value read back.ModObjectAction— includingflip.- Object state — the full redaction rules.
- Working with decks — authoring a pile's contents and facing.
tableobjectdefinition.locked#
locked?: boolean;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Whether the entity is pinned. A locked entity spawns with a static rigidbody, so physics never moves it
and a player cannot drag it or act on it — the one exception being unlock, which is the only action the
host still allows on a locked target (packages/shared/src/tableObjects.ts,
isObjectActionAllowedForTarget).
Returns
boolean | undefined. Absent means false. The host reads it as definition.locked ?? false and uses
the same value to decide the spawn body type (apps/web/src/playcanvas/TabletopRuntime.ts, createObject).
How, why and when to use it
Lock the furniture at spawn: the board, the scoring track, the reference tile. Anything that is scenery
rather than a piece gets nudged out of alignment within a minute of play if it is draggable, and every nudge
is a snapshot broadcast to every peer. The alternative is spawning with
ObjectPhysics.bodyType set to "static", which a mod
cannot do at all — ObjectPhysics is not a field of this interface, so locked is the only way a spawn can
ask for an immovable entity. Prefer kind: "board" when the piece is genuinely a board, since that kind is
static by default and needs no flag; use locked for the pieces of other kinds you want held still.
Gotchas
A lock stops players, not the host. The gate that refuses actions on a locked target runs on intents
arriving from a player or a spectator. A mod running on the host raises its intents locally and never passes
through it, so locked: true does not stop your own
api.objectAction calls.
Locking is not the same as a static body type. A board is already static and still perfectly
draggable; the lock is what stops the hand. Applies to: every object kind.
A locked entity can be unlocked by anyone who can reach it. The flag is table state, not a permission —
send unlock and it moves again. If a piece must never move, lock it and keep it out of a zone players can
interact with.
See also
ObjectPhysics.bodyType— the read-only body setting a lock overrides.ModObjectAction— includinglockandunlock.TableObjectState.locked— the value read back.- Object actions — the locked rule and its one exception.
- RIGIDBODY — the authored body type a lock replaces.
tableobjectdefinition.tapped#
tapped?: boolean;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Whether the entity starts in its tapped (used, exhausted, turned-aside) state — the card-game convention for
"this has been spent this turn". It is declared here and on
TableObjectState, and it is the one flag on this
interface that a spawn cannot set.
Returns
boolean | undefined. Absent means the entity is not tapped, and so does any value you supply — see the
gap below.
How, why and when to use it
Tapping is how a game marks a permanent as having acted: a creature that attacked, a land that produced, a
worker that was placed. Since neither the spawn nor any mod action reaches it, model "spent" as your own
state — a tag on the entity, or a set of ids in
api.setSavedData — and render the difference through your own
UI. Saved data is the better of the two when the spent set changes every turn, because a tag edit is a
snapshot broadcast per entity and saved data is one write; use a tag when a player needs to see the state on
the piece rather than in a panel. Applies to: every object kind.
Gotchas
Known gap. A
tappedvalue on a definition never reaches the table.TabletopRuntime.ts'screateObjectbuilds the runtime definition field by field andtappedis not one of them, so it is dropped on the spawn path — and on the snapshot path too, since an entity rebuilt from a snapshot goes through that same function. It is also absent fromtableObjectDefinitionSchema(packages/shared/src/tableObjects.ts), sosetup.jsoncannot carry it. The flag itself works whenever something sets it:applyObjectActionwritesdefinition.tappedfortapanduntap,objectStateEq(packages/shared/src/snapshotDelta.ts) compares it, and it replicates and persists correctly. See Known limitations.
No script surface can tap an entity either. ModObjectAction stops at ten names, tap and untap are
not among them, and the frame refuses anything outside those ten
(apps/web/src/mods/sandbox/modSandbox.html, SAFE_OBJECT_ACTIONS). Table Scripting declares the action but
publishes no ObjectHandle method for it, and the per-kind participant gate refuses it for every kind. See
Known limitations.
See also
TableObjectState.tapped— the same flag, read back.ModObjectAction— the ten actions a mod may request.api.setSavedData— where game-logic state belongs instead.- Object actions — the full vocabulary and who may send each.
- Known limitations — the maintained list of gaps like this one.
tableobjectdefinition.stackCount#
stackCount?: number;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Deck depth, 1..1000.
How many cards a pile holds. It is a deck's depth, and because a deck's body is one 0.2 mm sheet per card it
is also the deck's physical height — the runtime derives scale.y from it rather than the other way round
(apps/web/src/playcanvas/TabletopRuntime.ts, syncStackScale).
Returns
number | undefined. Absent means 1. On the setup.json path the schema requires an integer between
1 and 1000 inclusive (tableObjectDefinitionSchema, packages/shared/src/tableObjects.ts); the
api.createObject path does not re-check it.
How, why and when to use it
Set it when you want a pile whose size is the game state — a draw deck that visibly shrinks, a supply stack
players count by looking. The alternative is to seed metadata.cards with the exact card list you want,
which is what you do whenever the identities matter rather than just the count, and it is also what
overrides this field (see below). Use stackCount alone for an anonymous pile of interchangeable cards; use
metadata.cards for a real deck. Applies to: deck. On any other kind the number is stored and replicated,
and the only thing that reads it is the split gate, which refuses split unless the target is a deck
with more than one card.
Gotchas
On a deck the host overwrites it at spawn. createObject seeds the pile's card entries — from
metadata.customDeck when you supplied one, otherwise the standard 52 — and then sets stackCount to the
number of entries. So a deck spawned with stackCount: 10 and no metadata.cards ends up holding 52.
Supply metadata.cards with exactly the entries you want when the count has to be yours.
The height follows the count, not your scale. scale.y is rewritten as
stackCount × 0.000656 feet during the same spawn, so a deck's height is always an honest reading of how
many cards it holds.
It is not a container capacity. The ceiling on how many cards can be merged into a pile comes from
capacityLimit, not from this field.
See also
TableObjectDefinition.scale— which this field drives on a deck.TableObjectDefinition.metadata— wherecardsandcustomDecklive.TableObjectState.stackCount— the real count, read back.api.getContainerContents— reading the entries themselves.- Working with decks — authoring a pile instead of scripting one.
tableobjectdefinition.containerMode#
containerMode?: ContainerMode;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Which card a container hands out when something draws from it: "stack" takes the first entry, "queue"
takes the last, "random" takes any of them. It is declared here, it is a real replicated field on
TableObjectState, and a spawn that sets it gets
the behavior it asked for.
Returns
ContainerMode | undefined — "random", "stack" or "queue". Absent means the runtime's per-kind
default: "random" for a bag and "stack" for everything else (resolveContainerConfig,
packages/shared/src/tableContainers.ts).
How, why and when to use it
The distinction is the difference between a deck and a bag: a deck deals from the first card so shuffling is
what randomizes it, a bag reaches in and grabs one so it is random every time even unshuffled. Choose
"queue" when the pile is a discard you refill from the bottom. Set it here and nowhere else — the draw and
deal paths resolve the mode through containerDrawModeFor
(apps/web/src/playcanvas/TabletopRuntime.ts), which takes this field first. Applies to: deck, bag.
Every other kind refuses draw and deal outright, so the setting has nothing to govern.
Gotchas
metadata.containerMode is legacy, and it loses. The precedence is this field, then the metadata key,
then the per-kind default — so an entity carrying containerMode: "queue" and
metadata.containerMode: "random" draws from the back. The metadata key still works on an object that has
not been through a load, but migrateTableSnapshot moves it onto this field and deletes it from metadata
the first time the table is loaded. Do not write both.
It reaches the table through the spawn and through every snapshot rebuild. createObject carries the
field onto the runtime entity when it is present and leaves it absent when it is not, so no spurious value
appears in a delta for a container that never declared one.
See also
ContainerMode— the three values.TableObjectDefinition.metadata— where the legacy key used to live.TableObjectDefinition.capacityLimit— the same story for capacity.api.getContainerContents— reading a container's entries.- Object kinds — which kinds are containers.
tableobjectdefinition.capacityLimit#
capacityLimit?: number;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
The most cards a container is allowed to hold. The host checks it when cards are merged into a pile and
refuses the merge that would overflow it. It is declared here, it is a real replicated field on
TableObjectState, and a spawn that sets it gets
the ceiling it asked for.
Returns
number | undefined. Absent means no ceiling. The merge check resolves the limit through
containerCapacityFor (apps/web/src/playcanvas/TabletopRuntime.ts) — this field first, then the legacy
metadata.containerCapacity key, then unlimited — treating a non-numeric or non-positive legacy value as
absent and flooring a fractional one. On the setup.json path the schema constrains this field to an
integer between 1 and 1000 inclusive.
How, why and when to use it
A capacity is what stops a player from shaking a 200-card monster together out of every pile on the table —
useful when your rules depend on a discard staying separate from a draw deck, or when a supply stack has a
fixed size. The alternative is checking the count yourself in an onTableEvent handler and splitting the
pile back apart, which is worse in every way: the merge has already replicated, players have already seen it,
and your correction is a second broadcast. Declare the number here and the host refuses the merge before it
happens. Applies to: deck, bag.
Gotchas
metadata.containerCapacity is legacy, and it loses. This field wins over it, and
migrateTableSnapshot moves the key onto this field and deletes it from metadata on load. Writing both is
how the two spellings drift.
The ceiling is read off the merge target, not off the cards arriving. The host looks up the capacity of the pile the cards are being combined into, so a limit on the wrong entity does nothing.
It gates combine only. Nothing checks it when a card is returned to a pile by any other route, so a
container can still end up holding more than its declared ceiling.
A refused merge is silent. The combine does not happen — no error, no diagnostic. If a merge that should work does not, check the target's capacity first.
See also
TableObjectDefinition.metadata— where the legacy key used to live.TableObjectDefinition.containerMode— the same story for draw order.TableObjectDefinition.stackCount— the current depth, which this bounds.- Limits and caps — every bound the platform enforces.
- Object kinds — which kinds are containers.
tableobjectdefinition.tags#
tags?: string[];
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Author tags match /^[a-z0-9_-]+$/i — a : is rejected, so dt: names are unwritable.
Author labels you attach to the entity so you can find it again. They are the filter
api.listObjects narrows on, and the host also writes them onto the
live entity's engine tags alongside its own reserved dt: ones, so the editor and the runtime search the same
set.
Returns
string[] | undefined. Absent means the entity carries only the platform's own dt: tags — dt:object
and dt:kind:<kind> — which you cannot write and cannot remove. The host normalizes whatever you supply:
each entry is trimmed, lowercased and de-duplicated, anything that fails the author pattern is dropped
silently rather than rejecting the spawn, and the list is truncated at 100 entries
(packages/shared/src/objectTags.ts, normalizeObjectTags).
How, why and when to use it
Tag the pieces your rules act on as a group — ["score-token"], ["hand-card", "starter"] — and then find
them with a single listObjects call instead of walking the snapshot and matching labels. The alternative is
a distinctive label prefix and a string comparison over getSnapshot().objects, which works and costs you
a full snapshot clone plus your own loop every time. Tag when the set has more than one member or when
membership is a property of the piece rather than of its name. This is the only field you can filter on at
spawn time, and it is the only one you cannot change afterwards from a mod — there is no tag setter in the
API, so decide the tags here. Applies to: every object kind.
Gotchas
An author tag matches /^[a-z0-9_-]+$/i and is at most 32 characters. A colon is deliberately outside
that class, which is what makes the platform's dt: namespace unforgeable — a mod cannot write dt:internal
and hide an entity from the editor. Anything invalid is dropped from the list, so a spawn with one bad tag
still succeeds with the rest.
Tags are lowercased on the way in. ["Blue"] is stored as ["blue"], and the filter normalizes its
needles the same way, so a search for "Blue" still finds it.
api.listObjects and the table-scripting world.getAllObjects do not offer the same filter. The mod
side takes tag, tags and match; the table-script side takes a single tag. Do not assume a filter that
works in one surface exists in the other. See
Known limitations.
See also
api.listObjects— the call these feed.ModObjectFilter— the filter shape and itsmatchmodes.TableObjectState.tags— the normalized list, read back.- Tags and groups — the same tags in the editor.
- IDs, Names and Tags — the
dt:namespace and why it is reserved.
tableobjectdefinition.components#
components?: ObjectComponentState[];
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
The optional engine components the new entity carries. Two types are addable, light and camera
(OBJECT_COMPONENT_TYPES, packages/shared/src/objectComponents.ts), and they are the only things this array
ever holds. render, collision and rigidbody are intrinsic — every entity has them, they are configured
through the per-kind defaults and never listed here.
Returns
ObjectComponentState[] | undefined. Absent means the entity carries no optional components, which is the
ordinary case and what almost every spawn wants. The array is capped at 8 entries with at most one per
type, so two light entries is not a brighter light, it is an invalid array. Applies to: every object
kind — a light on a card behaves exactly as a light on a custom prop.
How, why and when to use it
Your game needs a lamp over the draw pile, and the table's room lighting is the same for everyone.
Attaching a light to the entity you spawn ties the light to the piece: it moves when a player moves the
piece and it disappears when the piece does. The alternative is to author the lighting once in the editor's
Inspector, which is the better answer whenever the light belongs to the table rather than to a piece your
script created — room lighting authored there is cheaper and survives without your mod running. Attach a
component when the light or camera is part of a piece whose existence you decide at run time.
Gotchas
Nothing fills in the defaults on this path. The runtime applies props field by field
(apps/web/src/playcanvas/TabletopRuntime.ts, applyObjectComponents), so supply every property of the
type you use. The server also parses the whole snapshot against the shared schema before it autosaves
(apps/server/src/store.ts, autosaveRoomSnapshot), and an entry that does not match
ObjectComponentState fails that parse for the entire table, not only for your entity.
A camera cannot take over the view. The runtime pins an object camera's priority strictly below the
table camera and refuses to enable a second one while another is enabled, writing one log line about it. Ship
enabled: false unless the camera is the point of the piece.
A table script cannot see this field at all. Parenting and components are both absent from the table-scripting declarations, so do not carry this across surfaces. See Known limitations.
See also
ObjectComponentState— the two shapes and every prop.TableObjectState.components— the same array, read back.api.createObject— the call this field rides on.- Optional engine components — the caps and the two-tier model.
- Add Component — the same two types, added by hand in the editor.
tableobjectdefinition.displayName#
displayName?: string;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
The HUMAN name ("Chess Board"). Absent = fall back to label.
The human name for the entity — Chess Board, Red Knight — free-form text with spaces, capitals and
punctuation. It is for people to read and for nothing to resolve:
id is the address and
label is the slug. Setting it here is the only way a
mod names an entity, and a mod is the only script surface that can.
Returns
string | undefined. Absent means the entity falls back to label everywhere a name is shown — the
editor's Hierarchy row and its Inspector Name field both render displayName || label
(objectDisplayName, packages/shared/src/tableObjects.ts). The schema allows 1 to 80 characters, and the
host carries the value onto the entity only when it is truthy (apps/web/src/playcanvas/TabletopRuntime.ts,
createObject), so displayName: "" spawns an entity with no display name rather than an empty one.
Applies to: every object kind.
How, why and when to use it
Your labels are slugs — score-red, deck-draw-pile-1 — because that is what a slug is for, and an author
opening your mod in the editor then sees a hierarchy of slugs. displayName is what makes that list
readable without touching the key your rules run on. The alternative most authors reach for is writing a
readable label instead, and for a card that is a mistake with consequences: a card's label is its
identity and is what hidden-information redaction rewrites, so renaming it changes which card it is. Set
label for machines, displayName for people, and never let the two swap jobs.
Gotchas
A mod cannot change it afterwards. Nothing on the mod surface renames an entity once it exists; the value is fixed at the spawn and after that only the editor changes it.
Redaction removes it from a hidden card, and only from a hidden card. When the host withholds a card's
identity from a viewer it rewrites label to Card, drops metadata.cardId, sets metadata.__redacted
and deletes displayName entirely (packages/shared/src/tableObjects/redaction.ts,
redactObjectForViewer) — so every hidden card looks alike, with no residual "this one had a special name"
signal. The moment the card is face-up, in the viewer's hand, or revealed to their team, the name is public
again, because that is the same entitlement that shows them the card. So a readable name is safe to write —
but it is not a hiding place. Anything that must stay secret while a card is face-up belongs in
secretMetadata. No other kind's
displayName is ever redacted.
A table script can neither read nor set it. ObjectData.name on that surface is filled from label.
See Known limitations.
See also
TableObjectState.displayName— the same name, read back, with its fallback.TableObjectDefinition.label— the slug, and a card's identity.TableObjectDefinition.secretMetadata— where a card's real identity belongs.api.createObject— where all three names are set.- IDs, names and tags — the three names in full.
tableobjectdefinition.parentId#
parentId?: string | null;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
The id of the entity this new one attaches to. Parenting is transform and organization — move or rotate the ancestor and its descendants come with it — and it is never a physics constraint. A joint is the constraint, and welding is a third, separate thing that merges an assembly into one rigid body.
Returns
string | null | undefined. Absent or null means a root entity, which is what a spawn without this
field produces. The schema allows 1 to 96 characters. Naming a parent that does not exist yet is safe: the
host attaches the entity to the scene root and the graph settles parents-before-children on the next snapshot
apply (apps/web/src/playcanvas/TabletopRuntime.ts, applyObjectParent). A link that stays dangling, or is
self-referential, cyclic, or deeper than eight levels, is rewritten to null when the table is loaded
(migrateTableSnapshot). Applies to: every object kind.
How, why and when to use it
You spawn a status marker that belongs on a player's board, and a player then drags the board across the table. Parenting the marker at spawn is what makes it travel with the board instead of being left behind, and doing it in the definition closes the window where the marker exists loose. The alternative is to leave both as root entities and move the marker yourself whenever the board moves, which costs you a hook and leaves the marker lagging. Parent when "these move together" is the whole relationship; reach for a joint when both bodies keep simulating against each other, and for welding when the assembly should behave as a single rigid body.
Gotchas
By design. Grabbing any member of an assembly moves its root ancestor rather than the piece the player clicked (
apps/web/src/playcanvas/TabletopRuntime.ts,resolveGrabTarget) — "I glued this token to its base, so moving it should move the base" is what an author means by parenting, and it removes the accidental-detach failure mode entirely. This is not expected to change. Write your rules against the id a hook hands you rather than the piece you imagine a player touched, and mark a genuinely independent child withmetadata.grabbableWhileParented. See Grabbing inside an assembly.
A parented child is held kinematic. It still collides and shoves loose pieces around, and it stops simulating on its own — a dynamic child of a moving ancestor fights the engine every frame and drifts. The authored body type is untouched, so detaching restores it.
position, rotation and scale stay world-absolute, so give the world position you want the entity
to occupy rather than an offset from its ancestor. A table script cannot see this field at all. See
Known limitations.
See also
TableObjectState.parentId— the same link, read back, and the three-waynulldistinction.- Parenting — depth limits, welding and the per-child opt-out.
- Object state — how parenting survives a save and a host migration.
tableobjectdefinition.material#
material?: SoundMaterial;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
What the entity is made of, as far as sound is concerned: one of wood, cardboard, metal, plastic,
card, tile, generic or silent. It is the last step of sound resolution — with no per-action override
and no mod binding in the way, the engine pairs this material with whatever action just happened and plays
the first-party clip set for the pair.
Returns
SoundMaterial | undefined. Absent means the entity sounds like its kind's default, and the defaults are
card for card and deck, plastic for die and token, wood for board and card-holder, tile
for bag, and generic for custom (DEFAULT_SOUND_MATERIAL_BY_KIND,
packages/shared/src/soundSets.ts). silent is a real value rather than an absence: it resolves to no clip
at all, for every action. Applies to: every object kind.
How, why and when to use it
Your game ships wooden meeples, and a meeple is a token, so out of the box every one of them clicks like
plastic. One material: "wood" at spawn covers every action that entity performs, because it changes what
the resolver falls back to rather than patching one moment. The alternative is
api.setObjectSound, which writes one action at a time and is
the right tool when a single action should differ from the rest: a chest that creaks when it opens but is
placed like the wood it is. Set material for what the piece is, and an override for what one of its
actions does.
Gotchas
It changes how the entity sounds, not how it weighs. A material can imply a physics baseline —
defaultObjectPhysicsForKind derives mass, friction and restitution from a material and a scale — but that
derivation runs when an author asks for it in the editor's Inspector, and a spawn does not run it. A mod
cannot set physics either, so a piece spawned with material: "metal" sounds metallic and weighs whatever
its kind weighs.
An override outranks it. Resolution consults
soundSetOverrides for the action first,
then a mod's own declared binding, then this material
(apps/web/src/playcanvas/audio/resolveSoundRef.ts, resolveSoundRef). Setting a material does not undo an
override already on the entity.
Nothing on the mod surface changes it afterwards. The material is fixed at the spawn; after that a mod's only lever on an entity's sound is a per-action override.
See also
SoundMaterial— the eight values.TableObjectState.material— the same field, read back.api.setObjectSound— changing one action instead of all of them.- Sound sets — how a material and an action become a clip.
- Object kinds — the per-kind defaults in their wider context.
tableobjectdefinition.soundSetOverrides#
soundSetOverrides?: Partial<Record<SoundAction, SoundRef>>;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
Per-action sound overrides, set as the entity is created. Each key is one of the seventeen
SoundAction values and each value is a
SoundRef — a semantic first-party pointer or one of your mod's own
declared sounds. An override is the first thing sound resolution consults, ahead of a mod binding and ahead
of the entity's material.
Returns
Partial<Record<SoundAction, SoundRef>> | undefined. Absent means the entity resolves normally — a mod
binding if one matches, otherwise its material.
The record is sparse: an action you leave out is not silenced, and only the actions you name are diverted.
This is authoritative table state — it replicates to every peer and is saved with the table, exactly as
api.setObjectSound writes it later. Applies to: every
object kind.
How, why and when to use it
Your game's treasure chest is a wooden prop that should creak when a player sets it down and stay ordinary
wood for everything else. One { place: { kind: "mod", modId: manifest.id, name: "chest-creak" } } in the
definition gives you that with no follow-up call. The alternative is
api.setObjectSound after the spawn, and it is the right tool
whenever the decision depends on play — a coin that rings only once a player has claimed it. Set the
override here when it is a fixed property of the piece: the entity is never briefly wrong, and you skip
having to find it again, which api.createObject does not help you do.
Gotchas
Write only a semantic builtin or a sound your own mod declared. api.setObjectSound refuses a
{ kind: "mod" } ref that names another mod or a name your manifest never declared, with A mod may only
set overrides to its own declared sounds. Treat that as the rule for this field too — a ref pointing
anywhere else is not a supported way to reach a sound.
A { kind: "mod" } ref that resolves to nothing plays nothing. The lookup asks the loaded mod's declared
sets for the name (apps/web/src/playcanvas/audio/resolveSoundRef.ts, resolveRef); a name with a typo, or
a mod that is not loaded in this session, yields no clip and no error.
An override on an action disables your own binding for it too. A set bound through
ModSoundSet.action is step two of resolution, and an override
is step one, so the two do not stack.
See also
SoundRef— the two shapes a value can take.TableObjectState.soundSetOverrides— the same map, read back.api.setObjectSound— writing one after the entity exists, and clearing one.ModSoundSet— declaring the sounds a{ kind: "mod" }ref can name.- Sound sets — the full resolution order.
tableobjectdefinition.metadata#
metadata?: Record<string, unknown>;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
The freeform property bag copied onto the new entity — the extension point for everything the schema has no field for, and the one place a mod ever writes it. It replicates in every snapshot and is saved with the table, so what you put here outlives your script's own variables.
Returns
Record<string, unknown> | undefined. Absent means the entity gets {}, not a missing field — the host
writes metadata ?? {} onto the new entity (apps/web/src/playcanvas/TabletopRuntime.ts, createObject),
which is why TableObjectState.metadata is always
present on the read side. Values come back typed unknown, so each read needs a typeof or Array.isArray
check. Applies to: every object kind, with different reserved keys per kind.
How, why and when to use it
You spawn one marker per player and need to know, three hooks later and with only an id in hand, which
player it belongs to. Writing metadata: { seat: "red" } puts that fact on the entity where any read of the
snapshot recovers it. The alternative for anything you want to search by is
tags, because api.listObjects filters on kind and
tags and has no metadata filter at all — matching on metadata means fetching everything and looping
yourself. For anything that changes during play, use
api.setSavedData. Tag what you query, save what changes, put in
metadata the facts fixed the moment the piece exists.
Gotchas
The runtime keeps its own keys in here. modId marks which mod owns the entity, scriptId attaches an
object script, standardPresetId names the preset a piece was built from, cardId and sourceDeckId
identify a drawn card and its pile, cards holds a container's entries, materialId is an editor material
assignment, grabbableWhileParented exempts a child from grab escalation, and __redacted marks a
withheld identity. Namespace your own keys so you cannot collide with one.
modId is what turns on your mod's default sounds. A ModSoundSet
binding only runs for an entity whose metadata.modId is a string
(apps/web/src/playcanvas/TabletopRuntime.ts, resolvableFromObject), and nothing sets it for you — pass
metadata: { modId: manifest.id } at spawn if you want that binding to reach the pieces you create.
It rides in every snapshot. The whole bag is re-sent per entity on every keyframe, so keep it to identifiers and small values and put anything bulky in saved data.
See also
TableObjectState.metadata— the read side, itsunknownvalues, and__redacted.TableObjectDefinition.tags— the field you can actually filter on.api.setSavedData— mod state that can change after the spawn.- Object kinds — which metadata keys each kind reads.
- Limits and caps — the sizes the host enforces on a snapshot.
tableobjectdefinition.secretMetadata#
secretMetadata?: Record<string, unknown>;
| Badge | Value |
|---|---|
| Authority | host-authoritative |
| Timing | sync |
| Capability | spawn-object |
| Availability | mod |
SECRET data, withheld on the wire from any peer not entitled to this entity's identity. The place to record what a face-down card really is. Max 2 KiB of JSON.
The freeform property bag that does not reach every player. Same shape as
metadata — Record<string, unknown>, any JSON
value per key — and one difference that is the whole point: the host strips it from the snapshot it sends to
any peer who is not entitled to the entity's identity. It is where "which card is this really" belongs.
Returns
Record<string, unknown> | undefined. Absent means the entity has no secret, and the field stays absent
on the read side too — unlike metadata, it is never defaulted to {}, because an empty bag would enter
every snapshot delta as a change. Capped at 2 048 bytes of JSON (MAX_SECRET_METADATA_BYTES), measured
on the UTF-8 encoding of JSON.stringify(value); a definition over the cap fails
tableObjectDefinitionSchema and the whole spawn is dropped with a diagnostic. Applies to: every object
kind, but only card ever hands it to a non-host viewer — and a mod is always a non-host viewer, on every
peer including the host.
How, why and when to use it
You are dealing a hand of role cards and you need to know which player got the traitor — without every
client learning it from the snapshot. Spawn each card with secretMetadata: { role: "traitor" }. To read it
back while the card is still face down, declare read-hidden-information and take it from
api.getUnredactedSnapshot on the host; once the card is
face up, api.getObject carries it like any other field. The
alternative authors reach for — a readable displayName like Traitor — is broadcast to everyone the moment
the card exists, because the Name is not part of the identity a face-down card hides.
Use it for facts about an entity that are fixed at spawn and secret. For a secret that changes during
play, use api.setSavedData with an object id: mod saved data is
host-authoritative too, and is not per-viewer redacted at all — so it never reaches a peer's snapshot in a
form a mod can read. For anything you want to search by, use
tags — and remember tags are public.
Gotchas
A deck's secret is host-only, always. There is no "the deck is face-up so show it" case: a deck has no
per-viewer identity notion (its ordered contents are withheld from every peer), so no entitlement exists to
grant one from. The same holds for token, board, die, bag, custom and card-holder. Only card
ever releases it, and only to a viewer who may already see that card's face.
A face-up card's secret is public. The rule is the identity entitlement, not a separate switch: flip the
card up and every peer receives secretMetadata in the next snapshot along with the card's real label.
That is correct — the card is telling everyone what it is — but it means you cannot use this field for
something that must stay hidden after a reveal.
It is a replication boundary, not encryption. The host's runtime has the value in full and it is written
into the save unredacted, so treat a save file as you would the host's own memory. It hides a secret from the
other players; it does not hide one from whoever is hosting. What it does hide from, since 2026-08-14, is
every read-world mod read — including a mod running on the host, which is the assumption most likely to
break an existing mod.
Present in a mod read means "the card is face-up". Do not treat a missing secretMetadata as "the author
set nothing" — see
TableObjectState.secretMetadata.
See also
TableObjectState.secretMetadata— the read side, and why absence is ambiguous.TableObjectDefinition.metadata— the public bag.TableObjectDefinition.displayName— the human name, and why it is the wrong place for a secret.api.createObject— the only mod call that writes it.- Limits and caps — the 2 KiB cap alongside the other snapshot budgets.
TableObjectState#
Surface B — mod script · interface · 25 members
A replicated object as a mod sees it: PLAIN DATA, structurally cloned out of the
snapshot. Writing to it does nothing — use api.objectAction for physical state
and api.setSavedData for game-logic state.
position/rotation/scale are WORLD-ABSOLUTE even when parentId is set.
One entity's full replicated state, as a mod sees it. It is the same shape the host validates with
tableObjectStateSchema (packages/shared/src/tableObjects.ts), the same shape that travels to every peer in
a TableSnapshot, and the same shape the persistence layer saves — handed to you as a structured clone.
api.getObject,
api.listObjects,
api.getHandObjects,
api.getSnapshot and the object field of an
onTableEvent payload all hand you one.
How, why and when to use it#
Every rule a mod enforces starts by asking a question about an entity: is this deck locked, whose hand is
this card in, how tall is the pile, is that token where the board expects it. TableObjectState is the
answer to all of them, and it is deliberately the whole record rather than a filtered view — a mod reads
displayName, parentId, physics and components, none of which the table-scripting ObjectData shape
publishes. The alternative most authors reach for is to keep their own mirror of the table in saved data and
update it from hooks; that mirror drifts the first time a player does something your hooks do not cover, so
read the state and keep saved data for the things the table does not model (scores, phases, who has passed).
Read it fresh at the moment you need it rather than caching it across an await.
Gotchas#
Writing to it changes nothing. The value is a structured clone made at the moment you asked, disconnected
from the table. Physical changes go through api.objectAction and
your own game-logic state through api.setSavedData; there is no
third path and no setter anywhere on this shape.
position, rotation and scale are world-absolute even when parentId names an ancestor. A token
parented to a board reports where it actually stands on the table, not an offset from the board. That is a
deliberate wire-format decision — every consumer (peers, saved games, the import/export bridges) already reads
them as world values, and storing them as local offsets would make reconstruction order-dependent.
A table script sees ten of these fields, not twenty-five. Table Scripting's ObjectData publishes id,
kind, name, position, rotation, faceUp, locked, stackCount, tags and metadata and nothing
else. Do not
carry a mod's reading of displayName, parentId or components across into a table script, and do not
assume the reverse either. See Known limitations.
See also#
api.getObject— one id to one state.api.listObjects— a filtered set of them.TableObjectDefinition— the narrower shape you write when you spawn.- Object state — the same fields with schema limits and the 7 sub-collections.
- Object kinds — what each field means per kind.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
kind |
TableObjectKind |
|
label |
The slug. There is no way to read displayName when it is absent. |
string |
position |
Readonly<Vector3> |
|
rotation |
Readonly<Vector3> |
|
scale |
Readonly<Vector3> |
|
color |
string |
|
ownerSeat |
string | null |
|
faceDown |
boolean |
|
locked |
boolean |
|
stackCount |
number |
|
velocity |
Readonly<Vector3> |
|
angularVelocity |
Readonly<Vector3> |
|
metadata |
Readonly<Record<string, unknown>> |
|
tapped |
boolean |
|
containerMode |
ContainerMode |
|
capacityLimit |
number |
|
tags |
May contain platform-owned dt:-prefixed tags a mod cannot author. |
readonly string[] |
displayName |
string |
|
parentId |
string | null |
|
physics |
Readonly<ObjectPhysics> |
|
components |
readonly ObjectComponentState[] |
|
material |
SoundMaterial |
|
soundSetOverrides |
Readonly<Partial<Record<SoundAction, SoundRef>>> |
|
secretMetadata |
SECRET author data. Present only when THIS peer is entitled to the entity's identity — the host strips it from everyone else's snapshot. | Readonly<Record<string, unknown>> |
tableobjectstate.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The entity's address, and the only field that resolves one. api.getObject, api.objectAction,
api.getContainerContents and the object scope of api.getSavedData all take this value and nothing else —
no lookup anywhere in the platform accepts a label or a displayName. The schema requires 1–96 characters;
the runtime assigns crypto.randomUUID() when a spawn does not supply one.
How, why and when to use it
A hook payload tells you a card was drawn and hands you payload.event.objectId; three turns later you want
to score that card. Store the id, not the state — an id is a plain string you can put in
api.setSavedData and read back after a reload, whereas the state
you read is a snapshot-in-time clone whose position and faceDown are stale by the time you use them. The
alternative authors reach for is matching on label, which is wrong twice over: labels are not unique across
kinds, and a card's label is rewritten when the host withholds its identity from a viewer. Applies to:
every object kind.
Gotchas
An id can address nothing. Ids outlive the entities they name: a drawn card leaves its deck, a combine
merges two decks into one, a split produces a new entity, and a deleted entity's id is never reused but is
also never resolvable again. api.getObject reports that as null,
not as an error.
Comparison is exact. Ids are compared as strings, with no trimming and no case folding.
The id you get back from a spawn is real before the entity is.
api.createObject emits an intent; the host validates it
afterwards. A well-formed request is not a guarantee that the entity exists.
See also
api.getObject— turning an id back into state.api.objectAction— the one mutation that takes an id.TableObjectState.label— the slug, which is not an address.- IDs, names and tags — the three names and which one resolves.
tableobjectstate.kind#
readonly kind: TableObjectKind;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
What sort of thing the entity is, and the field that decides how the runtime renders it, what collider it
gets, what its default mass and friction are, and which actions do anything to it. The full set is eight:
card, deck, die, token, board, bag, custom, card-holder (TABLE_OBJECT_KINDS,
packages/shared/src/tableObjects/kinds/index.ts). It is fixed at creation — nothing converts an entity from
one kind to another.
How, why and when to use it
You want to score only the dice that landed, or shuffle only the draw piles, and a hook payload has handed
you a mixed bag of entities. Branch on kind before you act, because most actions are silently ignored on
the wrong kind rather than reported as an error — draw on a token does nothing at all. The alternative,
api.listObjects({ kind }), is the better tool when you are
fetching rather than filtering something you already hold: it does the same comparison on the host's side
and hands you a shorter list. Use tags when the distinction you care about is a role in your game rather than
a physical type — "scoring die" is a tag, die is a kind.
Gotchas
A preset's id is not a kind. The standard-library "Card Holder" preset is kind: "custom" carrying
metadata.standardPresetId, even though a card-holder kind also exists. Read kind for physics and action
behavior, and metadata.standardPresetId for "which preset was this built from".
A one-card deck becomes a card. Drawing the last entry from a deck produces a card entity with a new
id and removes the deck, so an id you stored for a deck resolves to nothing afterwards.
See also
TableObjectKind— the eight values.- Object kinds — per-kind fields, colliders, physics defaults and reachable actions.
api.listObjects— filtering by kind on the host's side.- Object actions — what each action does per kind.
tableobjectstate.label#
readonly label: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The slug. There is no way to read displayName when it is absent.
The entity's slug: the machine-facing key, 1–80 characters, unique within the scene. It is not the human
name — that is displayName — and it is not an
address, because nothing resolves an entity by it. For kind: "card" it carries more weight than anywhere
else: the label is the card's identity, which is why the hidden-information system rewrites it.
How, why and when to use it
You are matching a drawn card against a table of scoring values, and the card's face is what you need to
know. label is where a card's identity lives when metadata.cardId is absent, so it is the value a scoring
table keys on. For anything you print to a player, prefer displayName || label — that is the same fallback
the runtime uses for an entity's name in the Hierarchy. The alternative for identification generally is
id, and for every non-card purpose it is the right one:
labels are not unique across kinds and are free to change, ids are not.
Gotchas
A card a mod cannot see reports label: "Card", on every peer including the host. The read-world reads
resolve the least-privileged view (packages/shared/src/tableObjects/redaction.ts,
redactObjectForRestrictedViewer), so a face-down card with no team reveal comes back with its label replaced
by the neutral placeholder REDACTED_CARD_LABEL, its metadata.cardId deleted, and metadata.__redacted set
to true. Check for metadata.__redacted before you treat a label as an identity. This is the anti-cheat
boundary and it is not expected to change; a mod that needs real faces declares read-hidden-information and
reads api.getUnredactedSnapshot.
A deck's ordered contents are withheld from every mod read. The same pass keeps at most the publicly
visible first card in metadata.cards and drops the rest, so counting a deck's entries off it undercounts on
every peer. Read stackCount for the real pile height —
that field is never redacted.
Renaming a card changes which card it is. Because the label is the identity, editing it for readability
is a gameplay change, not a cosmetic one. Put the readable text in displayName.
See also
TableObjectState.displayName— the human name.TableObjectState.id— the address.TableObjectState.metadata— wherecardIdand__redactedlive.api.getContainerContents— why a pile no longer reports its order.api.getUnredactedSnapshot— the elevated read, for real card faces.- IDs, names and tags — the three names in full.
tableobjectstate.position#
readonly position: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Where the entity is, in feet, measured from the table origin. The host reads it off the live scene entity
with getPosition() at the moment it builds the snapshot (apps/web/src/playcanvas/TabletopRuntime.ts,
toState), so it is a world-absolute value: x and z lie in the table plane and y is height above the
origin.
How, why and when to use it
You want to know whether a played card landed inside the discard area, or which of four scoring tracks a
token was dropped on. Compare x and z with a tolerance and ignore y, because a piece resting on a board
sits higher than one on the table and is no farther away. The alternative is to author zones and snap points
in the editor and let the runtime pull dropped pieces into alignment — prefer that when the layout is fixed
while you are authoring, and compute from position when the layout depends on something you only learn at
runtime. Applies to: every object kind.
Gotchas
It is world-absolute even when parentId names an ancestor. A token parented to a board reports its real
place on the table, not an offset from the board. That is deliberate: every consumer already reads these as
world values, and storing offsets would make snapshot reconstruction order-dependent.
It is a reading, not a promise. A piece a player is still dragging, or one that has not settled after a
throw, reports where it was when the host serialized the snapshot. Watch
velocity if you need "has it stopped", or wait for the
drop hook.
A mod cannot write it. There is no move action on the mod surface — the ten names
api.objectAction accepts do not include a transform. Place a
piece by spawning it where you want it, or let players move it.
See also
Vector3— the triple, and its units per field.TableObjectState.parentId— why this stays world-absolute.TableObjectState.velocity— whether it is still moving.api.createObject— the one place a mod chooses a position.- Object state — the wire format, and why transforms are world.
tableobjectstate.rotation#
readonly rotation: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The entity's orientation as Euler angles in degrees, read off the live scene entity with
getEulerAngles() when the host builds the snapshot (apps/web/src/playcanvas/TabletopRuntime.ts,
toState). y is the heading — the spin around the vertical axis, which is what faces a piece at a seat —
while x and z are pitch and roll and describe a piece that is tipped or standing on edge.
How, why and when to use it
You want to know which way a played tile is oriented, or whether a die has come to rest flat rather than
leaning against something. Read rotation.y for heading questions and treat x and z as "is it lying
level" checks. The alternative for card faces is
faceDown, and it is the right one: the host maintains
that flag through the flip action and through draws, whereas inferring a card's face from its pitch means
reimplementing that logic and getting it wrong for a card mid-flip. Applies to: every object kind.
Gotchas
Degrees, not radians, and not normalized. Values run past 360 and go negative as a piece spins, so compare headings with a modulo rather than with equality.
It is world-absolute even when parentId names an ancestor. Rotating a parent rotates its children in
the scene graph, and each child's snapshot reports the resulting world orientation, not a local offset.
Euler angles are a reading of a quaternion. Two visually identical orientations can report different triples, so treat this as a value to compare loosely, never as an identity.
See also
Vector3— the triple, and its units per field.TableObjectState.faceDown— the flag to read instead, for cards.TableObjectState.angularVelocity— whether it is still turning.api.objectAction—rotate, which turns an entity 90°.- Object actions — what
flipandrotatedo per kind.
tableobjectstate.scale#
readonly scale: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The entity's size multiplier on each axis, relative to its kind's native dimensions. It is a world scale
in this document: when an entity is parented, the runtime divides out the ancestor's scale and writes the
remainder as a local scale, so what you read here is the size the entity actually renders at
(apps/web/src/playcanvas/TabletopRuntime.ts, applyParentedWorldScale). vector3TupleSchema requires
three finite numbers and sets no minimum or maximum of its own.
How, why and when to use it
You are spawning a piece next to an existing one and want it to match, or you are working out how much table
a board occupies before you place tokens on it. Read the scale of the entity you are matching rather than
hard-coding a number, because a mod's own setup.json and the standard presets both set per-kind sizes you
would otherwise have to duplicate. The alternative — assuming the kind's default — breaks the moment an
author resizes a piece in the editor. Applies to: every object kind, with one kind where the value is not
authored at all: see below.
Gotchas
A deck's y is derived, not authored. The runtime recomputes a deck's thickness as its card count times
a fixed sheet thickness (syncStackScale), so a deck that has been drawn from reports a smaller scale.y
than the one that was spawned. Read stackCount if you
want the pile height as a number.
Rescaling an assembly rescales its members. When a parented entity's ancestor is rescaled, the runtime multiplies every descendant's authored scale by the same factor and clamps the result to the range 0.01–64, so a child's scale changes without anyone having edited it.
The collider follows, and it is refitted, not scaled blindly. A change here rebuilds the entity's collision shape from its measured bounds, so a rescaled piece stacks and collides correctly rather than keeping an old hitbox.
See also
Vector3— the triple, and its units per field.TableObjectState.stackCount— what drives a deck's thickness.TableObjectState.physics— explicit collider size, when auto-fit is wrong.- Object kinds — the native size each kind scales from.
- Welding — what happens to an assembly's bodies when it is scaled.
tableobjectstate.color#
readonly color: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The entity's tint, as a six-digit hex string. The schema accepts exactly #rrggbb, case-insensitively, and
rejects anything else — no three-digit shorthand, no alpha, no named colors. It is always present on a state:
a spawn that omits it gets the kind's default from DEFAULT_COLOR
(apps/web/src/playcanvas/TabletopRuntime.ts).
How, why and when to use it
You are running a game where each player's pieces are a color and you need to know whose token just landed
on a space. Reading color works, and it is the only visual property a mod can see — but prefer a tag or a
metadata value for ownership if you control the spawn, because a color is a rendering choice a player or an
author can change without meaning to change whose piece it is. Use color when the color genuinely is the
rule (a color-matching game), and ownerSeat when the rule is about who holds it. Applies to: every object
kind — how the tint is applied varies, since a custom entity with an imported model shows the model's own
materials rather than a flat tint.
Gotchas
Case is not normalized. #FF0000 and #ff0000 are both valid and both stored as written, so compare
case-insensitively.
A material assignment overrides the tint visually and leaves this field alone. An entity carrying
metadata.materialId renders with that material, and color still reports whatever it was set to. Read
metadata.materialId first if you are trying to describe what a player is actually looking at.
There is no action that changes it. The ten actions a mod can request do not include a recolor, so this is a read-only fact about an entity for the life of the session unless a player edits it.
See also
TableObjectState.ownerSeat— the ownership field, when that is what you mean.TableObjectState.tags— the durable way to mark a role.TableObjectState.metadata— wherematerialIdlives.- Materials and textures — what a material assignment replaces.
tableobjectstate.ownerSeat#
readonly ownerSeat: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Which seat is holding the entity in hand. It names a seat, never a peer — seats belong to the table, so an
entity stays attached to its seat when the player sitting there leaves. The schema allows up to 40 characters
and requires the field to be present, so it is null rather than absent whenever nothing holds the entity.
Returns
string | null. A seat identifier such as north when the entity is in that seat's hand; null means the
entity is on the shared table — nobody's hand, which is the ordinary state for boards, dice and anything
that has been played. null is not an error and is not "unknown".
How, why and when to use it
You are counting hand sizes to enforce a limit, or deciding whether a card a player just flipped was theirs
to flip. ownerSeat is the field that answers both, and it is what
api.getHandObjects groups on — any entity with a non-empty
ownerSeat counts as being in that seat's hand regardless of kind
(getHandObjectsFromSnapshot, packages/shared/src/tableObjects.ts). Reach for getHandObjects when you
want the whole hand and for this field when you already hold one entity and need to know where it lives. Do
not use it as a "who owns this piece" marker for pieces on the table — put that in a tag or in metadata,
because a hand transfer rewrites this field. Applies to: every object kind.
Gotchas
It drives hidden-information redaction together with faceDown. A face-down card or deck with an
owner is visible only to that seat and, in team play, that seat's team; a face-down card with no owner is
host-only until it is flipped or revealed
(packages/shared/src/tableObjects/redaction.ts, isCardIdentityVisibleToViewer). That asymmetry is
deliberate — hand privacy is expressed by faceDown, not by an owner alone.
A seat outlives its occupant. Key persistent per-player state on the seat, not on a peer id, or it is orphaned the moment somebody reconnects.
A mod cannot set it. None of the ten actions a mod can request moves an entity into or out of a hand;
deal is the closest, and the host decides the seats it deals to.
See also
api.getHandObjects— a whole seat's hand in one call.TableObjectState.faceDown— the other half of the privacy rule.api.getMySeat— which seat this client holds.TableHandState— the grouped shape.- Host authority — who owns the decision, and why.
tableobjectstate.faceDown#
readonly faceDown: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Whether the entity is showing its back. It is always present on a state — a spawn that omits it gets true
for a deck and false for everything else (apps/web/src/playcanvas/TabletopRuntime.ts, createObject).
Together with ownerSeat it is the input to the host's
hidden-information rules, so it is not merely a rendering flag.
How, why and when to use it
You want a rule like "a played card scores only once it is face up", or you are dealing a hand and need to
know whether the cards arrived hidden. Read this rather than inferring the face from
rotation: the host maintains the flag through the
flip action, through draws, and through a deck adopting its first entry's face, and reimplementing that
from Euler angles gets a card mid-flip wrong. Applies to: card and deck as a face; die, token,
board, bag, custom and card-holder carry the field and the flip action still toggles it, but
nothing about their identity depends on it.
Gotchas
It is not the same question as "can I see what this is". A face-up card is public to everyone; a
face-down card in a seat's hand is visible to that seat and its team; a face-down card with no owner is
host-only (packages/shared/src/tableObjects/redaction.ts, isCardIdentityVisibleToViewer). Read
metadata.__redacted to find out whether the copy you are holding had its identity removed, rather than
deducing it from this flag.
The runtime's flip handler has no kind guard. applyObjectAction
(apps/web/src/playcanvas/TabletopRuntime.ts) turns the entity over and inverts this flag whatever it is
pointed at, so a flip that reaches a die flips a die. Check kind before you ask.
A deck's flag follows its first entry. Drawing or shuffling can change a deck's faceDown without any
player having flipped anything, because the host sets a deck's face from the entry now on top.
See also
TableObjectState.ownerSeat— the other half of the privacy rule.api.objectAction—flip, and the nine other actions.TableObjectState.metadata— where__redactedandrevealTeamlive.- Object actions — what
flipdoes per kind. api.getContainerContents— per-entry faces inside a container.
tableobjectstate.locked#
readonly locked: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Whether the entity is pinned. true means the host has switched its rigidbody to static, so physics leaves
it alone and a player cannot drag it or act on it — except to unlock it. It is always present on a state and
defaults to false when a spawn omits it; most board presets ship locked: true, because a board is meant
to stay where it was placed.
How, why and when to use it
Your mod shuffles the draw piles at the start of a turn and an author has pinned one of them to the table.
Reading locked first is how you tell "we deliberately skipped the pinned deck" from a rule that quietly
does the wrong thing, because a pinned entity is pinned for a reason and shuffling it anyway defeats the
author's intent. It is also the honest way to answer "did the lock take?" after you requested one: read the
entity again on the next snapshot rather than assuming. The alternative — tracking lock state in your own
saved data — is correct until a player unlocks something from the object menu, after which your copy is
wrong for the rest of the session. Applies to: every object kind.
Gotchas
It is the first thing the participant gate checks. isObjectActionAllowedForTarget
(packages/shared/src/tableObjects.ts) refuses every action except unlock on a locked entity, before it
looks at the kind at all. That gate runs on a player's or spectator's own client and again on the host for
intents that arrive from a participant.
The gate is not applied to actions raised on the host. applyObjectAction
(apps/web/src/playcanvas/TabletopRuntime.ts) carries no lock check of its own, so an action that reaches
the runtime from the host side lands on a locked entity as readily as on an unlocked one. Read this field and
decide for yourself rather than relying on a refusal.
Locking is not the same as a static rigidbody. An entity authored with physics.bodyType: "static" is
already immovable by physics and stays perfectly draggable. The lock is what stops the hand, and unlock
restores whichever body type was authored rather than forcing dynamic.
See also
api.objectAction—lockandunlock.TableObjectState.physics— the authored body type a lock overrides.- Object actions — the gate, and which surfaces it applies to.
- RIGIDBODY — what static means in the editor.
tableobjectstate.stackCount#
readonly stackCount: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How many cards a pile holds. The schema takes an integer from 1 to 1000, the field is always present on a
state, and a spawn that omits it gets 1. For a deck the host keeps it equal to the length of
metadata.cards after every draw, deal, split, combine and shuffle, so it is the pile height rather than a
number somebody set once.
How, why and when to use it
You want a rule like "reshuffle the discard pile into the draw pile when the draw pile runs out", and you
need to know how many cards are left. stackCount is the cheap answer: one number on the state you already
hold, and — unlike a deck's ordered contents — it is never withheld from a viewer, so it reads the same on
every peer. The alternative, api.getContainerContents,
is the right call when you need to know which cards are in there and not merely how many, and it costs a
round-trip. Applies to: deck, where the host maintains it as the pile height. On every other kind it
stays at whatever the spawn asked for — 1 unless somebody set it — and no runtime behavior reads it.
Gotchas
It drives the deck's rendered thickness. syncStackScale
(apps/web/src/playcanvas/TabletopRuntime.ts) sets a deck's scale.y to its card count times a fixed sheet
thickness, so a deck's size changes as it is played down. Do not read scale to infer a count; read this.
A deck that reaches one card stops being a deck. Drawing the last entry produces a card entity with a
new id and removes the deck, so a stackCount of 1 on a deck is the last moment that id resolves.
split is refused for a participant below two cards. The participant gate requires
kind === "deck" && stackCount > 1 (isObjectActionAllowedForTarget,
packages/shared/src/tableObjects.ts), so the count is the thing that decides whether a player can halve a
pile.
See also
api.getContainerContents— the entries themselves, in draw order.TableObjectState.scale— the thickness this drives.TableObjectState.capacityLimit— the intended ceiling.- Object kinds —
deckandbagin full. - Limits and caps — where the 1000 comes from.
tableobjectstate.velocity#
readonly velocity: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How fast the entity was moving, in feet per second along each world axis, when the host built the
snapshot. It is read straight off the Ammo body (rigidbody.linearVelocity) in
apps/web/src/playcanvas/TabletopRuntime.ts, toState, with no smoothing. An entity with no rigidbody — and
any entity while the field would otherwise be unavailable — reports the zero vector.
How, why and when to use it
You want to score a die only once it has stopped tumbling, or to hold off on a placement rule while a
thrown token is still sliding. velocity near zero on both this field and
angularVelocity is the closest a mod gets to
"at rest". The alternative — and the better one when it fits — is to wait for the drop hook, which the host
raises when a player lets go, because a hook fires once at a defined moment while this field needs you to
poll and guess a threshold. Use the hook for turn-taking and this field for "has the physics settled".
Applies to: every object kind.
Gotchas
It is stale the moment you read it. The value describes the instant the host serialized the snapshot, not the instant your handler runs. A die reading zero has stopped as of that snapshot and nothing stops a player knocking it a frame later.
A locked entity reads zero because it is static, not because it settled. Check
locked before treating a zero as "it came to rest".
Zero is also what a missing body reports. toState substitutes the zero vector when the entity has no
rigidbody, so a zero here does not by itself prove the entity is simulated at all.
See also
TableObjectState.angularVelocity— the rotational half of the same reading.TableObjectState.physics— body type and damping, which decide how quickly this falls to zero.Vector3— the triple, and its units per field.- Hooks and capabilities — the drop hook, and why it beats polling.
tableobjectstate.angularVelocity#
readonly angularVelocity: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How fast the entity was spinning about each world axis when the host built the snapshot, taken straight off
the Ammo body (rigidbody.angularVelocity) in apps/web/src/playcanvas/TabletopRuntime.ts, toState. It is
a raw physics reading, converted by nothing on the way out, so it is not in the degrees that
rotation uses. An entity with no rigidbody reports the
zero vector.
How, why and when to use it
You want to know whether a thrown die has finished tumbling, and its position has stopped changing while it
is still rolling on the spot. Linear velocity alone misses that case, which is exactly why the runtime's own
settle check tests the length of this vector as well. Compare its magnitude against a small threshold
alongside velocity, and prefer the drop hook when what
you actually care about is a player having let go rather than the physics having quietened down. Applies
to: every object kind.
Gotchas
Do not compare it to a rotation delta. The two fields are in different units and describe different
things — one is a live spin rate, the other is an orientation in degrees. Use this only for magnitude tests.
It is stale the moment you read it, exactly like the rest of the state you are holding. Read it again
after the next snapshot rather than reasoning across an await with the old value.
A settled-looking reading is not a settled die face. The host decides that, and publishes the answer as
faceValue on the object's state once the die is genuinely at rest — a value this poll can only approximate,
and never the number itself. See
tableObjectDefinitionSchema.faceValue.
See also
TableObjectState.velocity— the linear half of the same reading.TableObjectState.rotation— the orientation, in degrees.TableObjectState.physics—angularDamping, which decides how fast this decays.- Hooks and capabilities — the events that fire once instead of needing a poll.
tableobjectstate.metadata#
readonly metadata: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The entity's freeform property bag: Record<string, unknown>, always present (an entity with nothing in it
carries {}), and the extension point every kind uses for the data that has no dedicated field. It holds
both what an author wrote at spawn and what the platform keeps there, and because every value types as
unknown each read needs a check before you use it.
How, why and when to use it
You spawned a token with metadata: { role: "wizard" } and, five hooks later, you have an id and need to
know what it was. metadata is where that answer survives, because it rides the replicated state instead of
living in a variable your frame loses on reload. The alternative for your own mutable game state is
api.setSavedData, and that is the right one whenever the value
changes during play — nothing on the mod surface writes metadata after a spawn. Use metadata for facts
fixed at creation and saved data for everything that moves. Applies to: every object kind, with different
keys per kind.
Keys you will meet on a mod's reads: cardId and sourceDeckId (identity of a drawn card and the pile it
came from), cards and stackModelVersion (a container's ordered entries), revealTeam (which team a card
is revealed to), __redacted (set by the host when it withheld this entity's identity from the viewer),
standardPresetId (the standard-library preset an entity was built from), materialId (an editor material
assignment), customModelAssetId (an imported model), scriptId (an attached object script), and
grabbableWhileParented (a child's opt-out from grab escalation). You may still meet containerMode,
containerCapacity and infinite here on an object that has not been through a load, but they are legacy
spellings of the first-class containerMode / capacityLimit / container fields, which outrank them and
which a load migrates them into.
Gotchas
Check __redacted before you trust an identity, on every peer including the host. The read-world reads
resolve the least-privileged view (packages/shared/src/tableObjects/redaction.ts,
redactObjectForRestrictedViewer), so for a card no spectator is entitled to see, metadata.cardId is
deleted, label is rewritten to Card, and metadata.__redacted is set to true. For a deck it keeps at
most the publicly visible first entry in metadata.cards and drops the rest — so metadata.cards is never a
pile's real order on this surface. A mod running on the host gets exactly the same treatment; the elevated
read is api.getUnredactedSnapshot, gated by
read-hidden-information.
secretMetadata is a sibling of this field, not a key inside it, and a read-world result almost never
carries it. It is stripped from every kind — the one exception being a card whose face is public anyway, and
a public card's secret is not a secret. Do not hide anything in metadata expecting the same treatment:
metadata is public except for the identity keys named above.
Every value is unknown. Narrow before you use one — typeof value === "string", an Array.isArray
check, and a default for the case where an older version of your mod never wrote the key.
It rides in every snapshot. Metadata is replicated per entity on every broadcast, so a large blob costs bandwidth for the whole session. Keep it to identifiers and small values, and put anything bulky in saved data, which is scoped to your mod.
See also
api.setSavedData— per-mod and per-entity state you can change.api.createObject— the one place a mod writes metadata.TableObjectState.label— the other half of a card's identity.api.getUnredactedSnapshot— the elevated read, for the keys this one deletes.- Object kinds — which metadata keys each kind actually reads.
- Limits and caps — the sizes the host enforces.
tableobjectstate.tapped#
readonly tapped?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Whether the entity has been turned sideways to mark it as used — the trading-card-game convention, kept as a
flag rather than as a rotation so it survives a piece being moved. The host writes it in applyObjectAction's
tap/untap branch (apps/web/src/playcanvas/TabletopRuntime.ts) and nowhere else.
Returns
boolean | undefined. The field is optional in the schema, so absent means the entity has never been
tapped or untapped — it is not a synonym for false in a comparison, and state.tapped === false is a
different test from !state.tapped. Use the falsy test unless you specifically care about the difference.
How, why and when to use it
You are writing a game where a card is exhausted when it is used and refreshes at the start of its owner's
turn, and you want to display how many untapped cards a seat has left. This field is the only place that
state is recorded, so read it. What you cannot do is change it: tap and untap are not among the ten
actions api.objectAction accepts, so a mod that needs its own
exhaustion flag keeps one in api.setSavedData scoped to the
entity, and treats this field as somebody else's marker. Applies to: every object kind — the tap branch has
no kind guard.
Gotchas
Known gap.
TabletopRuntime.createObjectbuilds an entity's runtime record withouttapped, andtoStatepublishes that record — so an entity rebuilt from a snapshot rather than updated in place loses the flag. That rebuild happens on a peer receiving an entity for the first time, on a save being loaded, and after host migration. Delta replication itself is correct:tappedis inobjectStateEq's field list (packages/shared/src/snapshotDelta.ts), so a tap on an entity a peer already has does reach that peer. Keep your own exhaustion flag in per-entity saved data if it has to survive a reload. See Known limitations.
No participant can set it. isObjectActionAllowedForTarget returns false for tap and untap on
every kind, so a player's intent is refused. See
Known limitations.
See also
api.setSavedData— where a mod keeps its own per-entity flags.api.objectAction— the ten actions a mod can request.- Action vocabularies — which surface reaches
tapat all. - Object actions — the kind-by-action matrix.
tableobjectstate.containerMode#
readonly containerMode?: ContainerMode;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The draw order in force for a container: "random", "stack" or "queue"
(containerModeSchema, packages/shared/src/tablePrimitives.ts). stack takes the entry a player can see on
the pile, queue takes the one at the far end, random takes any of them. The standard bag and deck presets
set it, the item editor writes it, and it survives a spawn, a snapshot rebuild and a host migration.
Returns
ContainerMode | undefined. The field is optional, and absent
means no draw order was declared — in which case the host uses its per-kind default: random for a bag,
stack for everything else.
How, why and when to use it
You are writing a bag-building game and want to tell a player whether the next draw is a lottery or the piece
they can see. Read this and fall back to the per-kind default when it is absent; that pair is the real
behavior, because the draw path resolves the mode the same way (resolveContainerConfig,
packages/shared/src/tableContainers.ts). Applies to: deck and bag. On every other kind the field is
carried and nothing reads it.
Gotchas
The default is per kind, so ?? "stack" is wrong on a bag. An undeclared bag draws at random. Branch on
kind before you substitute a default.
A legacy metadata.containerMode may still be the live answer, but not for long. The resolution order is
this field, then the metadata key, then the default — and migrateTableSnapshot moves the key onto this field
and deletes it from metadata on load, so an object you read shortly after a spawn can still carry the old
spelling while the same object after a save cycle carries only this one.
stack takes the visible entry, not the last one added. The runtime renders a container's first entry as
the face a player sees, and stack removes exactly that one — so "the pile's showing card" and "what a draw
gives you" are the same thing.
See also
ContainerMode— the three values.TableObjectState.metadata— where the legacy spelling lived.api.getContainerContents— the entries, in the order a draw walks them.- Object kinds —
deckandbagin full. - Standard presets — the presets that declare a mode.
tableobjectstate.capacityLimit#
readonly capacityLimit?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The ceiling on how many entries a container holds: an integer from 1 to 1000 in the schema. The standard container presets set it — 60 for the Bowl, 100 for the Bag, 200 for each Go Bowl, 1000 for the Infinite Bag — the item editor writes it, and it survives a spawn, a snapshot rebuild and a host migration.
Returns
number | undefined. The field is optional, and absent means no ceiling was declared, which the runtime
treats as unlimited.
How, why and when to use it
You want to tell a player that the chip bank is full before they try to put another stack into it. Read this:
it is the number the host enforces. containerCapacityFor (apps/web/src/playcanvas/TabletopRuntime.ts)
resolves it through resolveContainerConfig — this field first, then the legacy metadata.containerCapacity
key, then unlimited — and a combine whose result would exceed it is dropped. Compare against
stackCount for how full the container is now.
Applies to: deck and bag. On every other kind the field is carried and nothing reads it.
Gotchas
It gates combine only. Nothing checks it when a card is returned to a pile by any other route, so a
container can hold more than its declared ceiling.
A refused merge is silent, and the check runs against the pile being merged into.
A legacy metadata.containerCapacity still resolves, but it is outranked and it is migrated away.
migrateTableSnapshot moves the key onto this field and deletes it from metadata on load, so treat this
field as the single spelling.
See also
TableObjectState.stackCount— how full it is now.TableObjectState.metadata— where the legacy spelling lived.- Object kinds —
baganddeckin full. - Limits and caps — the platform-wide ceilings.
- Standard presets — the presets that declare a limit.
tableobjectstate.tags#
readonly tags?: readonly string[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
May contain platform-owned dt:-prefixed tags a mod cannot author.
The entity's author tags: up to 100 of them, each 1–32 characters matching ^[a-z0-9_-]+$, stored lowercase.
They are the cheap membership mechanism — "this is a scoring die", "this belongs to the red side" — and they
are what api.listObjects's tag, tags and match options
filter on.
Returns
readonly string[] | undefined. Both the empty array and the absent field occur and mean the same thing —
no tags: a spawn seeds the field with a normalized array, which is [] when nothing was declared, while
TabletopRuntime.setObjectTags deletes the key outright when an edit clears the last tag. Read it as
state.tags ?? [] rather than reaching straight for .length, or hand it to objectTagsMatch, which
accepts undefined.
How, why and when to use it
You need every deck that is a draw pile, and kind: "deck" alone also gives you the discard. A tag is the
right tool because it is a durable fact about the entity that rides the replicated state, costs nothing to
filter on, and survives a rename. Reach for metadata
instead when you need a value rather than membership — "which player owns this" is a metadata entry, "is
this a scoring piece" is a tag. Applies to: every object kind.
Gotchas
No dt:-prefixed tag ever appears here. The platform's own entity tags — dt:object, dt:internal,
dt:kind:card, dt:child — live on the engine entity, not in this array. The author character class has no
:, so a platform tag is unrepresentable as an author tag and tableObjectStateSchema rejects one outright
rather than accepting and ignoring it (packages/shared/src/objectTags.ts, isUserTag). That separation is
deliberate: it is what stops a mod forging dt:internal to hide an entity from the editor.
A filter whose needles are all invalid matches nothing. objectTagsMatch treats an empty needle list as
"no filter" but an all-invalid list as unsatisfiable, so listObjects({ tags: ["dt:internal"] }) returns
nothing rather than the whole table. Needles are normalized the way stored tags are, so "Blue" finds
"blue".
A mod cannot change them. There is no tag-writing method on the mod surface; tags are set at spawn and
edited in the editor. Track anything that changes during play in
api.setSavedData.
See also
api.listObjects— filtering ontag,tagsandmatch.ModObjectFilter— the filter shape, in full.TableObjectState.metadata— for values rather than membership.- Tags and groups — authoring them.
- IDs, names and tags — the reserved
dt:namespace in full.
tableobjectstate.displayName#
readonly displayName?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The entity's human-readable name — Chess Board, Red Knight — free-form text of 1–80 characters. It is
what the Hierarchy row shows and what the engine entity is named after, and it is purely for people: no
lookup anywhere resolves an entity by it. That is id's job,
and the machine-facing key is label.
Returns
string | undefined. The field is optional, and absent usually means the author never set one — the
ordinary state, not an error. The one other case: for a card whose face is not public, the field is deleted
before a mod ever sees it (see the gotcha below), so on this surface absence is ambiguous. The platform's own
fallback is displayName || label (objectDisplayName), and every surface that shows a name uses it, so a mod
that prints state.displayName || state.label matches what a player sees in the editor and the object
menu — except that for a face-down card it prints Card where a seated player may be reading the real name.
How, why and when to use it
You are logging a move to the event feed or writing a label into a UI panel, and deck-draw-pile-1 is not
what a player should be reading. Use displayName with the label fallback for anything a person sees, and
never for anything you compare — two entities are free to share a display name, and the author can change it
mid-session without changing what the entity is. Applies to: every object kind.
Gotchas
It is not the card's identity, and it is redacted with one — on every peer including the host. For
kind: "card" the identity is label, and when the least-privileged viewer is not entitled to it the
read-world reads rewrite label to Card, set metadata.__redacted and delete displayName, so
displayName || label reads Card and every hidden card reads alike. It is deleted rather than replaced on
purpose: a placeholder would leak the one bit "this card was specially named". Read label plus
metadata.__redacted when you need to know which card it is; a mod that needs the real name declares
read-hidden-information and reads
api.getUnredactedSnapshot. No other kind's displayName
is redacted.
A table script cannot read it at all. Table Scripting's ObjectData.name is populated from label, and
displayName is not published to that surface. See
Known limitations. Do not carry a
mod's access to this field into a table script.
A mod cannot write it. Nothing on the mod surface renames an entity after it is spawned; the value comes
from api.createObject or from the editor.
See also
TableObjectState.label— the slug, and a card's identity.TableObjectState.secretMetadata— the field that exists so a name never has to carry a secret.TableObjectState.id— the address.api.createObject— where a mod sets a name.- IDs, names and tags — the three names in full.
tableobjectstate.parentId#
readonly parentId?: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The id of the entity this one is attached to. Parenting is a transform and organization relationship — move
or rotate the ancestor and its descendants come with it — and never a physics constraint; a joint is the
constraint, and welding is the third, separate thing. The schema allows 1–96 characters, null, or absence.
Returns
string | null | undefined, and the three are not interchangeable. A string names the ancestor; null
means the entity was explicitly detached; undefined means it was never parented. The distinction is load
bearing for delta replication, which compares this field with a strict equality that treats null and
undefined as different values (packages/shared/src/snapshotDelta.ts). Test with state.parentId ?? null
when all you want to know is "does it have an ancestor".
How, why and when to use it
You want to score the pieces sitting on a player board and ignore everything else on the table, and the
author built the board as an assembly. parentId is what tells you which entities belong to it — walk the
list you got from api.listObjects and collect the ones whose
parentId matches. The alternative is a tag on each child, and it is the better choice when the grouping is
a rule of your game rather than a physical attachment, because a player rearranging the table changes
parenting and does not change tags. Applies to: every object kind.
Gotchas
position, rotation and scale stay world-absolute. A parented entity reports where it actually is,
not an offset from its ancestor, so you never have to compose transforms to find out where a child sits.
A pick-up or drop reports the assembly root, not the piece the player touched. Grabbing a child escalates
to its top ancestor unless that child carries metadata.grabbableWhileParented, so a rule that credits a
move to the visually grabbed piece is wrong for every assembly. That escalation is deliberate — see
Parenting.
A table script cannot see this field at all, so parenting is invisible on that surface. See Known limitations.
Broken links are pruned, never repaired. migrateTableSnapshot rewrites self-parents, dangling
references, cycles and over-deep chains to null on load, so a corrupt save cannot wedge the runtime in an
infinite walk — but it also means an ancestor id you stored can come back as null after a reload.
See also
api.listObjects— the set you walk to find children.TableObjectState.physics—weldChildren, the one physics-affecting part of an assembly.- Parenting — grab escalation and the per-child opt-out.
- Welding — when an assembly becomes one rigid body.
- Object state — parenting, persistence and the import/export story.
tableobjectstate.physics#
readonly physics?: Readonly<ObjectPhysics>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Per-entity physics overrides, modeled on a PlayCanvas rigidbody plus collision pair: bodyType, mass,
friction, restitution, linearDamping, angularDamping, collisionShape, collisionSize,
collisionOffset, rigidbodyEnabled, collisionEnabled and weldChildren. Every one of those is itself
optional, and an absent field means "use the runtime's per-kind default" rather than a zero.
Returns
ObjectPhysics | undefined. Absent means the entity carries no
overrides at all and is running entirely on its kind's defaults — the ordinary state for anything spawned
from a preset. A present object still tells you nothing about the fields it omits; those are on defaults too.
defaultObjectPhysicsForKind (packages/shared/src/tableObjects.ts) is where those defaults are described,
and they are deliberately not written onto state, so absence is the normal, meaningful answer.
How, why and when to use it
A player complains that your custom miniature falls through the board, and you want your mod to log which
collision shape the author gave it before you tell them to change it. Reading physics is how a mod reports
what was authored. It is a diagnostic read, not a control: nothing on the mod surface writes physics, so if a
piece needs different mass or a different collider, that is an editor change or a setup.json value, not
something a mod can fix at runtime. Applies to: every object kind.
Gotchas
Zod clamps the ranges, so a value outside them never reaches you. mass must be positive; friction,
restitution, linearDamping and angularDamping are each 0–1; collisionSize requires all three
components strictly positive. The host rejects an out-of-range payload rather than clamping it into range.
collisionSize and collisionOffset are in object-local units, not feet. They are measured with the
entity's scale at identity, and the runtime multiplies the scale in when it builds the collider — so
comparing them directly against a position is a unit error.
rigidbodyEnabled and collisionEnabled disable, they do not remove. Every entity has a rigidbody and a
collision component for its whole life; these flags turn them off while keeping the authored settings, and
absent means enabled.
weldChildren sits on the ancestor. It merges a parented assembly into one compound body, which changes
how the whole group collides and makes the children individually ungrabbable. Read it on the ancestor, not on
the child.
See also
ObjectPhysics— every field and its range.TableObjectState.locked— the flag that overridesbodyTypewhile it is set.TableObjectState.material— the other input to the suggested defaults.- RIGIDBODY — the same fields, in the editor.
- Object kinds — the per-kind defaults an absent field falls back to.
tableobjectstate.components#
readonly components?: readonly ObjectComponentState[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Which optional engine components the entity is actually carrying. Reading it answers one question a mod
cannot answer any other way: whether this entity emits light or holds a camera. Neither shows up in kind —
a lamp prop and a plain prop are both custom — and both are set at creation time, so this is a report of
what the author or the spawning mod built. The write side, and the field's caps and defaults, are on
TableObjectDefinition.components.
Returns
readonly ObjectComponentState[] | undefined. Absent means no optional components, and it is never [] —
removing the last one drops the field rather than leaving an empty array
(packages/shared/src/objectComponents.ts, removeObjectComponent), deliberately, so the snapshot delta
cannot report an empty-versus-absent change that was never made. Test with
state.components?.some((c) => c.type === "light") rather than reaching for .length. Applies to: every
object kind.
How, why and when to use it
You are writing a mod that adds its own lighting and want to leave an already-lit table alone. Reading
components tells you what is there before you spawn anything, and it is the only signal that distinguishes
a light-bearing prop from an ordinary one. The alternative is a tag convention you agree with the author,
and that is the better tool whenever the question is a role in your game — "this is the spotlight my rules
control" is a tag; "this entity has a light component" is this field.
Gotchas
A mod cannot change it. No api method edits components on an existing entity; the only write is the
definition you hand to api.createObject.
At most one entry per type, so find is always enough and a reduce over duplicates is wasted work.
A table script cannot see this field at all. See Known limitations.
See also
TableObjectDefinition.components— the write side, the caps, and the props you have to supply.ObjectComponentState— the two shapes and every prop.- Optional engine components — the intrinsic-versus-optional split.
tableobjectstate.material#
readonly material?: SoundMaterial;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
What the entity was explicitly declared to be made of — and only that. Reading it tells you whether someone
made a decision about this piece's substance, which is a different question from what the piece sounds like:
an entity with no material still sounds like something. The values, the per-kind fallbacks and the
reasons to set one are on
TableObjectDefinition.material.
Returns
SoundMaterial | undefined. Absent means nobody set one, not that the entity is generic — the resolver
substitutes the kind's default at play time, so a die with no material still resolves as plastic. Read
state.material when you want to know what was authored, and treat absence as "the kind decides". silent
is a value like any other and means the entity makes no sound. Applies to: every object kind.
How, why and when to use it
Your rules treat metal coins differently from wooden tiles and you have a list of entities from
api.listObjects with no other way to tell them apart. Branching on
material works when your mod is the one that set it at spawn, and it is unreliable for pieces an author
placed, because most of them leave the field absent. The alternative is a tag, and for a rule that cares
about a game role rather than a substance it is the right one — tag your metal coins if "metal" is a rule,
and read material if what you need is the authored physical fact.
Gotchas
Absent is the common case. Do not write state.material === "generic" to mean "unremarkable"; that
comparison is false for every entity nobody configured.
It does not tell you what plays. An entity's
soundSetOverrides and any binding your mod
declared both outrank the material. If what you want is the sound itself, call
api.playSound with { objectId, action } and let the host run the
full resolution rather than reimplementing it.
Nothing on the mod surface writes it after the spawn. To change an entity's sound later, write a
per-action override with api.setObjectSound.
See also
TableObjectDefinition.material— the write side, the eight values and the per-kind defaults.SoundMaterial— the enum itself.api.playSound— playing an entity's resolved sound without resolving it yourself.- Sound sets — material, action and clip, end to end.
tableobjectstate.soundSetOverrides#
readonly soundSetOverrides?: Readonly<Partial<Record<SoundAction, SoundRef>>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The per-action sound diversions the entity carries. Reading it tells you which actions have already been
redirected and by whom — a { kind: "mod" } entry carries the modId that owns it, so you can see whether
the override is yours, another mod's, or an author's editor choice. What the map means and how to write one
are on
TableObjectDefinition.soundSetOverrides
and api.setObjectSound.
Returns
Readonly<Partial<Record<SoundAction, SoundRef>>> | undefined. Absent means no action has been
overridden, and within the map an action that is missing is not silent — it resolves from a mod binding or
the entity's material. Only actions somebody set
explicitly appear as keys. Applies to: every object kind.
How, why and when to use it
You called api.setObjectSound, which returns nothing, and want
to know it landed. Re-fetching the entity with api.getObject once a
snapshot has arrived and reading this map is the only confirmation available. The second reason to read it is
courtesy: check before you overwrite, because an author who set a piece's place sound in the editor has
made a deliberate choice, and a mod that stamps over it silently is the bug they report to you.
Gotchas
Writing to it does nothing. Entity state is a structural clone of the snapshot, and that includes this
nested map — state.soundSetOverrides.place = … mutates a copy that nothing reads.
An entry can point at a mod that is not here. A { kind: "mod" } ref is stored verbatim and saved with
the table, so a table loaded without that mod keeps the entry and resolves it to no clip.
A table script cannot see this field at all. Do not carry a rule that reads it across to that surface. See Known limitations.
See also
TableObjectDefinition.soundSetOverrides— the write side, and the resolution order.api.setObjectSound— setting or clearing one entry.SoundRef— the two shapes a value can take.- Sound sets — where an override sits in the full resolution order.
tableobjectstate.secretMetadata#
readonly secretMetadata?: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-hidden-information |
| Availability | mod |
SECRET author data. Present only when THIS peer is entitled to the entity's identity — the host strips it from everyone else's snapshot.
The author's secret property bag. Unlike
metadata, which is identical in every peer's copy of
the snapshot, this field is withheld from anyone not entitled to the entity's identity — and since the six
read-world reads became least-privileged, the only thing read-world is entitled to it on is a card
whose face is already public, on every peer including the host. A public card's secret is not a secret;
everything you actually wanted this field for reads undefined.
Returns
Record<string, unknown> | undefined, values typed unknown (each read needs a typeof or
Array.isArray check). Absent is ambiguous by design — it means either the author set no secret, or the
reader is not entitled to see it, and nothing on the mod surface distinguishes those two. Never infer "the
author set nothing" from its absence. metadata.__redacted tells you the entity's wire copy was
neutralized, which narrows the guess but is set for a redacted deck as well.
Where it survives: in
api.getUnredactedSnapshot(), and there only on the
host, where it is present on every kind whenever the author set it. Every read-world read —
getObject, listObjects, getSnapshot, getContainerContents, getHandObjects, getZoneObjects —
strips it from a deck, a bag and every non-card kind unconditionally, and from a card unless that
card's face is already public. That last case is the only way this field reaches a mod through read-world,
and it carries nothing you could not have read off the card itself.
How, why and when to use it
Your mod runs on the host and needs the truth about a face-down card — resolving a scoring rule, checking a
win condition, deciding what a reveal should announce. That truth is real, and it is exactly the fact you
deliberately kept out of label and displayName so the other players would not receive it. Reaching it
takes the read-hidden-information capability and
api.getUnredactedSnapshot(); read it once, keep the
derived answer, and go back to the narrow reads.
Write your logic so it degrades honestly on a peer: treat undefined as "not my business", and let
the host be the one that decides. That is the same discipline every host-authoritative rule already needs —
see Host authority.
Gotchas
api.getObject(id).secretMetadata on a face-down card is undefined even on the host. This is the
change most likely to break an existing mod: read-world used to hand the host its own view, and no longer
does. Nothing about the field moved — the read did. Declare read-hidden-information and take it from the
unredacted snapshot instead.
Your mod runs on peers too. The same script runs in every player's browser against that player's
snapshot, and a peer's snapshot was already redacted at the wire boundary before it arrived. A rule written
as if (card.secretMetadata?.role === "traitor") silently evaluates false on every client that was not
entitled — which is the correct security outcome and a confusing bug if you expected the branch to run.
Even the elevated read cannot fix that one: a capability cannot hand back what a peer was never sent.
It flips public when the card does. The gate is the card's identity entitlement, not an independent switch, so flipping a card face-up puts its secret in the next snapshot for everyone. Anything that must stay hidden after a reveal does not belong here.
A table script cannot see it at all. Table Scripting's ObjectData publishes no secret field, the same
way it publishes no displayName. Do not carry mod-surface access to it into a table script.
Reading it is not proof of anything. It is a replication boundary, not a signature: the host can write whatever it likes into the value, and a peer only ever sees what the host chose to send.
See also
api.getUnredactedSnapshot— the only read that returns this field when it still holds a secret.TableObjectDefinition.secretMetadata— the write side, the per-kind rule and the 2 KiB cap.TableObjectState.metadata— the public bag, and__redacted.TableObjectState.label— a card's identity, rewritten toCardwhen hidden.- Host authority — why a rule belongs on the host.
- Known limitations — what redaction does and does not cover.
TableContainerContentEntry#
Surface B — mod script · interface · 8 members
One entry inside a deck or bag. There are TWO sorts, told apart by kind:
"card"— one card, top-first. CarriescardIdandfaceDown."object"— one RUN of identical pieces in a piece bag:countcopies of the same thing, withentryKeyas their identity andobjectKindas what they are.
A container holds one sort or the other, never both, so an array is all cards or all
objects. Branch on kind before reading the fields of either.
One entry inside a deck or bag, as api.getContainerContents
reports it. There are two sorts, told apart by kind:
kind |
Describes | Carries |
|---|---|---|
"card" |
One card. | cardId, faceDown |
"object" |
One run of identical pieces in a piece bag. | entryKey, objectKind, count |
Both carry kind, index and label. A container holds one lane or the other, never both, so an array is all
cards or all objects — branch on kind before reading the fields of either.
Neither sort is an entity. They live in the container's own stored list, never appear in
api.listObjects, and have no id, position or owner.
Applies to: deck and bag. Every other kind resolves an empty array rather than an error, and so does a
bag in the holder form — an open bowl's pieces are ordinary entities on the table.
The two lanes are redacted quite differently, and the difference is deliberate:
- A pile of CARDS resolves to at most ONE entry — the container's publicly-visible front card, when there is one. A pile's order is host-only information, so the array is length 0 or 1 and there is nothing to walk.
- A bag of PIECES resolves to every run it holds. A piece bag's contents are unseen, not secret: everybody
watched each piece go in, and which piece the next draw yields is decided by the host's private RNG at draw
time rather than derived from the list. The one exception is a bag whose author turned
secretContentson, which resolves[]— there is no partial answer.
How, why and when to use it#
For a card pile the question this answers is narrow: what is the pile showing. For a piece bag it is much broader — the full inventory, grouped by sort, with a count each — which is enough to drive a supply rule, a "what can I draw" panel or an end-of-round check without any elevated read.
Two things it deliberately cannot tell you either way: a card pile's order, and a secret bag's runs. Both are
reached only by declaring read-hidden-information and reading the container in
api.getUnredactedSnapshot.
Treat the reply as a photograph rather than a live view — a draw or a shuffle between your read and your next
line changes it.
Gotchas#
An entry is not an entity, and cannot be turned into one. There is no id here to pass to
api.getObject or
api.objectAction. Act on the container instead: draw and deal
are what turn an entry into an entity with an id of its own.
The card-only and object-only fields are all optional, so strict makes you narrow. Reading cardId on an
object entry or count on a card entry compiles to undefined, not to an error. Test kind first.
An empty array has several meanings, and they are indistinguishable on purpose. The container is empty; it is
a card pile whose front card is face-down; it is a holder; it is a bag with secretContents on; the container
sits inside a hidden seat zone; or the id named no container at all. A reply that told those apart would be an
oracle over exactly the information the redaction exists to withhold.
Read the count off the container, not off the reply. stackCount on the container entity is public and never
redacted — though it is clamped to 1000 for a bag, and is never below 1 even when the container is empty.
See also#
api.getContainerContents— the one call that returns these.api.getUnredactedSnapshot— the elevated read, and the only way to a pile's order or a secret bag's runs.ContainerMode— which end of a pile adrawtakes from.api.objectAction—draw,deal,shuffle,split.- Object kinds — what
deckandbageach store. - Host authority — who owns the container you are reading.
Members#
| Signature | Description | Returns |
|---|---|---|
kind |
"card" | "object" |
|
index |
number |
|
label |
string |
|
cardId |
"card" entries only — the card's face, e.g. "AS" or a custom deck's card id. |
string |
faceDown |
"card" entries only. |
boolean |
entryKey |
"object" entries only — the identity shared by every piece in this run. |
string |
objectKind |
"object" entries only — what a piece from this run becomes when it is drawn. |
TableObjectKind |
count |
"object" entries only — how many copies this run holds. |
number |
tablecontainercontententry.kind#
readonly kind: "card" | "object";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The discriminant. "card" marks one card; "object" marks one run of identical pieces in a piece bag. The
reader stamps it while it maps the container's stored list
(packages/shared/src/tableObjects.ts, getContainerContentsFromObject), and it is the field to branch on
before you read anything else on the entry.
Applies to: deck and bag. A deck only ever produces "card". A bag produces "card" when it is in
the card lane and "object" when it holds pieces — never a mixture, because a container holds one lane or the
other.
How, why and when to use it
Narrow on it first, every time. The card-only fields
(cardId,
faceDown) and the object-only fields
(entryKey,
objectKind,
count) are all declared optional, so reading the
wrong one on the wrong entry gives you undefined rather than an error. A switch (entry.kind) with a branch
each is the shape that cannot go quietly wrong.
Gotchas
It does not tell you the container's kind. A "card" entry says nothing about whether it came out of a
deck or a bag. Read the container entity through
api.getObject if that distinction matters.
"object" is not objectKind. This says which sort of entry you are holding;
objectKind says what the pieces inside the run
are. The two sit one line apart and are easy to confuse.
The array length differs sharply between the two. A card pile resolves to at most one entry — the publicly-visible front card — so a loop over a card reply iterates 0 or 1 times. A piece bag resolves to every run it holds, which can be dozens. Code written against one shape and pointed at the other will be wrong in a way no type error catches.
An entity can also be kind: "card". If your helper takes either an entry or a
TableObjectState, test for a field only one of the two has — such
as cardId or entryKey — and use kind as the narrowing that follows.
See also
TableContainerContentEntry— the shape it discriminates.TableContainerContentEntry.cardId— the card lane's identity.TableContainerContentEntry.entryKey— the piece lane's identity.TableObjectKind— the entity-kind union this one is not.api.getContainerContents— where entries come from.
tablecontainercontententry.index#
readonly index: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Where this entry sits in the container's stored list. Present on both sorts of entry, and what it counts differs between them:
- On a
"card"entry it is the position in the pile, counted from the visible front face. Entry0is the face the runtime renders on the outside. - On an
"object"entry it is the position of the run in the bag's stored order — insertion order, one index per sort of piece rather than per piece.
On a card reply it is always 0. Since 2026-08-14
api.getContainerContents resolves at most one card entry —
the publicly-visible front card — so the only index a mod can read in the card lane is the front one. The field
is still in the shape because the same type describes an unredacted pile, but as a variable it carries no
information there.
On a piece reply it counts up. A bag of pieces resolves every run it holds, so indices run 0, 1, 2, …
across the reply.
Applies to: deck and bag. Numbering is assigned by the same mapping pass for both
(packages/shared/src/tableObjects.ts, getContainerContentsFromObject).
How, why and when to use it
In the card lane, realistically you do not — read it as a sanity check that you are looking at the front of the
pile and write the rule against cardId. Anything
that needs to know what is underneath is asking for the pile's order, which no read-world read reports on any
peer; that rule declares read-hidden-information and reads metadata.cards off the container in
api.getUnredactedSnapshot.
In the piece lane it is a stable position for the length of one reply, which makes it fine as a list key in a UI.
It is not an identity: use
entryKey for that, because a draw from an earlier
run renumbers everything after it.
Gotchas
A 0 does not mean the container holds one thing. It means that entry is first. A card reply's length says
nothing about the pile's height, and a piece run's index says nothing about its
count. The container's public height is
stackCount on the entity — never redacted, though clamped to 1000 for a bag and never below 1 even when empty.
It is not the draw order for a bag. A bag draws at random by default, and even in "stack"/"queue" mode a
run is a group rather than a position in a pile. Index 0 is not "next".
Which end a draw takes from still depends on the container's mode. "stack" takes entry 0, "queue"
takes the last entry, and "random" takes one the host chooses — so the entry you can see is the one a deck is
about to deal and is not the one a bag is about to deal. See
ContainerMode.
Every mutation renumbers. A draw uncovers the next card or shrinks a run, and a shuffle reorders a pile
outright, so an index you stored before an action describes something else afterwards. Re-read the container
rather than reasoning about the number.
See also
TableContainerContentEntry— the two sorts of entry, and how long each reply is.TableContainerContentEntry.cardId— the card lane's identity, and what to key on instead.TableContainerContentEntry.entryKey— the piece lane's identity.ContainerMode— which end of the pile is drawn from.api.objectAction— the actions that change the front card.api.getContainerContents— the call that assigns this.
tablecontainercontententry.label#
readonly label: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The entry's name. Present on both sorts of entry, and — this is the part that surprises people — the two
lanes fill it from different things
(packages/shared/src/tableObjects.ts, getContainerContentsFromObject):
| On a | label holds |
|---|---|
"card" entry |
The card's slug, the same string as cardId. Assigned from one source, so the two are never different. |
"object" entry |
The run's display name, falling back to the stored definition's slug when the author set none. |
Applies to: deck and bag.
How, why and when to use it
On a card entry, read it when you are matching against an entity you were handed as a
TableObjectState, which gives you label rather than cardId:
one comparison then works on both shapes. Where the shapes are not mixed, prefer cardId — it is the field that
says what it means.
On an object entry it is the string to show — "Black Stone ×180" — because that is the lane where a human
name actually reaches it. Match on
entryKey instead of on this; display names are
not unique and two runs can share one.
Gotchas
⚠ It is not the same kind of value in the two lanes. On a card entry it is the slug, which is an identity and
drives hidden-information redaction — so treating it as text to show a player couples your UI to an anti-cheat
key. On an object entry it is a display name where one was authored, which is not an identity at all. A helper
that reads label without first narrowing on
kind is doing two different things by accident.
On a card entry it is redundant, and that is the whole of it. Both fields come from one value, so a mod that
reads label and a mod that reads cardId observe the same string. Do not build a rule on a difference between
them.
Redaction reaches it exactly as it reaches the rest of the entry. A card reply holds at most one entry — the
pile's publicly-visible front card — on every peer including the host. A piece bag's runs ride in clear unless
its author turned secretContents on, in which case the reply is [] and there is no label to read. The
identities behind a redacted pile are absent, not disguised, so there is no invented value here to guard
against.
See also
TableContainerContentEntry.cardId— the same string on a card entry, better named.TableContainerContentEntry.entryKey— the identity to match on in the piece lane.TableContainerContentEntry.kind— narrow on this first.TableObjectState—labelon a real entity.- Identity, names and tags — the three names and what each one is for.
tablecontainercontententry.cardId#
readonly cardId?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
"card" entries only — the card's face, e.g. "AS" or a custom deck's card id.
Which card this is. The string is the card's identity, and it is the same string the host writes onto the
label of the card entity a draw creates — so one value addresses the card while it is buried in the pile
and after it is in somebody's hand. For a container the platform filled in itself the ids are the standard
52: rank then suit, A♠ through K♣, with 10 spelled in full and the four suit characters ♠ ♥ ♦ ♣.
Applies to: deck, and a bag in the CARD lane. A custom deck's ids are whatever its author wrote into the
deck definition, and nothing constrains them to the rank-and-suit spelling.
It is a "card"-entry field, and undefined on the other sort. A bag holding pieces resolves
kind: "object" runs, which carry
entryKey instead. Narrow on
kind before reading this, or strict will hand you
string | undefined and a rule that silently never matches.
How, why and when to use it
Your rule turns on a specific card — "the round ends when the Queen of Spades is showing on the discard pile",
"score by the rank on the front of the stack". cardId is the value you compare against, and on this surface it
only ever names the pile's publicly-visible front card: the reply holds at most one entry. For everything else,
subscribe to onCardDrawn and keep your own tally of what has come out, because nothing on the read-world
surface will tell you what is still in the pile.
Gotchas
It is an identity, not a name to show a player. Nothing formats it and nothing translates it, so a custom
deck's sword-of-truth reaches your UI exactly as stored. Keep a display map of your own if the ids are not
presentable.
A cardId you get here is real, but you will only ever get one. The identities behind the front card are
withheld from every mod read on every peer, the host included — the reply is one entry or none, never a
fabricated pile. A rule that must compare against cards it cannot see declares read-hidden-information and
reads metadata.cards off the container in
api.getUnredactedSnapshot.
Duplicates are allowed. Nothing enforces uniqueness inside a container, and combine merges two piles
without checking, so one deck can hold two A♠ entries. This identifies a card, not a place in the pile — so
seeing A♠ on the front does not tell you the other one is gone.
See also
TableContainerContentEntry.label— the field that repeats this value.TableContainerContentEntry.index— position, and why it is always0on a card entry.TableContainerContentEntry.entryKey— the piece lane's equivalent.api.getUnredactedSnapshot— the elevated read, and the only way to a pile's other identities.TableObjectState— where the same string lands aslabelafter a draw.- Build a deck — where custom card ids are authored.
tablecontainercontententry.faceDown#
readonly faceDown?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
"card" entries only.
Whether this card is stored back-up. It is absolute, not relative to the container: true means this card comes
out of the pile showing its back, whichever way the container itself is turned. A pile's stored entries can mix
the two freely — a discard pile with a face-up front and a face-down history is expressible.
Applies to: deck, and a bag in the CARD lane. An entry whose stored form does not name a value inherits
the container's own faceDown.
It is a "card"-entry field, and undefined on the other sort. A bag holding pieces resolves
kind: "object" runs, and a piece has no face to be down — nothing on a run corresponds to this. Narrow on
kind first; undefined here is "not a card", not
"face up".
On this surface it reads false in all but one case. An entry only survives redaction when a spectator with
no seat and no team could legitimately see that face, and a back-up card is exactly what that test excludes. The
exception is a container owned by a seat whose hand zone the author declared public with
hideFromOthers: false; there, a face-down entry is not private information and rides through as true.
How, why and when to use it
Read it to decide whether the front card is worth acting on, and expect it to answer false almost always. What
you cannot do here any more is survey a pile — "how many discards are still hidden" is a question about the
pile's order, which no read-world read reports on any peer. That rule declares read-hidden-information and
reads metadata.cards off the container in
api.getUnredactedSnapshot, where the per-entry mix is
intact. The container entity's own faceDown remains readable and describes how the pile is turned, not what
any card in it is doing — use that when you are about to flip the whole pile.
Gotchas
It does not mean "hidden from you"; the reply's length does. A card the least-privileged viewer may not
identify is not marked here, it is simply absent — so an empty reply, not a faceDown: true, is the signal that
a pile is showing nothing. Entitlement is decided by the redactor, never by this flag.
A draw carries this value onto the new entity. The host rotates the drawn card to an absolute x of 180
degrees when the entry is face down and 0 when it is not, so it never inherits the container's accumulated
flips. Reading the entry tells you which way the card will land.
Known gap. A
bagwhose authoredmetadata.cardsholds bare card-id strings, with no per-entryfaceDownand nometadata.stackModelVersion, reports the opposite of what its draw produces. The reader defaults such an entry to the container's ownfaceDown(packages/shared/src/tableObjects.ts,getContainerContentsFromObject) while the draw path defaults it throughdeckEntryDefaultFaceDown(apps/web/src/playcanvas/TabletopRuntime.ts), which inverts for unversioned data. Adeckis unaffected — the runtime stamps the current stack-model version onto every deck as it creates it — and so is any container whose entries namefaceDownoutright. Author bag contents as objects,{ cardId, faceDown }, and the two agree. See Known limitations.
See also
TableContainerContentEntry— the entry this belongs to.TableObjectState—faceDownon the entity a draw creates.api.objectAction—flip,drawanddeal.- Object state — the container fields around it.
tablecontainercontententry.entryKey#
readonly entryKey?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
"object" entries only — the identity shared by every piece in this run.
The identity shared by every piece in this run. Present on kind: "object" entries only — a card entry carries
cardId instead, and leaves this undefined.
Applies to: a bag holding pieces. A deck, a card bag and a holder never produce an entry with one.
Returns
string | undefined. undefined is the signal that you are holding a card entry; branch on
kind first and this is always a string.
The value is derived by containerItemIdentityKey (packages/shared/src/tableContainers.ts) from what a piece
is — kind, model reference, colour, materials and scale — and from nothing about where it was. Two pieces that
look alike share it and collapse into one run; a blue cube never shares it with a red one. Treat it as opaque:
it is stable within a session and across a save, and its spelling is not part of this contract.
How, why and when to use it
It is what lets a mod tell two runs apart without relying on
label, which is a display string and can repeat. A
rule that tracks a supply — "how many red meeples are left in the bag" — keys on this and sums
count across the matching run.
Gotchas
A mod cannot draw by it. There is no api call that takes an entry key;
api.objectAction's draw addresses the container and lets the host
choose. The key is for reading, not for aiming. The table-script surface does have
BagObject.takeObject({ key }) — that is one of the real
differences between the two surfaces.
It survives a reload; do not persist it across versions. The derivation is an implementation detail. A key written into saved data and compared against one derived by a later build is a bug waiting for a release — compare keys you read in the same call.
It is absent on the surface that matters most. A bag whose author turned secretContents on resolves []
from api.getContainerContents, so there is no entry and no
key at all. Those runs reach a mod only through
api.getUnredactedSnapshot with read-hidden-information.
See also
TableContainerContentEntry— the two sorts of entry.TableContainerContentEntry.count— how many the run holds.TableContainerContentEntry.objectKind— what a drawn copy becomes.api.getContainerContents— the one call that returns these.
tablecontainercontententry.objectKind#
readonly objectKind?: TableObjectKind;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
"object" entries only — what a piece from this run becomes when it is drawn.
What a piece from this run becomes when it is drawn — the
TableObjectKind of the entity a draw creates. Present on
kind: "object" entries only.
Applies to: a bag holding pieces. It is undefined on a card entry, where the answer is always card and
is carried by the entry's own kind.
Returns
TableObjectKind | undefined. undefined means you are holding a card entry.
How, why and when to use it
Read it to decide whether a run is worth acting on before anything is drawn: a rule that only cares about dice
can skip every run whose objectKind is not "die". It is also what lets one display helper render a bag's
contents with the right icon per run without drawing a piece to find out what it is.
Gotchas
It is not the container's kind, and it is not the entry's kind. Three different things sit close together
here: the container is a bag; the entry's kind is the discriminant "card" | "object"; and objectKind is
what is inside. Reading the wrong one is the easiest mistake on this shape.
One bag can hold several kinds. A bag takes any mix of pieces, so two runs in one reply can disagree. Do not infer a container-wide type from the first entry.
"card" and "deck" never appear here. Cards are the other lane — a container holds cards or pieces,
never both — so a card in a bag arrives as a kind: "card" entry, not as an object run naming card.
The kind union grows. Write a default branch rather than enumerating what exists today.
See also
TableObjectKind— the union it draws from.TableContainerContentEntry.kind— the discriminant, and the thing this is not.TableContainerContentEntry.entryKey— the run's identity.- Object kinds — what each kind is.
tablecontainercontententry.count#
readonly count?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
"object" entries only — how many copies this run holds.
How many copies this run holds. A piece bag's contents are run-length encoded — twenty identical black stones
are one entry with count: 20, not twenty entries — so this is the size of the group the entry describes.
Present on kind: "object" entries only.
Applies to: a bag holding pieces. A card entry describes exactly one card and leaves this undefined.
Returns
number | undefined. Where present it is a positive integer: a run that reaches zero is removed rather than left
behind at 0.
How, why and when to use it
It is the number a supply rule turns on — how many meeples are left in this colour, has the tile bag run low
enough to end the round — and the number to show a player next to
label. Summing it across the reply gives the bag's
true total, which is the one number read-world will give you exactly.
Gotchas
It is not the container's stackCount, and the two disagree on a big bag. The container entity's public
count is clamped to 1000 (MAX_CONTAINER_PUBLIC_COUNT, packages/shared/src/tableContainers.ts), so a bag of
2,000 stones publishes 1000, and an empty container still publishes 1 because the field's schema has a
minimum of one. Sum count for the real number; read stackCount for what every peer sees.
A secret bag gives you nothing to sum. A bag whose author turned secretContents on resolves [] from
api.getContainerContents — there is no partial answer and no
count. Those runs reach a mod only through
api.getUnredactedSnapshot with read-hidden-information.
A holder gives you nothing either, for a different reason. An open bowl's pieces are ordinary entities on the
table, so it resolves [] and
api.listObjects is where you count them.
Re-read it, do not cache it. Every draw and every drop rewrites the runs.
See also
TableContainerContentEntry.entryKey— what the run is.TableContainerContentEntry— the two sorts of entry.api.getContainerContents— the one call that returns these.TableObjectState— where the clampedstackCountlives.
TableHandState#
Surface B — mod script · interface · 3 members
One seat's hand.
One seat's holdings, as api.getHandObjects groups them. A "hand"
here is a filter, not a container: the platform gathers every entity in the snapshot whose ownerSeat is a
non-empty string and buckets it by that seat. Three readonly fields — the seat name, a count, and the full
TableObjectState of every entity in it.
Applies to: every object kind. A token, a die or a board with an ownerSeat lands in a hand exactly as a
card does.
How, why and when to use it#
You are enforcing a hand limit at end of turn, or scoring what each side kept. The alternative is
api.listObjects plus your own reduce over ownerSeat, which
returns the same entities and leaves you to write the grouping and to remember that "" is not a seat.
TableHandState is also the only shape that answers "which seats are holding anything at all" in one call.
Group with getHandObjects() when you are surveying the table; pass a seat when one player's hand is the
subject.
Gotchas#
Seats you get nothing back for are absent, not empty — including in the single-seat form. With no seat
argument the array has one entry per seat that owns at least one visible entity, so its length counts
readable hands rather than players. Passing a seat resolves [] rather than one entry with objectCount: 0
when that seat holds nothing — and, just as importantly, when everything it holds was concealed. The two are
deliberately indistinguishable: an empty entry would tell you a hand exists that you may not read, which is
one bit more than you are entitled to. Always guard the find/index.
A seat is not a peer. Seats belong to the table, so an entity keeps its ownerSeat after its owner
disconnects and the hand keeps reporting it. Key per-player state on the seat name rather than on a peer id.
Card identities are blanked on every peer, the host included. The mod read redacts to the least-privileged
view (packages/shared/src/tableObjects/redaction.ts, redactHandStatesForRestrictedViewer), rewriting a
hidden card's label to the literal "Card", deleting displayName and metadata.cardId, and setting
metadata.__redacted. Counting a hand works everywhere; reading what is in it does not work anywhere. A
rules engine that must see the faces declares read-hidden-information and reads
api.getUnredactedSnapshot.
objectCount is recomputed, not copied. It counts the entities that survived redaction, so it can never
report a number the objects array does not support — and on a table with a hidden seat zone it is not the
seat's real holding count.
See also#
api.getHandObjects— the one call that returns these.TableObjectState— the per-entity shape inobjects.api.getMySeat— the seat name to pass in.api.getUnredactedSnapshot— the elevated read, for a rule that must see the faces.- Object state —
ownerSeatand the fields around it. - Host authority — who owns the state you are reading.
Members#
| Signature | Description | Returns |
|---|---|---|
seat |
string |
|
objectCount |
number |
|
objects |
readonly TableObjectState[] |
tablehandstate.seat#
readonly seat: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Which seat this hand belongs to. It is the seat identifier itself — the same string as an entity's ownerSeat
and the same one api.getMySeat returns — never a peer id and never a
display name.
Applies to: every entry. It is a plain string and is never empty, because a seat only appears when
something claims it: the grouping form skips any entity whose ownerSeat is "" or null, and the single-seat
form echoes the argument you passed.
How, why and when to use it
You are printing "north is over the hand limit" or keying a per-player score map. The alternative is the peer id
from a hook payload, which is what most authors reach for because that is what identifies a player elsewhere —
and it is the wrong key here, because a hand outlives its occupant. An entity keeps its ownerSeat when a player
disconnects, so the hand keeps reporting, and a replacement who takes the seat inherits it. Key anything that
belongs to the position on the seat, and anything that belongs to the person on the peer id.
Gotchas
A seat is not a team. They are separate assignments with separate hooks, and a player can hold either, both or neither. Grouping hands by team means mapping seats to teams yourself.
The grouping form reports only seats that own something. A seated player holding nothing produces no entry
at all, so the set of seat values you get back is not the seat roster. Use the peer hooks or
api.getSnapshot when you need who is sitting where.
The single-seat form echoes whatever you passed. Ask for a seat nobody occupies and you get one entry naming
it, with nothing in it — so seat coming back does not confirm that the seat exists.
See also
api.getHandObjects— the two call shapes.api.getMySeat— this client's own seat.onSeatChanged— when the seat map moves.- Zones and seats — where seats are defined.
tablehandstate.objectCount#
readonly objectCount: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How many entities this seat owns. It is assigned as objects.length in the same pass that builds the array
(packages/shared/src/tableObjects.ts, getHandObjectsFromSnapshot), so the two never disagree and neither one
is a cached total the platform maintains between calls.
Applies to: every object kind. A seat that owns three cards, a die and a locked player board reports 5.
How, why and when to use it
You are enforcing a hand limit, or showing every player how many cards their opponents are holding. objectCount
is the number to compare, and it is readable on every client — counting is public even where identities are not,
which is exactly what makes it the right field for a shared hand-size display. The alternative that looks
equivalent is the container's stackCount, and it answers a different question: stackCount is the height of a
pile entity, while this is a tally of separate entities each with an id of its own.
Gotchas
It counts entities, not cards. An owned token, die or board is in the total. Filter
objects by kind when your limit is about cards.
A hand of zero is only reachable through the single-seat form. The grouping form omits seats that own
nothing, so objectCount: 0 appears when you asked for one named seat and never when you asked for all of them.
It is a count at one instant. A player drawing between your read and your next line changes it, and nothing notifies you — re-read rather than incrementing a copy.
See also
TableHandState.objects— the entities being counted.api.getHandObjects— the call, and why an empty seat is missing.api.getContainerContents— counting a pile instead of a hand.- Limits and caps — the ceilings the platform enforces.
tablehandstate.objects#
readonly objects: readonly TableObjectState[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The entities this seat owns, as TableObjectState values — id,
kind, world position in feet, rotation, tags, metadata — the same values
api.listObjects would return for the same entities, and redacted the
same way. The array is a structured clone: writing to it changes nothing.
Applies to: every object kind. Membership is decided by ownerSeat alone, so anything a seat owns is here —
a token dropped in an owned zone as much as a card.
These are hands, so this is where redaction bites hardest. A face-down card in a seat's hand is precisely
what the hidden-information model conceals, and the read-world reads resolve the least-privileged view on every
peer including the host. Expect identity-neutralized entities: same ids, same positions, no card faces. Use this
to know that a seat holds five cards, not which five.
How, why and when to use it
You need to act on what a player is holding — discard their hand at end of round, flip everything face up at
scoring, count what is in it. Because each element carries an id, you can feed it straight to
api.objectAction, which is the thing
objectCount cannot do for you — and acting on ids works
perfectly well without knowing the faces. A rule that must adjudicate the faces (whose hand wins, is this
discard legal) declares read-hidden-information and reads
api.getUnredactedSnapshot instead. The alternative for
enumeration is api.listObjects() and your own ownerSeat filter; that returns the same entities and is the
better choice when you want one flat list across all seats rather than a per-seat grouping.
Gotchas
The order is snapshot order, not hand order. Entities appear in the order the snapshot lists them, which follows creation rather than how the cards are fanned in front of the player. Sort by something you control if presentation order matters.
Hidden cards are blanked on every peer, the host included. The redactor rewrites such a card's label to
the literal "Card", deletes metadata.cardId and displayName, and sets metadata.__redacted to true
(packages/shared/src/tableObjects/redaction.ts, redactObjectForRestrictedViewer). The entity is still there
with its real id, position and kind — only the identity is withheld. Test metadata.__redacted before you trust
a label, wherever your mod is running.
An entity can be missing, and a whole seat can be missing. An entity a hidden seat zone conceals is
dropped from this array rather than blanked, and objectCount is recomputed from the survivors so it never
reports a number the array cannot support. A seat whose entries all vanish is dropped from the reply
entirely — including for the single-seat query form, which then resolves [] rather than one empty entry. "This
hand is completely concealed" and "this seat holds nothing" are deliberately the same answer.
Nothing about it is live. These are copies taken when the call answered. Holding one and reading its
position later gives you the position it had then.
See also
TableObjectState— every field on each element.TableHandState.objectCount— the same array's length.api.objectAction— acting on the ids you find here.api.getUnredactedSnapshot— the elevated read, for a rule that must see the faces.- Object state —
ownerSeatand how an entity joins a hand. - Host authority — who owns the state you are reading.
ModSeatZone#
Surface B — mod script · interface · 9 members
ONE seat zone's geometry — where it is, not what is standing in it.
This is what makes "put the deck in the deck area" expressible. getZoneObjects answers
with a zone's occupants and has always been the only zone read, so a script that wanted to
PLACE something had no way to learn where the zone was and had to carry hard-coded
coordinates that a moved seat silently invalidated.
Geometry only, and deliberately: a zone box is table layout, drawn on the table for whoever
may see it. Nothing about a zone's CONTENTS is here — those stay behind getZoneObjects,
which redacts to the least-privileged view and drops what you may not know.
ModSeatZone is one live seat zone's geometry, as api.listSeatZones returns it: where the zone is, how big its footprint is, which way it faces, and what it is called. It is a plain structural clone taken per call, so mutating it changes nothing anywhere.
How, why and when to use it#
Use it to place things. A pile, a marker or a board that belongs in a named area needs that area's world position, and this is the only read that supplies one — api.getZoneObjects answers what is standing in a zone, never where the zone is.
The pairing is deliberate: find the zone here by name or templateZoneId, then pass its id to api.getZoneObjects to see what is already there.
Gotchas#
It describes a zone, never its contents. There is no occupant list, no count and nothing derivable about either. A hidden zone reports its box like any other, because that box is drawn on the table; what stands inside it is a different question with a different, redacting answer.
position.y is the play surface under the zone. It is not the authored box plane, which is a render detail that sits a hair below the table top and does not follow a table swap. Spawn at position.y and the piece rests where it should on whatever table the scene is using. Containment ignores y entirely.
Zones exist only for CLAIMED seats at a live table. An empty seat contributes nothing here even though the scene defines its zones. In Edit Mode every seat's zones are live, so the same code sees more zones there.
See also#
api.listSeatZones— the call that returns these.api.getZoneObjects— what is standing in one.onZoneEnter— when that set changes.
Members#
| Signature | Description | Returns |
|---|---|---|
seat |
The seat this zone belongs to. Zone ids repeat across seats; this is what separates them. | string |
id |
The zone's id AT THIS SEAT — what api.getZoneObjects(seat, id) and the onZoneEnter/onZoneLeave payloads use. For a template-linked seat this is the derived seat-zone-<seat>-<templateZoneId>, not the id authored on the template. |
string |
templateZoneId |
The id as authored on the seat TEMPLATE, when this zone came from one; otherwise null. |
string | null |
name |
The author's name for the zone, or null when it was never named. |
string | null |
type |
null for a legacy zone authored before zone types existed. |
ModZoneType | null |
position |
World-space centre, and the point to spawn at. | Readonly<Vector3> |
size |
Footprint on the table plane. A zone has no height. | Readonly<{ x: number; z: number }> |
rotationY |
Rotation about Y, in DEGREES. Spawn a pile with this to have it face the seat. | number |
tagFilter |
Tags that gate occupancy. Empty means the zone matches everything. | readonly string[] |
modseatzone.seat#
readonly seat: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
The seat this zone belongs to. Zone ids repeat across seats; this is what separates them.
The seat this zone belongs to, e.g. "red".
How, why and when to use it
Pair it with id whenever you call api.getZoneObjects, which takes both halves. It is also how you tell one seat's "Deck Area" from another's when you asked for every seat's zones at once.
Gotchas
Zone ids repeat across seats, so this is not decoration. Four seats linked to one template each own a zone with the same authored name and the same templateZoneId. seat is what separates them.
See also
api.getMySeat— the seat this peer is sitting in.api.getZoneObjects— takes(seat, id).
modseatzone.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
The zone's id AT THIS SEAT — what api.getZoneObjects(seat, id) and the
onZoneEnter/onZoneLeave payloads use. For a template-linked seat this is the derived
seat-zone-<seat>-<templateZoneId>, not the id authored on the template.
The zone's id at this seat — the handle api.getZoneObjects and the zone hooks use.
How, why and when to use it
Carry it straight to api.getZoneObjects or compare it against a zoneId from an onZoneEnter payload. Do not build one by hand.
Gotchas
Do not match on it, and do not parse it. For a template-linked seat this is a derived string (seat-zone-<seat>-<templateZoneId>) whose shape is not a contract and which can be truncated for a long template id. Match on name or templateZoneId instead — both survive a rename of the derivation.
Unique only within its seat. Two seats can hold the same id. Key on (seat, id).
See also
ModSeatZone.templateZoneId— the handle that is stable across seats.api.getZoneObjects— where this id is used.
modseatzone.templateZoneId#
readonly templateZoneId: string | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
The id as authored on the seat TEMPLATE, when this zone came from one; otherwise null.
The stable handle across seats: every seat linked to the template has the same
templateZoneId and a different id. Match on this (or on name) rather than parsing
id, whose derived shape is not a contract.
The id as authored on the seat template, when this zone came from one; null when the zone was authored on the seat itself.
How, why and when to use it
This is the stable handle across seats. Every seat linked to one template shares a templateZoneId and has its own derived id, so one line of code can mean "the deck area, on whichever seat is asking" without any string surgery.
Gotchas
null is normal. A scene whose seats were authored individually — the common case for an older scene — has no template, so every zone reports null here and you match on name instead.
A very long template id round-trips clipped. The derived id is capped, so a template zone id near a hundred characters comes back truncated and will not compare equal. Keep template zone ids short.
See also
ModSeatZone.id— the per-seat id this was derived into.ModSeatZone.name— the other stable way to match.
modseatzone.name#
readonly name: string | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
The author's name for the zone, or null when it was never named.
The author's name for the zone box, or null when it was never named.
How, why and when to use it
The most readable way to find a zone: match case-insensitively on a name your setup instructions ask the author to use ("Deck Area", "Supply Area"). It survives the seat being moved, rotated, rescaled or re-linked to a template.
Gotchas
This is the BOX's name, not the seat's. A seat label like "Red Player Zone" is a different thing and is not returned here.
An unnamed zone reports null, not a placeholder. The Hierarchy shows such a row as Zone N, but that is a label the UI invents for display and it is not a name you can match on.
You cannot author the player's scene. If your mod depends on a named zone, say which name in your setup instructions and degrade with a visible message when it is missing — never silently.
See also
ModSeatZone.templateZoneId— matching by template id instead.api.listSeatZones— the call that returns these.
modseatzone.type#
readonly type: ModZoneType | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
null for a legacy zone authored before zone types existed.
The zone's type — hand, area, hidden, scripting and the rest — or null for a legacy box authored before zone types existed.
How, why and when to use it
Filter by behaviour rather than by name: hand is the seat's private hand area and its deal target, hidden conceals its occupants from anyone not entitled to the seat, scripting is a pure trigger volume with no behaviour of its own.
Gotchas
null means untyped, not "unknown to you". Such a box is inert: never a hand, never an interaction gate. It still reports geometry and still fires zone events.
Type does not tell you whether you may see inside. A hidden zone's box is public; its occupants are not. That distinction lives in api.getZoneObjects, which drops what you may not know.
See also
ModZoneType— the full list.api.getZoneObjects— the read that respects a hidden zone.
modseatzone.position#
readonly position: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
World-space centre, and the point to spawn at.
y is the play surface under the zone — the height a piece placed here comes to rest
on. It is NOT the authored box plane: a zone box is drawn a hair below the table top, and
that plane stays where it was authored if the table is later swapped for a taller one.
Spawning at position.y (plus a small drop, if you want it to settle) therefore keeps
working across a table change. Containment still ignores y entirely.
The zone's world-space centre.
How, why and when to use it
Spawn at it. Lift y a little so the object starts above the table and settles, and pair it with rotationY so the object faces the seat:
position: { x: zone.position.x, y: zone.position.y + 0.2, z: zone.position.z }
Gotchas
World space, always — even for a zone materialized from a seat template, whose authored position is seat-local. The conversion has already happened.
y is the table plane, and containment ignores it. An object held high above the zone still counts as inside it, so do not treat this as a height test.
See also
ModSeatZone.rotationY— the other half of a correct placement.api.createObject— putting something there.
modseatzone.size#
readonly size: Readonly<{ x: number; z: number }>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
Footprint on the table plane. A zone has no height.
The zone's footprint on the table plane: { x, z }, in feet.
How, why and when to use it
Lay several things out inside one zone — a row of piles, a grid of tokens — by dividing the footprint rather than by guessing offsets that a rescaled seat would invalidate.
Gotchas
There is no y. A zone is a footprint, not a box: containment is two-dimensional by design.
It is the size AFTER the seat template's scale. A template-linked seat's zones are materialized at the template's uniform scale, so this is what is really on the table rather than what was authored.
The world unit is a FOOT. A poker card is about 0.21 × 0.29, so a zone sized in tens is a zone spanning the table.
See also
ModSeatZone.position— the centre this is measured around.api.getZoneObjects— what is inside the footprint.
modseatzone.rotationY#
readonly rotationY: number;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
Rotation about Y, in DEGREES. Spawn a pile with this to have it face the seat.
The zone's yaw about the Y axis, in degrees.
How, why and when to use it
Pass it as the spawned object's rotation. This is the whole reason to read a zone rather than hard-code a position:
rotation: { x: 0, y: zone.rotationY, z: 0 }
Seats face the middle of the table, so each one's zones carry a different yaw and a pile spawned without it faces the wrong player.
Gotchas
Degrees, not radians. The object schema is degrees throughout, so this drops straight in — but a trigonometric helper of your own will want the conversion.
Forgetting it does not look like a rotation bug. It looks like a card-orientation bug: everything is in the right place and reading upside down for three of the four seats.
See also
ModSeatZone.position— the other half of a correct placement.api.createObject— takesrotationat spawn.
modseatzone.tagFilter#
readonly tagFilter: readonly string[];
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-world |
| Availability | mod |
Tags that gate occupancy. Empty means the zone matches everything.
The tags that gate occupancy. Empty means the zone matches everything.
How, why and when to use it
Read it to understand why a zone reports fewer occupants than you can see standing in it. The same filter is applied by api.getZoneObjects and by the zone hooks, so the pull and the push always describe one set.
Gotchas
It gates OCCUPANCY, not entry. A filtered-out object can still be dropped in the zone; it simply is not a member, so no event fires and it is absent from the contents read.
Author tags only. Platform-owned dt: tags are not authorable and never appear here.
See also
api.getZoneObjects— applies this filter.onZoneEnter— fires for the same filtered set.
ModDeckSummary#
Surface B — mod script · interface · 9 members
One saved deck, WITHOUT its list — the browse row.
These are DiceyTable deck rows: what a person built or imported in the deck builder for this game. They are not the mod's own content and they are not table state.
ModDeckSummary is one saved DiceyTable deck without its list — the browse row that api.listDecks returns. It carries what a picker needs to draw: a name, an author, a card count and a thumbnail id.
How, why and when to use it#
Render a chooser from these, then call api.getDeck with the chosen id for the decklist itself. The split is not an optimisation detail: a page of 24 decks would otherwise ship 24 decklists to render 24 names.
Gotchas#
No decklist, and no card ids. thumbnailCardId is the one card id here, and it is a representative rather than a member you can rely on.
Narrower than the deck row. Favourite counts, comment counts, fork lineage and the deck's card source are all real fields of a deck and none of them is on this shape. The source in particular is withheld because it is not a script's to know — the platform derives it from your mod.
See also#
api.listDecks— the call that returns these.api.getDeck— the same deck, with its list.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
name |
string |
|
description |
string |
|
formatId |
A format id from this game's cardSchema.json, or null when the author chose none. |
string | null |
visibility |
"private" decks are only ever the CALLER'S own. |
"private" | "public" |
username |
The author's public username; null when unavailable. Never an account id. |
string | null |
totals |
Total physical cards, and how many distinct ids. | Readonly<{ cards: number; distinctCards: number }> |
thumbnailCardId |
The card whose art represents the deck, or null. |
string | null |
updatedAt |
string |
moddecksummary.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
The deck's id.
How, why and when to use it
The only thing api.getDeck takes. Keep it opaque — it identifies the deck and nothing else.
Gotchas
Holding an id is not permission to read it. A deck can be made private after you listed it, and api.getDeck will then answer null. Handle that rather than assuming a listed deck stays readable.
See also
api.getDeck— what to pass it to.
moddecksummary.name#
readonly name: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
The deck's name, as its author typed it.
How, why and when to use it
The label for a picker row. Show it verbatim.
Gotchas
Author-supplied text. Truncate it for layout if you must, but do not parse it — a deck name is not a format, a side or a strategy, however often it contains one.
See also
ModDeckSummary.description— the longer text.
moddecksummary.description#
readonly description: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
The author's description of the deck. Empty string when they wrote none.
How, why and when to use it
A second line in a picker row, or the body of a detail view. It is also searched by query.search, so a player looking for "vong swarm" can find a deck whose name says neither.
Gotchas
Empty is a string, not null. Test length, not existence.
It can be long. Clamp it in a fixed-height row rather than letting it push your layout around.
See also
ModDeckQuery.search— searches this field too.
moddecksummary.formatId#
readonly formatId: string | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
A format id from this game's cardSchema.json, or null when the author chose none.
A format id from your game's cardSchema.json, or null when the author never chose one.
How, why and when to use it
Group or filter a picker by format, and label the row so a player can see at a glance that a deck is not legal for the game they are about to play.
Gotchas
null is the common case, not an error. Most decks are built without a format chosen.
It is a claim, not a validation. Nothing checks that the decklist actually satisfies the format's rules; that is your game's job if you want it enforced.
See also
ModDeckQuery.formatId— filtering on it.
moddecksummary.visibility#
readonly visibility: "private" | "public";
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
"private" decks are only ever the CALLER'S own.
"private" or "public".
How, why and when to use it
Badge a row so a player can tell which of their decks other people can see, and dedupe when you show "mine" and "public" lists side by side.
Gotchas
A "private" deck here is always the caller's own. The "public" scope returns nothing else, so this never reveals a stranger's private deck — there is no query that would.
See also
ModDeckQuery.scope— which decks you asked for.
moddecksummary.username#
readonly username: string | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
The author's public username; null when unavailable. Never an account id.
The author's public username, or null when it is unavailable.
How, why and when to use it
Attribute a deck in a browse list — "Shadow Collective, by mattl" — which is most of what makes a public list navigable.
Gotchas
Never an account id. This is the public profile name and there is no identifier behind it on this shape.
null happens. A profile can be missing or withheld; fall back to the deck name alone rather than printing "null".
See also
ModDeckSummary.name— the deck's own name.
moddecksummary.totals#
readonly totals: Readonly<{ cards: number; distinctCards: number }>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
Total physical cards, and how many distinct ids.
{ cards, distinctCards } — the physical card count, and how many distinct card ids it uses.
How, why and when to use it
Show the size of a deck in a picker row, and sanity-check it against your game's rules before the player commits — a 30-card list where the format wants 60 is worth saying out loud before it is on the table.
Gotchas
cards is physical. A playset of four counts four. It is the number of cards that will be in the pile, which is what makes it the right number to compare against a deck-size rule.
Computed server-side from the stored list. It is consistent with what api.getDeck returns for the same deck, so you do not need to fetch a list to display a count.
See also
api.getDeck— the list these totals describe.
moddecksummary.thumbnailCardId#
readonly thumbnailCardId: string | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
The card whose art represents the deck, or null.
The card id whose art represents the deck, or null.
How, why and when to use it
Resolve it with api.resolveCards to draw a picker row with the deck's key card rather than a generic tile.
Gotchas
It is a representative, not a guaranteed member. Do not use it to reason about the deck's contents.
null is normal for a deck whose key-card partition is empty. Fall back to a placeholder tile.
See also
api.resolveCards— turning it into art.
moddecksummary.updatedAt#
readonly updatedAt: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
When the deck was last written, as an ISO-8601 string.
How, why and when to use it
Sort or label a picker so the deck someone was working on ten minutes ago is the one at the top.
Gotchas
A string, not a Date. Parse it if you need to compare; ISO-8601 also sorts correctly as text, which is usually enough.
It tracks the ROW, not the list. A rename bumps it without the decklist having changed.
See also
ModDeckQuery.offset— paging a list sorted this way.
ModDeckEntry#
Surface B — mod script · interface · 3 members
One line of a decklist: a card id and how many copies, in one partition.
ModDeckEntry is one line of a decklist: a card id, how many copies, and which partition it belongs to.
How, why and when to use it#
Expand it into physical cards before you build a pile. metadata.cards is the ordered physical stack, so a count: 4 line becomes four entries with distinct ids — not one entry carrying a count.
Gotchas#
A decklist is not a stack. Handing entries straight to metadata.cards builds a pile with one card per LINE, which is how a 60-card deck becomes a 20-card pile.
Order is the stored order, not a shuffle. Shuffle after you spawn.
See also#
api.getDeck— the call that returns these.api.resolveCards— names and art for the ids.
Members#
| Signature | Description | Returns |
|---|---|---|
cardId |
string |
|
count |
number |
|
partitionId |
The partition id from this game's cardSchema.json, or null for the default one. |
string | null |
moddeckentry.cardId#
readonly cardId: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
The card id, keyed on your schema's key role.
How, why and when to use it
Resolve it with api.resolveCards for a name and art, and give each physical copy a distinct id ("bolt#1", "bolt#2") when you expand the line, so anything addressing a single card later is unambiguous.
Gotchas
It is your game's id, so an id you do not recognise is a stale deck, not a platform bug. A card removed from your catalogue leaves the decklists that named it untouched — resolve, and report the ones that come back empty.
See also
api.resolveCards— resolving it against your catalogue.
moddeckentry.count#
readonly count: number;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
How many copies of this card the deck holds.
How, why and when to use it
The loop bound when expanding a line into physical cards.
Gotchas
Expanding it is your job, and forgetting to is the single most common way a spawned deck comes out the wrong size.
A pile is capped at 1000 cards. Stop at the cap and say so rather than truncating silently.
See also
api.getDeck— where the entries come from.
moddeckentry.partitionId#
readonly partitionId: string | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
The partition id from this game's cardSchema.json, or null for the default one.
The partition this line belongs to — a partition id from your game's cardSchema.json — or null for the default one.
How, why and when to use it
Group the entries by partition and spawn one pile per group: a main deck and a supply are two piles, not one.
Gotchas
null means the DEFAULT partition, not "no partition". Resolve it against your schema's default: true entry. Dropping those lines silently loses most of a typical deck.
Partitions are your schema's, so an unknown id is a deck built under an older schema. Decide whether to place it in the default pile or refuse — but decide, rather than letting it vanish.
See also
ModDeckEntry.cardId— what is in the partition.
ModDeckRecord#
Surface B — mod script · interface · 2 members
A saved deck WITH its list.
ModDeckRecord is a saved deck with its list: everything a ModDeckSummary carries, plus entries and readable. It is what api.getDeck resolves.
How, why and when to use it#
The shape you build piles from. Check readable, group entries by partition, expand each line by its count, and spawn.
Gotchas#
readable: false and an empty deck are different facts that look identical if you only inspect entries. Check the flag first.
See also#
api.getDeck— the call that returns it.ModDeckSummary— the same deck without its list.
Members#
| Signature | Description | Returns |
|---|---|---|
entries |
readonly ModDeckEntry[] |
|
readable |
False when the stored list could not be read. | boolean |
moddeckrecord.entries#
readonly entries: readonly ModDeckEntry[];
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
The decklist: one ModDeckEntry per distinct card, in stored order.
How, why and when to use it
Group by partitionId, expand by count, and build one pile per partition.
Gotchas
Empty can mean "could not be read". Always read it together with readable.
One entry per distinct card, not per physical card. The line is {cardId, count}; the pile is the expansion.
See also
ModDeckRecord.readable— read it first.ModDeckEntry— one line.
moddeckrecord.readable#
readonly readable: boolean;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
False when the stored list could not be read.
⚠ Check it. A malformed row resolves entries: [], so without this bit "this deck is
empty" and "this deck could not be read" are the same answer — and spawning the first
when it is really the second puts an empty pile on the table over someone's real deck.
Whether the stored decklist actually parsed.
How, why and when to use it
Gate everything on it. false means the deck row exists and its list could not be read, so entries is empty for a reason that has nothing to do with the deck being empty.
Gotchas
This is the whole preservation control, and the client cannot derive it. Without the check, "this deck could not be read" and "this deck is empty" arrive as the same value — and spawning the first as if it were the second puts an empty pile on the table over somebody's real deck.
Do not offer to overwrite an unreadable deck. The contents are still in the column; a write is what would lose them.
See also
ModDeckRecord.entries— what the flag qualifies.
ModDeckQuery#
Surface B — mod script · interface · 5 members
Filter for api.listDecks. Omit entirely for the caller's own decks, newest first.
ModDeckQuery is the filter api.listDecks accepts: which decks, matching what, and how many.
How, why and when to use it#
Omit it entirely for the caller's own decks, newest first — which is the right default for a picker. Add scope: "public" for a community list, and search for a box the player types into.
Gotchas#
There is no source parameter, and that is the point. The platform derives the card source from your mod, so every query is scoped to your own game's deck pool and no filter can widen it.
See also#
api.listDecks— the call that takes it.
Members#
| Signature | Description | Returns |
|---|---|---|
scope |
"mine" (default) — every deck the signed-in caller owns, any visibility. "public" — public decks by anyone, including the caller's own. |
"mine" | "public" |
search |
Free text over name and description. | string |
formatId |
Restrict to one format id from this game's cardSchema.json. |
string |
limit |
1..100. Defaults to 24. | number |
offset |
number |
moddeckquery.scope#
scope?: "mine" | "public";
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
"mine" (default) — every deck the signed-in caller owns, any visibility.
"public" — public decks by anyone, including the caller's own.
There is no scope that returns another person's private decks; the server refuses it.
"mine" (the default) for every deck the caller owns at any visibility, "public" for public decks by anyone.
How, why and when to use it
Two tabs in a picker: the player's own shelf, and what the community has shared. Most players want the first and reach for the second when they have not built anything yet.
Gotchas
"public" includes the caller's own public decks. It is a visibility filter, not an "other people" filter — dedupe by id if you show both at once.
There is no scope that returns someone else's private decks. The server refuses it, so no combination of filters here reaches one.
See also
ModDeckSummary.visibility— the per-row answer.
moddeckquery.search#
search?: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
Free text over name and description.
Free text matched against a deck's name and description.
How, why and when to use it
Wire it to a search box. Re-query on change rather than filtering a cached page client-side, or the box only ever searches the first 24 decks.
Gotchas
Capped at 120 characters and trimmed past that, so a pasted paragraph is not an error — it is just a long prefix.
It does not search card contents. "every deck with Vader in it" is not this parameter.
See also
ModDeckSummary.description— one of the fields searched.
moddeckquery.formatId#
formatId?: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
Restrict to one format id from this game's cardSchema.json.
Restrict the results to one format id from your game's cardSchema.json.
How, why and when to use it
Narrow a picker to the format the table is actually playing, so a player cannot pick a deck that is illegal before they have started.
Gotchas
Decks with no format chosen are excluded, not included. Most decks have formatId: null, so filtering on a format can empty a picker that looked full — offer an "any format" option.
See also
ModDeckSummary.formatId— the value being matched.
moddeckquery.limit#
limit?: number;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
1..100. Defaults to 24.
How many summaries to return. 1–100; defaults to 24.
How, why and when to use it
Ask for one page's worth. A picker that draws ten rows should ask for ten, not for a hundred it then throws away.
Gotchas
Clamped, not honoured blindly. A limit above 100 is reduced, and a limit of 0 reads as "unspecified" and gives the default rather than nothing.
Every UI element you draw costs a host mutation. The page size that keeps one rebuild inside the per-tick budget is smaller than you would guess — ten rows is a comfortable number.
See also
ModDeckQuery.offset— the other half of paging.
moddeckquery.offset#
offset?: number;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-decks |
| Availability | mod |
How many rows to skip, for paging.
How, why and when to use it
Advance by limit for a next-page button.
Gotchas
There is no total. This shape returns an array, not a count, so "page 3 of 7" is not derivable — draw a next button that disables when a page comes back shorter than limit.
Capped. A very large offset is clamped rather than walking forever.
See also
ModDeckQuery.limit— the page size.
TableUiWidgetType#
Surface B — mod script · type
declare type TableUiWidgetType =
| "text" | "button" | "checkbox" | "input" | "select" | "panel" | "canvas" | "layout";
The eight values a UI element's type can take: text, button, checkbox, input, select, panel,
canvas and layout. The type decides which DOM control the renderer builds, which props it reads, and —
for four of the eight — which interaction it dispatches back to your script. button, checkbox, input
and select dispatch; text, panel, canvas and layout never do.
How, why and when to use it#
You want a player-facing control, so you pick a type and immediately hit the question that decides your
whole layout: which of these can I hang a hook on? Pick button for a one-shot command, checkbox for a
toggle you also want to display the state of, input for free text, and select for a choice from a list
you supply — those four are the entire interactive vocabulary. Use panel (or layout, or canvas — the
renderer builds the identical column
container for all three) purely to group and order children, and put the hook on the interactive child
rather than on the group. The alternative authors reach for is a panel with a hook prop, expecting the
whole group to be clickable; that prop is accepted, stored and never fires.
Gotchas#
Applies to: every widget type. An unrecognized type — a typo, a number, or an omitted type on a
create — becomes panel, silently and with no diagnostic (apps/web/src/playcanvas/TabletopRuntime.ts,
sanitizeUiWidgetType). A misspelled "buton" therefore renders an empty container. On an update an
absent type keeps the element's existing type instead.
panel, canvas and layout render the same thing. All three fall through to the same <div> with
the same column-flex styling; only the CSS class name differs. canvas builds no HTML canvas element and
gives you no drawing context.
input and select drop their children. The renderer emits a void <input> for one and builds the
other's contents from its options prop, so any element whose parentId names either is never drawn.
text, button, checkbox, panel, canvas and layout all render their children — a button's
children go inside the button.
select options are data, not child elements. They come from props.options (up to 200 entries of
{ value, label? }), and the change event reports the chosen value in ModUiEventPayload.value — the
same field an input uses. A placeholder renders as an inert disabled first row, never as a value.
See also#
- Table UI widget types — the full per-type table of props and dispatched interactions.
TableUiElementDefinition.type— the field this type annotates.api.setUiElement— where you choose it.onUiEvent— the payload the three interactive types send.
TableUiTextVariant#
Surface B — mod script · type
Presentational ROLES a widget may ask for. A closed list on purpose: a mod names the role and the app decides what it looks like, so a dialog stays consistent with the product around it and an author cannot restyle the chrome. There is no colour, font or size prop anywhere.
declare type TableUiTextVariant = "title" | "subtitle" | "body" | "caption" | "error";
The five presentational roles a text widget may ask for: title, subtitle, body, caption and
error. You name the ROLE the text plays; the app picks the size, weight and colour that role has
everywhere else in the product. There is no font, colour or size prop anywhere on this surface, and this is
the reason there does not need to be.
How, why and when to use it#
You are building a dialog and the heading needs to read as a heading. Set variant: "title" on the text
widget and it takes the app's own heading treatment - the same one the player already recognises from every
other panel - rather than whatever your development machine happened to render. Use caption for the
supporting line under a control, and error for a failure message you want a player to notice; both carry
the app's established meaning for those roles, so a player does not have to learn yours.
Gotchas#
An unknown value is neutral, not an error. A variant the renderer does not recognise is dropped and
the text draws in the default body treatment. Nothing is logged, so a typo looks like a variant that "did
nothing" rather than one that was rejected.
It is a role, not a style. title does not promise a particular pixel size and may render differently
on a phone, in a modal and in a screen-anchored panel. If your layout depends on an exact height, it will
drift - use TableUiLayoutHints to arrange, not type size.
See also#
TableUiButtonVariant- the same idea for buttons.TableUiWidgetType- which widget reads which props.api.setUiElement- where you set it.
TableUiButtonVariant#
Surface B — mod script · type
declare type TableUiButtonVariant = "primary" | "secondary" | "ghost" | "danger";
The four presentational roles a button widget may ask for: primary, secondary, ghost and danger.
As with TableUiTextVariant you name the role and the app
supplies the treatment, so the one confirming button in your dialog reads as the confirming button and a
destructive one reads as destructive.
How, why and when to use it#
Your dialog has an "Import deck" and a "Cancel". Mark the first primary and leave the second
secondary, and the emphasis lands where you meant it. Reach for ghost when a button is one of several
equal choices - a row of filter chips, a list of decks - because a row of primary buttons has no
emphasis at all. Use danger only for something a player cannot undo.
Gotchas#
danger styles, it does not confirm. Nothing about the variant adds a confirmation step; if the action
is destructive you still have to ask, in your own UI, before you act on the click.
A chip's chosen state is selected, not a variant. There is no "active" variant. Draw a segmented
control by setting selected: true on the chosen button and re-sending the elements when the choice moves -
the mod owns that state, exactly as it owns every other piece of what the dialog is showing.
See also#
TableUiTextVariant- the same idea for text.TableUiWidgetType- thebuttonprops in full.onUiEvent- the payload a click sends back.
TableUiLayoutHints#
Surface B — mod script · interface · 9 members
Arrangement hints for any element, passed as layout.
Instructions, not styles: every value is an enum or a bounded number, and the host clamps
each one again. scroll plus maxHeight is how you get a result list that scrolls inside a
dialog instead of pushing it open; grow gives that list the free space.
Unrecognised keys are preserved but not drawn, so a hint added in a later platform version does not make an older client reject the whole element.
Arrangement instructions for one element, passed as its layout. Every member is either a small enum or a
number with a fixed ceiling - a direction, an alignment, a gap, a scroll flag - and the host clamps each one
again on the way in. It is what lets a mod build a dialog with a filter row across the top and a scrolling
result list below it without the platform ever handing an untrusted author a styling channel into the app's
own chrome.
How, why and when to use it#
You have a list of eighty decks to show inside a dialog. Give the list container
{ direction: "column", gap: 6, grow: true, scroll: true } and it takes the free space in the dialog body
and scrolls inside it, instead of growing until the dialog runs off the screen. Give the filter row above it
{ direction: "row", gap: 6, wrap: true } and the chips flow onto a second line on a narrow screen. Those
two hints are most of what any real mod panel needs.
Gotchas#
There is no colour, font, size or position here, and there will not be. The hints are deliberately a closed vocabulary rather than a style object: an arbitrary style bag from an untrusted author is a styling injection into the app's chrome. If you want emphasis, use a variant.
An unrecognised key is kept but not drawn. Unknown members are replicated and returned on
TableUiElementState.layout untouched, and ignored by
the renderer - so a hint added in a later platform version does not make an older client reject your whole
element. It also means a typo is silent.
Hints ride every snapshot that carries the UI. They are small, but they are per element and per peer.
See also#
TableUiElementDefinition.layout- the field.TableUiPresentation- where a ROOT element is drawn, which is a separate question.- Limits and caps - what a table's UI is allowed to cost.
Members#
| Signature | Description | Returns |
|---|---|---|
direction |
"row" | "column" |
|
align |
"start" | "center" | "end" | "stretch" |
|
justify |
"start" | "center" | "end" | "between" |
|
wrap |
boolean |
|
grow |
Take the free space along the parent's main axis. | boolean |
scroll |
Scroll the overflow rather than growing. | boolean |
gap |
0–48 px. | number |
padding |
0–32 px. | number |
maxHeight |
0–1200 px. | number |
tableuilayouthints.direction#
direction?: "row" | "column";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Lay the element's children out in a row or a column. Setting it is what turns the element into a flex
container at all - an element with no direction keeps whatever default its widget type already had.
How, why and when to use it
A row of filter chips is direction: "row"; the list beneath it is direction: "column". Those two, nested,
are the skeleton of nearly every mod dialog.
Gotchas
It is the switch for every other hint. align, justify, wrap and gap describe a flex container, so
they take effect once a direction has made the element one. Setting gap alone on a plain text widget
changes nothing.
panel, canvas and layout are already columns. All three render the same container with a column
default, so direction: "column" on one of them is a no-op you may still want for clarity.
See also
TableUiLayoutHints- the whole hint set, and why it is closed.TableUiElementDefinition.layout- the field that carries it.api.setUiElement- the write that applies it.
tableuilayouthints.align#
align?: "start" | "center" | "end" | "stretch";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
How children sit across the CROSS axis - the axis direction is not. One of start, center, end or
stretch.
How, why and when to use it
In a row, align: "center" is what puts a chip, its label and its count on the same optical line instead
of hanging them from their tops. In a column, align: "stretch" makes every child the full width of the
container, which is what a list of rows almost always wants.
Gotchas
Cross axis, not reading order. In a row this is vertical and in a column it is horizontal. Reaching
for align to push a row's content to the right is the common mistake - that is
justify.
An unknown value is ignored. There are exactly four; anything else leaves the container's default.
See also
TableUiLayoutHints- the whole hint set, and why it is closed.TableUiElementDefinition.layout- the field that carries it.api.setUiElement- the write that applies it.
tableuilayouthints.justify#
justify?: "start" | "center" | "end" | "between";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
How children are distributed along the MAIN axis - the one direction names. One of start, center,
end or between.
How, why and when to use it
justify: "between" on a header row is how you get a title on the left and a close button on the right with
one hint and no spacer element. end is the right choice for a footer of buttons.
Gotchas
between needs at least two children and does nothing with one. With a single child it renders
identically to start, which reads as the hint being ignored.
Main axis, not cross axis. In a column, justify is vertical - it distributes rows down the container,
which only has a visible effect when the container is taller than its content.
See also
TableUiLayoutHints- the whole hint set, and why it is closed.TableUiElementDefinition.layout- the field that carries it.api.setUiElement- the write that applies it.
tableuilayouthints.wrap#
wrap?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Let children flow onto another line rather than being squeezed onto one. Defaults to off.
How, why and when to use it
A row of side/format filter chips is the case this exists for: on a wide screen it is one line, on a narrow one it becomes two, and neither needs you to measure anything.
Gotchas
Wrapping is not scrolling. A wrapped container grows taller. If the growth is what you were trying to
avoid, pair it with maxHeight and
scroll.
It needs a direction. Like every other hint here it describes a flex container, and an element with no
direction is not one.
See also
TableUiLayoutHints- the whole hint set, and why it is closed.TableUiElementDefinition.layout- the field that carries it.api.setUiElement- the write that applies it.
tableuilayouthints.grow#
grow?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Take the free space along the parent's main axis.
Take the free space along the parent's main axis. Defaults to off.
How, why and when to use it
Inside a dialog, the result list is the part that should absorb whatever height is left after the header and
the filter row have taken theirs. grow: true on the list says exactly that, and it is what makes
scroll: true on the same element behave - a container that has not claimed the space has no overflow to
scroll.
Gotchas
grow on a child of a non-flex parent does nothing. The parent has to be a flex container, so the parent
needs a direction (or be a panel/canvas/layout, which already are).
Two growing siblings share, they do not both win. There is no weight in this vocabulary; every grow
child takes an equal share of what is left.
See also
TableUiLayoutHints- the whole hint set, and why it is closed.TableUiElementDefinition.layout- the field that carries it.api.setUiElement- the write that applies it.
tableuilayouthints.scroll#
scroll?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Scroll the overflow rather than growing.
Scroll the element's overflow instead of letting it push the container open. Defaults to off.
How, why and when to use it
Any list whose length you do not control - search results, a deck catalogue, a log - wants this. Set it with
grow inside a dialog, or with
maxHeight anywhere else, and the container stops
growing at that point and scrolls the rest.
Gotchas
On its own it often looks like it did nothing. A container with no height constraint never overflows, so
there is nothing to scroll. It needs either grow: true inside a bounded parent (a modal body is bounded) or
an explicit maxHeight.
A modal body already scrolls. If your whole dialog is one long column, you may not need this at all - the dialog's own body scrolls. Use it when you want ONE region to scroll while a header or footer stays put.
See also
TableUiLayoutHints- the whole hint set, and why it is closed.TableUiElementDefinition.layout- the field that carries it.api.setUiElement- the write that applies it.
tableuilayouthints.gap#
gap?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
0–48 px.
Space between children, in pixels. 0-48, integer.
How, why and when to use it
gap: 6 between the rows of a list and gap: 8 between the controls of a toolbar covers almost everything.
Using a gap rather than padding on each child means the container's own edges stay flush.
Gotchas
Clamped, not rejected. A value above 48 is clamped to 48 and a negative one is dropped; neither
reports anything, so an out-of-range gap looks like one the renderer ignored.
Non-integers are dropped. The value is rounded, and a non-finite number (NaN, Infinity) is treated as
absent.
See also
TableUiLayoutHints- the whole hint set, and why it is closed.TableUiElementDefinition.layout- the field that carries it.api.setUiElement- the write that applies it.
tableuilayouthints.padding#
padding?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
0–32 px.
Inset between the element's edge and its content, in pixels. 0-32, integer.
How, why and when to use it
Use it on a container you want to read as a distinct block - a card in a list, a highlighted notice - where the content should not touch the border. A dialog body already has its own padding, so a top-level child of one usually wants none.
Gotchas
It applies on all four sides. There is no per-side control, deliberately: per-side padding is the point at which a hint vocabulary becomes a style sheet.
Clamped to 32. Same silent clamping as gap.
See also
TableUiLayoutHints- the whole hint set, and why it is closed.TableUiElementDefinition.layout- the field that carries it.api.setUiElement- the write that applies it.
tableuilayouthints.maxHeight#
maxHeight?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
0–1200 px.
A ceiling on the element's height, in pixels. 0-1200, integer. 0 (and any absent value) means no
ceiling.
How, why and when to use it
Outside a dialog - in a screen-anchored panel, say - this is how a list stops growing. Pair it with
scroll: the container stops at the ceiling and scrolls
the remainder, instead of running off the bottom of the viewport where a player cannot reach it.
Gotchas
Pixels, not rows. It is a raw height and knows nothing about how tall your rows render, which varies by text variant and by device. Leave headroom.
Without scroll, content is simply cut off. The height is capped either way; only scroll: true gives
the player a way to reach what is past the cap.
See also
TableUiLayoutHints- the whole hint set, and why it is closed.TableUiElementDefinition.layout- the field that carries it.api.setUiElement- the write that applies it.
TableUiVisibilityTarget#
Surface B — mod script · type
Who can see an element.
declare type TableUiVisibilityTarget =
| { scope: "all" }
| { scope: "seat"; seats: string[] }
| { scope: "team"; teams: string[] }
| { scope: "players"; peerIds: string[] };
Who renders an element, as a discriminated union on scope with exactly four members: { scope: "all" },
{ scope: "seat", seats }, { scope: "team", teams } and { scope: "players", peerIds }. Every peer
receives every element in the snapshot; this field decides which of them each peer draws
(apps/web/src/ui/App.tsx, isUiElementVisibleToViewer).
How, why and when to use it#
You are dealing a private bidding round and each player needs their own bid box that nobody else can see.
{ scope: "seat", seats: ["north"] } on that element is the whole mechanism — the host stamps it into the
snapshot and every other client skips it when it builds the overlay. Pick seat when the audience follows
the table position (a hand, a player mat), team when it follows the alliance, and players when it
follows the person regardless of where they sit — a spectator you want to brief, or a peer who has not sat
down. The alternative is one shared element whose text you rewrite per turn, which leaks: every peer holds
the element and can read its props.
Gotchas#
This is a rendering filter, not redaction. The element and its props are in the snapshot on every
peer's machine. Do not put a hidden card's identity in a text prop and rely on scope: "seat" to keep it
secret.
An empty array silently becomes { scope: "all" }. The host filters seats, teams and peerIds to
non-empty strings and falls back to { scope: "all" } when nothing survives
(apps/web/src/playcanvas/TabletopRuntime.ts, sanitizeUiVisibility), so
{ scope: "seat", seats: [] } shows your element to the whole table rather than to nobody. So does an
unrecognized scope. Delete the element instead of narrowing it to an empty audience.
The arrays are capped. seats and teams keep their first 16 entries and peerIds its first 64;
anything past that is dropped without a warning.
See also#
TableUiElementDefinition.visibility— setting it.TableUiElementState.visibility— reading it back.api.setUiElement— the write that carries it.- Host authority — why the host is the one that stamps it.
TableUiPresentation#
Surface B — mod script · type
Where an element is drawn, as it is STORED and read back. These nine anchors
are the only values that ever appear in getUiState() / listUiElements().
To SET a top-row anchor from a mod script you must use the upper-* spelling
— see TableUiPresentationInput.
declare type TableUiPresentation =
| { mode: "world" }
| {
mode: "screen";
anchor:
| "top-left" | "top-center" | "top-right"
| "middle-left" | "middle-center" | "middle-right"
| "bottom-left" | "bottom-center" | "bottom-right";
offsetX: number;
offsetY: number;
}
| {
/**
* A centred dialog over the table: backdrop, titled header, scrolling body.
*
* Only a ROOT element honours it — a child carrying `mode: "modal"` is drawn inline,
* exactly as a child carrying `mode: "screen"` already is.
*
* Show it to one person with `visibility: { scope: "players", peerIds: [peerId] }`.
*
* ⚠ `dismissible` draws a close button that dispatches this element's `onDismiss` prop
* and does NOTHING else. It does not delete the dialog — UI elements are replicated
* host-authoritative state, and a viewer hiding one locally would disagree with what
* every other peer and your own mod believe exists. Delete it in your hook.
*/
mode: "modal";
title?: string;
subtitle?: string;
size: "small" | "medium" | "large";
dismissible: boolean;
};
Where an element is drawn, as a discriminated union on mode with three members. { mode: "world" } places
the element in the overlay's normal document flow, stacked with the other flow-mode elements above the
table view. { mode: "screen", anchor, offsetX, offsetY } lifts it out of the flow and pins it to one of
nine fixed points on the viewport. { mode: "modal", title?, subtitle?, size, dismissible } draws it as a
centred dialog over the table, with a backdrop, a titled header and a scrolling body.
How, why and when to use it#
You want a turn banner that stays put while a player drags the camera around, so you reach for
{ mode: "screen", anchor: "bottom-center", offsetX: 0, offsetY: 24 } and it sits 24 pixels up from the
bottom edge no matter what the table does. Use screen for anything a player has to be able to find at a
glance — a scoreboard, a phase indicator, a confirm bar. Use world for content that belongs to a group you
built with a panel, because a flow-mode child lays out inside its ancestor while a screen-mode child is
pinned to the viewport and leaves its ancestor's box entirely.
Gotchas#
Only a ROOT element honours the mode. A child carrying mode: "screen" or mode: "modal" is drawn
inline inside its parent — the mode is an instruction about where a top-level element goes, and always has
been.
A modal's close button does not close it. dismissible draws the affordance and dispatches the
element's onDismiss prop; it deletes nothing. UI elements are replicated host-authoritative state, so a
viewer hiding one locally would disagree with what every other peer and your own mod believe exists. Call
api.deleteUiElement in the hook — that is the close.
A modal is not per-viewer on its own. Scope it the way you scope anything else:
visibility: { scope: "players", peerIds: [peerId] } shows the dialog to one person. Without a scope every
peer gets the same dialog over their table at once.
"world" does not mean world space. It is the flow-layout branch of the same HTML overlay — the element
is not projected into the 3D scene, does not follow an entity, and does not scale with the camera. The two
modes are "pinned to the viewport" and "not pinned to the viewport".
Which offset applies depends on the anchor, and three anchors read neither. offsetX is an inset from
the left edge for middle-left and bottom-left, an inset from the right edge for middle-right and
bottom-right, and is ignored by bottom-center and the three middle-* anchors that center
horizontally. offsetY is an inset from the bottom edge for bottom-left, bottom-center and
bottom-right, and is ignored by all three middle-* anchors, which pin to the vertical center
(apps/web/src/ui/App.tsx, getScreenAnchorStyles). middle-center reads neither offset. Both default to
0 and both take any finite number, negative included.
By design. The three upper anchors —
"top-left","top-center"and"top-right"— are legal in the schema, legal insetup.jsonand selectable in the editor, but unwritable in a mod script. Each contains the whole wordtop, which is one of the five tokens the static scanner'sdom-accessrule matches (packages/shared/src/modManifest.ts,bannedScriptPatterns), and both"and-are non-word characters, so the word boundaries fire on either side and the string is rejected wherever it appears — including inside a comment. The scanner runs whole-word regular expressions over raw text with no lexing and cannot tell an anchor string from a reference to the enclosing frame; that trade is a security boundary and is not expected to loosen. The value grew a second spelling instead: writeupper-left,upper-centerorupper-rightand the host normalizes each to its canonicaltop-*form before storing it, so this type — the read-back type — still only ever carries the nine canonical anchors. SeeTableUiPresentationInputfor what you may pass, and Script safety for the rule itself.
See also#
TableUiPresentationInput— the twelve spellings you may pass, and how they normalize to these nine.TableUiElementDefinition.presentation— setting it.TableUiElementState.presentation— reading it back, with the coercion rules.api.setUiElement— the write that carries it.- Script safety — the five scanner patterns and their workarounds.
TableUiPresentationInput#
Surface B — mod script · type
What you may PASS as presentation. Same as TableUiPresentation, plus three
mod-safe aliases for the top row: upper-left, upper-center, upper-right.
USE THE ALIASES from a mod script. The publish scanner's DOM rule is a
whole-word match on the window self-reference and has no lexer, so it fires
inside string literals too — that is deliberate (it is what catches
self["..."] lookups) and it is not relaxed. The practical effect is that a
script containing the literal canonical top-row spelling is rejected at
publish time. The upper-* aliases exist to give you those anchors anyway.
They are input-only: the host normalizes each one to its canonical value
before anything is stored, so a snapshot never carries two spellings and
getUiState() always reports the canonical form.
declare type TableUiPresentationInput =
| { mode: "world" }
| {
mode: "screen";
anchor:
| "upper-left" | "upper-center" | "upper-right"
| "top-left" | "top-center" | "top-right"
| "middle-left" | "middle-center" | "middle-right"
| "bottom-left" | "bottom-center" | "bottom-right";
offsetX: number;
offsetY: number;
}
| {
mode: "modal";
title?: string;
subtitle?: string;
/** Defaults to "medium". */
size?: "small" | "medium" | "large";
/** Defaults to true. */
dismissible?: boolean;
};
What you may pass as an element's presentation. It is
TableUiPresentation with three extra anchor spellings:
upper-left, upper-center and upper-right, which are accepted on the way in and normalized to
top-left, top-center and top-right before anything is stored. Twelve spellings go in; nine values come
out. This type exists only on the write side — TableUiElementState.presentation is always the
canonical nine, and so is every snapshot.
How, why and when to use it#
You want a scoreboard along the top edge of the screen. Writing anchor: "top-right" in a mod script gets
the whole file rejected at publish time, because the static scanner's dom-access rule matches the whole
word top and both the quote and the hyphen around it are non-word characters, so the boundary fires inside
the string. Write anchor: "upper-right" instead: it means the same anchor, it survives the scanner, and
the host stores top-right.
Reach for the alias whenever a mod script names a top-row anchor. Everywhere else — setup.json, the
editor, an imported snapshot — both spellings are accepted and normalize identically, so there is no reason
to prefer one, and the canonical form is what you will see written back.
Gotchas#
An alias never survives a round-trip. Create an element with upper-center and the value you read back
from api.setUiElement,
api.getUiState or
api.listUiElements is top-center. Any element.presentation.anchor === "upper-center"
comparison is dead code. Track what you asked for in your own variable rather than reading it back — and
note that you cannot write the canonical string to compare against either, for the same scanner reason.
Only the top row has an alias. The six middle-* and bottom-* anchors have exactly one spelling each;
there is no lower-left, and a spelling outside the twelve is silently rewritten to top-left
(apps/web/src/playcanvas/TabletopRuntime.ts, sanitizeUiPresentation).
The scanner rule did not move. The aliases exist because narrowing dom-access to skip quoted text
would stop it catching self["top"] — trading a false positive for a false negative, which is the failure
this rule is built to avoid. Expect the rule to keep rejecting the canonical top-row spellings, in code and
in comments, indefinitely.
See also#
TableUiPresentation— the stored shape, with all nine anchors and the offset rules.TableUiElementDefinition.presentation— the field that takes this type.api.setUiElement— the write that carries it.- Table UI widget types — the alias table.
- Script safety — the rule the alias exists for.
TableUiElementDefinition#
Surface B — mod script · interface · 11 members
What api.setUiElement accepts. Pass an existing id to update in place; omit
it to create. ownerModId is set by the host and cannot be spoofed.
Interactive props by type:
button—{ text?, disabled?, variant?, selected?, onClick?, hook? }checkbox—{ text?, checked?, onChange?, hook? }input—{ value?, placeholder?, onChange?, hook? }select—{ value?, placeholder?, disabled?, options?: { value, label? }[], onChange?, hook? }text—{ text?, variant?, hook? }(never dispatches)panel/canvas/layout—{ hook?, onDismiss? }(a container never dispatches by itself;onDismissis the hook amode: "modal"close button fires)
layout takes TableUiLayoutHints.
The single argument to api.setUiElement, and the only shape a
mod ever writes into the table's UI tree. Eleven fields, of which exactly one — type — is declared
required, and ownerModId is declared but ignored on input because the host fills it from the calling mod.
It is an upsert: supply an id that already exists and the host merges your fields over the stored
element, omit id and the host mints a UUID and creates one.
How, why and when to use it#
You are building a scoreboard panel with three labels under it, and you want the score labels to update
every turn without the panel flickering or duplicating. Write one definition per element with a fixed,
namespaced id (manifest.id + "-score-north"), and call setUiElement again with the same id and
only the fields that changed — every field you omit on an update keeps its stored value, so a repeat call
carrying only { id, type, props } is a targeted patch rather than a replacement. The alternative most
authors reach for is deleting the element and creating a fresh one each turn, which costs two mutations out
of the per-tick budget instead of one and gives the element a new id every time, so nothing you stored can
still address it.
Gotchas#
Omitting a field on an update is not the same as clearing it. The host reads
definition.<field> ?? existing.<field>, so undefined means "keep". The two fields you can genuinely
clear are parentId and ownerSeat, both by passing an explicit null.
Three of the eleven fields are inert at render time. layout, metadata and ownerSeat are stored,
replicated and returned to you unchanged, and the renderer reads none of them
(apps/web/src/ui/App.tsx, ModUiOverlay). Treat them as your own scratch space, not as styling.
See also#
api.setUiElement— the method this shape is the argument to.TableUiElementState— what the same element looks like coming back.- Table UI widget types — the eight values
typetakes and what each renders. - Limits and caps — the element and mutation ceilings a write is checked against.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
parentId |
string | null |
|
type |
TableUiWidgetType |
|
order |
number |
|
ownerSeat |
string | null |
|
visibility |
TableUiVisibilityTarget |
|
presentation |
TableUiPresentationInput |
|
layout |
TableUiLayoutHints |
|
props |
Record<string, unknown> |
|
metadata |
Record<string, unknown> |
|
ownerModId |
string |
tableuielementdefinition.id#
id?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
The address of the element you are writing, and the field that decides whether
api.setUiElement creates or updates. The host trims it, looks
it up, and merges your definition over the stored element when it finds one.
Returns
string, optional. Absent — or present but empty after trimming — means create: the host mints a UUID,
returns it on the resulting TableUiElementState, and that
minted value is the only copy, so a create you do not read the result of leaves you with no way to address
the element again. The snapshot schema accepts 1 to 96 characters.
How, why and when to use it
Your mod's setup runs again after a reload or a host migration, and you do not want a second copy of every
control it built last time. Choose the id yourself and prefix it with manifest.id
(manifest.id + "-score-panel"), and the second run updates the element the first run created instead of
adding a duplicate. The alternative is to let the host mint ids and hold them in a module variable, which
works within one run and is worthless across a restart, because the frame is torn down and your variable
goes with it. Let the host mint an id only for elements you build once and never touch again.
Gotchas
An id belonging to another mod throws. The host compares the stored element's ownerModId against
yours before anything else and rejects the write with UI element <id> is owned by another mod. Ids are
global across all mods on the table, which is why namespacing on manifest.id matters.
Nothing checks the length on the write path. setUiElement trims and otherwise takes the string as
given; the 96-character ceiling is the snapshot schema's (packages/shared/src/tableObjects.ts,
tableUiElementStateSchema), not the call's.
See also
api.setUiElement— the create-or-update rule this field drives.TableUiElementState.id— the resolved id you read back.TableUiElementDefinition.ownerModId— the field that decides whose id it is.api.deleteUiElement— the other call that takes this id.
tableuielementdefinition.parentId#
parentId?: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
The id of the element this one nests inside. Setting it is how you build a tree — a panel with three
labels under it is four setUiElement calls, three of which carry the panel's id here. The renderer draws
a child inside its ancestor's box and orders siblings among themselves.
Returns
string | null, optional, and the three states are distinct. A non-empty string parents the element,
and the host throws UI parent element not found: <id> when no element with that id exists — so the
ancestor has to be written first. An explicit null makes the element a root, and is the only way to
detach one. Absent keeps whatever the stored element already had, or null on a create. The snapshot
schema accepts 1 to 96 characters.
How, why and when to use it
You are placing a scoreboard: a heading and four score labels that have to move together and stay in the
same reading order. Create the panel first, then create each label with the panel's id here, and the
group lays out and orders as one unit — a tree is the only way to get elements to lay out together, since
there is no positioning field on an individual element (what
layout gives you is how a container arranges its
OWN children, not where it sits). The
alternative is five sibling roots each with its own presentation, which works for two elements and stops
working the moment you want to hide or delete the group in one call.
Gotchas
An element naming itself is quietly re-rooted, not rejected. Passing your own id here sets parentId
to null with no error.
A cross-mod parent is allowed, and it is fragile. The host only checks that the id exists, so you can
nest under another mod's panel. When that mod deletes its panel the cascade stops at your element — the
delete only removes descendants with the same ownerModId — and yours is left naming an id that no longer
exists. The renderer treats a dangling parentId as null and draws the element at the root, and a
snapshot rebuild rewrites the field to null outright
(apps/web/src/playcanvas/TabletopRuntime.ts, sanitizeUiState).
A child outlives its ancestor's visibility. Each peer filters by
visibility before it builds the tree, so a
child whose ancestor is hidden from that viewer is re-rooted and still drawn. Set the same visibility on
every element in a group you want to hide as a unit.
See also
TableUiElementDefinition.order— how siblings under one ancestor are sequenced.TableUiElementState.parentId— the resolved value.api.deleteUiElement— the cascade rule for a subtree.- Table UI widget types — which types render children and which drop them.
tableuielementdefinition.type#
type: TableUiWidgetType;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Which of the eight widgets this element is: text, button, checkbox, input, select, panel,
canvas or layout. It is the one field the declaration marks required, and it decides which DOM control the renderer
builds, which entries of props that control reads, and whether the element dispatches an interaction back
to your script at all.
Returns
TableUiWidgetType, required in the declaration but tolerant
at run time. A value outside the eight — a typo, a number, or an omitted type on a create — becomes
panel with no error and no diagnostic (apps/web/src/playcanvas/TabletopRuntime.ts,
sanitizeUiWidgetType). On an update, an absent type keeps the element's stored type instead of
falling back.
How, why and when to use it
You are adding an end-turn control and you need it to actually call you back, so the choice narrows
immediately: button, checkbox and input are the three types that dispatch, and everything else is
presentation. Pick button for a command, checkbox for a toggle whose state you also want on screen,
input for free text, and text for a label you rewrite from your own state. The alternative authors reach
for is a panel carrying a hook, on the assumption that a container can be clicked; it accepts the prop
and never fires. Reserve panel, layout and canvas for grouping.
Gotchas
Applies to: every widget type. Changing type on an existing element is allowed and takes effect
immediately, but props are not re-keyed — the old prop names are kept and the new renderer reads
whichever ones it recognizes, so switching a button to an input leaves onClick sitting unread in
props and gives you an empty field. Send the new props in the same call.
A typo costs you an invisible element, not an error. "buton" renders an empty column container that
draws nothing and dispatches nothing, and the call still resolves a TableUiElementState — with
type: "panel" on it, which is how you catch it.
See also
- Table UI widget types — the per-type table of props and dispatched interactions.
TableUiWidgetType— the eight values.TableUiElementDefinition.props— the payload each type reads.onUiEvent— what the three interactive types send back.
tableuielementdefinition.order#
order?: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Where this element sits among its siblings. The renderer buckets elements by parentId and sorts each
bucket by order ascending; two siblings sharing an order are broken apart by comparing their id
strings ascending (apps/web/src/ui/App.tsx, buildUiChildrenIndex). Order is scoped to one ancestor —
it never sequences an element against a cousin.
Returns
number, optional. The host uses your value only when it is an integer of 0 or more; a fraction, a
negative, NaN or a non-number is discarded without a warning. When it is discarded, the host keeps the
element's stored order if it has one, and otherwise assigns one past the highest order already used
under that ancestor — so the first element under a fresh ancestor gets 0 and each new sibling appends.
The snapshot schema accepts an integer from 0 to 200000.
How, why and when to use it
You are building a scoreboard whose rows have to read north, east, south, west regardless of which seat
filled in first, so you write order: 0, 1, 2, 3 explicitly and the sequence holds however the calls
interleave. Leave the field out when append order is the order you want — a log of turn events, a list of
bids as they arrive — because the automatic value already appends and one fewer field is one fewer thing to
keep consistent. Spread deliberate values (0, 10, 20) when you expect to insert between rows later;
consecutive integers force you to rewrite every following sibling to make room.
Gotchas
A tie falls back to id, which is a string comparison. Two siblings at order: 0 sort by
id.localeCompare, so panel-10 precedes panel-9. When sequence matters, set order.
Omitting it on an update is not the same as resetting it. An absent order keeps the stored value, so
the automatic append only ever happens on a create.
Nothing rejects a large value on the write path. setUiElement takes any non-negative integer; the
200000 ceiling belongs to the snapshot schema (packages/shared/src/tableObjects.ts,
tableUiElementStateSchema), which is what validates a saved or imported table.
See also
TableUiElementDefinition.parentId— the bucketordersorts within.TableUiElementState.order— the resolved value.TableUiState.elements— the different order a read arrives in.api.setUiElement— the write that carries it.
tableuielementdefinition.ownerSeat#
ownerSeat?: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
A seat name you can stamp on an element. The host stores it, replicates it and hands it back on
TableUiElementState unchanged. It is an annotation for your
own code — the renderer reads it nowhere, so it changes nothing about where the element is drawn or who
sees it.
Returns
string | null, optional, with three distinct states. A string is stored verbatim — no trim, no
lowercasing, and an empty string is accepted. An explicit null clears a stored value. Absent keeps
whatever the stored element had, or null on a create. The snapshot schema accepts up to 40 characters and
null.
How, why and when to use it
You have built one bid box per seat and a click arrives naming only the element id, so you need to get from
that id back to the seat the box belongs to. Stamping the seat here means the answer travels with the
element: read it back from api.listUiElements and you have
the mapping without keeping a parallel table in your own module — which is the alternative, and which does
not survive the frame being torn down and re-run. Encoding the seat in the element id works too and is
harder to read back; this field is the one meant for it.
Gotchas
It does not control visibility. Setting ownerSeat: "north" does not hide the element from anyone. The
field each peer filters on is
visibility, and a per-seat element needs
{ scope: "seat", seats: ["north"] } as well.
It is not validated against the table's seats. Any string is accepted, including one naming a seat that does not exist and one that is empty. Nothing tells you when the seat you named goes away.
A seat outlives its occupant. Seats belong to the table, not to the peer sitting in one, so an element
stamped north stays stamped when that player leaves and the next one sits down.
See also
TableUiElementDefinition.visibility— the field that actually restricts an audience.TableUiElementState.ownerSeat— the resolved value.TableUiElementDefinition.metadata— the general-purpose version of the same idea.api.getMySeat— the seat of the client running your mod.
tableuielementdefinition.visibility#
visibility?: TableUiVisibilityTarget;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Who draws this element. Every peer receives every element in the snapshot; this field is what each peer
consults before it builds its overlay, and the four scopes are all, seat, team and players
(apps/web/src/ui/App.tsx, isUiElementVisibleToViewer).
Returns
TableUiVisibilityTarget, optional. Absent keeps the
stored value on an update and defaults to { scope: "all" } on a create. The host also rewrites a
malformed value to { scope: "all" }: a non-object, an unrecognized scope, or a scope whose array holds
no non-empty string. seats and teams keep their first 16 entries and peerIds its first 64.
How, why and when to use it
Each player needs a bid box only they can see, so you write one element per seat with
{ scope: "seat", seats: ["north"] } and every other client skips it. Use seat when the audience follows
the table position, team when it follows the alliance, and players when it follows the person — a
spectator you want to brief, or someone who has not sat down and so has no seat to name. The alternative is
one shared element whose text you rewrite each turn, which is both a race between snapshots and a leak: the
element sits in every peer's snapshot and its props are readable there.
Gotchas
It hides the element, not the data. The props are replicated to every peer regardless of scope. Do
not put a hidden card's identity in a text prop and expect this field to keep it secret — model the
secret as host-held state and send only what a viewer is allowed to know.
An empty array shows the element to everyone. { scope: "seat", seats: [] } sanitizes to
{ scope: "all" }, which is the opposite of what an empty audience reads like. Delete the element rather
than narrowing it to nothing.
A peer with no peer id sees only the all scope, and the host sees everything: when a client has not
been assigned a peer id yet, a non-all element renders on the host and nowhere else.
See also
TableUiVisibilityTarget— the four scopes and their caps.TableUiElementState.visibility— the resolved value.TableUiElementDefinition.parentId— why a hidden ancestor does not hide its children.- Host authority — who decides what each peer is told.
tableuielementdefinition.presentation#
presentation?: TableUiPresentationInput;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Where the element is drawn: { mode: "world" } lays it out in the overlay's normal flow above the table
view, and { mode: "screen", anchor, offsetX, offsetY } pins it to one of nine fixed points on the
viewport. It is the only positioning field on an element — layout is stored and never read — so this is
the whole placement vocabulary.
Returns
TableUiPresentationInput, optional. Absent keeps
the stored value on an update and defaults to { mode: "world" } on a create. Twelve anchor spellings are
accepted and nine are stored: upper-left, upper-center and upper-right are normalized to their
canonical top-* values on the way in. Any mode other than "screen" is stored as { mode: "world" },
and an anchor outside the twelve is rewritten to a literal "top-left"
(apps/web/src/playcanvas/TabletopRuntime.ts, sanitizeUiPresentation), which also replaces a
non-finite offsetX or offsetY with 0. The same sanitizer runs when a client rebuilds table state from
a snapshot, so what you read back is what every peer renders.
How, why and when to use it
You want a phase banner a player can always find while they drag the camera around the table, so you pin it
with { mode: "screen", anchor: "bottom-center", offsetX: 0, offsetY: 24 } and it stays 24 pixels up from
the bottom edge whatever the view does. The alternative is { mode: "world" }, which is right for anything
that belongs to a group: a flow-mode element lays out inside its ancestor's box, while a screen-mode element
leaves that box entirely and pins to the viewport, so putting a screen-mode child under a panel takes it out
of the panel. Set screen on the group's root and leave every child in flow.
Gotchas
Which offset applies depends on the anchor. offsetX is an edge inset for the four *-left and
*-right anchors and is ignored by bottom-center and middle-center; offsetY is an inset from the
bottom edge for the three bottom-* anchors and is ignored by all three middle-* anchors, which pin to
the vertical center (apps/web/src/ui/App.tsx, getScreenAnchorStyles). middle-center reads neither.
The top row has a second, mod-safe spelling. Write upper-left, upper-center or upper-right from a
mod script; the canonical top-* forms are rejected before the script can be published.
By design. Each canonical top-row value contains the whole word
top, which is one of the five tokens the static scanner'sdom-accessrule matches (packages/shared/src/modManifest.ts,bannedScriptPatterns), and the surrounding"and-are non-word characters, so the boundaries fire and the file is rejected — including when the string sits in a comment. The scanner reads raw text with no lexing and cannot tell an anchor from a reference to the enclosing frame. Matching inside strings is what makes the rule worth having, so it was not narrowed; the schema grew the three aliases instead (packages/shared/src/tableObjects.ts,TABLE_UI_SCREEN_ANCHOR_ALIASES). They are input-only and are normalized before anything is stored, so an element created withupper-centerreads back astop-center. Both spellings are accepted insetup.json, in the editor and in an imported snapshot, and all six non-top anchors have a single spelling. See Script safety.
See also
TableUiPresentation— the two modes and all nine anchors.TableUiElementState.presentation— the resolved value.TableUiElementDefinition.layout— the field that looks like styling and is not read.- Script safety — the five scanner patterns and their workarounds.
tableuielementdefinition.layout#
layout?: TableUiLayoutHints;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Arrangement hints for the element, plus anything else you care to store on it. The recognised keys are
TableUiLayoutHints — direction, alignment, wrap, grow,
scroll, gap, padding and maxHeight — and the renderer draws from those. Every OTHER key is carried through
untouched: the host shallow-copies the record, replicates it and returns it on
TableUiElementState unchanged. Placement of a ROOT element
is a separate question and comes from
presentation; sequence comes from
order.
Returns
Record<string, unknown>, optional. Absent keeps the stored record on an update and defaults to {} on
a create. A non-object — a string, a number, null, or an array — becomes {}
(apps/web/src/playcanvas/TabletopRuntime.ts, sanitizeRecord), which is a shallow spread, so nested
objects are carried through by reference rather than cloned.
How, why and when to use it
You are laying out a dialog: a row of filter chips across the top and a long result list beneath it. Give
the row { direction: "row", gap: 6, wrap: true } and the list
{ direction: "column", gap: 6, grow: true, scroll: true }, and the list absorbs the free height and
scrolls inside the dialog instead of pushing it off the screen. That pair is most of what any real mod panel
needs. Anything you want to remember about a row rather than draw — a column index, an id you will match
on later — also belongs here or, better, in
metadata, which behaves identically but carries
no drawing meaning at all.
Gotchas
Only the recognised hints render; everything else is inert. A { width: 200 } here still changes no
pixel — width is not in the vocabulary. The hint set is closed on purpose: there is no colour, font, size
or position in it, because an arbitrary style bag from an untrusted author is a styling injection into the
app's own chrome. For emphasis, use a widget
variant instead.
An unrecognised key is silent in both directions. It is replicated and read back unchanged, and it draws nothing — which is what keeps a hint added in a later platform version from making an older client reject your whole element, and also what makes a typo invisible.
It counts against snapshot size. Every key is replicated to every peer on each snapshot that carries the UI. Keep it to the few values you read back.
See also
TableUiLayoutHints— the keys the renderer actually reads.TableUiElementDefinition.metadata— the other freeform record, with the same behavior.TableUiElementDefinition.presentation— the field that does place the element.TableUiElementState.layout— reading it back.- Limits and caps — what a table's UI is allowed to cost.
tableuielementdefinition.props#
props?: Record<string, unknown>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Everything the widget itself displays and everything it dispatches. Which keys are read depends entirely on
type: text reads text; button reads text, disabled and onClick; checkbox reads text,
checked and onChange; input reads value and placeholder and onChange; panel, canvas and
layout read none. Extra keys are stored and replicated untouched.
Returns
Record<string, unknown>, optional. Absent keeps the stored record on an update and defaults to {} on
a create — so a call that carries only { id, type } leaves the existing props intact. A non-object, null
or an array becomes {}. Individual keys are read defensively at render time: a non-string text
renders as empty, a missing text on a button renders the literal label Button, and disabled and
checked are coerced with Boolean(...).
The snapshot schema is stricter than the write path and caps the lengths: text up to 2000 characters on a
text widget and 300 on a button or checkbox, value up to 4000 and placeholder up to 300 on an
input, and onClick / onChange / hook between 1 and 80.
How, why and when to use it
You want a button that counts presses — "Rolled 3" — so on each onUiEvent you call
api.setUiElement again with the same id, the same type, and
props: { text: "Rolled " + n }. Sending only the props you changed is the point: this is a merge at the
record level, so the whole record is replaced by what you send, and the two things you must keep sending
are the label and the onClick name, or the button loses its hook. The alternative — deleting and
recreating the button — spends two of the per-tick mutations instead of one and gives it a new identity.
Gotchas
A props you send replaces the stored record; it does not merge key by key. Read the element back and
spread it (props: { ...element.props, text: next }) when you want to change one key and keep the rest.
A hook on the wrong widget type never fires.
Known gap. Only
button,checkboxandinputread an interaction hook — abuttonfromonClickfalling back tohook, acheckboxand aninputfromonChangefalling back tohook(apps/web/src/ui/App.tsx, the mod UI element renderer).text,panel,canvasandlayoutaccept the prop, store it, replicate it and dispatch nothing. Every widget type renders and nests correctly and the three interactive types fire reliably — put the hook on thebutton,checkboxorinputinside the container rather than on the container. See Known limitations.
See also
- Table UI widget types — the exact prop list per type.
TableUiElementDefinition.type— the field that decides which keys are read.onUiEvent— the payload a hook delivers.api.on— subscribing to the name you put inonClick.
tableuielementdefinition.metadata#
metadata?: Record<string, unknown>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
A freeform record for your own bookkeeping, stored on the element, replicated with it and returned on
TableUiElementState exactly as you sent it. Nothing in the
renderer reads any key of it, and nothing in the host interprets one.
Returns
Record<string, unknown>, optional. Absent keeps the stored record on an update and defaults to {} on
a create. A non-object, null or an array becomes {}
(apps/web/src/playcanvas/TabletopRuntime.ts, sanitizeRecord), and the copy is shallow, so a nested
object is carried by reference rather than cloned.
How, why and when to use it
An onUiEvent payload names the element that was clicked and nothing about what that element means, so a
scoreboard with a button per seat leaves you holding an id and a question. Stamping
metadata: { seat: "north", role: "bid" } on each button lets the handler look the element up and read its
own answer, instead of parsing the id or keeping a lookup table in a module variable that a restart wipes
out. The alternative for a single seat name is
ownerSeat, which is the same idea with a
declared field; use metadata when what you need to remember is more than a seat.
Gotchas
It is not private. Every peer receives the whole UI tree, so a value here is readable on every client
and by every other mod through
api.listUiElements. Put secrets in
api.setSavedData, which is scoped to your mod.
It costs snapshot bandwidth on every UI change. The record travels with the element each time the tree is broadcast. Keep it to the handful of keys you actually read back.
See also
TableUiElementDefinition.layout— the other freeform record, with identical behavior.TableUiElementState.metadata— reading it back.api.setSavedData— where per-mod state that is not per-element belongs.onUiEvent— the payload this field exists to enrich.
tableuielementdefinition.ownerModId#
ownerModId?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Which mod owns the element. It appears on the definition for symmetry with
TableUiElementState and is ignored on input — the host
sets it from the mod that made the call and never from the payload, so it cannot be spoofed. Ownership is
what decides whether a write is allowed at all.
Returns
string, optional and inert. Whatever you put here is discarded. The value the host stores is your
manifest.id, and the sandbox supplies it — the in-frame api.setUiElement(element) takes one argument,
and the mod id is attached on the way out of the frame.
How, why and when to use it
There is no situation in which you set this field. It matters as the field you read: a table can be
running several mods, api.listUiElements returns all of their
elements together, and element.ownerModId === manifest.id is how you narrow that to yours. The alternative
is to prefix every id you mint with manifest.id and filter on the prefix, which works and duplicates
information the host is already tracking — use the prefix for uniqueness and this field for the filter.
Gotchas
A write against another mod's element throws, it does not resolve null. The host compares ownership
first and rejects with UI element <id> is owned by another mod. — so
api.setUiElement rejects its promise, and
api.deleteUiElement resolves false for an element you do
not own.
Reading is not ownership-scoped and writing is. You can see every mod's elements and change only your own, which is deliberate — it lets a mod arrange itself around what is already on screen without being able to interfere with it.
See also
TableUiElementState.ownerModId— the value the host actually stores.api.setUiElement— the write the ownership check guards.api.listUiElements— the read that returns every mod's elements.- Mod capabilities — the other half of what a mod is allowed to do.
TableUiElementState#
Surface B — mod script · interface · 11 members
A live UI element.
One live UI element as the table actually holds it. It is the resolved form of
TableUiElementDefinition: the same eleven fields, all
of them present, with every default filled in, the host's id minted if you did not supply one, and
ownerModId set to the mod that wrote it. You get one back from
api.setUiElement and arrays of them from
api.listUiElements and
api.getUiState.
How, why and when to use it#
Your mod is restarting after a host migration and needs to know whether the panel it built last session
survived before it builds another one. Read the element array, filter on
element.ownerModId === manifest.id, and you have your own tree with its real ids and its current props
— which is the only reliable answer, because a mod's in-memory record of what it created does not survive
the frame being torn down and re-run. The alternative is to keep writing the same fixed ids and let the
upsert absorb the duplicate, which works and is cheaper; read the state when you need to branch on what
is there (skip the intro panel if the game already started) rather than to avoid a duplicate.
Gotchas#
Every field is readonly in the declaration, and the value is a copy. Assigning to
element.props.text changes nothing about the table — write through
api.setUiElement.
You can read every mod's elements and write only your own. The array is the whole table's UI from every
mod. ownerModId is the field that tells them apart, and the host refuses a write against an element whose
ownerModId is not yours with UI element <id> is owned by another mod.
See also#
TableUiElementDefinition— the shape you write.TableUiState— the whole tree plus its revision counter.api.listUiElements— the call that returns an array of these.- Table UI widget types — what each
typerenders.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
parentId |
string | null |
|
type |
TableUiWidgetType |
|
order |
number |
|
ownerSeat |
string | null |
|
visibility |
TableUiVisibilityTarget |
|
presentation |
TableUiPresentation |
|
layout |
Readonly<Record<string, unknown>> |
|
props |
Readonly<Record<string, unknown>> |
|
metadata |
Readonly<Record<string, unknown>> |
|
ownerModId |
string |
tableuielementstate.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
The element's address, and the only handle that resolves it. Every later call that touches this element —
an update through api.setUiElement, a removal through
api.deleteUiElement — takes this string, and every
onUiEvent payload carries it as elementId.
How, why and when to use it
An interaction arrives and tells you which element the player touched; matching that elementId against
the ids you hold is how a single handler serves a whole panel of buttons. Read this field off the
TableUiElementState that setUiElement resolves and keep it if you let the host mint the id — that
resolved value is the only copy, and there is no lookup by name, by mod or by type to recover it from.
The alternative is to choose every id yourself and never read this field at all, which is the sturdier
habit: an id you chose survives a restart, and one the host minted does not.
Gotchas
It is unique across the whole table, not per mod. Two mods cannot hold the same element id, which is why
prefixing yours with manifest.id matters — the host answers a collision with
UI element <id> is owned by another mod.
A minted id is a UUID and carries no meaning. It tells you nothing about type, ancestry or mod. Read
ownerModId,
type or
metadata for that; do not parse the id.
Ties in sibling order fall back to comparing this string. Two elements sharing a parentId and an
order are drawn in ascending id order, so row-10 precedes row-9.
See also
TableUiElementDefinition.id— supplying one, and what happens when you do not.api.deleteUiElement— the call that takes it.onUiEvent— where it arrives aselementId.TableUiState.elements— the array you search it in.
tableuielementstate.parentId#
readonly parentId: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
The id of the element this one nests inside, resolved. It is the field you reconstruct the tree from: the elements array is flat, and grouping it by this value is what turns it back into a hierarchy.
Returns
string | null. null means the element is a root — it was created without a parentId, it was
detached by an explicit null, or it named itself as its own ancestor and the host re-rooted it. A
snapshot rebuild also nulls a parentId naming an element that is no longer on the table
(apps/web/src/playcanvas/TabletopRuntime.ts, sanitizeUiState), so a dangling reference resolves to
null rather than lingering.
How, why and when to use it
Your mod is restarting and needs to know whether the panel it built last session still has its rows, so you
read the element array and bucket it by parentId — the panel's row count falls straight out. That is the
only way to answer it, because nothing returns an element's children: the array is flat and every
parent-child relationship in it lives in this one field. Filter to your own ownerModId first, or you will
be walking every other mod's tree as well.
Gotchas
A dangling reference behaves like null before it becomes null. Each peer's renderer buckets an
element under the root when its parentId names nothing it knows about, so an element can draw at the root
while this field still holds the missing id, until a snapshot rebuild rewrites it.
Ancestry does not survive a visibility filter. Each peer filters elements by
visibility before it builds the tree, so a
child whose ancestor is hidden from that viewer is drawn at the root on that client while this field is
unchanged. What you read here is the authored ancestry, not what any particular player sees.
Ownership does not follow ancestry. A child can belong to a different mod than its ancestor, and
deleting the ancestor removes only the descendants that share its ownerModId.
See also
TableUiElementDefinition.parentId— setting and clearing it.TableUiElementState.order— how siblings under one ancestor are sequenced.TableUiState.elements— the flat array you group.api.deleteUiElement— the cascade and where it stops.
tableuielementstate.type#
readonly type: TableUiWidgetType;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Which of the eight widgets this element resolved to: text, button, checkbox, input, select,
panel, canvas or layout. It is always one of those eight, because the host replaces anything else with panel
before storing — so reading it back is how you find out that a type you sent was not accepted.
How, why and when to use it
You sent "buton", the call resolved without complaint, and nothing on screen responds. Reading this field
off the returned element answers it in one line: a panel where you asked for a button means the string
did not match. Check it on the result of api.setUiElement
whenever the type comes from data rather than from a literal in your source — a preset table, a saved
layout, a value you built by concatenation — because that is where a typo actually reaches the call. A
literal in your source is checked by the editor and does not need the guard.
Gotchas
Applies to: every widget type. panel is both a legitimate type and the fallback for an unrecognized
one, so a panel you did not ask for and a panel you did are indistinguishable here.
Three of the seven render identically. panel, canvas and layout all produce the same column
container with only the CSS class differing, so this field is the only thing that tells them apart after
the fact.
Reading type does not tell you whether the element responds. Only button, checkbox and input
dispatch an interaction; a hook sitting in the props of any of the other four is stored, replicated and
never fired. See
TableUiElementState.props.
See also
TableUiWidgetType— the eight values.TableUiElementDefinition.type— setting it, and the fallback rules.- Table UI widget types — what each type renders and reads.
onUiEvent— where the same value arrives aswidgetType.
tableuielementstate.order#
readonly order: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
The element's resolved position among its siblings, always an integer of 0 or more. Either it is the value
you sent, or — when you sent none and the element was new — it is one past the highest order already in
use under the same ancestor, so the first element under a fresh ancestor holds 0.
How, why and when to use it
You are inserting a new row into a scoreboard you built earlier and you need a number that lands it between
two existing rows. Reading the neighbors' order values is the only way to compute one, because nothing
reports "the next free slot" and the host's own automatic value always appends to the end. Read the
siblings, pick a number between them, and send it. When you find the rows are at 0 and 1 with no gap,
that is the signal to rewrite the whole sequence with spacing (0, 10, 20) rather than to keep patching
around it.
Gotchas
It sequences siblings only. Two elements with different parentId values are never compared on
order — an element with order: 0 under one panel and one with order: 500 under another have no
relationship at all.
A tie is broken by id, ascending. Two siblings sharing an order are drawn in string order of their
ids, which puts row-10 before row-9.
The reading order of elements is not this order. api.getUiState
and api.listUiElements group by parentId before they sort
by order, so scanning the array top to bottom does not walk the tree in drawing order — see
TableUiState.elements.
See also
TableUiElementDefinition.order— setting it, and what a bad value does.TableUiElementState.parentId— the sibling group it applies within.TableUiState.elements— the exact order a read arrives in.api.listUiElements— the call that returns the sorted array.
tableuielementstate.ownerSeat#
readonly ownerSeat: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
The seat name stamped on the element, returned exactly as it was written. It is a label your mod puts there and reads back; no part of the renderer consults it, so it has no effect on placement, on styling or on who sees the element.
Returns
string | null. null means no seat was stamped — nobody set the field, or somebody cleared it with an
explicit null. A non-null value is the string as sent, untrimmed and uncased, and an empty string is a
legitimate stored value that is not the same as null. The snapshot schema accepts up to 40 characters.
How, why and when to use it
An onUiEvent gives you an element id and an actor, and a per-seat control panel needs the seat that
element belongs to before it can score anything. Reading this field off the matching element answers it
directly, which beats the alternatives: parsing the seat out of the element id is brittle, and mapping the
actor's peer id to their current seat answers a different question — who pressed it, not whose box it is.
Those diverge exactly when it matters, because one player pressing another's control is the case you wrote
the check for.
Gotchas
It never affected visibility and it does not report it. An element restricted to a seat is restricted by
visibility; reading ownerSeat tells you nothing
about who is rendering the element.
Nothing validates the seat against the table. The string can name a seat that never existed or one that has since been removed, and the value is unchanged when the player in it leaves — seats belong to the table, not to the peer occupying one.
See also
TableUiElementDefinition.ownerSeat— setting and clearing it.TableUiElementState.visibility— the field that does restrict an audience.onUiEvent— the actor this field is not.api.getMySeat— the seat of the client running your mod.
tableuielementstate.visibility#
readonly visibility: TableUiVisibilityTarget;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
The audience rule the host resolved for this element, always one of the four scopes: { scope: "all" },
{ scope: "seat", seats }, { scope: "team", teams } or { scope: "players", peerIds }. It is what each
peer consults when it decides whether to draw the element.
How, why and when to use it
You are about to hand a private bid box to a seat that has changed occupants, and you want to confirm the
element you wrote is actually restricted before you put anything sensitive in its label. Reading this back
is the check: a { scope: "all" } where you sent a seat scope means your array was empty or malformed and
the host widened it. That is worth doing precisely once, at creation — the alternative, trusting the write,
fails silently and in the direction that shows the element to everybody rather than to nobody.
Gotchas
{ scope: "all" } is both a choice and the fallback. The host rewrites a non-object, an unrecognized
scope, and any scope whose array survives filtering as empty into { scope: "all" }
(apps/web/src/playcanvas/TabletopRuntime.ts, sanitizeUiVisibility) — so a widened element and a
deliberately public one read identically here.
The arrays you read back are truncated, not the ones you sent. seats and teams hold at most 16
entries and peerIds at most 64; entries past the cap were dropped at write time.
This is a draw rule, not a redaction rule. Every peer holds the element and its props in the snapshot
whatever this field says. It controls what is rendered, never what is transmitted.
See also
TableUiVisibilityTarget— the four scopes in full.TableUiElementDefinition.visibility— setting it, and the coercions.TableUiElementState.parentId— why a hidden ancestor does not hide its children.- Host authority — who decides what each peer is told.
tableuielementstate.presentation#
readonly presentation: TableUiPresentation;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Where the element is drawn, resolved to one of the two modes. { mode: "world" } means it sits in the
overlay's normal flow; { mode: "screen", anchor, offsetX, offsetY } means it is pinned to one of nine
fixed points on the viewport. It is the only placement information an element carries.
How, why and when to use it
A second mod is running and has already pinned a banner to bottom-center, and you are about to pin yours
to the same spot. Reading this field across
api.listUiElements tells you which anchors are taken before
you write, which is the one thing that lets two mods share a screen without stacking on top of each other —
there is no layering or collision rule to fall back on. Filter to presentation.mode === "screen", collect
the anchors, and pick a free one.
Gotchas
This field always holds one of the nine canonical anchors, whatever was written. sanitizeUiPresentation
(apps/web/src/playcanvas/TabletopRuntime.ts) runs on the write and again when a client rebuilds table
state from a snapshot: it maps the three mod-safe upper-* aliases onto their canonical top-* values,
replaces an unrecognized anchor with "top-left", and replaces a non-finite offsetX or offsetY with
0. So an element created with anchor: "upper-center" reads back as "top-center" here — comparing this
value against an alias never matches. The renderer independently falls back to the top-left corner for an
unrecognized anchor, so a bad value draws in that corner either way.
A mode you read is one of exactly two. Anything that is not "screen" is rebuilt as
{ mode: "world" }, and the offsets and anchor are dropped with it — so a world-mode element never carries
a stale anchor from before it was switched.
The offsets are edge insets, and three anchors read neither of them. offsetY is ignored by all three
middle-* anchors and offsetX by bottom-center and middle-center, so a non-zero number in this object
does not mean the element moved.
See also
TableUiPresentation— the two modes, the nine anchors, and which offset each one reads.TableUiPresentationInput— the twelve spellings a write may use, and why.TableUiElementDefinition.presentation— setting it.api.listUiElements— the read that lets you check for a clash.- Table UI widget types — what is being placed.
tableuielementstate.layout#
readonly layout: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
The record stored under layout, returned to you as written. Its recognised keys are
TableUiLayoutHints, which the renderer draws from; every
other key is freeform and carries no meaning to the engine. Either way nothing here is computed by the table
— it is whatever some mod wrote.
Returns
Readonly<Record<string, unknown>>, always an object. {} means nothing was ever written — it is the
default on a create and the result of writing a non-object, null or an array, so an empty record is
indistinguishable from a rejected one.
How, why and when to use it
Your mod rebuilds its scoreboard after a restart and needs the column widths it chose the first time.
Storing them under layout and reading them back here keeps that decision attached to the element instead
of in a module variable that the frame teardown destroyed. Read it when your own earlier run is the author;
there is nothing useful to read from another mod's layout, because the keys are private conventions with
no shared vocabulary behind them.
Gotchas
A value here changed nothing on screen. Placement comes from
presentation and sequence from
order. Finding a width in this record does not mean
the element is that wide.
Every key you read was replicated to every peer. Nothing here is private to your mod or to the host.
See also
TableUiElementDefinition.layout— writing it.TableUiElementState.metadata— the other freeform record.TableUiElementState.presentation— the field that does place the element.- Limits and caps — what a table's UI is allowed to cost.
tableuielementstate.props#
readonly props: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Everything the widget displays and dispatches, as the host stored it. Which keys mean
anything depends on type: text on a text widget, text/disabled/onClick on a button,
text/checked/onChange on a checkbox, value/placeholder/onChange on an input, and nothing on
a panel, canvas or layout. Every other key you find was stored untouched.
Returns
Readonly<Record<string, unknown>>, always an object. {} means no props were ever written — the
default on a create, and also what the host stores when a write passed a non-object, null or an array.
An absent key inside the record is read defensively at render time rather than erroring: a non-string
text renders empty, a button with no text renders the literal label Button, and disabled and
checked are coerced with Boolean(...).
How, why and when to use it
You are patching one prop on a button you built earlier — the label counts presses, the onClick hook name
must not change — and a props you send replaces the stored record rather than merging into it. Reading
this field first and spreading it (props: { ...element.props, text: next }) is what keeps the hook alive
across the update. The alternative, re-sending every prop from a constant in your source, works and drifts
the moment two code paths write the same element with different constants.
Gotchas
It is a plain record, not a typed shape. The declaration is Record<string, unknown>, so every value
you read needs a typeof check before you use it — the editor will not do it for you and the host stored
whatever was sent.
A hook you find on the wrong widget type is dead weight.
Known gap. Only
button,checkboxandinputread an interaction hook — abuttonfromonClickfalling back tohook, acheckboxand aninputfromonChangefalling back tohook(apps/web/src/ui/App.tsx, the mod UI element renderer).text,panel,canvasandlayoutaccept the prop, store it, replicate it and dispatch nothing, so it is readable here and never fires. Every widget type renders and nests correctly and the three interactive types fire reliably — move the hook to thebutton,checkboxorinputinside the container. See Known limitations.
See also
TableUiElementDefinition.props— writing it, and the length caps.- Table UI widget types — the exact prop list per type.
TableUiElementState.type— the field that decides which keys are read.onUiEvent— what a live hook delivers.
tableuielementstate.metadata#
readonly metadata: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
The freeform record a mod attached to the element, returned as written. The host stores it, replicates it and interprets nothing in it, and the renderer reads no key of it — so every value here was put there by a mod, for that mod.
Returns
Readonly<Record<string, unknown>>, always an object. {} means nothing was written — the default on a
create, and also the result of a write that passed a non-object, null or an array. There is no way to
tell those two apart after the fact.
How, why and when to use it
An onUiEvent hands you an element id and an actor, and your handler needs to know what that particular
button was for — which seat's bid, which card slot, which phase. Looking the element up and reading its
own metadata is how a single handler serves a whole grid without a lookup table, and it is the version
that survives a restart, which a module variable does not. The alternative is one hook name per element,
which scales to about six elements and then becomes a wall of near-identical handlers.
Gotchas
It is readable by every mod and on every peer. The whole UI tree is replicated, so anything here is
visible through api.listUiElements on any client. Per-mod
state you do not want shared belongs in
api.setSavedData.
Reading another mod's metadata buys you nothing reliable. The keys are that mod's private convention,
with no schema and no guarantee they persist across its next release.
See also
TableUiElementDefinition.metadata— writing it.TableUiElementState.layout— the other freeform record.TableUiElementState.ownerModId— whose convention you are reading.api.setSavedData— where per-mod state belongs instead.
tableuielementstate.ownerModId#
readonly ownerModId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Which mod owns the element. The host sets it from the mod that made the call — never from the payload — and it is the field the write path checks before it lets anything through, so it is both an identity and a permission.
How, why and when to use it
Your mod is one of several on the table and
api.listUiElements returns all of their elements in one
array, so the first thing any handler does with that array is
filter(element => element.ownerModId === manifest.id). That is the intended way to find your own tree,
and it beats filtering on an id prefix, which is what authors reach for first: the prefix is a
convention you have to keep, while this field is set by the host and cannot drift. Read every element and
narrow at the point of use when you also want to know what else is on screen — which anchors are taken, how
many controls a player is already looking at.
Gotchas
You can read every mod's elements and write only your own. The reads are unfiltered by design, so a mod
can arrange itself around what is already there. The two writes are scoped:
api.setUiElement rejects with
UI element <id> is owned by another mod. and
api.deleteUiElement resolves false.
A delete cascade stops at the ownership line. Removing your panel removes only the descendants that
share your ownerModId; another mod's child of your panel survives, with a parentId naming an element
that is gone.
A snapshot rebuild can report system. When a stored element carries an ownerModId that is not a
valid mod id, the rebuild replaces it with the literal system
(apps/web/src/playcanvas/TabletopRuntime.ts, sanitizeUiState) — an element you cannot claim and cannot
write to.
See also
TableUiElementDefinition.ownerModId— why you never set it.api.listUiElements— the read this field narrows.api.deleteUiElement— the cascade and where ownership stops it.- Mod capabilities — the other half of what a mod is allowed to do.
TableUiState#
Surface B — mod script · interface · 2 members
The table's entire UI in one value: a revision counter and an elements array holding every live element
from every mod that has one. It is what api.getUiState resolves,
and it is also the ui field of a TableSnapshot, so the same two
fields describe the UI whether you read it directly or pull the whole table.
How, why and when to use it#
You have a handler on onUiEvent that runs on every click, and rebuilding your panel from scratch inside it
is wasteful when nothing about the tree has actually moved. Store revision the first time you read it and
compare it on each pass — an unchanged number means no element anywhere was created, updated or deleted, and
you can skip the work without walking elements at all. The alternative is
api.listUiElements, which gives you the identical array
without the counter; reach for that one when the array is all you want, and for TableUiState when the
cheap change check is the point.
Gotchas#
The two fields answer different questions and neither substitutes for the other. revision tells you
that something changed; it never tells you what. Diff elements yourself when you need the what.
A table with no UI at all still resolves a value. getUiState returns { revision: 0, elements: [] }
on an untouched table — null from that call means there is no table runtime attached to this client, which
is a different condition entirely.
See also#
TableUiState.revision— the counter and exactly when it moves.TableUiState.elements— the array and the order it arrives in.api.getUiState— the call that resolves this shape.TableUiElementState— one entry of the array.
Members#
| Signature | Description | Returns |
|---|---|---|
revision |
number |
|
elements |
readonly TableUiElementState[] |
tableuistate.revision#
readonly revision: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
A counter the host increments by exactly one on every accepted change to the UI tree: one per successful
create, one per successful update, and one per delete that removed at least one element — a delete that
cascades through a whole subtree still counts as one. It starts at 0 on a fresh table and never decreases
while a table runs.
How, why and when to use it
You have a handler that fires on every click and rebuilds your panel from the tree, and most of those
clicks changed nothing you care about. Store the revision, compare it on the next pass, and skip the whole
rebuild when the number is unchanged — an equal revision is a hard guarantee that no element anywhere was
created, updated or deleted in between, which is a single integer comparison instead of walking the
elements array and diffing each entry. The alternative, comparing elements.length, misses every in-place
update and every equal-sized swap.
Gotchas
It counts changes; it does not identify them. An increment tells you the tree moved and nothing about
which element or which field. Diff elements when you need that.
A refused or dropped write does not move it. Hitting the 2000-element ceiling, exhausting the 120 mutations allowed in one tick, or naming an element owned by another mod all leave the counter where it was — so an unchanged revision after your own write means the write did not take.
It is not monotonic across a snapshot load. Rebuilding table state from a snapshot adopts that snapshot's revision wholesale, so the number can jump forward or fall back at a host migration, a reload or a restored save. Compare it for equality to detect change; never treat a difference as a distance, and never persist it as a version number.
See also
TableUiState.elements— the tree the counter is counting changes to.api.getUiState— the only call that returns it.api.setUiElement— the write that moves it, and the three ways a write is dropped.- Limits and caps — the ceilings a dropped write hit.
tableuistate.elements#
readonly elements: readonly TableUiElementState[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | ui |
| Availability | mod |
Every live UI element on the table, from every mod, in one flat array. It is the same array
api.listUiElements returns, and the tree structure is not in
the array shape — it lives in each entry's parentId, which you group by yourself.
Returns
readonly TableUiElementState[]. An empty array means the table has no UI, which is the ordinary state
of a table nobody has built one on — it is not an error and it is not the same as
api.getUiState resolving null, which means there is no table
runtime at all. The table holds at most 2000 elements.
The array is sorted three ways in sequence: by parentId as a string, then by order ascending, then by
id ascending. Root elements sort first, because a null parentId compares as an empty string.
How, why and when to use it
You are rebuilding your own panel after a restart and need it as a tree, so you bucket the array by
parentId and walk down from your roots. Do the grouping rather than trusting the array order: the sort
puts all roots first and then orders the remaining buckets by the text of the parent id, so a grandchild
whose ancestor's id happens to sort early appears in the array before its own parent. Filter to
ownerModId === manifest.id before you group, or you will be reconstructing every other mod's tree
alongside yours.
Gotchas
It is not depth-ordered. A parent precedes its children only at the first level, where roots sort ahead of everything. Below that, bucket order follows the lexicographic order of parent ids, so ancestry is something you reconstruct rather than something the array gives you.
It is a snapshot of one moment, and it is a copy. Each entry is cloned before it leaves the host, so mutating one changes nothing about the table and holding one tells you nothing after the next change.
Another mod's elements are in here and cannot be removed by you. Reads span the whole table by design;
api.deleteUiElement resolves false for anything whose
ownerModId is not yours.
See also
TableUiElementState— one entry of this array.TableUiState.revision— the cheap way to know it changed.api.listUiElements— the same array without the counter.TableUiElementState.parentId— the field the tree actually lives in.
TableEvent#
Surface B — mod script · interface · 6 members
One line of the table's event log.
One line of the table's event log: a unique id, an ISO-8601
at timestamp, the
actor who caused it, a human-readable
message, an optional
objectId naming what the line is about, and an optional
revealsIdentity flag that drives hidden-information
redaction. The host writes every one of them through TabletopRuntime's log helper; you read them from
TableSnapshot.eventLog or receive them one at a time from the
onTableEvent hook.
Applies to: every object kind, and to the annotation collections too — a zone, snap point, vector line, decal, text label, joint or UI element edit writes a line as readily as an entity action does.
How, why and when to use it#
You want your mod to notice everything that happens at the table without wiring up a hook per action — a spectator
log, an undo prompt, a "nothing has happened for two minutes" nudge. onTableEvent hands you a TableEvent for
every logged line, which is the broadest reach a mod has. The alternative is the narrower
onObjectDropped and
onCardDrawn hooks, and it is the right
one when you care about exactly those two things — the host derives both from this same line by matching the
message prefix, so subscribing to the narrow hook saves you the string test. Reach for the raw event when the
thing you care about has no hook of its own.
Gotchas#
The array is newest-first and short. log unshifts each line onto the front and then calls splice(80), so
the host keeps the 80 most recent lines and the oldest fall off. tableEventSchema allows up to 5000, so an
imported or hand-built snapshot can carry far more — do not assume 80 is a maximum, only that the live host will
not exceed it.
It is prose, not a structured record. There is no action enum, no before-and-after value, and no target kind
on the line. The one piece of machine-usable structure is the message prefix the host itself matches on — see
message.
A mod sees fewer lines than the players do, on every peer including the host. The log a read-world read
returns is filtered to the least-privileged view (packages/shared/src/tableObjects/redaction.ts,
LEAST_PRIVILEGED_VIEWER), which drops identity-bearing lines — so your copy legitimately disagrees with the
event feed in both length and content, wherever your mod is running. A mod that must see the dropped lines
declares read-hidden-information and reads
api.getUnredactedSnapshot.
api.log does not write here. A mod's log line goes to the local session event list with the actor "Mod"
(apps/web/src/ui/App.tsx, the mod runner's log callback) and never enters TabletopRuntime's event log, so it
never appears in a snapshot or in onTableEvent.
See also#
TableSnapshot.eventLog— the array these lines live in.onTableEvent— the hook that delivers one at a time.TableEvent.actor— every value the actor really takes.TableEvent.revealsIdentity— why a mod's log is shorter.- Host authority — who writes the log and who receives it.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
at |
string |
|
actor |
string |
|
message |
string |
|
objectId |
string |
|
revealsIdentity |
True when message embeds a card identity; such lines are redacted per viewer. |
boolean |
tableevent.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
A unique address for one line of the log. The host mints it with crypto.randomUUID() at the moment the line is
written (apps/web/src/playcanvas/TabletopRuntime.ts, log), and tableEventSchema
(packages/shared/src/tableObjects.ts) allows 1–96 characters, so an imported log can use any short string.
How, why and when to use it
Your mod reads the event log on a timer or on every onTableEvent and needs to know which lines it has already
processed. The alternative is remembering an index into
eventLog, which goes wrong twice over: the array is
newest-first, so new lines arrive at index zero and shift everything, and the host trims it to 80 entries so old
indices stop meaning anything. Keep a Set of ids you have handled and check membership.
Gotchas
Ids go missing from the log a mod reads, on every peer including the host. The read-world reads redact to
the least-privileged view (packages/shared/src/tableObjects/redaction.ts, LEAST_PRIVILEGED_VIEWER), which
drops identity-bearing lines — id and all. Treat a missing id as normal, not as a fault, and never assume the ids
you handled on one client are the ids another client handled.
A Set of ids grows without bound. The log holds 80 lines and your set holds every id you have ever seen.
Prune it against the ids present in the current log, or key on
at for a watermark instead.
It addresses a log line, nothing else. It is not an entity id and resolves to nothing through
api.getObject — the entity a line is about is
objectId.
See also
TableEvent— the shape this addresses.TableSnapshot.eventLog— the array, and why it is newest-first.TableEvent.objectId— the id that does point at something on the table.onTableEvent— the hook that hands you one line at a time.
tableevent.at#
readonly at: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
When the line was written, as an ISO-8601 timestamp string. The host produces it with new Date().toISOString()
(apps/web/src/playcanvas/TabletopRuntime.ts, log), so it is UTC with millisecond precision and a trailing Z
— 2026-07-27T18:04:11.376Z. tableEventSchema (packages/shared/src/tableObjects.ts) validates it with
z.string().datetime(), so it is a string on the wire and never a Date.
How, why and when to use it
You want "nothing has happened for two minutes, nudge the active player", or you want to know whether two logged
actions were part of the same burst. The alternative is calling Date.now() inside your handler, which is right
when you care about your processing time and wrong for the table's timeline, because a mod handler runs after
the snapshot has crossed the network. Compare at values against each other for anything about the table, and
use the local clock only for your own bookkeeping.
Gotchas
It is the host's clock, not yours. Every line is stamped on the authoritative peer, so subtracting at from
your own Date.now() measures clock skew plus latency rather than elapsed time. Differences between two at
values are sound; the difference against a local reading is not.
Host migration restarts the clock source. A new host stamps subsequent lines from its own machine, so a log that spans a migration can step backwards across the seam. Sort by position in the array, which is authoritative, rather than by parsing timestamps.
Parse before you compare. These are strings; "2026-07-27T09:00:00.000Z" < "2026-07-27T10:00:00.000Z" happens
to hold for this exact format, and new Date(event.at).getTime() is what expresses the intent and survives a log
imported with a different offset.
See also
TableEvent— the shape this belongs to.TableSnapshot.eventLog— the array, newest first.TableSnapshot.tick— the other clock-like number, and what it actually counts.TableEvent.actor— who the line is about.
tableevent.actor#
readonly actor: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Who caused the line. It is a string of 1–80 characters (tableEventSchema,
packages/shared/src/tableObjects.ts), and the value is either a peer id or one of four fixed, capitalized
literals:
| Value | What it means |
|---|---|
| a peer id | The ordinary case at a real table. A participant sent the intent, and the host attributed the line to them (apps/web/src/ui/App.tsx, applyIntent(intent, selfPeerId ?? "You") and the remote-intent path). |
"Script" |
A table script raised the intent. The app passes this literal when it dispatches on the script host's behalf. |
"Host" |
An internal runtime path that named nobody — the default parameter of applyIntent (apps/web/src/playcanvas/TabletopRuntime.ts). |
"You" |
The local viewer where no peer id exists at all — an offline or solo table. selfPeerId ?? "You" produces it exactly when the first is null, so a real multiplayer table reports the acting peer's id here instead. |
"System" |
The runtime's own label for a refusal or a housekeeping line — a rejected intent, an applied limit, an undo (apps/web/src/playcanvas/intent/IntentDispatcher.ts). |
How, why and when to use it
You are writing a rule that must not react to its own corrections — the classic case is a mod that repositions a
piece and then sees its own move come back through onTableEvent. Reading actor and skipping "Script" and
"Host" is the only thing in the payload that answers "was this a person?". The alternative is comparing
positions or keeping a flag across the round-trip, which breaks the moment a second mod or a table script is
running too. Test the fixed literals first, then treat anything left over as a peer id.
Gotchas
Comparing against "script" never matches. The literals are capitalized, and the comparison is a plain
=== on a string, so a lowercase test is dead code that silently never fires. The same trap applies to
"host", "you" and "system".
Four of the five values are not peer ids. Keying a Map on actor mixes participants with runtime
machinery, and a lookup for "Host" finds no one. Filter the fixed strings out before you resolve anything.
A peer id is not a name. It is the id the signaling layer assigned, and it means nothing to a player. There is
no mod call that turns a peer id into a display name — the closest a mod gets is the displayName on a
ModPeerPayload from onPeerJoined, so cache the
mapping as peers arrive.
"Mod" never appears here. api.log writes to the local session event
list with that actor (apps/web/src/ui/App.tsx, the mod runner's log callback) and does not touch the
runtime's event log, so a mod's own output is absent from eventLog and from onTableEvent.
See also
TableEvent— the shape this belongs to.TableEvent.message— what the actor did.ModPeerPayload— where a peer id gains a display name.- Host authority — the actor table, in the concept page that owns it.
tableevent.message#
readonly message: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The line itself, as human-readable copy: spawned red-king, moved oak-board, flip A♠, updated hinge joint,
emptied draw-pile. It is a string of 1–500 characters (tableEventSchema,
packages/shared/src/tableObjects.ts). The host composes it from an action verb and the subject's label —
the slug — never its displayName (apps/web/src/playcanvas/TabletopRuntime.ts, log, and the intent handlers
in apps/web/src/playcanvas/intent/handlers.ts).
How, why and when to use it
You are building a spectator feed or a replay list and want a line a person can read without you re-deriving it —
that is exactly what this string is for. The alternative for game logic is a hook:
onCardDrawn and
onObjectDropped exist precisely so
you do not have to string-match, and the host derives both by testing this message for the prefixes "draw " and
"moved " (apps/web/src/ui/App.tsx, onEvent). Read the message to show it; subscribe to the hook to act on
it.
Gotchas
Only two prefixes are load-bearing, and they are the two the host matches itself. Every other phrasing here is
UI copy with no contract behind it — updated zone …, deleted fixed joint, stacked & shuffled … and the rest
change whenever the wording improves. Parsing them is a rule that breaks on a cosmetic edit.
A card's message embeds the card's identity. For kind: "card" the label is the card id, so a line
about a card names the card. That is why the host tags such lines
revealsIdentity and redacts them per viewer — the message
is the leak the flag protects against.
It never carries a displayName. A piece a player sees as Red King logs as its slug. If your feed needs the
human name, resolve it yourself from the entity's displayName through
api.getObject.
See also
TableEvent— the shape this belongs to.TableEvent.revealsIdentity— why some messages never reach a peer.onCardDrawn— the hook derived from the"draw "prefix.onObjectDropped— the hook derived from the"moved "prefix.- Object state —
id,labelanddisplayName, and which is which.
tableevent.objectId#
readonly objectId?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
What the line is about, addressed by id. Despite the name it is not always an entity: the intent handlers
pass the id of whatever the line describes, so a zone, snap point, vector line, decal, text label, joint or UI
element edit puts that annotation's id here (apps/web/src/playcanvas/intent/handlers.ts,
handleZoneUpsertIntent through handleUiElementDeleteIntent). tableEventSchema
(packages/shared/src/tableObjects.ts) declares it optional and 1–96 characters.
Returns
string | undefined. undefined means the line is about the table rather than one thing — a "System"
refusal, an applied limit, undid the last table action. It is also undefined on every line whose writer passed
no id at all.
How, why and when to use it
You are turning the log into a per-piece history — "everything that happened to this card" — and this is the only
field that links a line to a thing. The alternative is parsing the entity's label out of
message, which fails on every line whose subject is an annotation
and on any wording change. Filter on objectId, then resolve it through
api.getObject and treat a null result as "this id names an annotation
or a deleted entity", not as an error.
Gotchas
One path sends an empty string instead of omitting the field. The multi-select rotate in
apps/web/src/playcanvas/TabletopRuntime.ts logs rotated N object(s) with "" for the id, so guard with a
truthiness test (if (event.objectId)) rather than !== undefined.
Resolving it can fail for a line that is perfectly valid. The log outlives the table: an entity destroyed three turns ago still has its lines, and their ids resolve to nothing. Nothing prunes the log when an entity goes away.
A hidden card's lines are gone entirely, on every peer including the host. The log a mod reads is filtered
to the least-privileged view (packages/shared/src/tableObjects/redaction.ts, LEAST_PRIVILEGED_VIEWER), which
drops any line whose objectId names a card a spectator with no seat and no team may not identify — tagged or
not. The absence of lines for an id is expected rather than suspicious, and running on the host does not fill
the gaps back in.
See also
TableEvent— the shape this belongs to.TableEvent.revealsIdentity— the other half of the redaction rule.api.getObject— resolving the id when it does name an entity.TableSnapshot.joints— one of the annotation collections whose ids show up here.
tableevent.revealsIdentity#
readonly revealsIdentity?: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
True when message embeds a card identity; such lines are redacted per viewer.
The host's tag for "this line names a card". Because a card's label is its identity, a message such as
flip A♠ leaks the card to anyone who reads it — so the host sets this flag when the line's
objectId resolves to a kind: "card" entity, or when a caller
passes an explicit override for a card that has already left the table
(apps/web/src/playcanvas/TabletopRuntime.ts, log). The flag is what makes hidden-information redaction
possible on the log.
Returns
boolean | undefined. Only true is ever written — log spreads the key in conditionally
(...(revealsIdentity ? { revealsIdentity: true } : {})), so a line that does not name a card has no such
property at all rather than revealsIdentity: false. Test truthiness; a === false comparison never matches.
How, why and when to use it
You are mirroring the table's log into your own mod UI and need to know which lines carry information a given
player is not supposed to have. Reading this flag tells you which entries the host treats as sensitive, so you can
label them, group them or leave them out of a shared panel. The alternative is deciding for yourself by inspecting
objectId and the entity's faceDown state, which duplicates the
host's rule and drifts from it. Trust the flag; the entitlement check has already been made for you, and a line
that survived it names nothing a spectator could not have read over your shoulder.
Gotchas
A mod always sees fewer lines than the players do, and that is the flag at work. The log a mod reads is
filtered against LEAST_PRIVILEGED_VIEWER (packages/shared/src/tableObjects/redaction.ts) and drops a tagged
line unless both hold: the card is still on the table, and a spectator with no seat and no team is entitled to
its identity. So a tagged line about a card that was later stacked away disappears from every mod's log — a past
identity cannot leak through the log after the entity is gone. A second, belt-and-braces rule drops any line whose
objectId names a currently-hidden card whether it is tagged or not.
Running on the host does not buy you the full log. Since 2026-08-14 the same least-privileged filter is applied
on every peer, so a mod on the host reads exactly the log a mod on a spectator reads. Do not build a checksum over
eventLog and expect it to match what a player's event feed shows. A mod that must see the dropped lines declares
read-hidden-information and reads
api.getUnredactedSnapshot.
A mod cannot set it. There is no mod call that writes to the event log at all, so this flag is something you read and never something you control.
See also
TableEvent— the shape this belongs to.TableEvent.message— the string this flag protects.TableSnapshot.eventLog— the array this flag thins out.api.getSnapshot— the read that hands you an already-redacted log.api.getUnredactedSnapshot— the elevated read, including the tagged lines.- Host authority — why the host is the only peer with the whole picture.
TableSnapPointState#
Surface B — mod script · interface · 7 members
One scene-level snap point: a position in feet, a yaw in degrees, and a capture radius in feet. When a player releases
a dragged entity and the drop lands within a point's radius, the host teleports the entity onto the point and turns it
to the point's yaw. You reach the list through api.getSnapshot().snapPoints, and that is the only route: no api
method creates, edits or deletes a snap point. Points arrive from the editor's snap tool and from the snapPoints
array of an edit-scene setup.json.
How, why and when to use it#
You are writing a board game with fixed squares and you want to know which square a piece is standing on so you can
validate the move. The alternative is to compare the piece's position against coordinates you hard-code in your
script, which works right up to the first time the board is nudged or the author re-lays the grid — after that your
constants describe a board nobody is playing on. Reading the authored snap points instead means the geometry lives in
one place and your rules follow it. Match a piece to a square by finding the nearest point whose
snapRadius contains the piece, which is the same test
snapObjectAfterDrop (apps/web/src/playcanvas/TabletopRuntime.ts) applies, so your answer and the host's agree.
Gotchas#
Snap points are invisible at the table. refreshSnapAuthoringVisuals
(apps/web/src/playcanvas/TabletopRuntime.ts) builds a marker sphere and a vertical ray only while the editor's snap
tool is active. In play the point exists and captures drops, and nothing draws it.
Scene points outrank an entity's own snap points. snapObjectAfterDrop tests this collection first, then a
board's per-entity metadata.snapPoints, then the half-foot grid. A drop within a scene point's radius never reaches
the other two.
TableSnapshot.snapPoints is optional. It is declared readonly snapPoints?: readonly TableSnapPointState[], so
read it as snapshot.snapPoints ?? [] before you iterate.
See also#
TableSnapshot.snapPoints— the array this shape fills.api.getSnapshot— the only call that reaches it.setup.json— the file that ships snap points with a mod.- Limits and caps — the 1000-point ceiling on a snapshot.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
label |
string |
|
position |
Readonly<Vector3> |
|
rotationY |
number |
|
snapRadius |
number |
|
ownerSeat |
string | null |
|
metadata |
Readonly<Record<string, unknown>> |
tablesnappointstate.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The snap point's address. tableSnapPointStateSchema (packages/shared/src/tableObjects.ts) declares it
z.string().min(1).max(96); the editor's snap tool mints snap-<uuid> (createSnapPointState,
apps/web/src/ui/TableEditModeShell.tsx), and sanitizeSnapshotSnapPoints mints a bare crypto.randomUUID() for any
point that arrives without one.
How, why and when to use it
You are tracking which square of a board is occupied, and you need a key for the map you are building. The
alternative that looks natural is the point's x and z — but a float pair is a bad map key, and the editor's move
tool changes both the moment an author nudges the grid, so every entry you wrote is orphaned. Key on id, which the
move tool preserves, and treat position as the value rather
than the key.
Gotchas
A synthesized id is per-peer. The id sanitizeSnapshotSnapPoints mints for a point that arrived without one is
generated during that peer's parse. Never treat such an id as a value two peers agree on.
Nothing looks a point up by id. There is no api.getSnapPoint, and the host's own drop path
(snapObjectAfterDrop, apps/web/src/playcanvas/TabletopRuntime.ts) selects by distance, not by id. Scanning
api.getSnapshot().snapPoints is the whole access story.
See also
TableSnapPointState— the shape this addresses.TableSnapPointState.label— the field to show a player.TableSnapPointState.position— where the point sits.api.getSnapshot— the call that returns the list.
tablesnappointstate.label#
readonly label: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The snap point's short label. tableSnapPointStateSchema (packages/shared/src/tableObjects.ts) declares it
z.string().max(80) — note the missing .min(1), so unlike a decal's name the empty string is legal and is what
upsertSnapPoint (apps/web/src/playcanvas/TabletopRuntime.ts) writes when a definition omits it. The editor's snap
tool writes the literal "Snap point" instead, so a grid placed from the tool gives every square the same label. The
host quotes this string in the event-log line it writes when a point is created or deleted.
Returns
string. Never null and never absent, but frequently "" — the schema permits it and one of the two writers
produces it. Check for the empty string before you put this in front of a player.
How, why and when to use it
You are naming squares in a message — "you cannot move to e4 from there" — and this is where a thoughtful author put
the square's name. The alternative is deriving a name from the point's coordinates, which is what you fall back to
when the label is "" or when every point says "Snap point"; a mod that wants readable square names should read
label first and derive only as a fallback. Never key a map on it: nothing enforces uniqueness, and the tool's
default guarantees collisions.
Gotchas
There is no displayName here. TableSnapPointState carries id and label, and nothing else name-shaped.
It is not a slug. Free-form text with a length cap and no charset rule. Do not treat it as an identifier.
See also
TableSnapPointState.id— the field to key on.TableSnapPointState— the shape this belongs to.setup.json— where an authored label comes from.
tablesnappointstate.position#
readonly position: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Where the point sits, in feet, as a { x, y, z } record. The capture test uses x and z only — it compares the
squared horizontal distance from a dropped entity against
snapRadius squared — while y is used for the editor's
marker height and for the plane a move-drag slides along. The height an entity actually lands at is computed from the
surface below the point (restingYOnSurfaceBelow, apps/web/src/playcanvas/TabletopRuntime.ts), not from this y.
How, why and when to use it
You are asking "which square is this piece on?" and the honest answer is the nearest point whose radius contains it —
the same question snapObjectAfterDrop answers when the player lets go. The alternative is to read the piece's
position and round it to a grid you assume, which fails on any board that is rotated, offset or irregular, and
irregular boards are exactly the ones worth writing a mod for. Compare in the horizontal plane, square both sides
rather than taking a square root, and you match the host's test exactly.
Gotchas
Height is not part of the capture test. An entity dropped above the point at any altitude is captured, as long as
its x and z are within the radius.
A captured entity does not land here. The host teleports it to this x and z at the resting height of whatever
surface is underneath. Reading the entity's position after a snap gives you a different y from this one.
It is a Vector3 record, not a tuple. position.x, not position[0].
See also
TableSnapPointState.snapRadius— the distance the test allows.TableSnapPointState.rotationY— the yaw applied on capture.Vector3— the record shape.TableObjectState.position— the value you measure against it.
tablesnappointstate.rotationY#
readonly rotationY: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The yaw in degrees, about the vertical axis, that a captured entity is turned to. This is the only rotation a
snap point has — no pitch, no roll, no rotation record on TableSnapPointState. When snapObjectAfterDrop
(apps/web/src/playcanvas/TabletopRuntime.ts) captures a drop it writes this value into the entity's y euler angle
and keeps the entity's existing x and z angles untouched, so a card that was lying flat stays flat and a card that
was leaning stays leaning. The schema requires a finite number and imposes no range; sanitizeSnapshotSnapPoints
substitutes 0 when a snapshot carries no number, and the editor's snap tool writes 0.
How, why and when to use it
You are laying out a board where every seat's pieces should face that seat, and the point already knows which way that is — so your rules can read the intended facing rather than recomputing it from seat geometry. The alternative, calling a rotation yourself after the drop, means a mod-issued transform racing the host's own snap: the host has already turned the entity by the time your handler runs, and your second rotation shows up as a visible twitch. Read this field when you need to know the facing; leave the turning to the host.
Gotchas
Capture always applies it. There is no "leave the rotation alone" value — 0 means "face zero degrees", not "do
not turn". An entity dropped onto a point authored at 0 is straightened.
A per-entity snap point is different. A board's own metadata.snapPoints carry an optional rotationY, and
snapObjectAfterDrop skips the turn when it is absent. Scene points have no such option because this field is
required.
See also
TableSnapPointState.position— the other half of the landing transform.TableSnapPointState.snapRadius— what triggers the turn.TableObjectState.rotation— the three angles this writes one of.
tablesnappointstate.snapRadius#
readonly snapRadius: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How close a drop has to land, in feet, for the host to capture it onto this point. The test is horizontal: squared
x/z distance against this value squared. tableSnapPointStateSchema (packages/shared/src/tableObjects.ts)
requires a strictly positive number and caps it at 2 feet. The default is 0.12 feet — about an inch and a
half — written by upsertSnapPoint when a definition omits the field, by sanitizeSnapshotSnapPoints for any value
that is not a positive number, and by the editor's snap tool as SNAP_POINT_DEFAULT_RADIUS.
Returns
number. Always a positive count of feet. 0 never reaches you: the schema rejects it, and both sanitizers replace
a zero or negative value with 0.12. At the point of use snapObjectAfterDrop
(apps/web/src/playcanvas/TabletopRuntime.ts) additionally floors it with Math.max(radius, 0.01), so even a value
that slipped past validation captures within a hundredth of a foot rather than never capturing.
How, why and when to use it
You are deciding whether a piece counts as "on" a square, and using the point's own radius means your answer and the host's snapping behavior agree — a piece the host snapped is a piece your rules see as placed. The alternative is a tolerance you pick yourself, which is wrong in both directions: too tight and you reject pieces the host visibly placed, too loose and you claim squares the host left the piece next to. Where you want a looser rule than the host's for your own purposes, say so in your code with a named multiplier of this value rather than a bare constant, so the relationship survives an author widening the radius.
Gotchas
Overlapping radii resolve to the nearest point, not the first. snapObjectAfterDrop keeps scanning and takes the
smallest squared distance among every point that contains the drop.
A wide radius steals from the grid and from board-local points. Scene points are tested before a board's own
metadata.snapPoints and before the half-foot grid snap, so a 2-foot radius suppresses both across its whole
footprint.
See also
TableSnapPointState.position— the center the radius measures from.TableSnapPointState.rotationY— what capture does after the distance test.TableSnapPointState— the shape this belongs to.- Limits and caps — the schema ceilings in one place.
tablesnappointstate.ownerSeat#
readonly ownerSeat: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Which seat the author meant this point for. It is carried in every snapshot and the runtime never reads it:
snapObjectAfterDrop (apps/web/src/playcanvas/TabletopRuntime.ts) captures any entity that any player drops within
range, whatever seat is written here. The two writers are upsertSnapPoint, which defaults it to null, and
sanitizeSnapshotSnapPoints, which normalizes any non-string to null.
Returns
string | null. A seat identifier such as south when an author assigned one. null means the point belongs to no
seat, which is the ordinary state — the editor's snap tool writes null for every point it places, so a grid laid
with the tool is entirely unowned unless a setup.json says otherwise.
How, why and when to use it
You want a "these are your squares" rule — a player may place only into their own row — and this field is the
author's declaration of which squares those are. The alternative is inferring ownership from geometry, splitting the
board by which half of the table a point sits in, which breaks on any layout that is not two-sided. Read the field,
compare it against api.getMySeat, and enforce the rule in your own logic —
because the host will not: a point's owner seat is documentation for your mod, not a gate.
Gotchas
Ownership here restricts nothing. A player in the north seat can drop onto a point owned by south and the host
snaps it into place. There is no mod-reachable gate that refuses a placement by seat: enforce the rule in your own
handler, or accept the drop and correct it.
A seat is not a peer. Compare against a seat identifier, never a peer id.
See also
api.getMySeat— the viewer's seat.TableObjectState.ownerSeat— possession of an entity.
tablesnappointstate.metadata#
readonly metadata: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
A freeform bag hung on the snap point: Record<string, unknown>, always present, empty by default. The runtime writes
it and never reads it — upsertSnapPoint defaults it to {} and sanitizeSnapshotSnapPoints shallow-copies whatever
arrived (apps/web/src/playcanvas/TabletopRuntime.ts). It is the only place on a snap point to record what a square
means, since the shape itself carries no kind, no index and no board reference.
Returns
Readonly<Record<string, unknown>>. Never null and never absent; an unannotated point carries {}. Values are
unknown, so narrow each one before you read it.
How, why and when to use it
You are turning a grid of snap points into a playable board, which needs each square to know its file and rank, its
terrain, or which board it belongs to — none of which TableSnapPointState has a field for. The alternative is to
derive all of that from coordinates in your script, which works for a regular grid and stops working the moment the
author places an irregular board, and which puts the board's design in your JavaScript instead of in the scene.
Prefer metadata for anything intrinsic to the square; prefer your own saved data for anything that changes during
play, because a mod cannot write here.
Gotchas
A mod cannot write it. No api method emits a snap-point intent, so this bag is exactly what the author left.
Every key you add makes every broadcast more expensive. computeSnapshotRestSignature
(packages/shared/src/snapshotDelta.ts) JSON.stringifys the entire snapPoints array on every broadcast to decide
whether a delta is possible, and any change at all in that array forces a full snapshot. A board with a thousand
annotated points pays that stringify on every frame that broadcasts.
See also
TableSnapPointState— the shape this hangs on.TableSnapPointState.label— the one named string the shape does have.api.getSavedData— the writable store for state that changes.setup.json— where an authored bag comes from.
TableVectorLineState#
Surface B — mod script · interface · 6 members
One drawn line: an ordered run of at least two points in feet, a hex color, a thickness in feet, and a rotation the
renderer does not use. Every line on the table is a player's annotation — the drawing tool commits one line per
gesture, whether the gesture was freehand or one of the generated line / box / circle shapes. You reach the list
through api.getSnapshot().vectorLines, and that is the only route: no api method creates, edits or deletes a
vector line.
How, why and when to use it#
You want a house-rules mod that reacts to what players have drawn — clearing the table's annotations at the start of a
round, or counting how many marks a player left in a region. The alternative is to model the marks as entities you
spawn with api.createObject, which gives you something you can move and
delete but is not what the drawing tool produces, so it will not see a single stroke a player actually made. Read this
collection when you want to observe drawing; spawn entities when you want marks your mod owns. Since your mod cannot
remove a line, a rule that depends on the table being clean has to ask players to clear it rather than doing it for
them.
Gotchas#
Lines are drawn flat, and the renderer ignores rotation. createVectorLineOverlayEntity
(apps/web/src/playcanvas/TabletopRuntime.ts) lays one horizontal plane per segment, yaws it from the segment's x
and z, and places it just above the higher of the two endpoints. The rotation field is carried in the snapshot and
never read.
A mod's copy is a clone, not a live handle. api.getSnapshot hands you a structured clone of the last snapshot
this peer received (apps/web/src/ui/App.tsx, the runner's getSnapshot), so writing into points changes your copy
and nothing else.
TableSnapshot.vectorLines is optional. It is declared readonly vectorLines?: readonly TableVectorLineState[],
so read it as snapshot.vectorLines ?? [] before you iterate.
See also#
TableSnapshot.vectorLines— the array this shape fills.api.getSnapshot— the only call that reaches it.TableDecalState— the other free-drawn overlay on the same snapshot.Vector3— the shape of each point.- Limits and caps — the 10000-line ceiling on a snapshot.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
points |
readonly Vector3[] |
|
color |
string |
|
thickness |
number |
|
rotation |
Readonly<Vector3> |
|
metadata |
Readonly<Record<string, unknown>> |
tablevectorlinestate.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The line's address. tableVectorLineStateSchema (packages/shared/src/tableObjects.ts) declares it
z.string().min(1).max(96). The drawing tool mints a crypto.randomUUID() per committed gesture
(finishDrawGesture, apps/web/src/playcanvas/TabletopRuntime.ts), and sanitizeSnapshotVectorLines mints one for
any line that arrives without.
How, why and when to use it
You are diffing snapshots to notice what players drew since your last look, and the id is what makes that a set
difference rather than a geometry comparison. The alternative — comparing
points arrays — is expensive on a freehand stroke, which can
carry a thousand points, and gives the wrong answer for two identical strokes drawn twice. Keep the ids you have
seen, and treat any id you have not as new.
Gotchas
Ids are not sequential and carry no draw order. Position in the vectorLines array is append order for the
session, and a redraw of an edited line reuses no id from before it.
A synthesized id is per-peer. An id that sanitizeSnapshotVectorLines minted during this peer's parse is not a
value another peer shares.
See also
TableVectorLineState— the shape this addresses.TableVectorLineState.points— the geometry it names.TableSnapshot.vectorLines— the array to diff.api.getSnapshot— the call that returns it.
tablevectorlinestate.points#
readonly points: readonly Vector3[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The line's vertices in order, each a { x, y, z } record in feet. The schema requires at least 2 and at most
1000 (tableVectorLineStateSchema, packages/shared/src/tableObjects.ts), and sanitizeSnapshotVectorLines
drops the whole line rather than repairing it when fewer than two survive. The drawing tool rounds every
coordinate to three decimal places and discards any sample less than 0.015 feet from the previous one, so a freehand
stroke is already decimated before it reaches you.
Returns
readonly Vector3[]. Never empty and never shorter than two entries — anything shorter never enters the snapshot.
The array is a segment list, not a polygon: the renderer draws one quad from points[i] to points[i + 1] and never
closes the loop. The box and circle shapes therefore arrive with their first vertex repeated as the last one
(generateShapePoints, apps/web/src/playcanvas/TabletopRuntime.ts), which is how they read as closed.
How, why and when to use it
You want to know whether a player's mark falls inside a region your rules care about — a drawn boundary around a
territory, a line struck through a track. Reading the vertices is the only way to answer that: a drawn line is not an
entity, so api.listObjects will never return it and no filter reaches it.
Work in the horizontal plane when you test containment, because that is the plane the renderer flattens the stroke
into anyway.
Gotchas
Vertical offsets are not drawn. createVectorLineOverlayEntity (apps/web/src/playcanvas/TabletopRuntime.ts)
measures each segment's length from x and z only and places the quad at the higher of the two endpoints' y. A
segment whose endpoints differ only in height has zero measured length and is skipped entirely.
A thousand points is a legal stroke. A single freehand line can be the largest thing in a snapshot. Budget your own per-frame work accordingly, and prefer a cached summary over rescanning the array on every event.
See also
Vector3— the record shape of each vertex.TableVectorLineState.thickness— how wide the segments are drawn.TableVectorLineState— the shape this belongs to.- Limits and caps — the 1000-point and 10000-line ceilings.
tablevectorlinestate.color#
readonly color: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The stroke color as a six-digit hex string with a leading #. tableVectorLineStateSchema
(packages/shared/src/tableObjects.ts) enforces /^#[0-9a-f]{6}$/i, so there is no three-digit short form, no named
color and no alpha channel — the renderer applies a fixed 0.92 opacity of its own. Case is not normalized: #00E5FF
and #00e5ff both validate and both reach you exactly as written. The drawing tool's default is #00e5ff, and
sanitizeSnapshotVectorLines substitutes #ffffff for anything that fails the pattern.
Returns
string. Always a #rrggbb string after validation, never null and never empty. Lowercase it before you compare —
two players who picked the same swatch can still produce strings that differ by case.
How, why and when to use it
You want to attribute a drawing to the player who made it — "clear Blue's marks", or score only the lines a
particular side drew — and color is the only signal on the shape that distinguishes one author's strokes from
another's, since TableVectorLineState carries no actor field. The alternative is the event log: the host writes
updated vector line with the drawer as actor and the line's id as objectId (handleVectorLineUpsertIntent,
apps/web/src/playcanvas/intent/handlers.ts), which is authoritative but reaches you only through a hook you have to
be subscribed for at the time. Read color for a state-driven rule and the log for an event-driven one — and treat
color as a convention players can break, because nothing stops two of them choosing the same swatch.
Gotchas
The rendered color is not exactly this value. createFlatOverlayMaterial (apps/web/src/playcanvas/TabletopRuntime.ts)
copies the diffuse into the emissive at half intensity and blends at 0.92 opacity, so the line on screen is brighter
and more translucent than the hex suggests. Use the value for identity, not for matching a swatch in your own UI
pixel-for-pixel.
A rejected color becomes white, not the tool default. The sanitizer's fallback is #ffffff; the drawing tool's
default is #00e5ff. A white line means the value failed validation somewhere upstream.
See also
TableVectorLineState.thickness— the other appearance field.TableVectorLineState— the shape this belongs to.TableEvent— the log line that names who drew.TableTextLabelState— the other annotation with a hex color.
tablevectorlinestate.thickness#
readonly thickness: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How wide the stroke is, in feet, measured across the line rather than along it. tableVectorLineStateSchema
(packages/shared/src/tableObjects.ts) requires a positive number no greater than 10, and
sanitizeSnapshotVectorLines repairs rather than drops: a missing or non-positive value becomes 0.1 feet, and
anything above the ceiling is clamped to 10.
Returns
number. Always greater than zero, so you can divide by it without a guard.
How, why and when to use it
You are testing whether a token sits on a drawn boundary rather than merely near it:
points gives you the centerline, and this is the only field
that tells you how wide the band around that centerline is. The alternative is a tolerance constant of your own,
which is what most rules end up using and which stops matching the moment a player picks a different pen width.
Take the band from the line, not from your code — and halve it, because the value is the full width and a distance
test wants the distance from the center.
Gotchas
The drawn width is not the stored value. createVectorLineOverlayEntity
(apps/web/src/playcanvas/TabletopRuntime.ts) clamps to 0.005–0.24 feet before it scales the segment quads, so a
line stored at 10 draws exactly like one stored at 0.24 and a line stored at 0.001 draws like one at 0.005. If your
rule has to agree with what a player can see, clamp the same way.
The drawing tool's own range is narrower than the schema's. It offers 0.001–0.5 feet with a default of 0.08, so every value a player produces sits far below the schema ceiling. A stroke above 0.5 feet came from somewhere other than the tool.
The repair value and the tool default differ. A line at exactly 0.1 feet was most likely repaired by the sanitizer; the tool's untouched default is 0.08.
See also
TableVectorLineState.points— the centerline this widens.TableVectorLineState.color— the other appearance field.TableVectorLineState— the shape this belongs to.- Limits and caps — the schema ceilings on a snapshot.
tablevectorlinestate.rotation#
readonly rotation: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
An orientation triple carried alongside the stroke. The drawing tool writes { x: 0, y: 0, z: 0 } on every gesture
it commits (finishDrawGesture, apps/web/src/playcanvas/TabletopRuntime.ts), and
sanitizeSnapshotVectorLines substitutes the same zero triple whenever the field is missing or is not an object.
Nothing in the renderer reads it: createVectorLineOverlayEntity derives each segment's heading from the two
vertices it joins and places the quad from points alone, so a line's orientation lives entirely in its geometry.
How, why and when to use it
You want a stroke's bearing — "did the player rule this row through lengthwise, or across it?" — and this is the
field the name points you at. It is not the answer. Take the first and last entries of
points and measure the bearing between them in the
horizontal plane, which is both what is on screen and what survives a line drawn in either direction. Read this
field only when you are round-tripping a whole TableVectorLineState and need to preserve every property.
Gotchas
The sanitizer does not check the members. It accepts any object as the triple without looking for x, y or
z, so a line that arrived from outside the drawing tool can reach you with rotation.x as undefined even
though the declared type says number. Check before you do arithmetic on it.
A non-zero value means nothing has happened. No path in the runtime writes anything but zeros here and no path reads them back, so a non-zero triple is a marker that some other producer wrote the snapshot — not a sign that the line on screen is turned.
See also
TableVectorLineState.points— where a stroke's orientation actually is.TableVectorLineState— the shape this belongs to.TableDecalState.rotation— the same-named field on the other overlay, with different rules.Vector3— the record shape.
tablevectorlinestate.metadata#
readonly metadata: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
A free-form bag of values carried with the stroke, typed z.record(z.string(), z.unknown())
(tableVectorLineStateSchema, packages/shared/src/tableObjects.ts). It is always an object:
sanitizeSnapshotVectorLines shallow-copies whatever object arrives and substitutes {} for anything that is not
one. The drawing tool fills exactly two keys — interactionMode, always the string "draw", and drawingMode,
one of "freehand", "line", "box" or "circle".
How, why and when to use it
You want to treat a deliberately drawn "box" as a region your rules respect and ignore the freehand scribbling
around it. drawingMode is the only field that separates them — a box and a rough hand-drawn rectangle produce
points arrays that look alike, and the box's four corners are not distinguishable from a careful freehand loop
once they are in the array. Branch on drawingMode when the intent behind a stroke matters, and fall back to
measuring points only when it is absent.
Gotchas
Neither key is guaranteed. The schema constrains nothing about the contents, and the two the tool writes are a
convention rather than a contract. Test with typeof line.metadata.drawingMode === "string" before you switch on
it, and have an answer for a line that carries {}.
A mod cannot write here. api exposes no vector-line method at all, so this is something you read about a
player's stroke, never something you annotate. Keep your own per-line notes in
api.setSavedData, keyed by the line's
id.
Values are typed unknown. Nested objects, arrays, numbers and null all validate and all survive the
snapshot's clone. Narrow a value's type before you index into it.
See also
TableVectorLineState— the shape this belongs to.TableVectorLineState.points— the geometrydrawingModedescribes.TableDecalState.metadata— the same bag on the other overlay, where it is load-bearing.api.setSavedData— where a mod's own state goes instead.
TableDecalState#
Surface B — mod script · interface · 7 members
One stamped image: a texture URL, a position in feet, a rotation in degrees and a per-axis scale, rendered as a thin
unlit plane laid on whatever the stamp tool was pointing at. Every decal on the table is a player's stamp — the tool
commits one per click. You reach the list through api.getSnapshot().decals, and that is the only route: no api
method creates, edits or deletes a decal.
How, why and when to use it#
You want a mod that notices when a player has marked a board — a claim token stamped on a territory, a scoring mark
left on a track — and folds that into your own state. The alternative is to spawn a flat entity with
api.createObject, which is what you want when the mark is yours: an
entity has an id you can act on, a kind, tags, and a destroy action, where a decal has none of that from a mod's
side. Read this collection to observe what players stamped; spawn an entity when your rules need to place, move or
remove the mark themselves.
Gotchas#
A mod cannot ship decals. Loading a mod's edit-scene setup.json pushes decals: [] and vectorLines: [] into
the table (applyEditSceneSnapshot, apps/web/src/ui/GameCanvas.tsx), and exportEditSceneSnapshot
(apps/web/src/playcanvas/TabletopRuntime.ts) writes neither collection back out. Every decal a mod ever sees was
stamped by a player during the session.
metadata decides which of three transforms the renderer uses. With metadata.parentObjectId naming a live
entity the decal becomes a clipped mesh child of that entity; with metadata.surfaceNormal it is aligned to that
normal; with neither it falls back to the plain position / rotation / scale transform. createDecalOverlayEntity
and createParentedDecalEntity (apps/web/src/playcanvas/TabletopRuntime.ts) hold the three branches.
TableSnapshot.decals is optional. It is declared readonly decals?: readonly TableDecalState[], so read it as
snapshot.decals ?? [] before you iterate.
See also#
TableSnapshot.decals— the array this shape fills.api.getSnapshot— the only call that reaches it.TableVectorLineState— the other free-drawn overlay on the same snapshot.api.createObject— the mod-owned alternative to a stamp.- Limits and caps — the 5000-decal ceiling on a snapshot.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
name |
string |
|
url |
string |
|
position |
Readonly<Vector3> |
|
rotation |
Readonly<Vector3> |
|
scale |
Readonly<Vector3> |
|
metadata |
Readonly<Record<string, unknown>> |
tabledecalstate.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The decal's address in the snapshot. tableDecalStateSchema (packages/shared/src/tableObjects.ts) declares it
z.string().min(1).max(96). The stamp tool mints a crypto.randomUUID() per click (placeStamp,
apps/web/src/playcanvas/TabletopRuntime.ts), and sanitizeSnapshotDecals mints one for any entry that arrives
without a string in the slot. It is the only field on a decal that is unique — name is a repeated display string
and two stamps can share a position.
How, why and when to use it
You are scoring stamps as players place them and need a key for "already counted." The id is that key: hold the
set of ids you have seen in api.setSavedData, and treat any id in
snapshot.decals you have not seen as new. The alternative most rules reach for is keying by position, which
breaks as soon as two players stamp the same square, and by name, which the stamp tool sets to the same literal
for every decal it commits. It is also the join key back to the event log: the host writes
updated decal <name> and deleted decal <name> with this id as the line's
objectId (handleDecalUpsertIntent and handleDecalDeleteIntent,
apps/web/src/playcanvas/intent/handlers.ts), which is how you find out who stamped one.
Gotchas
No api method takes a decal id. It is a correlation key, not a handle — there is nothing to pass it to. A mod
observes decals and cannot create, move or remove one.
A synthesized id is per-peer. An id sanitizeSnapshotDecals minted while this peer parsed the snapshot is not
a value any other peer shares, so never send one over your own channel as if it were stable.
Array position is not identity. snapshot.decals is in the order the host applied the upserts and a deleted
decal shifts everything after it. Diff on the id.
See also
TableDecalState— the shape this addresses.TableDecalState.name— the display string it is not.TableSnapshot.decals— the array to diff.TableEvent.objectId— where the same id names the actor's log line.
tabledecalstate.name#
readonly name: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
A human-readable display string for the stamp, 1 to 80 characters
(tableDecalStateSchema, packages/shared/src/tableObjects.ts). There is no character-set rule, no uniqueness
rule and nothing that resolves a decal by it — it is free-form text that rides along for people to read.
sanitizeSnapshotDecals truncates anything longer than 80 characters and substitutes the literal "Decal" for a
missing or empty value.
Returns
string, never empty. In a live session it is almost always the literal "Stamp": the stamp tool writes that
same word on every click (placeStamp, apps/web/src/playcanvas/TabletopRuntime.ts), so the field carries no
information that distinguishes one player's mark from another's.
How, why and when to use it
You are writing a line to the table log — "cleared 4 stamps" — and want the wording to match what a player sees in
the host's own log lines, which read updated decal <name>. That is the whole of this field's usefulness. For
anything your rules branch on, use id to tell two decals apart and
metadata to find out what a decal was stamped onto; both carry
real information, and this one carries a word.
Gotchas
It is not an id and not a label. No lookup accepts it, nothing enforces uniqueness, and it is not a slug —
two decals with the same name are two different decals and the platform is content with that.
Do not use it to detect your own mod's marks. A mod cannot create a decal at all, so every name you read was written by the table's own tools. There is no path by which a name you chose appears here.
See also
TableDecalState.id— the address, and the thing to branch on.TableDecalState— the shape this belongs to.TableEvent.message— the log line this name is spliced into.api.log— writing your own line beside it.
tabledecalstate.url#
readonly url: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The image the stamp shows, as a directly loadable URL of 1 to 2048 characters
(tableDecalStateSchema, packages/shared/src/tableObjects.ts). It reaches the renderer unchanged:
getDecalOverlayMaterial hands the string to loadTextureIntoMaterial
(apps/web/src/playcanvas/TabletopRuntime.ts), which sets it as an image source with crossOrigin set to
"anonymous". The stamp tool narrows what a player can produce to a site-absolute path or an http/https URL,
falling back to /dicey-table-logo.png for anything else (normalizeStampUrl).
Returns
string, never empty. sanitizeSnapshotDecals drops the whole decal when the url is missing or empty rather
than repairing it, so an entry that reached you has one.
How, why and when to use it
You want to tell one kind of mark from another — a claim token versus a scoring cross — and the image is the only
field that carries the player's choice, since name is the same
literal for every stamp. Compare against the exact strings your game's own art is served from and treat anything
else as an unrecognized mark. The alternative is to derive intent from position, which needs a board layout your
mod cannot see; matching the url is the cheaper answer, and the honest one is to accept that a player can stamp
any image they like and design a rule that does not break when they do.
Gotchas
It is not a mod asset reference. The repo-relative paths you use for models, textures and sound variants are resolved through the asset resolver; this string is not, so a repo-relative path here reaches the image loader as a literal and does not resolve. Nothing about the image is stored on the platform's servers either — every peer fetches the bytes from the URL itself.
A failed load leaves a blank stamp and tells nobody. loadTextureIntoMaterial is given a one-entry list, so
when the image errors — the origin is down, the file moved, the response refuses a cross-origin read — there is no
next candidate to try, the material keeps no diffuse map, and no diagnostic reaches your mod. A decal you can see
in the snapshot is not proof a player can see anything on the table.
Materials are cached by the exact string. Two decals sharing a url share one texture; two urls differing only by a query string are two textures and two loads.
See also
TableDecalState— the shape this belongs to.TableDecalState.scale— how big the image is projected.- Mod assets — the repo-relative pipeline this field does not use.
api.getSnapshot— the only call that reaches a decal.
tabledecalstate.position#
readonly position: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Where the stamp sits, in feet, as a world-space point — the same frame of reference as
TableObjectState.position, so { x: 0, y: 1, z: 0 } is one foot above the table origin. The stamp tool records
the point on the surface the player clicked, nudged 0.004 feet out along that surface's normal so the plane starts
clear of it (placeStamp, apps/web/src/playcanvas/TabletopRuntime.ts); the renderer then lifts it a further
0.006 feet along the same normal before drawing.
How, why and when to use it
You want to know which region of a board a mark landed in — which territory, which track square — and this is the
point to test. Compare it against your own region bounds in feet, and do the test in the horizontal plane: a stamp
on a table top and a stamp on a card lying on that table differ by a few thousandths of a foot in y, which is
not a difference a rule can act on. The alternative is
metadata.parentObjectId, which names the entity the stamp
was placed on outright and is the better answer whenever your regions are entities — use the coordinates only
when the region is a painted area of a single board.
Gotchas
It is the world point at the moment of the click, and nothing rewrites it. A stamp placed on a board that a
player then drags across the table keeps the coordinates it was stamped with; the only writer is the upsert that
created it. Read metadata.parentObjectId and look the entity up with
api.getObject if you need to know where the mark is now.
The sanitizer does not check the members. It accepts any object in the slot without looking for x, y or
z, so a decal from outside the stamp tool can reach you with position.x as undefined despite the declared
type. Check before you do arithmetic.
The two normal offsets are render bookkeeping. The 0.004 and 0.006 feet exist to stop the plane fighting with
the surface underneath it for depth, not to describe anything about the mark. Do not treat the y value as a
height a rule can read.
See also
TableDecalState.scale— how far the stamp reaches from this point.TableDecalState.metadata— the entity the stamp was placed on.Vector3— the record shape.api.getObject— resolving that entity's current position.
tabledecalstate.rotation#
readonly rotation: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Euler angles in degrees for the stamp's plane, and the least useful of the three transform fields. The renderer
reads it on exactly one of its three branches — the fallback in createDecalOverlayEntity
(apps/web/src/playcanvas/TabletopRuntime.ts) taken only when the decal carries no metadata.surfaceNormal. Every
stamp a player commits carries one, so a decal's real orientation comes from
metadata: the surface normal it was projected against, plus
metadata.surfaceRotationDeg for how far the player spun the stamp around that normal.
How, why and when to use it
You want to know which way up a mark was placed — an arrow token pointing at a neighbor, a directional claim
marker — and this is the field the name sends you to. It is the wrong one. Read metadata.surfaceRotationDeg,
which is the angle the player actually chose and is wrapped into a single degrees value about the surface normal,
and read metadata.surfaceNormal for which way the stamped surface faces. Come back to this field only if you are
copying a whole TableDecalState and have to preserve every property on it.
Gotchas
The value the stamp tool writes is never applied. It writes { x: -90, y: 0, z: 0 } on every click and sets a
surface normal in the same breath, which sends the renderer down the branch that ignores this field entirely. A
-90 here tells you the decal came from the stamp tool, and nothing about how it looks.
The sanitizer does not check the members. Any object passes into the slot without a check for x, y or z,
so rotation.y can be undefined at runtime even though the declared type says number.
See also
TableDecalState.metadata—surfaceNormalandsurfaceRotationDeg, where the orientation really is.TableDecalState.position— the point this would turn about.TableDecalState— the shape this belongs to, and its three transform branches.TableVectorLineState.rotation— the same-named field on the other overlay.
tabledecalstate.scale#
readonly scale: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How big the stamp is projected, in feet. Declared as a per-axis triple, but the two branches the renderer
actually takes read scale.x only and use it as the square stamp's edge length:
createDecalOverlayEntity passes Math.max(0.02, Math.abs(scale.x)) to the surface-aligned transform, and
createParentedDecalEntity builds its clipped mesh from the same single number
(apps/web/src/playcanvas/TabletopRuntime.ts). scale.y and scale.z are read only on the fallback branch that
a player's stamp never takes. The stamp tool writes the same number into all three, in the range 0.2–4 feet with a
default of 0.8.
How, why and when to use it
You are testing whether a stamp covers a square on a board, and a point test against
position is not enough because a large stamp overlaps
neighbors. Take scale.x as the full edge length in feet and half it for a radius around the center point. The
alternative — treating the mark as a point and asking only which square its center is in — is the right choice
when your squares are much larger than a stamp, and stops being right the moment a player scales one up to cover
three of them.
Gotchas
A negative or tiny value does not shrink to nothing. The renderer takes the absolute value and floors it at 0.02 feet, and the surface-aligned branch clamps the result into 0.2–4 feet, so a stamp stored at 0.05 draws at 0.2 and one stored at 40 draws at 4. Clamp the same way if your rule has to agree with what a player sees.
Do not average the three axes. They are equal on every stamp the tool produces, but the value the renderer
uses is x, so x is the one to read. Averaging turns a malformed triple into a plausible-looking wrong answer.
The sanitizer does not check the members. Any object passes into the slot without a check for x, y or z,
and the default it substitutes when the field is missing entirely is { x: 1, y: 1, z: 1 } — one foot across, not
the tool's 0.8.
See also
TableDecalState.position— the center this measures out from.TableDecalState.url— the image being sized.TableDecalState.metadata— which of the three transform branches applies.Vector3— the record shape.
tabledecalstate.metadata#
readonly metadata: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
A free-form bag typed z.record(z.string(), z.unknown()) (tableDecalStateSchema,
packages/shared/src/tableObjects.ts) — and on a decal it is not decoration. It is the field that decides how the
stamp is drawn and the only field that links a stamp to an entity. sanitizeSnapshotDecals shallow-copies
whatever object arrives and substitutes {} for anything that is not one, so it is never null. The stamp tool
writes four keys:
| Key | Value | What it does |
|---|---|---|
interactionMode |
"stamp" |
Marks the decal as tool-placed. |
surfaceNormal |
{ x, y, z }, normalized |
Which way the stamped surface faces. Its presence selects the surface-aligned transform. |
surfaceRotationDeg |
number |
How far the player spun the stamp about that normal. |
parentObjectId |
entity id or null |
The entity the stamp landed on, if any. |
How, why and when to use it
You want to know what a player stamped, not only where — "is there a claim marker on this territory card?" —
and parentObjectId answers it directly: pass it to api.getObject and
you have the entity, its kind, its tags and its current position. The alternative is a coordinate test against
position, which is what you reach for first and which keeps
answering the old question after the board has been dragged, because the decal's stored coordinates are the ones
recorded at the click. Use the parent id whenever the thing being marked is an entity, and coordinates only for a
painted region of one.
Gotchas
None of the four keys is schema-guaranteed. The schema constrains nothing about the contents; the four are a
convention the stamp tool follows. Check each with a typeof test, and treat parentObjectId as absent when it
is null — the tool writes null explicitly for a stamp that landed on the table itself.
parentObjectId can name an entity that no longer exists. Nothing removes a decal when its parent is deleted;
refreshDecalOverlays falls back to drawing it in world space. A getObject that resolves null here
means the mark outlived what it was marking.
A mod cannot write here. api exposes no decal method, so this is read-only in every direction. Keep your own
notes about a stamp in api.setSavedData, keyed by the decal's
id.
See also
TableDecalState— the shape this belongs to, and its three transform branches.TableDecalState.rotation— the fieldsurfaceRotationDegreplaces.api.getObject— resolvingparentObjectId.TableVectorLineState.metadata— the same bag on the other overlay.
TableTextLabelState#
Surface B — mod script · interface · 7 members
One free-standing text annotation positioned in the table's world space, with no entity behind it. Seven fields:
an id that addresses it, the
text itself, a world
position in feet, a
scale multiplier, a hex
color, a
fontSize and a
metadata bag. You reach labels through
TableSnapshot.textLabels, which a live host always populates.
Applies to: nothing kind-specific. A text label is its own collection — it is not an entity, it has no rigidbody, and no object kind carries one.
How, why and when to use it#
You have inherited a table built by someone else — an import, a saved session, an authoring tool — and you want to
know what annotations it carries so your mod can key off them (a lane marker, a scoring track, a "deal here"
note). The alternative most authors reach for is spawning a token entity per annotation and reading it back with
api.listObjects; that puts a physics body on the table for something
that never needed one and makes the annotation draggable. Read textLabels when the annotation is authored data
you want to consume, and use api.setUiElement instead whenever a
player has to actually see something — that is the surface with a renderer behind it.
Gotchas#
Known gap. Nothing draws a text label. The host stores, replicates, sanitizes, migrates and persists the collection (
apps/web/src/playcanvas/TabletopRuntime.ts,upsertTextLabel,sanitizeSnapshotTextLabelsandsnapshot), and the table UI'saddTextLabelhandler inapps/web/src/ui/App.tsxis written but reaches no control, so no render path and no authoring control exist for it today. The data half is complete and correct: a label written into a snapshot survives a save, a reload, a delta rebuild and a host migration unchanged, and every field is validated. Read labels as annotation data, and put anything a player must see in front of them through the UI element API. See Known limitations.
A mod cannot create, edit or delete one. api has no text-label method at all. The only way into the
collection is the host applying a text-label-upsert or text-label-delete intent
(apps/web/src/playcanvas/intent/handlers.ts, handleTextLabelUpsertIntent), which a mod cannot emit.
Every label is rebuilt from scratch on each snapshot the host applies. applySnapshot replaces the whole array
through sanitizeSnapshotTextLabels, so an identity comparison against a label object you kept from an earlier
read never matches. Copy the fields you need out.
See also#
TableSnapshot.textLabels— where you get the array.api.getSnapshot— the one read that returns it.api.setUiElement— the surface that does render text to players.- Limits and caps — the 1000-label ceiling and every other collection cap.
- Known limitations — the full list of documented gaps.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
text |
string |
|
position |
Readonly<Vector3> |
|
scale |
number |
|
color |
string |
|
fontSize |
number |
|
metadata |
Readonly<Record<string, unknown>> |
tabletextlabelstate.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The label's address. It is a string of 1–96 characters (tableTextLabelStateSchema,
packages/shared/src/tableObjects.ts), unique within
TableSnapshot.textLabels, and it is what the host's upsert
keys on: writing a label whose id already exists replaces that entry rather than adding a second one
(apps/web/src/playcanvas/TabletopRuntime.ts, upsertTextLabel).
How, why and when to use it
You are correlating labels across two reads — a snapshot you took at setup and one you take three turns later —
and you need to know which entries are the same annotation. The alternative that looks obvious is matching on
text, and it fails the first time two lanes are both labeled
Discard or somebody edits the wording. Key your own Map on id, exactly as the host does, and treat text as
display copy that can change under you.
Gotchas
The host mints one when the intent omits it. upsertTextLabel falls back to crypto.randomUUID(), so a label
created without an explicit id gets a UUID that nothing else predicts. An intent that wants a stable, meaningful
id has to supply it.
A repaired snapshot can renumber a label. sanitizeSnapshotTextLabels replaces a non-string id with a fresh
crypto.randomUUID() rather than dropping the entry, so ids survive a normal session intact but are not
guaranteed across a corrupted or hand-edited save.
It addresses a label, never an entity. Passing one to
api.getObject resolves null — labels and entities are separate
collections with separate id spaces.
See also
TableTextLabelState— the shape this addresses.TableSnapshot.textLabels— the array it indexes into.TableTextLabelState.text— the field you would wrongly match on.- Intents — the
text-label-upsertintent that sets it.
tabletextlabelstate.text#
readonly text: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The label's content — the words the annotation carries. It is a string of 1–256 characters
(tableTextLabelStateSchema, packages/shared/src/tableObjects.ts), free-form, with no markup and no escaping
applied. The host echoes it into the event log when a label changes: the line reads
updated text label "<text>" (apps/web/src/playcanvas/intent/handlers.ts, handleTextLabelUpsertIntent).
How, why and when to use it
Your mod keys off annotations an author placed — a lane called Discard, a track called Round — and text is
the string you match against. The alternative is stashing the marker in
metadata, which is the better home for anything your
rules depend on: metadata has no length limit, no sanitizer rewrite, and no reason for a human to edit it.
Read text when you are describing the table to a person; read metadata when you are branching on it.
Gotchas
A missing or empty value becomes the literal "Label". sanitizeSnapshotTextLabels
(apps/web/src/playcanvas/TabletopRuntime.ts) substitutes "Label" for anything that is not a non-empty string
and truncates the rest to 256 characters, so this field is never null, never empty, and never longer than 256 —
even when the incoming snapshot said otherwise.
Nothing renders it. The string round-trips through every snapshot correctly and no code draws it — see
TableTextLabelState for the gap and the workaround.
Truncation is by UTF-16 code unit, not by character. substring(0, 256) can cut an emoji or a surrogate pair
in half. Keep well under the limit if the text carries anything outside the Basic Multilingual Plane.
See also
TableTextLabelState— the shape, and why nothing draws it.TableTextLabelState.metadata— the better home for machine-readable state.TableEvent.message— where this string shows up in the log.- Limits and caps — the collection ceilings around it.
tabletextlabelstate.position#
readonly position: Readonly<Vector3>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Where the label sits in the table's world space, as a Vector3 of x, y and
z. World units are feet, and the axes match every other position in a snapshot: x and z run across the
tabletop and y is height, so { x: 0, y: 1, z: 0 } is one foot above the world origin. All three components
must be finite (vector3TupleSchema, packages/shared/src/tableObjects.ts); there is no range clamp.
How, why and when to use it
You want to know which board or seat an annotation belongs to, so you compare its position against
TableObjectState.position and its scale — both in feet, both in
the same frame, so the arithmetic is a plain box test. The alternative is recording the association in
metadata when the label is authored, which is sturdier
because it survives somebody nudging the label later. Use the geometry when you are consuming a table you did not
author, and metadata when you control both ends.
Gotchas
It is a world position, never a local one. Labels have no parent and take part in no hierarchy, so there is no frame to convert from and none of the parenting rules that apply to entities apply here.
The runtime's sanitizer checks only that it is an object. sanitizeSnapshotTextLabels
(apps/web/src/playcanvas/TabletopRuntime.ts) accepts any non-null object for this field and casts it, so the
three-finite-numbers guarantee comes from tableTextLabelStateSchema on the validated paths — a migrateTableSnapshot
load or an import — and not from the live snapshot-apply path. Read the components defensively when the snapshot
came from outside.
Nothing places a marker at this point. The coordinate is stored and replicated and no renderer consumes it —
see TableTextLabelState.
See also
Vector3— the three-number shape.TableTextLabelState— the shape this belongs to.TableSnapshot.textLabels— the array to walk.
tabletextlabelstate.scale#
readonly scale: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
A dimensionless size multiplier for the label as a whole, distinct from
fontSize. It is a number that must be strictly positive
and no greater than 5 (tableTextLabelStateSchema, packages/shared/src/tableObjects.ts). It is not a length,
so it carries no unit — it is a factor, and 1 is the authored default the host applies when an upsert omits it
(apps/web/src/playcanvas/TabletopRuntime.ts, upsertTextLabel).
How, why and when to use it
You are ranking annotations by prominence — a table title against a lane caption — and scale is the field the
author sets to say "this one is bigger". The alternative is reading
fontSize, which is the wrong comparison because the two
fields multiply rather than substitute: a label at scale: 2 with fontSize: 12 is authored larger than one at
scale: 1 with fontSize: 18. Compare the product when you want a single ordering, and read scale alone when
you want the author's explicit emphasis.
Gotchas
Out-of-range input is repaired, not rejected. sanitizeSnapshotTextLabels takes Math.min(scale, 5) for a
positive number and substitutes 1 for anything absent, non-numeric, zero or negative. A snapshot can never
present a scale outside (0, 5].
The authoring path is narrower than the schema. The table UI clamps its draft to [0.2, 5]
(apps/web/src/ui/App.tsx, addTextLabel), so a value under 0.2 is valid state that no in-app control
produces.
Nothing scales. No renderer reads this field — see
TableTextLabelState for the gap and what to use instead.
See also
TableTextLabelState.fontSize— the other size field, and its unit question.TableTextLabelState— the shape, and why nothing draws it.TableTextLabelState.position— the one field here that is measured in feet.- Limits and caps — the caps that apply to the collection.
tabletextlabelstate.color#
readonly color: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The label's color as a six-digit hex string. The schema is a regex, /^#[0-9a-f]{6}$/i
(tableTextLabelStateSchema, packages/shared/src/tableObjects.ts), so #ff8800 is valid and #f80, red,
rgb(255,136,0) and #ff8800ff are all rejected. There is no alpha channel. The host writes #ffffff when an
upsert omits the field (apps/web/src/playcanvas/TabletopRuntime.ts, upsertTextLabel).
How, why and when to use it
Your mod mirrors an authored annotation into a UI panel and wants the panel to match the table's own color
scheme, so you pass this string straight into
api.setUiElement. The alternative is picking a color in your own code
from the label's text or a
metadata key, which is what you want when the color
should follow your rules rather than the author's taste. Prefer this field whenever the author's choice is the
thing you are trying to reproduce.
Gotchas
Invalid input becomes #ffffff, not an error. sanitizeSnapshotTextLabels tests the same regex and falls
back to white for anything that fails, so a snapshot can never present a malformed color and a mistyped color is
indistinguishable from a deliberate white one.
Case is preserved here. The regex is case-insensitive and the text-label sanitizer stores the string as
written, so #FF8800 and #ff8800 both survive as typed. Lowercase before you compare two colors for equality.
Nothing is tinted. No renderer reads this field — see
TableTextLabelState for the gap and the workaround.
See also
TableTextLabelState— the shape, and why nothing draws it.api.setUiElement— the surface that does render to players.TableVectorLineState— the other annotation carrying a hex color.TableTextLabelState.text— the content this colors.
tabletextlabelstate.fontSize#
readonly fontSize: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The label's type size. It is a number that must be strictly positive and no greater than 100
(tableTextLabelStateSchema, packages/shared/src/tableObjects.ts), and the host writes 24 when an upsert
omits it (apps/web/src/playcanvas/TabletopRuntime.ts, upsertTextLabel). The unit is undetermined: no code
in the runtime reads this field, so nothing establishes whether the number means feet, pixels or points — see
Gotchas.
How, why and when to use it
You are ranking authored annotations by prominence and want the author's explicit type size rather than the
scale multiplier alone. The two fields are separate and
multiply, so the honest single ordering is scale * fontSize, and reading fontSize on its own is right only
when you want the base size before emphasis. Because no unit is pinned down, treat the value as an ordering key
rather than a measurement — do not convert it into feet to size something else on the table.
Gotchas
Known gap. Nothing consumes
fontSize, so it has no unit. The host validates it, defaults it to24, caps it at100(sanitizeSnapshotTextLabels) and replicates it in every snapshot, and no render path reads it back — text labels are not drawn at all. The separatefontSizeon the seat-zone label material (apps/web/src/playcanvas/TabletopRuntime.ts,seatZoneLabelMaterial) is measured in world units of height, and it is a different field on a different shape; do not carry that unit over to this one. The value stores and round-trips faithfully, so an authored size survives a save and a host migration intact. Read it as an ordering key, and render player-visible text throughapi.setUiElement. See Known limitations.
Out-of-range input is repaired, not rejected. sanitizeSnapshotTextLabels takes Math.min(fontSize, 100) for
a positive number and substitutes 24 for anything absent, non-numeric, zero or negative, so a snapshot never
presents a value outside (0, 100].
The authoring path is narrower and integral. The table UI rounds its draft and clamps it to [8, 100]
(apps/web/src/ui/App.tsx, addTextLabel), which is the closest thing to a unit hint the code offers — an
integer in that band reads as a point-or-pixel size rather than a length in feet. It remains a hint, not a
guarantee.
See also
TableTextLabelState— the shape, and why nothing draws it.TableTextLabelState.scale— the multiplier this combines with.api.setUiElement— the surface that renders text to players.- Known limitations — the full list of documented gaps.
tabletextlabelstate.metadata#
readonly metadata: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
A free-form bag of author-supplied values carried alongside the label. The schema is
z.record(z.string(), z.unknown()) (tableTextLabelStateSchema, packages/shared/src/tableObjects.ts) — string
keys, anything at all for values, and no key count or value size limit of its own. It is always an object: the
host writes {} when an upsert omits it (apps/web/src/playcanvas/TabletopRuntime.ts, upsertTextLabel).
How, why and when to use it
You need an annotation to mean something to your rules — "this is lane 3", "this is the scoring track" — and
metadata is where that association belongs. The alternative every author tries first is encoding it in
text, which breaks as soon as somebody rewords the caption
and which the sanitizer will happily truncate at 256 characters. Put the machine-readable key here and let text
stay the words a person reads.
Gotchas
Read-only, and not a place a mod can write. api has no text-label method, so a mod consumes whatever the
author put here and cannot add to it. Per-mod state that needs to persist belongs in
api.setSavedData, which is scoped to your mod and replicated the
same way.
Values are typed unknown, so validate before you trust. The schema accepts any JSON-representable value,
including null, nested objects and arrays, and the snapshot's structured clone preserves all of it. Check the
shape of a value before you index into it.
A non-object is replaced with {}. sanitizeSnapshotTextLabels shallow-copies the incoming object and
substitutes an empty one for anything that is not an object, so the field is never null and never undefined on
a snapshot the host produced.
See also
TableTextLabelState— the shape this belongs to.TableTextLabelState.text— the field to stop overloading.api.setSavedData— where a mod's own persistent state goes.TableSnapshot.modObjectSavedData— the per-entity equivalent, and its caps.
TableJointState#
Surface B — mod script · interface · 7 members
One physics constraint tying two entities together. It names the two endpoints by id, picks one of three
constraint shapes through type, says whether the pair still
collide (enableCollision), and carries the impulse at
which the constraint gives way (breakForce). The host turns
each entry into a PlayCanvas joint component (apps/web/src/playcanvas/TabletopRuntime.ts,
buildJointComponentData); you read them through
TableSnapshot.joints.
Applies to: any two distinct entities that both carry a rigidbody component. refreshJoints skips a joint
whose endpoint is missing or has no rigidbody — the entry stays in the snapshot, and nothing simulates it.
How, why and when to use it#
You want to know why two boards on the table move together before your mod tries to reposition one of them
independently, or you want to spot the hinge holding a box lid so your rules do not treat the lid as a loose
entity. The alternative reading is TableObjectState.parentId and the welded
assembly it describes, which is what an author uses when two entities must never move relative to each other and
should drag as one. A joint is the other answer: two independent bodies with a constraint between them, so a hinge
can swing and a spring can stretch, and the connection can break. Check joints when a piece is not where your
arithmetic says it should be — a constraint is the usual reason.
Gotchas#
A self-joint is dropped in silence. upsertJoint returns early when objectAId equals objectBId — no
error, no log line, and an existing joint under that same id is left exactly as it was, so the write looks like
it succeeded and changed nothing.
Deleting either entity deletes the joint. removeJointsForObject filters out every joint naming the removed
entity and rebuilds the rest, so a joint never outlives its endpoints.
Every joint entity is destroyed and rebuilt when any joint changes. refreshJoints tears down the whole set
and recreates it from the array, so a table carrying hundreds of joints pays for all of them on each edit.
A mod cannot create, edit or delete one. api has no joint method. The collection changes only when the host
applies a joint-upsert or joint-delete intent (apps/web/src/playcanvas/intent/handlers.ts,
handleJointUpsertIntent), or when an entity is removed.
See also#
TableSnapshot.joints— where you get the array.TableJointState.type— the three constraint shapes, spelled out.api.getSnapshot— the one read that returns it.- Welding — the other way two entities become one moving thing.
- Limits and caps — the 3000-joint ceiling.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
type |
"fixed" | "hinge" | "spring" |
|
objectAId |
string |
|
objectBId |
string |
|
enableCollision |
boolean |
|
breakForce |
number |
|
metadata |
Readonly<Record<string, unknown>> |
tablejointstate.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The joint's address. It is a string of 1–96 characters (tableJointStateSchema,
packages/shared/src/tableObjects.ts), unique within
TableSnapshot.joints, and it is the key the host's upsert matches
on — writing a joint under an existing id replaces that entry rather than adding a second constraint between the
same pair (apps/web/src/playcanvas/TabletopRuntime.ts, upsertJoint). The scene entity the host builds for it
is named Joint:<id> and tagged internal.
How, why and when to use it
You are tracking which constraints your mod has already accounted for across successive snapshot reads, and you
need a stable handle for each. The alternative is keying on the endpoint pair — objectAId plus objectBId —
which looks natural and quietly collapses two joints that connect the same two entities in different ways (a
hinge and a spring holding one lid). Key on id and treat the endpoint pair as a query, not an identity.
Gotchas
The host mints one when the intent omits it. upsertJoint falls back to crypto.randomUUID(), so a joint
created without an explicit id gets an unpredictable UUID.
A repaired snapshot can renumber a joint. sanitizeSnapshotJoints (apps/web/src/playcanvas/TabletopRuntime.ts)
replaces a non-string id with a fresh crypto.randomUUID(), so ids hold within a session and are not guaranteed
across a corrupted or hand-edited save.
An id survives a broken constraint. Nothing removes an entry from joints when the physics constraint gives
way — the array changes only through upsertJoint, deleteJoint and removeJointsForObject. Seeing the id is
not evidence that the joint is still holding.
See also
TableJointState— the shape this addresses.TableSnapshot.joints— the array it indexes into.TableJointState.breakForce— why a live id is not a live constraint.- Intents — the
joint-upsertintent that sets it.
tablejointstate.type#
readonly type: "fixed" | "hinge" | "spring";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Which of the three constraint shapes this joint is. The union is closed — "fixed", "hinge" and "spring",
and nothing else (tableJointTypeSchema, packages/shared/src/tableObjects.ts). The host expands the chosen
value into the six degrees of freedom of a PlayCanvas joint component
(apps/web/src/playcanvas/TabletopRuntime.ts, buildJointComponentData):
type |
Linear X / Y / Z | Angular X / Y / Z | What it does |
|---|---|---|---|
fixed |
locked / locked / locked | locked / locked / locked | The two entities hold a rigid relative pose. All six degrees are frozen. |
hinge |
locked / locked / locked | locked / free / locked | Rotation about the vertical axis only. The other five degrees are frozen. |
spring |
limited ±0.25 ft on each axis, sprung (stiffness 50, damping 0.2) |
locked / locked / locked | The pair can drift up to three inches apart on any axis and is pulled back. No rotation. |
How, why and when to use it
You are deciding whether your mod can move one endpoint independently, and the answer is entirely in this field: a
fixed joint means moving one entity drags the other, a hinge means the second one swings, and a spring means
it follows loosely and returns. The alternative reading is TableObjectState.parentId and its welded assembly,
which is what the author uses when two entities must behave as one draggable body with no give at all. Branch on
type before you compute a target position, and write a default case so a future fourth value does not fall
through silently.
Gotchas
The hinge axis is not configurable, and it is world-vertical. The host creates the joint entity with
setPosition and no rotation (refreshJoints), so the component's frame is world-aligned and the free angular
axis is always Y. There is no field that changes it.
An unrecognized value becomes "fixed". sanitizeSnapshotJoints keeps "hinge" and "spring" and maps
everything else — including a typo, null and a missing field — to "fixed", so a snapshot never presents a
fourth value and a mistyped type is indistinguishable from a deliberate fixed joint.
The spring constants are fixed by the runtime. Stiffness 50, damping 0.2 and a ±0.25-foot limit on each
linear axis are hard-coded in buildJointComponentData; no field on TableJointState tunes them.
See also
TableJointState— the shape this belongs to.TableJointState.breakForce— the one physics dial an author does control.TableJointState.enableCollision— whether the joined pair still collide.- Welding — the rigid alternative to a
fixedjoint.
tablejointstate.objectAId#
readonly objectAId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The id of the first of the two entities the constraint binds — an entity address of 1–96 characters
(tableJointStateSchema, packages/shared/src/tableObjects.ts), not a label and not a displayName. The host
positions the joint's scene entity at this endpoint's world position before attaching the component
(apps/web/src/playcanvas/TabletopRuntime.ts, refreshJoints), which is the only asymmetry between the two ends.
How, why and when to use it
You have an entity id from api.getObject or a hook payload and you want
to know what it is attached to, so you filter joints for entries naming it on either side. The alternative is
inferring the relationship from positions — two entities that move together — which is unreliable the instant a
player drags a stack. Test both objectAId and objectBId in the same pass; the pair order records how the joint
was authored, not a direction of control.
Gotchas
It is not validated against the live table. tableJointStateSchema checks only that the string is 1–96
characters, so a snapshot can name an entity that does not exist. refreshJoints skips such a joint — the entry
stays in joints, and nothing simulates it. Resolve the id before you trust the constraint.
A joint with both ends missing is still an entry. sanitizeSnapshotJoints drops an entry only when an
endpoint is a non-string or when the two ids are equal, not when the entity is absent, so a dangling joint
survives an applySnapshot intact.
Neither endpoint may have label substituted for it. The three names are distinct: id addresses, label is
the slug, displayName is the optional human name. Only the id resolves here.
See also
TableJointState.objectBId— the other endpoint.TableJointState— the shape, and when a joint is skipped.api.getObject— resolving an endpoint id to its entity.TableObjectState— what an entity id addresses.
tablejointstate.objectBId#
readonly objectBId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The id of the second of the two entities the constraint binds — an entity address of 1–96 characters
(tableJointStateSchema, packages/shared/src/tableObjects.ts). It must differ from
objectAId: the host refuses a joint whose two ends are the
same entity (apps/web/src/playcanvas/TabletopRuntime.ts, upsertJoint), and sanitizeSnapshotJoints drops such
an entry outright.
How, why and when to use it
You are walking the joint graph — "what is this lid ultimately attached to?" — and each hop needs the far end of the constraint you are standing on. The alternative is assuming A is always the anchor and B always the moving part; the runtime encodes no such rule, and the only difference between the two ends is that the joint's scene entity is spawned at A's position. Treat the pair as unordered when you are reasoning about the connection, and read the order only when you care where the joint entity sits.
Gotchas
A self-joint is refused in silence. When both ids match, upsertJoint returns without storing anything and
without logging — an existing joint under that id is left as it was, so the write appears to succeed.
It is not validated against the live table. A snapshot can name an entity that has been removed;
refreshJoints skips the joint and the entry stays in joints with nothing simulating it.
Deleting this entity removes the joint. removeJointsForObject filters out every joint naming a removed
entity and rebuilds the set, so a joint never survives the loss of either end.
See also
TableJointState.objectAId— the other endpoint, and the one the joint entity is placed at.TableJointState— the shape, and when a joint is skipped.TableSnapshot.joints— the array to walk.api.getObject— resolving an endpoint id to its entity.
tablejointstate.enableCollision#
readonly enableCollision: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Whether the two joined entities still collide with each other. false — the host's default when an upsert omits
the field (apps/web/src/playcanvas/TabletopRuntime.ts, upsertJoint) — lets them interpenetrate freely, which
is what a hinge on a lid or a spring inside a mechanism normally wants. true keeps the pair solid to each other,
so the constraint and the collision response fight for the same space. The host passes the value straight through
to the PlayCanvas joint component (buildJointComponentData).
How, why and when to use it
You are working out why a hinged lid sinks into its box or judders when it closes, and this flag is the first
thing to read: with true the two bodies are pushing each other apart on every frame the joint pulls them
together. The alternative explanation authors reach for is
breakForce being too low, which produces a joint that
releases and stays released rather than one that vibrates. Read enableCollision for jitter and contact
artifacts; read breakForce when a connection has come apart.
Gotchas
Any truthy value becomes true. sanitizeSnapshotJoints coerces with Boolean(raw.enableCollision), so a
non-empty string or a non-zero number arriving in a snapshot lands as true, and a missing field lands as
false. The state is always a real boolean.
It only governs the joined pair. Collision with every other entity on the table is unaffected — that is the
entities' own collision and rigidbody components, not the joint.
Changing it rebuilds every joint on the table. refreshJoints destroys and recreates the whole joint set on
any change, so toggling this flag on one joint costs the full rebuild.
See also
TableJointState— the shape, and the rebuild behavior.TableJointState.breakForce— the other physics dial, and the other failure mode.TableJointState.type— which degrees of freedom the constraint locks.- RIGIDBODY — the per-entity body settings this interacts with.
tablejointstate.breakForce#
readonly breakForce: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
How much punishment the constraint takes before it gives way. The host hands the number to the PlayCanvas joint
component (apps/web/src/playcanvas/TabletopRuntime.ts, buildJointComponentData), which forwards it to the
physics constraint as its breaking-impulse threshold — so it is a dimensionless dial on the solver, not a force
in pounds or newtons and not a length in feet. The default is 1000: upsertJoint substitutes it when the intent
omits the field and then clamps the result to [1, 1_000_000], and tableJointStateSchema
(packages/shared/src/tableObjects.ts) requires a positive number no greater than 1_000_000.
How, why and when to use it
You are diagnosing a mechanism that keeps coming apart under a hard flick, and this is the number that decides
whether it does. The alternative reading is type — a spring
stretches where a fixed joint stays rigid — and that explains give, not separation. Compare breakForce against
the default when a joint releases and against nothing at all when it merely wobbles. The value at the top of the
range, 1_000_000, is the authored way to say "this never breaks"; the runtime default of 1000 is deliberately
breakable, unlike the engine's own default of roughly 3.4e38.
Gotchas
An absent or invalid value becomes 1000, not "unbreakable". sanitizeSnapshotJoints substitutes 1000 for
anything missing, non-numeric, zero or negative and caps the rest at 1_000_000, so a snapshot never presents a
value outside [1, 1_000_000] — and a joint whose author never thought about breaking is a joint that can break.
A broken constraint stays in the snapshot. Nothing in the runtime removes an entry from
joints when the physics constraint gives way; the array is
changed only by upsertJoint, deleteJoint and removeJointsForObject. Reading a joint tells you it was
authored, not that it is still holding, and there is no field on TableJointState that reports the broken state.
There is no conversion to world units. World distances are feet and entity masses are their own scale, and the
threshold sits on the solver's impulse, so no arithmetic relates this number to either. Tune it by trying values,
and record the working one in metadata if your rules need it.
See also
TableJointState— the shape, and when a joint is not simulated at all.TableJointState.type— the degrees of freedom this threshold protects.TableJointState.enableCollision— the other physics dial, and the other failure mode.- RIGIDBODY — the bodies the constraint acts on.
tablejointstate.metadata#
readonly metadata: Readonly<Record<string, unknown>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
A free-form bag of author-supplied values carried alongside the constraint. The schema is
z.record(z.string(), z.unknown()) (tableJointStateSchema, packages/shared/src/tableObjects.ts) — string
keys, anything at all for values, no key count and no value size limit of its own. It is always an object: the
host writes {} when an upsert omits it (apps/web/src/playcanvas/TabletopRuntime.ts, upsertJoint).
How, why and when to use it
You want a joint to mean something to your rules — "this is the treasure chest lid", "this hinge counts as a door"
— and no other field on TableJointState can carry that. The alternative is inferring the role from the endpoint
ids, which ties your mod to entity ids that change every time the table is rebuilt from a fresh setup. Read the
author's key from metadata and keep your own per-mod state in
api.setSavedData.
Gotchas
Read-only from a mod. api exposes no joint method, so a mod consumes what the author wrote and cannot add
to it, edit it or clear it.
Values are typed unknown. The schema accepts null, numbers, nested objects and arrays alike, and the
snapshot's structured clone preserves all of it. Check a value's shape before indexing into it.
A non-object is replaced with {}. sanitizeSnapshotJoints shallow-copies the incoming object and
substitutes an empty one for anything that is not an object, so the field is never null or undefined on a
snapshot the host produced.
See also
TableJointState— the shape this belongs to.TableTextLabelState.metadata— the same bag on the other annotation.api.setSavedData— where a mod's own persistent state goes.TableSnapshot.modSavedData— how that state rides the snapshot.
TableSnapshot#
Surface B — mod script · interface · 13 members
The whole replicated table. A mod's world IS this snapshot — never the scene
graph. pc.Entity, findByTag and engine guids are not reachable.
On a non-host peer this is the snapshot the HOST sent, so hidden-information
redaction has already been applied: face-down card identities you are not
entitled to are stripped, as are identity-bearing eventLog lines.
The whole replicated table in one object: every entity, the six annotation collections (zones, snap points, vector
lines, decals, text labels, joints), the UI tree, the event log, and the two saved-data maps. This is a mod's
entire world — the PlayCanvas scene graph, pc.Entity and engine guids are not reachable from a mod at all. The
shape is schema v2, and migrateTableSnapshot() (packages/shared/src/tableObjects.ts) upgrades a v1 save to v2
and is idempotent, so an old saved session loads without a mod doing anything.
Applies to: every table, every role. api.getSnapshot is the only call
that returns the whole shape.
How, why and when to use it#
Your mod has been handed a table it did not build — a resumed session, a save someone else made — and you need to
reconstruct your own bookkeeping in one pass before the first turn: which entities exist, what zones they sit in,
what the log already says. The alternative is api.listObjects, which
is the right call in every hot path because it returns entities only, filtered, without cloning the table. Take
the snapshot once at setup for the things listObjects cannot give you — zones, snap points, joints, the UI tree,
the event log — keep what you need, and use the narrow reads afterwards.
Gotchas#
It is a structured clone, so writing to it changes nothing. The host answers
api.getSnapshot with cloneStructured(...)
(apps/web/src/ui/App.tsx, the mod runner's getSnapshot callback). Assigning to a field mutates your private
copy and nothing else; the copy also does not track the table, so it goes stale the moment anything moves.
What a mod receives is redacted on every peer, the host included. Since 2026-08-14 the shape you get from
api.getSnapshot is the least-privileged view — what a spectator with
no seat and no team is entitled to see (packages/shared/src/tableObjects/redaction.ts,
LEAST_PRIVILEGED_VIEWER). Face-down card identities are stripped, a deck's or bag's ordered contents are gone,
identity-bearing eventLog lines are dropped, secretMetadata is removed from every kind, and an entity a
hidden seat zone conceals is missing from objects altogether — so objects.length is not the entity count on a
table that uses them. A mod running on the host is granted nothing by living where the secrets are kept. If your
mod genuinely needs the real table, declare read-hidden-information and call
api.getUnredactedSnapshot.
Every optional collection is present on a live table. TabletopRuntime.snapshot() always emits zones,
snapPoints, vectorLines, decals, textLabels, joints, ui, modSavedData and modObjectSavedData. The
? in the declaration exists for older saves and external imports, so guard the field on a snapshot whose
provenance you do not control and stop worrying about it on one you got from the host.
See also#
api.getSnapshot— how you get one, and whatnullmeans.api.listObjects— the cheaper read for entities alone.TableObjectState— one element ofobjects.api.getUnredactedSnapshot— the elevated read, for a mod that needs the real table.- Limits and caps — every collection's ceiling in one table.
- Async and snapshots — what an
awaitinvalidates.
Members#
| Signature | Description | Returns |
|---|---|---|
roomId |
string | null |
|
hostPeerId |
string | null |
|
tick |
number |
|
objects |
readonly TableObjectState[] |
|
snapPoints |
readonly TableSnapPointState[] |
|
vectorLines |
readonly TableVectorLineState[] |
|
decals |
readonly TableDecalState[] |
|
textLabels |
readonly TableTextLabelState[] |
|
joints |
readonly TableJointState[] |
|
ui |
TableUiState |
|
eventLog |
readonly TableEvent[] |
|
modSavedData |
Per-mod saved data, keyed by mod id. Prefer api.getSavedData(). |
Readonly<Record<string, string>> |
modObjectSavedData |
Per-mod, per-object saved data. Prefer api.getSavedData({ objectId }). |
Readonly<Record<string, Record<string, string>>> |
tablesnapshot.roomId#
readonly roomId: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The id of the multiplayer room the table is connected to. The runtime copies it from the connection status
(TabletopRuntime, setRoomContext), so every peer in the room reads the same string out of its own snapshot.
Returns
The room id, or null when the table belongs to no room — a solo session, or a table open in the Edit Mode
shell. null means "there is no room", never "the id is unknown".
How, why and when to use it
Your mod keeps a running scoreboard and wants to notice that it has been carried into a different room, so it
can start over instead of resuming someone else's totals. The alternative most authors reach for is minting
their own game id and storing it with api.setSavedData — and that is the better tool when you want the
bookkeeping to survive the move, because saved data travels inside the save file. Read roomId for the
opposite question: whether the table in front of you is the same table you were on a moment ago.
Gotchas
This identifies a room, not a game. Load one save into two rooms and everything matches except roomId.
Anything you want to follow the save belongs in saved data, not in a comparison against this field.
Your snapshot is a clone taken at one instant, so it does not follow a change of room. Take a fresh snapshot rather than caching the id for the session.
See also
tablesnapshot.hostPeerId— the other connection field, and the one that moves.api.getSnapshot— how you get one, and whatnullmeans.api.setSavedData— where bookkeeping that outlives a room belongs.- Host authority — what a room is, and who owns the table inside it.
tablesnapshot.hostPeerId#
readonly hostPeerId: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The connection id of the peer that holds table authority. When that peer disconnects the server picks a
successor, rewrites the room's host and broadcasts a host migration (apps/server/src/signaling/hostMigration.ts,
handlePeerDisconnectHostMigration), so this value names whoever owns the table right now rather than whoever started it.
Returns
The host's peer id, or null when no peer holds authority — a room still in the lobby, or a table with no room
at all.
How, why and when to use it
You are chasing a desync and want each diagnostic line your mod logs to name the peer whose state produced it,
so two players' logs can be lined up afterwards. The alternative authors reach for first is api.getMySeat(),
which answers a different question — it gives a seat, and a spectator has none, so it tells you nothing about
authority. Use hostPeerId to label state you are reporting, and let host-restricted calls report their own
refusal instead of branching: api.setSavedData and api.setUiElement throw on a peer that is not the host, so
the guard you were about to write already exists.
Gotchas
You cannot compare this against yourself. No api method returns your own peer id, so hostPeerId === me is
not a test you can write. The peer ids you can see arrive in hook payloads —
ModPeerPayload.peerId on onPeerJoined and
onPeerLeft, and ModTurnStartPayload.peerId
on onTurnStart.
The value changes mid-session when a migration moves authority. Your snapshot is a clone and does not follow it, so re-read rather than caching it.
See also
tablesnapshot.roomId— the room the host is hosting.api.getSnapshot— how you get one, and whatnullmeans.- Host authority — who applies a change, and what migration does to work in flight.
tablesnapshot.tick#
readonly tick: number;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
A frame counter. The runtime adds one to it on every pass of its update loop (TabletopRuntime, update) and
stamps the current value into each snapshot it builds, so tick says which frame of the host's simulation you
are looking at.
How, why and when to use it
You are logging your mod's decisions while you debug and want to tell "I read the table twice in one frame"
apart from "I read it twice, a frame apart" — two reads carrying the same tick came from the same frame. The
alternative is TableEvent.at on the event log, which is a
real ISO-8601 timestamp and is what you want the moment your question involves time rather than frames. Use
tick to order two reads of the table; use at to say how long ago something happened.
Gotchas
It is not a clock. The counter advances once per rendered frame, so the gap between two ticks depends on the host's frame rate and converts to no fixed duration.
It advances whether or not anything changed. A different tick is not evidence that the table moved. When
your question is "did the UI change", ui.revision answers it
directly; for entities, compare the entities.
It can move backward. Applying a snapshot sets the counter from that snapshot (TabletopRuntime,
applySnapshot), so loading a save or recovering from a resync drops tick to the incoming value. It is also
not the wire ordering counter the delta protocol uses — that one never reaches a mod.
See also
api.getSnapshot— the read that stamps the tick you see.tablesnapshot.eventLog— timestamps, when a frame count is the wrong unit.- Async and snapshots — why the table can move between two reads.
tablesnapshot.objects#
readonly objects: readonly TableObjectState[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Every entity on the table, in one array. This is the only collection in a snapshot whose elements are things a player can pick up and act on; the seven annotation collections beside it are table dressing, not entities.
Applies to: every object kind. A card that is inside a deck or a bag is not an element of this array — the
container holds its contents in metadata.cards, and
api.getContainerContents is what unpacks them.
How, why and when to use it
Your mod loads into a session already in progress and has to rebuild its own index of the table in one pass —
which pieces exist, which are in which state — before it can answer anything. The alternative,
api.listObjects, filters on the host's side and hands back a
shorter list, and it is the right call in every hot path and every hook handler. Read objects when you want
one internally consistent view of the whole table at a single instant, which repeated filtered reads cannot
give you.
Gotchas
Match by id, never by position in the array. The order the host builds this in is an implementation
detail, and an entity's index changes as pieces are created and removed. id is the only thing that addresses
an entity; label is the slug (and, for a card, its identity), and displayName is the human name.
A redacted card arrives looking like a card you can read, on every peer including the host. For a card
whose face is not public, the mod read rewrites label to Card, deletes metadata.cardId and
displayName, and sets metadata.__redacted to true (packages/shared/src/tableObjects/redaction.ts,
redactObjectForRestrictedViewer). Test that marker before you treat a card's label as its identity.
The array is not the entity count. An entity a hidden seat zone conceals is dropped from it entirely
rather than neutralized, so objects.length undercounts on a table that uses them — and it undercounts by
the same amount wherever your mod runs. A mod that needs the real set declares read-hidden-information and
reads api.getUnredactedSnapshot.
See also
TableObjectState— one element of this array, field by field.api.listObjects— the filtered read for a subset.api.getContainerContents— what is inside a deck or a bag.- Object state — the schema behind every field, including the three names.
tablesnapshot.snapPoints#
readonly snapPoints?: readonly TableSnapPointState[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The alignment targets authored on the table. Each one names a spot and a facing — position, rotationY — plus
a snapRadius in feet, and the host pulls an entity dropped inside that radius onto the spot instead of leaving
it where the player let go. A board's squares, a card game's play slots and a miniature's basing marks are all
snap points.
Returns
Absent only when the snapshot did not come from a live table. Every optional collection on the snapshot follows
the same rule, so read it as snapshot.snapPoints ?? [] before you iterate.
How, why and when to use it
You want to know which board space a piece is standing on so you can score it, and comparing raw coordinates
against a grid you hard-coded in your mod means re-deriving a layout the table already knows. Read snapPoints
and match an entity's position to the nearest one instead — the authored layout stays the single source of
truth, and moving a slot in the editor does not silently break your scoring. The alternative, tagging each piece
with the space it is on, is what you fall back to when a space is a rule your mod invented rather than a spot
on the table.
Gotchas
A snap point does not record what is on it. There is no occupancy field and no reverse lookup; the point is a target the host aims at during a drop, and reading "what is here" is your own position comparison.
The radius is measured flat, and the nearest point wins. The host compares the drop against each point in
x and z only, ignoring height, and takes the closest one whose snapRadius contains the drop
(TabletopRuntime, snapObjectAfterDrop). Points stacked vertically therefore compete with each other, and
rotationY is forced onto the piece while its other two rotation axes survive the snap.
See also
TableSnapPointState— every field of one snap point.- Object state — the schema, with the bounds on each field.
- Limits and caps — how many a table can hold.
tablesnapshot.vectorLines#
readonly vectorLines?: readonly TableVectorLineState[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The freehand lines drawn on the table. Each one is an ordered run of points with a color, a thickness and a rotation — the record of somebody dragging the draw tool across the surface, replicated and saved like anything else on the table.
Returns
Absent only when the snapshot did not come from a live table. Every optional collection on the snapshot
follows the same rule, so read it with a ?? [] (or ?? {}) fallback before you iterate.
How, why and when to use it
A team is playing a drawing game and you want to score whether anybody drew anything this round, or to clear the
board between rounds by counting what is there before and after. Reading vectorLines is the only way a mod
learns that drawing happened at all — there is no drawing hook, and
api.listObjects never returns a line, because a line is not an
entity. Poll it from a turn hook when your rules care about the annotations; ignore it entirely when they do
not, because it is the collection most likely to be large.
Gotchas
One line can carry a thousand points. The schema allows up to 1000 per line and up to 10000 lines, so a
table where players have been scribbling produces a snapshot whose bulk is here rather than in objects. Read
points.length before you walk it, and prefer a filtered read for anything you can get another way.
A mod can read lines but cannot make or remove one. Nothing in the api surface creates, edits or deletes a
vector line — they come from the draw tool and from loaded saves. If your game needs a mark a mod controls, use
a text label or a spawned entity instead.
See also
TableVectorLineState— every field of one line.- Object state — the schema, with the bounds on each field.
- Limits and caps — the ceiling on lines and points.
tablesnapshot.decals— the other kind of mark on the surface.
tablesnapshot.decals#
readonly decals?: readonly TableDecalState[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The images stamped onto the table and onto pieces. Each decal is a flat quad with a url for its art, a name
for humans, and a position, rotation and scale — a blood splatter on a board, a faction crest on a tile, a
"reserved" mark on a play area.
Returns
Absent only when the snapshot did not come from a live table. Every optional collection on the snapshot
follows the same rule, so read it with a ?? [] (or ?? {}) fallback before you iterate.
How, why and when to use it
Your game marks captured territory with a stamp and you want the score to follow the marks rather than a
parallel tally your mod keeps, which drifts the first time a player stamps or clears one by hand. Reading
decals makes the table itself the record. The alternative is spawning a flat entity per mark, which is what
you want when the mark has to be picked up, flipped or counted as a piece — a decal cannot be any of those
things.
Gotchas
A decal is not an entity and has no physics. The runtime builds it as a render-only quad
(TabletopRuntime, createDecalOverlayEntity), so nothing collides with it, nothing picks it up, and
api.listObjects never returns one.
metadata.parentObjectId binds a decal to a piece. When it names an entity that exists, the decal is
attached to that entity and travels with it; when the entity is gone the decal falls back to a free-standing
overlay at its stored world position rather than disappearing (TabletopRuntime, refreshDecalOverlays). So a
stamp can outlive the piece it was stamped on — resolve the id before you attribute a decal to an entity.
See also
TableDecalState— every field of one decal.- Object state — the schema, with the bounds on each field.
tablesnapshot.vectorLines— drawn marks rather than stamped ones.- Limits and caps — how many decals a table can hold.
tablesnapshot.textLabels#
readonly textLabels?: readonly TableTextLabelState[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The floating text placed in the world. Each label carries its text, a world position, a color, a fontSize
and a scale — the sign over a play area, the name on a territory, the "discard" caption beside a pile.
Returns
Absent only when the snapshot did not come from a live table. Every optional collection on the snapshot
follows the same rule, so read it with a ?? [] (or ?? {}) fallback before you iterate.
How, why and when to use it
You want to read the captions the mod's author wrote into the scene — the names of the territories, the labels
on the play areas — so your rules can talk about a region by the name a player can actually see, instead of by
an id nobody reads. The alternative for text your mod produces at runtime is a
UI element, which you can create, update and delete; read
textLabels for the authored signage that is part of the board, and use the UI tree for anything that changes
as the game goes.
Gotchas
This is not the UI tree, and the two do not meet. Writing a text element with api.setUiElement produces
a UI widget, never an entry here, and nothing in the api surface creates, edits or deletes a text label. They
come from the editor and from loaded saves.
Two fields scale it. fontSize and scale both affect how large the text renders, so a label that looks
wrong in your game is as likely to have an unexpected scale as an unexpected fontSize. Read both before you
conclude anything about size.
See also
TableTextLabelState— every field of one label.- Object state — the schema, with the bounds on each field.
tablesnapshot.ui— text a mod can actually write.- Limits and caps — how many labels a table can hold.
tablesnapshot.joints#
readonly joints?: readonly TableJointState[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The physics constraints tying pairs of entities together. Each record names two entity ids, a type — fixed,
hinge or spring — a breakForce, and whether the two bodies still collide with each other. The runtime
turns each one into a real Ammo constraint on a hidden helper entity (TabletopRuntime, refreshJoints), so a
hinge really does swing and a breakForce really does snap under load.
Returns
Absent only when the snapshot did not come from a live table. Every optional collection on the snapshot
follows the same rule, so read it with a ?? [] (or ?? {}) fallback before you iterate.
How, why and when to use it
Your game has a lid on a box or a spinner on a board, and your rules need to know that two pieces are bound
before treating them as one thing to score or to move. The alternative a reader reaches for first is
TableObjectState.parentId, which is the other relationship and the right one when the pieces form a
hierarchy that moves as a unit; a joint leaves both bodies independently dynamic and lets physics decide what
happens between them. Read parentId for assembly, joints for mechanism.
Gotchas
A joint can name an entity that is gone. Nothing prunes a dangling record: the sanitizer keeps any joint
with two distinct, non-empty ids (TabletopRuntime, sanitizeSnapshotJoints), and the constraint builder
quietly skips one whose endpoints are missing or have no rigidbody. Resolve both ids against objects before
you trust a joint to describe something real.
A joint is not a weld. Welding is physics.weldChildren on a parent entity, which merges its descendants
into one compound body; that is a field on the entity, not a record here. Looking for a weld in joints finds
nothing.
See also
TableJointState— every field of one joint.- Object state — the schema, and the parenting/joint/weld split.
tablesnapshot.objects— where you resolveobjectAIdandobjectBId.- Limits and caps — how many joints a table can hold.
tablesnapshot.ui#
readonly ui?: TableUiState;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The mod UI tree: a revision counter and every element every mod has placed, flattened into one array with
parentId expressing the nesting. This is one shared tree, not a tree per mod — your panel and another mod's
panel are siblings in the same structure, and each element records which mod owns it in ownerModId.
Returns
Absent only when the snapshot did not come from a live table. Every optional collection on the snapshot
follows the same rule, so read it with a ?? [] (or ?? {}) fallback before you iterate.
How, why and when to use it
You are rebuilding your mod's panel after loading into a session in progress and need to know which of your
elements already exist, so you update them instead of duplicating them. The alternative,
api.listUiElements, gives you the same elements without the rest
of the table and is the cheaper read whenever the UI is all you want. Take the tree out of a snapshot when you
are already reading the snapshot for other reasons and want one consistent view of the table and its UI
together.
Gotchas
You see every mod's elements, and you own only your own. Filter on ownerModId before you touch anything.
api.setUiElement throws on an element id another mod owns, and api.deleteUiElement returns false for one
(TabletopRuntime, upsertUiElement and deleteUiElementInternal), so an unfiltered loop is a mix of thrown
errors and silent no-ops rather than a cross-mod edit.
revision counts element writes, not frames. The host adds one on every element upsert and every delete
(TabletopRuntime, upsertUiElement and deleteUiElementInternal), so comparing it between two reads is the
cheap test for "did any mod change the UI" — unlike
tick, which moves whether or not anything happened.
See also
TableUiState— the wrapper:revisionplus the element array.TableUiElementState— every field of one element.api.listUiElements— the narrow read.- Object state — the schema behind the widget types and their props.
tablesnapshot.eventLog#
readonly eventLog: readonly TableEvent[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
The table's activity log — the same lines players read in the event feed, newest first. Each entry carries an
id, an ISO-8601 at timestamp, an actor display name, a human-readable message, and the objectId it was
about when it was about one.
How, why and when to use it
Your mod loads into a session that has been running for twenty minutes and your panel wants to show what has
happened so far, before your own hooks have seen anything. The alternative for everything after that moment is
api.on('onTableEvent', …), which delivers each line as it happens and is the
right tool for reacting; the log is how you backfill the history you missed. Read it once at setup, then
subscribe.
Gotchas
Identity-bearing lines are gone on every peer, the host included. The log a mod reads is filtered to the
least-privileged view: any line tagged revealsIdentity whose card a spectator with no seat and no team is not
entitled to see is dropped, as is any line naming a card hidden from that viewer at the time
(packages/shared/src/tableObjects/redaction.ts, LEAST_PRIVILEGED_VIEWER). The log you get therefore has gaps
even on the host, it is not the log the players are reading in the event feed, and a gap in it is not a bug to
work around. A mod that must see the dropped lines declares read-hidden-information and reads
api.getUnredactedSnapshot instead.
The live log is short. The runtime keeps the most recent 80 lines and discards the rest as new ones arrive
(TabletopRuntime, log), well under the schema's ceiling — so an imported save can hand you a longer log than
a running table ever will. Treat it as a recent-history window, not an audit trail.
actor is a display name, and display names are not trusted. Redaction keys on connection identity, never
on this string. Key your own rules on objectId and on the seat and peer fields the hook payloads carry.
See also
TableEvent— every field of one line.api.on—onTableEvent, for lines as they arrive.api.getUnredactedSnapshot— the elevated read, including the lines this one drops.- Host authority — why your copy of the table is not the host's.
tablesnapshot.modSavedData#
readonly modSavedData?: Readonly<Record<string, string>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Per-mod saved data, keyed by mod id. Prefer api.getSavedData().
The table-scoped saved string of every mod on the table, as a raw map keyed by mod id. One string per mod — not
a key-value store per mod — so a mod that needs structure encodes it into that single value and parses it back.
api.getSavedData is the supported read: the host injects your mod
id, so it answers with your own entry and you never name a key.
Returns
Absent only when the snapshot did not come from a live table. Every optional collection on the snapshot
follows the same rule, so read it with a ?? [] (or ?? {}) fallback before you iterate.
How, why and when to use it
You are debugging a save that does not resume and want to see what actually persisted, including whether your
mod's entry survived at all. api.getSavedData is what your game
logic calls, every time, because it is scoped and does not oblige you to know your own mod id; reach into the
raw map only when the question is about the map itself.
Gotchas
You can read other mods' entries here. The map is keyed by mod id and arrives whole, so another mod's stored
string is visible to yours even though api.getSavedData scopes to your own. That is a read of a snapshot the
host already broadcasts to every peer — saved data is not private, and nothing you put in it is hidden from
anyone at the table.
Writes belong to the host. api.setSavedData throws on a peer that is not the host, and an accepted write
reaches everyone with the next snapshot rather than the moment it resolves — so this map is behind your own
write until then.
See also
api.getSavedData— the scoped, supported read.api.setSavedData— the write, and what it costs.tablesnapshot.modObjectSavedData— the per-entity half of the same store.- Async and snapshots — when a write becomes visible.
tablesnapshot.modObjectSavedData#
readonly modObjectSavedData?: Readonly<Record<string, Record<string, string>>>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Per-mod, per-object saved data. Prefer api.getSavedData({ objectId }).
Per-entity saved strings, as a raw two-level map: mod id, then entity id, then one string. This is where a mod
parks state that belongs to a particular piece — which player claimed this token, how many charges this card has
left. api.getSavedData({ objectId }) is the supported read; the
host injects your mod id, so you name the entity and never your own mod.
Returns
Absent only when the snapshot did not come from a live table. Every optional collection on the snapshot
follows the same rule, so read it with a ?? [] (or ?? {}) fallback before you iterate.
How, why and when to use it
You are auditing a table where a token's charge count came back wrong after a reload and want to see every
entity your mod has written against, not only the one you suspect.
api.getSavedData({ objectId }) is the call your game logic makes,
because it is scoped and takes one entity at a time; walk the raw map when you need the whole picture in one
read.
Gotchas
Deleting an entity discards its saved data. Applying a snapshot drops every entry whose entity id is not in
objects (TabletopRuntime, sanitizeModObjectSavedData), so a piece that leaves the table takes its stored
string with it. Keep anything that has to outlive the piece in the table-scoped
modSavedData instead.
You can read other mods' entries here. The outer key is a mod id and the map arrives whole, so another mod's
per-entity strings are visible to yours even though api.getSavedData scopes to your own. It is a read of a
snapshot the host already broadcasts to every peer; treat everything in it as public.
See also
api.getSavedData— the scoped, supported read.tablesnapshot.modSavedData— the table-scoped half of the same store.tablesnapshot.objects— the entity ids the inner keys refer to.- Async and snapshots — when a write becomes visible.
ModTurnInfo#
Surface B — mod script · interface · 3 members
What api.getTurn() returns. Read from locally cached context — synchronous.
ModTurnInfo is the three-field answer api.getTurn() returns: whether
turn order is running, whose turn it is, and whether that is the client running this mod. It is assembled inside
your sandbox frame from a context object the host pushes in — a fresh literal on every call, so two calls are
never the same object and holding one gives you a value that never updates.
How, why and when to use it#
You are inside a button handler and the question is "is this player allowed to act right now". ModTurnInfo
answers it with no await and no round-trip, which matters because a UI handler that awaits a snapshot has
already let the click through by the time it knows. The alternative is the
onTurnChanged hook, which is the
right tool when the transition is the event — starting a timer, clearing per-turn flags. Use the hook to react
and this shape to gate.
Gotchas#
Read enabled first, and treat the other two as meaningless when it is false. With turn order off, the
host has no active player, so activePeerId is null and isMyTurn carries no information about permission.
Before the host's first context push, all three take their defaults — false, null, false — because the
frame starts with an empty context object and reads each field with a fallback. The host pushes context before it
runs your file, so setup sees real values; a frame that somehow reads earlier sees the defaults rather than an
error.
See also#
api.getTurn— the call that returns it.ModTurnInfo.isMyTurn— which peer "my" is, and when it lies.onTurnChanged— the transition hook.onTurnStart— the seat, team and action limit of the turn that just began.- Host authority — why the host owns turn order.
Members#
| Signature | Description | Returns |
|---|---|---|
enabled |
boolean |
|
activePeerId |
string | null |
|
isMyTurn |
boolean |
modturninfo.enabled#
readonly enabled: boolean;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | read-context |
| Availability | mod |
Whether the host has turn order switched on. true means the table is running an order and one participant is
up; false means free play, where everybody acts whenever they like. It mirrors the same flag the seat panel
shows, pushed into your frame whenever the host changes it.
Returns
boolean. It is false before the host's first context push and false whenever turn order is off — the frame
reads it as self.__diceytableCtx?.turnEnabled ?? false, so an absent context and a disabled order are the same
value. There is no third state for "unknown".
How, why and when to use it
Your mod enforces a turn rule, and it has to work on a table where the group never turned turn order on. Reading
enabled first is what stops the rule from refusing every action on such a table, because with the order off
activePeerId is null and a naive activePeerId === myPeerId check fails for everyone. The alternative is to
require turn order in your mod's description and hope — which fails silently and blames your mod. Branch on
enabled: enforce the rule when it is true, and let everything through when it is false.
Gotchas
It says the order is running, not that the order is populated. The host can have turn order enabled with no
active participant, in which case this is true and activePeerId is null. Check both.
Every peer reads the same value. Unlike isMyTurn, this field is not resolved per client, so a mod running
on four peers gets four identical answers.
See also
api.getTurn— the call that returns it.ModTurnInfo.activePeerId— who is up, when there is somebody.ModTurnInfo.isMyTurn— the per-client answer.onTurnChanged— the hook that fires when this flag flips.
modturninfo.activePeerId#
readonly activePeerId: string | null;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | read-context |
| Availability | mod |
The peer id of the participant whose turn it is. It is the same identifier the peer roster and every hook payload
use, so it compares directly against ModPeerPayload.peerId, ModSeatChangedPayload.peerId and
ModUiEventPayload.actorPeerId.
Returns
string | null. null means nobody is up — turn order is off, or it is on with an empty order — and it is
also the value before the host's first context push. It is never an empty string: the frame reads
self.__diceytableCtx?.activePeerId ?? null, so an absent field becomes null rather than "".
How, why and when to use it
You want your UI to say "Waiting for Ada" rather than "Waiting". This id is the only thing in ModTurnInfo you
can join against anything else: hold your own map from peer id to display name, built from
onPeerJoined and
onSeatChanged, and look the active
peer up in it. The alternative for the narrower question "am I up" is
isMyTurn, which the host has already resolved for you and which you should prefer over
comparing this id against a peer id of your own.
Gotchas
A peer id is not a seat. It identifies a connection, so it changes when the same person rejoins and it tells
you nothing about which side of the table they are on. Key anything that has to survive a reconnect on the seat
from api.getMySeat or ModSeatChangedPayload.seat instead.
There is no display name here, and no way to get one from api. The mod surface exposes peer ids and the
display name that arrives on a peer hook payload; nothing turns an id into a name on demand. Record the names as
they arrive.
The host re-pushes context only when the turn state, your seat, your team or your peer id changes. Nothing else refreshes this field, so it is stable between turns rather than continuously recomputed.
See also
api.getTurn— the call that returns it.ModTurnInfo.enabled— check this before trusting the id.ModTurnInfo.isMyTurn— the comparison already done for you.onTurnChanged— carries both the new and the previous active peer.
modturninfo.isMyTurn#
readonly isMyTurn: boolean;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | read-context |
| Availability | mod |
Whether it is the turn of the peer whose browser is running this copy of the mod. "My" is that client, not the
host and not the mod's author: a mod loaded on four peers reports true on at most one of them at a time. The
host computes it as activePeerId === thisPeerId and pushes the answer in, so the frame reads a boolean rather
than comparing anything itself.
Returns
boolean. false before the host's first context push, and false on any peer that is not up. Because the host
pushes the resolved answer rather than the inputs, this is the one field in ModTurnInfo you cannot recompute
from the others inside the frame — the frame has no peer id of its own to compare against.
How, why and when to use it
You are rendering a table UI button and want it live for the player whose turn it is and inert for everybody else.
isMyTurn is the right test because it is already per-client: the same handler, running in four frames, reaches
four different answers with no seat bookkeeping. The alternative — comparing
activePeerId against a peer id you tracked yourself — needs a peer id the mod
surface does not hand you, so it turns into seat plumbing you do not need. Use activePeerId when you want to
name the player, and isMyTurn when you want to decide what this client may do.
Gotchas
It lags a turn change by one push. The value is whatever the host last sent, and the host sends on change, so between the moment a turn advances and the moment the update reaches your frame this reads the previous answer. A handler that fires from a hook in that window sees the older value; re-read it at the point of decision rather than caching it.
true here is not permission. The host decides what a peer may do, and a mod's gate on this flag is a
courtesy to the player, not a rule. A peer that ignores it is stopped — or not — by the host, which is where
authority lives.
Check enabled first. With turn order off and no peer id yet established, the host's
comparison is null === null, which reports true — so on a solo or not-yet-connected table this field is true
while there is no turn at all.
See also
api.getTurn— the call that returns it.ModTurnInfo.enabled— the flag that makes this field meaningful.ModTurnInfo.activePeerId— the id, for naming the player.api.getMySeat— the other per-client context read.- Host authority — why a client-side gate is not enforcement.
ModSavedDataScope#
Surface B — mod script · interface · 1 members
Scope for api.getSavedData / api.setSavedData. Omit for the mod's
table-wide slot; pass { objectId } for a per-object slot.
ModSavedDataScope picks which of your mod's storage slots a saved-data call addresses. It has one optional
field, objectId: omit the argument entirely for the mod's table-wide slot, or pass { objectId } for the slot
attached to one entity. The mod id is never part of it — the host injects that from the manifest, which is what
makes another mod's storage unreachable rather than merely discouraged.
How, why and when to use it#
Your game has a running score and each token has a charge count. Those want different scopes: the score belongs to
the table, so it goes in the table-wide slot; the charge belongs to the piece, so it goes in
{ objectId: token.id } and rides with that entity through the snapshot. The alternative — one JSON blob in the
table-wide slot with an object keyed by entity id — works and costs you the cleanup, because on every write the
host prunes per-entity slots whose entity has left the table and leaves your hand-rolled map alone. Use the
table-wide slot for
anything about the game; use { objectId } for anything about a piece.
Gotchas#
Both slots are namespaced to your mod id, not shared. Two mods writing { objectId: "die-1" } write to two
different places, and neither can read the other. There is no cross-mod storage.
The scope selects a slot, not a key within one. A slot holds one string. Anything more structured is your own
JSON.stringify on the way in and your own parse on the way out.
See also#
ModSavedDataScope.objectId— the one field, and what an unknown id does.api.getSavedData— reading a slot.api.setSavedData— writing one, and why it is host-only.TableSnapshot— where both slots ride, asmodSavedDataandmodObjectSavedData.
Members#
| Signature | Description | Returns |
|---|---|---|
objectId |
string |
modsaveddatascope.objectId#
objectId?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | saved-data |
| Availability | mod |
The id of the entity whose per-entity slot you want. Present and a string, the call addresses your mod's slot
for that one entity; absent — or present with any non-string value — the call addresses your mod's table-wide
slot instead, because the frame only forwards objectId when typeof scope.objectId === "string".
Returns
string | undefined. Omitting it is not an error and not a lookup failure — it selects a different slot.
api.getSavedData(), api.getSavedData({}) and api.getSavedData({ objectId: 42 }) all read the same
table-wide value; only api.getSavedData({ objectId: "die-1" }) reads the entity's.
How, why and when to use it
You are storing a token's charge count and you want it to disappear when the token does. Naming the entity here gets you that: the host keys the slot by your mod id and the entity id, and prunes slots for entities that have left the table each time it writes. The alternative is a table-wide blob keyed by entity id, which you have to garbage-collect yourself and which grows for the whole session. Use this field when the value belongs to a piece and should die with it; omit it when the value belongs to the game.
Gotchas
An unknown entity id reads and writes differently. getSavedData({ objectId }) for an entity that is not on
the table resolves null — indistinguishable from "nothing stored". setSavedData(data, { objectId }) for the
same id rejects with Cannot persist saved data for unknown object. Read the id back from a live entity
rather than from your own memory before writing.
It has to look like an id, not merely be a string. The host tests both the mod id and this value against
/^[a-z0-9][a-z0-9._-]*[a-z0-9]$/i; a value that fails it rejects a write with
Invalid object id for saved data key. and resolves null on a read.
Applies to: every object kind. Nothing about the slot depends on what the entity is — a card, a deck and a
board all get the same one-string slot.
See also
ModSavedDataScope— the shape this field belongs to.api.getSavedData— the read, and the four thingsnullcovers.api.setSavedData— the write, and every way it rejects.TableObjectState.id— where to get an id you can trust.
ModObjectFilter#
Surface B — mod script · interface · 4 members
Filter for api.listObjects. Omit entirely to list everything.
ModObjectFilter is the optional argument to api.listObjects and the
only place a mod narrows a table read before the results cross the sandbox boundary. Four optional fields —
kind, tag, tags and match — and no required one: omit the whole object and every entity comes back.
Every field is coerced, never validated. A wrong type is dropped rather than raised, an over-long tags list
is truncated, and an unrecognized match becomes "any". The normalization runs twice, once in your frame and
again on the host, because the frame is untrusted and its result is a convenience rather than a guarantee.
How, why and when to use it#
Your game owns four scoring dice on a table that also has a chess set on it, and you want the four. Tag them at
spawn and filter on the tag: a filtered listObjects returns only what you asked for, so you are not shipping the
whole table across the boundary and then discarding most of it in JavaScript. The alternative — calling
listObjects() bare and filtering the array yourself — gives identical answers and costs a structured clone of
every entity on the table per call, which is the difference that matters inside a hook that fires on every event.
Filter here when the predicate is a kind or a tag; filter in your own code when it is anything else, since these
four fields are the entire vocabulary.
Gotchas#
This is not the table script's filter. world.getAllObjects takes { kind, tag } and nothing more. Do not
port a { tags, match } filter between the surfaces and expect it to narrow anything. See
Object filters are not symmetrical.
tag and tags combine; they do not override. Supplying both gives the matcher tag plus every entry of
tags, which is a wider set than either alone under match: "any" and a narrower one under "all".
Applies to: every object kind. The matcher reads kind and tags off each entity and nothing else, so no
kind is exempt and no kind gets extra fields to filter on.
See also#
api.listObjects— the call, and what an empty array means.ModObjectFilter.tags— the 32-entry truncation.ModObjectFilter.match— the two-value coercion.TableObjectKind— the valueskindaccepts.- Object filters are not symmetrical — the asymmetry in full.
Members#
| Signature | Description | Returns |
|---|---|---|
kind |
TableObjectKind |
|
tag |
Legacy single tag. Behaves as a one-element tags; combines with tags. |
string |
tags |
Silently TRUNCATED to the first 32 entries, in the frame and again on the host. | string[] |
match |
"all" intersects; anything else (including omitted) is coerced to "any". |
"any" | "all" |
modobjectfilter.kind#
kind?: TableObjectKind;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Narrows the result to entities of one kind, compared as an exact string against each entity's kind. It is the
cheapest filter in the shape — the host applies it first, before any tag matching — and it is the only field that
narrows on what an entity is rather than on what an author labeled it.
Returns
TableObjectKind | undefined. Omitting it skips the kind test entirely and leaves every kind in the result. The
frame forwards it only when typeof filter.kind === "string", so a number, a null or an array is dropped and
behaves exactly like omitting the field.
How, why and when to use it
Your mod scores dice and the table also holds cards, a board and a bag. { kind: "die" } is the narrowing you
want, because kind is fixed at spawn and cannot drift the way a tag can when a player duplicates a piece. The
alternative is a tag you apply yourself at createObject time, which is the right answer when you own only
some of the dice — kind cannot tell your four scoring dice from the four a player dragged out of the standard
library. Filter on kind when you want a category; add a tag when you want ownership.
Gotchas
An unrecognized kind string matches nothing rather than raising. The comparison is a plain === against each
entity's kind, so a typo such as "dice" returns an empty array and no diagnostic. Take the value from
TableObjectKind rather than typing it.
It is a single value, not a list. There is no way to ask for card and deck in one call. Make two calls, or
call listObjects() once and group the result yourself.
Applies to: every object kind. No kind is excluded from the comparison and none gets special handling.
See also
ModObjectFilter— the shape this field belongs to.api.listObjects— the call that takes it.TableObjectKind— every value it accepts.- Object kinds — what each kind is and how it behaves.
modobjectfilter.tag#
tag?: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Legacy single tag. Behaves as a one-element tags; combines with tags.
The single-tag form of the filter, which predates tags and is kept for scripts written against it. The host
turns it into the first entry of the needle list and then runs the ordinary tag match, so { tag: "score" } and
{ tags: ["score"] } are the same query.
Returns
string | undefined. Omitting it contributes no needle. The frame forwards it only when
typeof filter.tag === "string", so any other type is dropped silently.
How, why and when to use it
You want one tag and no more — "every piece my mod spawned" — and { tag: "my-mod" } says that in fewer
characters than the array form. Reach for tags the moment there are two, because
combining tag with tags is a source of surprise: the two add together rather than one replacing the
other, so { tag: "a", tags: ["b"], match: "all" } demands both a and b. Pick one field per call and the
filter reads the way it behaves.
Gotchas
Needles are normalized the way stored tags are. The host trims, lowercases and de-duplicates them
(packages/shared/src/objectTags.ts, normalizeObjectTags), so " Score " finds score. Case is never a
reason a filter misses.
A needle that is not a valid author tag makes the filter match nothing. An author tag is 1–32 characters of
[a-z0-9_-], so a space, a : or a dt: prefix drops the needle — and when every needle drops, the matcher
deliberately returns nothing rather than everything, so a mod asking for dt:internal cannot receive the whole
table. An empty result from a filter you believe should match is worth checking against that character class
first.
Applies to: every object kind. Tags live on every entity, and this field reads them all the same way.
See also
ModObjectFilter.tags— the general form, and the 32-entry cap.ModObjectFilter.match— what "combines" means once there are two needles.api.listObjects— the call that takes it.- Tags and groups — authoring the tags this matches, and the reserved
dt:namespace.
modobjectfilter.tags#
tags?: string[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
Silently TRUNCATED to the first 32 entries, in the frame and again on the host.
The general tag filter: a list of author tags an entity is tested against, combined by
match. It is the field to reach for whenever the answer involves more than one tag, and
it is the one field in the shape with a hard size limit.
Returns
string[] | undefined. Omitting it contributes no needles. What you pass is not what the matcher sees: the frame
first drops every entry that is not a string, then keeps only the first 32
(filter.tags.filter((tag) => typeof tag === 'string').slice(0, 32) in
apps/web/src/mods/sandbox/modSandbox.html), and the host repeats both steps independently before matching
(apps/web/src/mods/SandboxedModRunner.ts, the listObjects branch). Neither step reports anything: a 40-entry
list is answered as if you had passed the first 32.
How, why and when to use it
You want the red pieces that belong to your mod, and that is two tags rather than one — so this field plus
match: "all" is the query. The alternative, calling listObjects({ tag: "my-mod" }) and filtering the returned
array on red yourself, gives the same answer and ships every one of your mod's pieces across the sandbox
boundary first; the difference grows with the table. Filter here for anything expressible as one to thirty-two
tags, and in your own code for anything that is not — a numeric range in metadata, for instance, which no field
here can express.
Gotchas
The truncation is silent, at 32 entries, and happens twice. Nothing warns, nothing throws, and the extra
entries are gone before the matcher runs — so under match: "all" a 33rd tag you were relying on to narrow the
result quietly stops narrowing it, and the call returns more than you expected rather than fewer.
Invalid needles are dropped, and dropping them all matches nothing. An author tag is 1–32 characters of
[a-z0-9_-]; a needle with a space, a : or the reserved dt: prefix is discarded. If every needle is discarded
the matcher returns nothing rather than everything (packages/shared/src/objectTags.ts, objectTagsMatch) — a
deliberate choice so a mod asking for dt:internal cannot be handed the whole table.
Applies to: every object kind. Every entity carries a tag list, and this field reads it the same way for all of them.
See also
ModObjectFilter.match— union or intersection over these needles.ModObjectFilter.tag— the single-tag form, which adds to this list.api.listObjects— the call, and what an empty array means.- Limits and caps — this cap alongside every other one.
- Object filters are not symmetrical — why a table script has no
tags.
modobjectfilter.match#
match?: "any" | "all";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | read-world |
| Availability | mod |
"all" intersects; anything else (including omitted) is coerced to "any".
How the needles from tag and tags are combined. "all"
intersects — an entity has to carry every needle. "any" unions — one is enough. It has no effect at all when
the filter supplies no needles, because tag matching is skipped entirely in that case.
Returns
"any" | "all" | undefined. The coercion is filter.match === 'all' ? 'all' : 'any'
(apps/web/src/mods/sandbox/modSandbox.html), repeated on the host: exactly the string "all" intersects, and
every other value unions. Omitting the field, passing undefined, passing "ALL", passing "and" and passing
7 are all "any". There is no error and no diagnostic for a value that is not one of the two.
How, why and when to use it
You tag pieces my-mod and red, and "the red pieces my mod owns" is match: "all" while "anything red or
mine" is "any". The trap is that "any" is what you get by saying nothing, so an intersection you forgot to ask
for silently returns a superset — often the whole of your mod's pieces — and your code goes on to act on all of
them. Write match explicitly on every multi-tag call, even when "any" is what you want, so the intent is on
the page rather than in the default.
Gotchas
A typo widens the result instead of failing. "ALL" is not "all", so the comparison falls through to
"any" and the call returns the union. This is the one coercion in the shape that changes an answer rather than
dropping an input, and it is why an unexpectedly large result is worth checking here first.
Under "all", the 32-entry truncation of tags also widens the result. Dropping a needle removes a
constraint from an intersection, so a list longer than 32 matches more entities than you asked for.
Applies to: every object kind. The combination rule is the same whatever the entities are.
See also
ModObjectFilter.tags— the needle list this combines, and its cap.ModObjectFilter.tag— the single-tag form, which joins the same list.api.listObjects— the call that takes it.ModObjectFilter— the whole shape, and its coerce-never-validate rule.
ModActionRegistration#
Surface B — mod script · interface · 2 members
What api.registerAction accepts.
ModActionRegistration is the two-field argument to
api.registerAction: an id and a label. The frame posts the
object verbatim and the host reads label out of it to write one line — registered action <label> — into the
running client's event feed. That is the whole of what happens to it.
How, why and when to use it#
You want players to be able to trigger something in your mod — reshuffle, end the round, deal a hand. This shape
is not how you get that, and reaching for it is the most common wrong turn on the mod surface. The mechanism that
works is a table UI element: api.setUiElement with a button whose
props carry an onClick hook name, and api.on subscribed to that name. That
path renders a control, respects the element's visibility, and delivers a payload naming the element, the
actor's peer id and their role. Use ModActionRegistration only when you also want the declaration to appear in
the feed next to the button.
Gotchas#
No button is rendered and nothing calls back.
Known gap. The host's handler appends
registered action <label>to the event feed and does nothing else (apps/web/src/ui/App.tsx, theregisterActioncallback passed to the mod runner). There is no UI and no callback channel, so theregister-actioncapability grants the ability to write one log line. The log line itself is written reliably and the value is delivered intact. Build the control as abuttonUI element with anonClickhook and subscribe withapi.on; that path is wired end to end. See Known limitations.
Nothing validates the shape. The object is posted as it is, so a missing label produces
registered action undefined in the feed rather than an error.
See also#
ModActionRegistration.id— the field nothing reads yet.ModActionRegistration.label— the field that reaches the feed.api.registerAction— the call.api.setUiElement— the mechanism that gives a player a control.api.on— subscribing to that control's hook.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
label |
string |
modactionregistration.id#
id: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | register-action |
| Availability | mod |
The identifier you give the action. It is declared as a required string, it is delivered to the host intact, and
no code on the receiving side reads it: the host's handler takes label for its log line and touches nothing
else. Supply it as the type requires, and design nothing around it.
How, why and when to use it
Give it the value you would use if the registry became real — a stable, lowercase, mod-namespaced slug such as
"my-mod:reshuffle" — because that is the value a future registry would key on and it costs nothing now. Do not
use it as a handle: there is no call that takes an action id back, no unregister, and no payload that carries one,
so a variable holding this string can only ever be compared against another copy of itself. When you need an
identifier a player's click can actually be traced to, that is the id of a
TableUiElementDefinition, which arrives on every
ModUiEventPayload.
Gotchas
Nothing reads it, so nothing rejects it either. An empty string, a duplicate of an id you already registered, or a value colliding with another mod's are all accepted without complaint. Uniqueness is a convention you keep, not one the host enforces.
It is required by the type and absent from the outcome. The declaration is id: string; — TypeScript makes
you supply it, and the feed line it produces would be identical without it.
See also
ModActionRegistration— the shape, and why the whole call writes only a log line.ModActionRegistration.label— the field that does reach the feed.api.registerAction— the call.- Known limitations — the gap in full.
modactionregistration.label#
label: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | register-action |
| Availability | mod |
The human-readable name of the action, and the only part of the registration that has any observable effect: the
host interpolates it into the event-feed line registered action <label>. Write it as you would a menu item —
"Reshuffle the discard pile" — because a person reading the feed is the entire audience.
How, why and when to use it
You are announcing to everyone watching the feed that your mod offers something, at the moment your mod loads.
That is worth doing next to a button you created with
api.setUiElement — the feed line explains the control, the control
does the work. The alternative for the announcement alone is api.log, which
writes a line you word yourself and needs only the log capability rather than register-action. Prefer
api.log unless you want the registration wording specifically.
Gotchas
It is a slug in name only — it is not the label on an entity. The two are unrelated: an entity's label is
its lowercase slug and its card identity, while this one is free-form display text with no character class, no
length cap and no uniqueness rule.
A missing or non-string value reaches the feed as written. The host interpolates whatever arrives, so a
registration with no label produces the literal line registered action undefined.
The line is local to the client that ran the mod. It is written into that peer's event feed and is not broadcast, so a mod running on four peers writes four independent lines rather than one shared announcement.
See also
ModActionRegistration— the shape, and what the call does and does not do.ModActionRegistration.id— the other field, which nothing reads.api.log— the simpler way to write a feed line.api.setUiElement— the way to give a player a control.
ModHostMessagePayload#
Surface B — mod script · interface · 5 members
Payload of onHostMessage — one peer's copy of this mod calling api.sendToHost.
🔴 actorPeerId is stamped by the host from the channel the message arrived on, never read
out of the message. That is what makes it worth trusting: a sender chooses name and data
freely and can lie about both, and cannot claim to be somebody else. Key every authority
decision on actorPeerId — resolve it to a seat yourself and check the seat is the one the
request concerns.
data is UNTRUSTED. Check its shape before you use it, exactly as you would a plugin response.
ModHostMessagePayload is what onHostMessage receives: the name and payload a peer sent with api.sendToHost, plus who sent it and when.
How, why and when to use it#
Switch on name, validate data, and key every authority decision on actorPeerId. Resolve that peer id to a seat from your own record of the seat hooks — never from anything inside data.
Gotchas#
Two of these five fields are the sender's and three are the host's. name and data are chosen by the sender and can be anything. actorPeerId, actorRole and at are stamped by the host. Trust accordingly.
There is no modId. A message is delivered only to the mod that sent it, so the field would only ever say your own id.
See also#
api.sendToHost— the outbound half.onHostMessage— the hook that delivers this.
Members#
| Signature | Description | Returns |
|---|---|---|
name |
The name the sender chose. At most 64 characters. | string |
data |
The sender's payload. Untrusted — validate it. | unknown |
actorPeerId |
WHO sent it, from the channel. Never from the message. | string |
actorRole |
"host" | "player" | "spectator" |
|
at |
string |
modhostmessagepayload.name#
readonly name: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | host-message |
| Availability | mod |
The name the sender chose. At most 64 characters.
The message name the sender chose. At most 64 characters.
How, why and when to use it
Your discriminator. Branch on it first and return early for a name you do not implement — a mod will grow more of these, and an unrecognised name must be a no-op rather than a crash.
Gotchas
Namespace it if your mod is large. These are your own names in your own mod's space, so there is no collision with the platform — but "load-deck" and "loadDeck" are two different messages and one of them will silently do nothing.
Truncated, not rejected, past 64 characters. A long name arrives clipped, so it will not match what you compared against.
See also
api.sendToHost— where the name is chosen.
modhostmessagepayload.data#
readonly data: unknown;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | host-message |
| Availability | mod |
The sender's payload. Untrusted — validate it.
The payload the sender passed, re-parsed from JSON. Untrusted.
How, why and when to use it
Check its shape before you use it, exactly as you would a plugin response: it came from a peer you do not control, over a channel that carries whatever that peer put on it.
Gotchas
A sender can send anything, including nothing. data is unknown because it genuinely is: test for the fields you need rather than destructuring and hoping.
It survived JSON, so its types are JSON's. A Date arrives as a string, a Map as {}, undefined as absent. Send primitives, arrays and plain objects.
Never take identity from it. A seat, a peer id or a role inside data is the sender's claim about itself. actorPeerId is the fact.
See also
ModHostMessagePayload.actorPeerId— the field that is not the sender's to choose.
modhostmessagepayload.actorPeerId#
readonly actorPeerId: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | host-message |
| Availability | mod |
WHO sent it, from the channel. Never from the message.
Who sent it — the peer id of the data channel the message arrived on.
How, why and when to use it
Every authority decision keys on this. Resolve it to a seat with your own map built from onSeatChanged / onPeerJoined, then check that the seat is the one the request concerns: a message asking to fill the red seat's deck area is only legitimate from the peer sitting in the red seat.
Gotchas
🔴 This is the only field a sender cannot forge, and it is the reason the payload is worth anything. The host stamps it from the transport; the wire format has no field for it at all. A peer id inside data is not this and must never be used as if it were.
A peer id is not a seat and not an account. It is per-session: the same person rejoining is a new peer id, and a seat can change hands. Map it yourself, live.
See also
onSeatChanged— how to build the peer-to-seat map.ModHostMessagePayload.data— the half that is the sender's.
modhostmessagepayload.actorRole#
readonly actorRole: "host" | "player" | "spectator";
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | host-message |
| Availability | mod |
The sender's role as the host knows it: "host", "player" or "spectator".
How, why and when to use it
Refuse what a spectator should not be able to ask for. A spectator can run your mod and can call api.sendToHost, so a message that would change the game needs this check.
Gotchas
Also stamped by the host, not by the sender. Like actorPeerId, it comes from the host's own record of the room.
It is the role at RECEIPT. Somebody who sends and then sits down arrives labelled with the role they had when the message landed.
See also
ModHostMessagePayload.actorPeerId— the identity to pair it with.
modhostmessagepayload.at#
readonly at: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | host-message |
| Availability | mod |
When the host received the message, as an ISO-8601 string.
How, why and when to use it
Ordering and diagnostics — telling two messages apart in a log, or ignoring one that arrived after a state change made it moot.
Gotchas
Receipt, not send. It is the host's clock at the moment of delivery, so it says nothing about how long the message spent in flight — and it deliberately does not carry the sender's clock, which no peer should have to trust.
A string, not a Date. ISO-8601 sorts correctly as text, which is usually all you need.
See also
onHostMessage— the hook that stamps it.
ResolvedCard#
Surface B — mod script · interface · 2 members
One catalogue row, as api.resolveCards returns it.
ResolvedCard is one row of your game's card catalogue, as api.resolveCards()
returns it: the id you asked about, and the data your data/cardSchema.json declared for it. It is a plain
structural clone assembled per call, so mutating it changes nothing anywhere.
Returns#
{ cardId, data }. Nothing else — no position, no owner, no table state. A ResolvedCard describes what a card
is, never where it is or who is holding it.
How, why and when to use it#
You have card ids off the table and want to reason about the cards themselves — scoring a hand, checking a play
against a card's type, or logging something a human can read. Build a Map keyed by cardId from the result and
look ids up through it.
Gotchas#
The array you get back is not aligned with the ids you sent. Unknown ids produce no ResolvedCard at all, so
the result can be shorter and in a different correspondence. Never zip the two arrays by index.
Copies collapse. Several physical copies of one card (bolt#1, bolt#2) resolve to a single entry keyed by
the definition id, because they share one catalogue row.
See also#
api.resolveCards— the call that returns these.ResolvedCard.data— the field values themselves.
Members#
| Signature | Description | Returns |
|---|---|---|
cardId |
The card id, as matched against the schema's key role. |
string |
data |
The catalogue row's fields, keyed by the schema's declared field keys. | Readonly<Record<string, string | number | boolean | null>> |
resolvedcard.cardId#
readonly cardId: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-cards |
| Availability | mod |
The card id, as matched against the schema's key role.
The card's stable identity — the value your deck schema's key role names, and the id a saved decklist and a
spawned table object both store.
Returns
string. Always the definition id, never a physical-copy instance id: ask about bolt#3 and you get back
bolt.
How, why and when to use it
This is the join key. Because the result array drops unknown ids and collapses copies, matching by index is
wrong; key a Map on cardId and look up through it.
Gotchas
It is not the id you passed in, when you passed a copy. A script that echoes cardId back into something
expecting the instance id will address the wrong thing — keep the original alongside if you need it.
See also
ResolvedCard.data— the values keyed by your schema's field keys.
resolvedcard.data#
readonly data: Readonly<Record<string, string | number | boolean | null>>;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | async |
| Capability | read-cards |
| Availability | mod |
The catalogue row's fields, keyed by the schema's declared field keys.
The catalogue row's values, keyed by the field keys your data/cardSchema.json declares. If your schema declares
name, cost and type, those are the keys here.
Returns
Record<string, string | number | boolean | null>. The keys are yours, not the platform's — this object has no
fixed shape, and reading a key your schema does not declare gives undefined.
How, why and when to use it
Read the fields your own game defined. Because you authored the schema, you know the key names and their types; nothing here needs discovery at runtime.
Gotchas
A blank field can be null. Check before arithmetic — a card that leaves cost empty gives null, and
null + 1 is 1 rather than an error, which silently skews a total.
The art role is not in here for a plugin-sourced game. Composed art URLs are baked into the rendered sheet,
so the field carrying them is dropped from card data rather than duplicated onto every card.
See also
ResolvedCard.cardId— the key to join on.
ModSetupManifest#
Surface B — mod script · interface · 4 members
The subset of the manifest handed to setup. A mod does NOT get its whole
manifest — entry, assets, description and the rest are unreachable.
ModSetupManifest is the second argument your setup function receives, and it is a four-field extract of
your manifest rather than the manifest itself: id, name, capabilities and soundSets. The host builds it
literally, as those four properties copied off the parsed manifest
(apps/web/src/mods/SandboxedModRunner.ts, run, typed as a Pick), and posts it into your frame with the
message that runs your file — so it is present and complete before your first line executes.
manifest.entry and manifest.assets are undefined inside setup, and so are description, version,
compatibility, license, tags and everything else the manifest carries.
By design. A script has no use for its own file paths or its store listing, and handing them over would widen the surface for nothing. This is not expected to change. The four fields you do get are the four a script needs — its own id for namespacing, its name for log lines, its granted capabilities, and the sound sets it declared. Put anything else your script needs in the script, as a constant. See Known limitations.
How, why and when to use it#
Two things belong in almost every setup: prefixing your UI element ids with manifest.id so they cannot collide
with another mod's, and reading manifest.capabilities.allowed so the script degrades instead of throwing when a
grant is missing. The alternative — hard-coding your own id as a string constant — works right up until you
rename the mod, and then two copies of the truth disagree. Take the id from the argument; take everything the
argument does not carry from a constant, because there is no second call that fetches the rest.
See also#
ModSetupManifest.capabilities— what you were actually granted.ModSetupFunction— the signature this arrives through.api— the first argument.- Manifest reference — the whole manifest, most of which stops at the frame.
- Known limitations — the boundary in full.
Members#
| Signature | Description | Returns |
|---|---|---|
id |
string |
|
name |
string |
|
capabilities |
{ readonly version: "1"; readonly allowed: readonly ModCapability[]; } |
|
soundSets |
readonly ModSoundSet[] |
modsetupmanifest.id#
readonly id: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | none |
| Availability | mod |
Your mod's id, copied from the id field of its manifest. It is the same string the host uses to key your
sandbox frame, your saved-data namespace and the ownerModId on every UI element you create, so it is the one
value that ties everything your mod owns together.
How, why and when to use it
You are creating a UI element and every mod at the table is writing into the same element tree — so
api.setUiElement({ id: manifest.id + ":score", … }) is how yours cannot collide with another mod's. The
alternative is a constant at the top of your file, which is what most authors write first and which becomes wrong
the day the manifest id changes and nothing tells you. Read it from the argument for anything that has to match
what the host thinks your mod is called, and use it to filter listUiElements() down to your own elements by
comparing against each element's ownerModId.
Gotchas
It is not a display name. It matches /^[a-z0-9][a-z0-9._-]*[a-z0-9]$/i and is 3–96 characters, which makes
it safe as a key and wrong in a sentence. Use name for anything a player reads.
You never pass it to api. The host injects it into every gated call, which is why getSavedData takes a
scope and not a mod id and why one mod cannot address another's storage. A signature that appears to want it is
the host-side interface, not yours.
It is not the URL slug. A mod can carry a separate slug for its public page; that field is not among the
four setup receives, and this id is what routing falls back to when there is none.
See also
ModSetupManifest— the four fields, and why there are only four.ModSetupManifest.name— the human-readable one.api.setUiElement— where namespacing an id matters most.- Manifest reference — the
idfield's rules where it is authored.
modsetupmanifest.name#
readonly name: string;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | none |
| Availability | mod |
Your mod's display name, copied from the manifest's name field — 1 to 80 characters of free-form text, the same
string the store listing and the mod picker show. The host also uses it itself, for the sandbox frame's title and
for the <name> loaded. line that appears in the event feed once your file has run.
How, why and when to use it
Every line your mod writes to the shared event feed competes with every other mod's, so
api.log(manifest.name + ": dealt 5 cards.") is the difference between a feed a player can read and a wall of
anonymous messages. The alternative is writing the name as a literal in your script, which drifts the moment
somebody renames the mod in Edit Mode and republishes. Use this field for anything a person reads; use
id for anything a machine matches on.
Gotchas
It is not unique and not validated beyond its length. Two mods at the same table can share a name. Anything
that has to identify your mod uses id.
The host has already logged it once. The <name> loaded. line is written after your setup resolves, so a
boot line of your own is the second mention rather than the first.
See also
ModSetupManifest— the four fieldssetupreceives.ModSetupManifest.id— the key, as opposed to the name.api.log— the call this field is mostly for.- Manifest reference — where
nameis authored and its length cap.
modsetupmanifest.capabilities#
readonly capabilities: {
readonly version: "1";
readonly allowed: readonly ModCapability[];
};
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | none |
| Availability | mod |
What your mod was granted, copied from the capabilities block of its manifest: a literal version of "1" and
an allowed array of capability slugs. Declaring a slug here is what makes the matching api calls callable at
all — the sandbox builds a Set from allowed before it runs your file, and every api method opens by testing
that set and throwing Missing mod capability: <slug> if the slug is absent. A manifest with no capabilities
block is granted ["log"] and nothing else, in both the schema's default and the frame's own fallback.
How, why and when to use it
You are shipping a mod whose scoreboard is nice-to-have and whose rules enforcement is not, and you want the
scoreboard to disappear rather than take the mod down with it. Read manifest.capabilities.allowed at the top of
setup and skip the optional feature when its slug is missing. The alternative is wrapping every call in
try/catch, which works — the throw is synchronous, even from the methods that return a promise — but leaves
you catching a control-flow exception per call and silently swallowing real errors alongside the capability one.
Check the list once; reserve try/catch for failures you cannot predict.
Gotchas
The list describes what was declared, not what is guaranteed. For ten of the twelve capabilities the
host re-validates the message against your grants before it acts, and that check is the authoritative one — most
sharply for read-hidden-information, which no forged in-frame message gets past. The two it
cannot re-check are read-context and subscribe-events: getMySeat, getMyTeam, getTurn and on are
answered inside your frame from the contextUpdate and hookEvent messages the host pushes to every running mod
unconditionally, so for those two the gate is frame-local. See
the two capabilities the host cannot re-check.
Publishing checks the other direction. validateManifestCapabilities
(packages/shared/src/modManifest.ts) scans your script for calls whose capability is not in allowed and
refuses the mod with an undeclared-capability error. Declaring a slug you never use is not flagged, so an
over-broad list costs you nothing mechanically and everything in what a player reads before installing.
allowed is an array, not a set. Duplicates validate — the schema caps the array at 20 entries against a
vocabulary of 11 — and collapse when the frame builds its set. Deduplicate before you show the list to anyone.
read-world no longer means what it meant. Since 2026-08-14 the six reads it gates answer as the
least-privileged viewer — a spectator with no seat and no team — on every peer including the host. A mod that
used to adjudicate hidden cards through read-world alone now needs read-hidden-information and
api.getUnredactedSnapshot. It is never implied, and adding
it to allowed is a visible change to what a player reads before installing.
See also
ModSetupManifest— the four fieldssetupreceives.ModCapability— the eleven slugs.- Mod capabilities — the capability-to-method matrix and the exact rejection.
- Known limitations — the two-place enforcement in full.
modsetupmanifest.soundSets#
readonly soundSets?: readonly ModSoundSet[];
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | none |
| Availability | mod |
The custom sound sets your mod declared in its manifest, handed back to you verbatim. Each entry is a
ModSoundSet: a logical name, one to sixteen interchangeable variants
as repo-relative audio paths, and optional material, action and loop fields. The manifest schema caps the
array at 64 sets.
Returns
readonly ModSoundSet[] | undefined. Optional in the manifest schema and optional here, so it is undefined —
not an empty array — for a mod that declared none. Read it as manifest.soundSets ?? [].
How, why and when to use it
You are about to play a sound by name and api.playSound({ modSound: "round-end-bell" }) fails silently when the
name matches nothing — no error, no clip, no diagnostic. Building a Set of your declared names from this field
at the top of setup turns that into a failure you can log yourself. The alternative is a list of name constants
in your script, which is what most authors write and which drifts the first time a set is renamed in the manifest
without the script being touched. Take the names from the argument; a constant is only safe for a name that
appears in exactly one place.
Gotchas
It carries declarations, not audio. variants are repo-relative paths on the same GitHub-and-cache pipeline
as your models and textures — nothing here is a URL you can load, and nothing is uploaded to a server.
The host builds its own set from the same field. It refuses a
api.setObjectSound reference naming a sound that is not in this
list, or one naming another mod's id. A name you can read here is a name you can bind; anything else is rejected
with a diagnostic and no sound.
Duplicate names collapse. Registration is keyed by name, so two sets sharing one leave only the later
reachable — and both are individually valid, so nothing warns you.
See also
ModSoundSet— the shape of each entry.ModSoundSet.name— the handle everything else uses.api.playSound— the{ modSound }form these names feed.- Manifest reference — where
soundSetsis authored.
ModSetupOptions#
Surface B — mod script · type
The host's answers to the setupOptions your manifest declared, chosen in the
lobby before the table existed.
Every option you declared is present — a declared option always has a default,
so you never have to handle undefined for one. Declare no options and this is
an empty object. You get the VALUES only; labels, help text and bounds stay in
the manifest.
declare type ModSetupOptions = Readonly<Record<string, string | number | boolean>>;
ModSetupOptions is the type of the third argument of setup — the host's answers to the
setupOptions your manifest declared, chosen before the table exists. It arrives with the same message that
runs your file, so it is complete before your first line executes: there is no method to call and nothing to
await.
It carries values only. Labels, help text, bounds and choice lists stay in the manifest, because those
belong to the form and not to you; what is passed is one entry per declared option, keyed by the option's
key. Resolution happens on the host, against your manifest, and the frame is handed the result — a script
can no more forge a setup option than it can forge its own capabilities.
Two properties the resolver guarantees:
- Every option you declared is present. A declared option always has a
default, so a value is neverundefinedand never needs a fallback of your own. - Nothing you did not declare is present. A stored answer for a key your manifest does not declare is dropped before the record is built, so a lookup finds a value you declared or nothing at all.
A stored value that is wrong-typed or out of bounds resolves to that option's default rather than throwing.
The refusal for a bad value belongs where a host picks it and there is a person to tell; by the time setup
runs there is no UI, and a table that will not open is the worse failure.
⚠ There is no lobby form for these yet. The delivery is live, but nothing in the product asks a host the
questions, so unless the room's owner sets them through the API every option resolves to its default. Your
game runs; it runs the defaults. That is a good reason to make each default the configuration you would
ship if you could only ship one.
How, why and when to use it#
Read the values you care about at the top of setup and branch there, rather than consulting the record deep
in a hook: the answers cannot change after the table starts, so re-reading them buys nothing and spreads your
configuration across the file. A sensible default on every option is what lets you write if (options.teams)
with no guard at all.
The argument is additive. A mod written as setup(api, manifest) is unaffected by it: JavaScript passes an
extra argument and the function ignores it, and a mod that declares no setupOptions receives an empty record
rather than undefined. No existing mod needs republishing.
Example#
// content/scripting-api/examples/modsetupoptions.js
// Mod script: read the host's answers once, first thing in setup, and branch there.
// The manifest that goes with this declares three options - a "starting_hand"
// number, a "teams" toggle and a "house_rule" select - so all three keys are
// present here whether or not the host touched any of them.
// manifest capabilities.allowed: ["log", "spawn-object"]
/**
* @param {ModApi} api
* @param {ModSetupManifest} manifest
* @param {ModSetupOptions} options
*/
exports.setup = function setup(api, manifest, options) {
// No guard and no fallback: a declared option always has a default, so a value
// is never undefined. `Number(...)` is narrowing for the typechecker, not a
// defence - the host already refused anything that is not a number.
const startingHand = Number(options.starting_hand);
const teams = options.teams === true;
api.log(manifest.name + ": dealing " + startingHand
+ (teams ? " to each team." : " to each player."));
for (let seat = 0; seat < (teams ? 2 : 4); seat += 1) {
api.createObject({
kind: "token",
label: "hand-marker-" + seat,
position: { x: seat * 2, y: 0.1, z: 0 },
color: "#ffeb3b",
stackCount: 1,
metadata: { seat: seat, cardsDue: startingHand, houseRule: options.house_rule }
});
}
};
starting_hand, teams and house_rule are the option keys from the manifest, and they are the only keys
on the record — the labels and choice lists that produced them stay in the manifest where the form reads them.
Gotchas#
The answers are frozen at Start, not merely stable. They are chosen while the room is still a lobby, and
the server refuses a write once the table has started — setup has already run by then, so a later edit would
describe a configuration the table is not using. There is no hook that tells you an option changed, because
one cannot.
A setupOptions block on a scriptless mod is read by nobody. Only a mod with an entry.script has
anywhere for a value to arrive; a pack whose entry is a setup.json alone has no code at all — see
setupOptions for how publishing treats that.
See also#
ModSetupFunction— the signature this arrives through.ModSetupManifest— the second argument, and what it does not carry.- Manifest reference — where the questions are declared.
ModSetupFunction#
Surface B — mod script · type
A mod's entry point. May be async; the sandbox awaits it.
The third argument is additive: setup(api, manifest) is still correct and is
unaffected by it.
Named ModSetupFunction, not ModSetup, because ModSetup already means
something else across the platform: the parsed contents of a mod's
setup.json (its templates and pre-placed objects).
declare type ModSetupFunction = (
api: ModApi,
manifest: ModSetupManifest,
options: ModSetupOptions
) => void | Promise<void>;
The signature of a mod's entry point:
(api: ModApi, manifest: ModSetupManifest, options: ModSetupOptions) => void | Promise<void>. The sandbox
resolves one function out of your exports, calls it once with the injected api and a four-field extract of your
manifest, and awaits whatever it returns. Everything a mod does after load is set up from inside it.
The third parameter carries the host's answers to the setupOptions your manifest declared, resolved on the
host and passed with the message that runs your file. It is additive: setup(api, manifest) is still
correct and is unaffected by it, and a mod that declares no options receives an empty record rather than
undefined — see ModSetupOptions.
The name is ModSetupFunction rather than ModSetup because ModSetup already means something else
across the platform — the parsed contents of a mod's setup.json, its
templates and pre-placed entities. The two are unrelated.
How, why and when to use it#
Top-level code in a mod file runs too: api, exports and module are function parameters, in scope from
the first line, so api.log("hello") at top level works. The question is what belongs where, and the
dividing line is the manifest argument. Constants, helper declarations and pure data belong at top level.
Every api.on, every api.setUiElement and anything that reads your own id, name, grants or sound sets
belongs in setup, because manifest reaches you only as this function's second argument — there is no
second call that fetches it. Registering hooks here also keeps the whole of your mod's behavior in one
place a reader can find.
Gotchas#
The loaded. line is posted after your function resolves. The frame awaits the call and only then
posts <mod name> loaded. to the mod console, so an async setup that awaits something which never
settles leaves you with no confirmation line and no error either — which reads exactly like a mod that
failed to load.
A throw leaves you partly registered. The frame catches it, posts a setup-phase diagnostic carrying
the message and the first four lines of the stack (apps/web/src/mods/sandbox/modSandbox.html,
emitError), and stops — but handlers registered before the throw are already live and keep firing. Do
your validation before you register anything.
It runs exactly once per frame, and reloading builds a new frame. Reloading one mod disposes that mod's
iframe and creates a fresh one, so setup runs again in a clean realm with no state carried over
(apps/web/src/mods/SandboxedModRunner.ts, run). Other mods' frames are untouched — persist anything
that has to survive a reload with api.setSavedData.
See also#
ModExports— the two properties this function can be attached to.exports— the object you assign it to, and the assignment that does not work.ModSetupManifest— the four fields the second argument carries.ModSetupOptions— the third argument, and what the host may put in it.- Your first mod — the whole file, end to end.
- Execution order — when this runs relative to the table's own load.
ModExports#
Surface B — mod script · interface · 2 members
The shape of the object a mod file publishes its entry point on. Two declared members —
setup and
default, both optional and both
ModSetupFunction — plus an index signature,
[key: string]: unknown, which accepts every other property and gives none of them any meaning. It types
both exports and
module.exports, which start out as the same object.
How, why and when to use it#
Write exports.setup. It is the conventional form, it is what every example on this site uses, and it is
the name a reader opening your repository looks for first. default exists so a file produced by a bundler
that emits exports.default runs without being rewritten, and it is checked before setup — so a file
that defines both runs default and silently ignores setup, which is a confusing thing to leave in a
repository. Define one.
Gotchas#
The index signature means a misspelled entry point compiles. exports.setUp = function … ; is a legal
assignment against this type and matches none of the three paths the sandbox looks at, so the mod loads,
prints its loaded. line, and does nothing. When a mod appears inert, look for your own first log line
rather than for a platform error — there will not be one.
You cannot swap the object out. Assigning module.exports = { setup } replaces the object the first
resolution path reads from and breaks all three. See
Known limitations, and
exports for the three forms that do work.
See also#
exports— the injected object, and the resolution order in full.module— the other name for the same object.ModSetupFunction— what either property has to hold.- Anatomy of a mod — where the script file sits in the repository.
Members#
| Signature | Description | Returns |
|---|---|---|
setup |
The conventional entry point: js exports.setup = async function setup(api, manifest) { ... }; |
ModSetupFunction |
default |
Alternative entry point, checked BEFORE setup. |
ModSetupFunction |
modexports.setup#
setup?: ModSetupFunction;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | none |
| Availability | mod |
The conventional entry point:
exports.setup = async function setup(api, manifest) { ... };
The conventional entry point. Assign a function here and the sandbox calls it once as setup(api, manifest) after
your file has finished executing, awaiting the result — so an async setup is fine. It is the last of the
three paths the sandbox checks: it resolves module.exports.default || exports.default || exports.setup and takes
the first match, so a function on either default path shadows this one.
How, why and when to use it
This is the form to write when you are authoring the file by hand, and the form every example on this site uses:
exports.setup = async function setup(api, manifest) { … };. The alternative is
exports.default, which behaves identically and exists so that a
build step compiling export default produces something the sandbox recognizes. Pick one. Assigning both is the
mistake that costs an afternoon — default wins, this function never runs, and nothing warns you.
Gotchas
Known gap. Reassigning
module.exports = { setup }silently does nothing. The sandbox createsexportsandmodulebefore running your file and resolves the entry point against those objects (apps/web/src/mods/sandbox/modSandbox.html), so replacingmodule.exportsleaves all three paths unresolved. The mod still loads, your top-level code still runs, and the event feed still shows the mod's… loaded.line — onlysetupnever fires, with no diagnostic. Assign onto the exports object instead of replacing it. See Known limitations.
A non-function is ignored in silence. The sandbox tests typeof setup === 'function' and does nothing when it
is anything else, so exports.setup = setup() — calling it by accident — loads cleanly and runs nothing.
Your top-level code runs first, either way. The whole file executes before the entry point is resolved, and
api is in scope at the top level, so anything you do outside setup happens whether or not setup is ever
found. Keep the work inside the function so that a failure to resolve it is visible.
See also
ModExports.default— the path checked before this one.ModSetupFunction— the signature this holds.ModSetupManifest— the second argument it receives.- Your first mod — the entry point in a working repository.
modexports.default#
default?: ModSetupFunction;
| Badge | Value |
|---|---|
| Authority | all-peers |
| Timing | sync |
| Capability | none |
| Availability | mod |
Alternative entry point, checked BEFORE setup.
The alternative entry point, and the one the sandbox looks at first. It resolves
module.exports.default || exports.default || exports.setup and takes the first match, so a function here wins
over exports.setup. It exists because a build step that compiles
export default function setup() { … } emits exactly this property, which lets a mod authored as an ES module run
without its entry point being rewritten by hand.
How, why and when to use it
Use this when a bundler produces your entry.script and you would otherwise be patching its output every build.
Write exports.setup when you are authoring the file directly — it names what it is, and it spares the next reader
working out which of two entry points wins. What you must not do is name both:
this one is checked first, so exports.setup becomes dead code that looks live, and nothing warns you.
Gotchas
The first two paths are the same property. The sandbox creates const exports = {}; const module = { exports };,
so module.exports.default and exports.default read the same slot until something replaces module.exports —
and replacing it is exactly what breaks entry-point resolution altogether. Assign onto the exports object; see
ModExports.setup for what happens when you do not.
A falsy value falls through. The resolution is an || chain, so exports.default = 0 or = null is skipped
and exports.setup is tried next. The result is only called if it is a function, so a truthy non-function stops
the search and runs nothing.
See also
ModExports.setup— the conventional path, and the reassignment gap in full.ModSetupFunction— the signature this holds.api— the first argument the resolved function receives.- Anatomy of a mod — where
entry.scriptsits in a repository.
exports#
Surface B — mod script · const
Your module's exports. The sandbox takes the FIRST of
module.exports.default, exports.default, exports.setup and, if it is a
function, awaits setup(api, manifest) once.
Reassigning module.exports = { setup } does NOT work — none of the three paths
resolve and your setup silently never runs.
declare const exports: ModExports;
The object you attach your entry point to. The sandbox creates a bare object before it reads your file and
passes it in as a function parameter alongside module and api, then
takes the first of module.exports.default, exports.default and exports.setup that is a function
and awaits it once with (api, manifest). There is no module loader in the frame: no require, no
import, no ES module semantics, and no second file — a mod is one script.
How, why and when to use it#
Write this, at top level, and put everything else inside it:
exports.setup = function setup(api, manifest) { … };
Name the function even though it is being assigned. The frame reports a failed setup by posting the first
four lines of the stack, and a named function is the difference between a diagnostic that points at your
entry point and one that points at <anonymous>. The alternative is exports.default, which is worth
using when a build step already emits that shape — but it is checked ahead of setup, so pick one and
delete the other rather than leaving both.
Gotchas#
Known gap. Reassigning the object —
module.exports = { setup };— loads without an error and never runs your setup. The sandbox evaluatesmodule.exports.default || exports.default || exports.setupagainst the objects it created before running your file (apps/web/src/mods/sandbox/modSandbox.html), so replacingmodule.exportsbreaks the first path and leaves the other two pointing at the original, still-empty object. Loading succeeded, so you get the mod'sloaded.log line and no diagnostic at all. All three supported forms work correctly: assign ontoexportsormodule.exports—exports.setup = …,exports.default = …, ormodule.exports.default = …— and never replace either. See Known limitations.
Anything else you put here is inert. The type carries an index signature, so exports.helpers = {…}
compiles and nothing reads it. There is no second file to import it from and no other mod can reach it —
your frame is the only consumer, so a plain top-level const says the same thing more honestly.
See also#
ModExports— this object's declared shape.module— the same object, under its other name.ModSetupFunction— the signaturesetuphas to match.- Your first mod — the smallest file that runs.
- Sandbox limits — why there is no
requireto reach for.
module#
Surface B — mod script · const
declare const module: { exports: ModExports };
A one-property object, { exports: ModExports }, wrapped around the same object
exports refers to. The sandbox builds both before it reads your file
and hands them in as function parameters, so your first line runs with module.exports and exports
pointing at one object under two names. module.exports.default is the first of the three paths the frame
checks when it looks for your entry point.
It exists for compatibility and nothing more — this is not Node, and module carries none of the fields
that name would suggest.
How, why and when to use it#
You are porting a script written for Node, or keeping a file recognizable to the build step that produced
it, and it publishes through module.exports. Assigning onto it works: module.exports.default = setup;
is checked first of the three and runs. The alternative — exports.setup — is the conventional DiceyTable
form, the one every example on this site uses, and the one to reach for in anything new. Use module when
an existing file already does; use exports.setup when you are writing from scratch.
Gotchas#
Replacing it is the one thing that does not work. module.exports = { setup }; swaps out the object
the first resolution path reads from, so none of the three resolve and your setup never runs — with no
error, because loading itself succeeded. See
Known limitations and
exports for the forms that do.
exports is not module.exports after you touch either binding. They are the same object only while
neither has been reassigned. Once a script assigns to module.exports, the two diverge — which is exactly
how the trap above is sprung.
Nothing else from a module system is here. module has no id, filename, loaded or paths, and
there is no require anywhere in the frame; reaching for one throws a ReferenceError that arrives as a
setup-phase diagnostic. Everything a mod needs is in the one file plus api.
See also#
exports— the same object, and the full resolution order.ModExports— the shape both names point at.ModSetupFunction— what you are publishing.- Sandbox limits — the language a mod script actually gets.
setTimeout#
Surface B — mod script · const
Deferred callback. setInterval is banned by the scanner — use this and re-arm.
declare const setTimeout: (handler: () => void, timeout?: number) => number;
The frame's deferred-callback timer, declared because a mod script really can call it. The mod sandbox is a real
iframe document, so the standard ES and timer globals are present; setTimeout is declared in
DICEYTABLE_MOD_API_DTS (packages/shared/src/modScripting.ts) so a script that uses one also typechecks in
the editor, which compiles against lib: ["es2020"] with no DOM and no @types.
Parameters#
| Name | Type | Required | Notes |
|---|---|---|---|
handler |
() => void |
yes | Called once, with no arguments. It is declared to take none, so the extra-arguments form (setTimeout(fn, ms, a, b)) does not typecheck — close over what you need instead. |
timeout |
number |
no | Milliseconds. Omitted means "as soon as possible", not "never". |
Returns#
number — the handle to pass to clearTimeout. Keep it if there is any
chance you will want to cancel; there is no other way to reach a pending callback.
How, why and when to use it#
Two things need it: debouncing (a burst of hook events where you only want to act once the burst stops) and anything periodic, because the looping timers are refused by the static scanner and a deferred callback that re-arms itself is the supported shape. Write the re-arm with a bound, as the example does — a mod that schedules itself forever keeps running after the game it was written for is over, and nothing on the table will stop it.
Prefer an event to a timer whenever one exists. api.on covers turns, seats,
teams, peers, draws and drops; a timer that polls for something a hook already reports is latency and CPU spent
for nothing.
Example#
// content/scripting-api/examples/settimeout.js
// Mod script: a repeating job is written as a deferred callback that re-arms
// itself, because the looping timers are rejected by the scanner. Bounding the
// number of passes keeps it from running for the life of the table.
// manifest capabilities.allowed: ["log", "read-world"]
const PERIOD_MS = 5000;
const MAX_PASSES = 3;
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
let passes = 0;
function checkTable() {
passes += 1;
void api.listObjects({ kind: "die" }).then((dice) => {
api.log(manifest.name + ": pass " + passes + " sees " + dice.length + " dice.");
});
if (passes < MAX_PASSES) {
setTimeout(checkTable, PERIOD_MS);
}
}
setTimeout(checkTable, PERIOD_MS);
api.log(manifest.name + ": first table check in " + PERIOD_MS + " ms.");
};
The event feed shows one line at load and three more, five seconds apart.
Gotchas#
The two looping timers are a scanner rejection, not a missing declaration. Writing the word setInterval or
requestAnimationFrame anywhere in your file — including in a comment — fails the timer-loop rule and the mod
cannot be registered (packages/shared/src/modManifest.ts, bannedScriptPatterns). That is why neither appears in
the declarations: they are not available, and a declaration would advertise a call that cannot ship.
Nothing cancels your timers when the mod stops. Disposing a mod removes its iframe, which takes its pending
callbacks with it — but a timer armed during setup on a table nobody is watching still runs on the host's
machine until then. Bound your re-arms.
It is not gated by a capability, and it is not a way around one. The callback runs in the same frame with the
same api object and the same grants, so a call your manifest does not allow is refused just as hard inside a
timer as outside one.
It runs on every peer that loaded the mod, not only the host. A timer that calls a host-only method — anything
that writes — will reject on a player's machine. Branch on
api.getTurn() or on what a read tells you, not on the timer firing.
See also#
clearTimeout— cancelling a pending callback.api.on— the fourteen hooks, which are almost always the better trigger.- Script safety — the
timer-looprule, its message and its workaround. - Sandbox limits — what the mod frame provides and what it removes.
clearTimeout#
Surface B — mod script · const
Cancel a pending setTimeout.
declare const clearTimeout: (handle: number) => void;
Cancels a callback setTimeout has not run yet. It is declared alongside
it in DICEYTABLE_MOD_API_DTS (packages/shared/src/modScripting.ts) so that holding and clearing a handle
typechecks in the editor, which compiles a mod script against lib: ["es2020"] with no DOM and no @types.
Parameters#
| Name | Type | Required | Notes |
|---|---|---|---|
handle |
number |
yes | The value setTimeout returned. A handle that has already fired, or that was already cleared, is ignored — clearing twice is safe. |
How, why and when to use it#
Anything you schedule conditionally needs it: a slow-turn reminder that should not fire once the turn has moved on, a debounce that a newer event supersedes, a "still waiting for a second player" nudge that a join makes pointless. The pattern is always the same three lines — keep the handle in a variable, clear it before arming a new one, and null the variable inside the callback so the variable never names a handle that has already run.
If you find yourself tracking a growing list of handles, that is usually a sign the work belongs on
api.on instead: hooks arrive when the thing actually happens and need no
bookkeeping.
Example#
// content/scripting-api/examples/cleartimeout.js
// Mod script: arm a slow-turn reminder, and cancel it when the turn moves on
// before it fires. The handle setTimeout returned is the only way to do that.
// manifest capabilities.allowed: ["log", "subscribe-events"]
const REMINDER_MS = 30000;
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
/** @type {number | null} */
let reminder = null;
function cancelReminder() {
if (reminder !== null) {
clearTimeout(reminder);
reminder = null;
}
}
api.on("onTurnStart", (payload) => {
cancelReminder();
const who = payload.peerId === null ? "the active player" : payload.peerId;
reminder = setTimeout(() => {
reminder = null;
api.log(manifest.name + ": " + who + " has been thinking for 30 seconds.");
}, REMINDER_MS);
});
api.on("onTurnChanged", cancelReminder);
api.log(manifest.name + ": slow-turn reminders armed.");
};
A turn that ends inside thirty seconds produces no reminder line; one that does not produces exactly one.
Gotchas#
A handle is only meaningful in the frame that issued it. Every mod runs in its own sandbox iframe, so a number one mod stored says nothing about another mod's timers — there is no shared timer table and no way to reach one.
Clearing does not run the callback. Whatever the callback was going to do does not happen. If the work has to happen either way, do it in the cancel path too rather than relying on the timer.
It is not gated by a capability. Like setTimeout it is a frame global, not an api method, so it needs no
manifest entry — and it grants nothing that the manifest would otherwise withhold.
Null the variable inside the callback, not only in the cancel path. A handle that has already fired is stale;
clearing it is harmless, but a variable that still holds one makes "is a reminder pending?" read true when it is
not, which is how a debounce quietly stops debouncing.
See also#
setTimeout— the call that produces the handle.api.on— the hooks, which usually replace a timer entirely.- Script safety — why the looping timers are refused and this one is not.
- Sandbox limits — the language subset a mod script runs in.
crypto#
Surface B — mod script · const
The Web Crypto subset a mod can use.
declare const crypto: {
randomUUID(): string;
getRandomValues<T extends ArrayBufferView>(array: T): T;
};
The two Web Crypto members a mod script can use: randomUUID() for an id nothing else will collide with, and
getRandomValues(array) for uniform random numbers. The mod sandbox is a real iframe document, so the browser's
crypto object is there; the declaration in DICEYTABLE_MOD_API_DTS
(packages/shared/src/modScripting.ts) narrows it to the subset that is worth using and makes both calls
typecheck in the editor, which compiles against lib: ["es2020"] with no DOM and no @types.
Returns#
randomUUID() returns a string — a version-4 UUID such as 1f2e3d4c-5b6a-4708-9c1d-2e3f4a5b6c7d.
getRandomValues(array) fills the typed array you hand it with random values in place and returns the same
array, so const draw = new Uint32Array(1); crypto.getRandomValues(draw); leaves the number in draw[0].
How, why and when to use it#
randomUUID() is the right way to name a UI element, a saved-data slot or an entity label your mod invents at
run time. Ids collide in exactly the situation that is hardest to debug — two copies of the same mod, or two
players creating something in the same second — and a counter that starts at zero each time the mod loads
collides on the second load. Namespace the id with your mod id and let the UUID do the rest.
getRandomValues is for anything a player could be tempted to predict: who goes first, a hidden setup, a shuffle
your own code performs. It is not a substitute for the table's own randomness — shuffle and roll are host
actions and the host owns their outcome — but for a decision your script makes, it is a stronger source than the
alternative, and it costs nothing.
Example#
// content/scripting-api/examples/crypto.js
// Mod script: choose a starting piece from a uniform random draw, and stamp one
// run's log lines with a correlation id. Both come from the frame's crypto.
// manifest capabilities.allowed: ["log", "read-world"]
/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
const runId = crypto.randomUUID().slice(0, 8);
const tokens = await api.listObjects({ kind: "token" });
if (tokens.length === 0) {
api.log(manifest.name + " [" + runId + "]: no tokens to choose from.");
return;
}
const draw = new Uint32Array(1);
crypto.getRandomValues(draw);
const chosen = tokens[draw[0] % tokens.length];
api.log(manifest.name + " [" + runId + "]: " + chosen.label + " goes first.");
};
The event feed shows one line such as Kingmaker [1f2e3d4c]: red-pawn goes first.
Gotchas#
Every peer runs its own copy of the mod, so every peer gets different numbers. A mod that picks a starting
player locally picks a different starting player on each machine. Anything the whole table has to agree on must
be decided once, by the host, and then told to everyone — put the decision behind a
api.getTurn() check or in host-only state such as
api.setSavedData, which rejects on a player anyway.
getRandomValues returns the array, it does not return a number. It mutates in place; the return value is
the same object, provided so calls can be chained. Read the element.
Modulo a random 32-bit value is very slightly biased. For picking one of a handful of table pieces that does not matter. For anything a player could exploit, draw again when the value falls in the biased tail.
No other Web Crypto member is declared. crypto.subtle and the rest are outside the declared subset — a mod
that needs signing or hashing is doing something the sandbox is not meant to support.
See also#
api.setUiElement— the main consumer of a generated id.api.objectAction— asking the host to shuffle, for randomness the whole table must agree on.api.getSavedData— where a generated id usually has to be persisted.- Sandbox limits — what else the mod frame provides.
