Dicey Table

Mod hooks and capabilities

A mod learns that something happened in exactly one way: it registers a handler with api.on(eventName, handler) and the host pushes events into the sandbox frame. There is no tick, no polling primitive and no delegate object. Fourteen hook names are dispatched by the platform, and one further channel is unbounded — a UI widget can name a hook of its own.

No mod code runs on a player's or spectator's client at all. Say that once and the authority column below mostly follows from it.

Every hook needs the subscribe-events capability, because api.on needs it. Four hooks need a second capability as well: onZoneEnter, onZoneLeave, onTriggerEnter and onTriggerLeave require read-world, and that requirement is enforced by the host rather than by your frame — a mod without it is never sent the message, so its handler never runs and nothing is reported. It is the existing read-world slug in all four cases; no capability value was added for trigger volumes. Every other hook reaches you as soon as you can register for it.

No hook needs read-hidden-information, and none of them carries hidden information. The four crossing hooks name entities by objectId only — there is no card face, no label and no secretMetadata in any payload. See Ids you cannot resolve for what that means now that the read-world pull reads are least-privileged.

The fourteen hooks#

Hook Payload When it fires
onTableEvent ModTableEventPayload Every line the table writes to its event log. The broadest hook — it fires for everything.
onCardDrawn ModTableEventPayload An event-log line whose message starts with "draw ", i.e. a draw action was applied.
onObjectDropped ModTableEventPayload An event-log line whose message starts with "moved ", i.e. a local drag ended with the entity somewhere new.
onTurnStart ModTurnStartPayload A turn begins — turn order started, or the turn advanced. Host only.
onTurnChanged ModTurnChangedPayload The turn state changed: switched on, switched off, or a new active peer.
onPeerJoined ModPeerPayload A peer id appears in the connected-peer list that was not there before.
onPeerLeft ModPeerPayload A peer id disappears from the connected-peer list, for any reason.
onSeatChanged ModSeatChangedPayload A peer's seat assignment changes — claimed, swapped or released.
onTeamChanged ModTeamChangedPayload A peer's team assignment changes. Raised independently of the seat.
onUiEvent ModUiEventPayload A button, checkbox or input this mod owns was clicked or changed.
onZoneEnter ModZoneEventPayload An entity came to be inside a seat zone. Every zone type, once per crossing. Host only, and needs read-world.
onZoneLeave ModZoneEventPayload It moved out, it left the table, or the seat was released and the zone went with it. Host only, and needs read-world.
onTriggerEnter ModTriggerEventPayload An entity came to be inside a trigger volume authored on a model. Once per crossing. Host only, and needs read-world.
onTriggerLeave ModTriggerEventPayload It moved out of the volume, or it left the table. Host only, and needs read-world.

The last four are the same shape of thing, split by whose geometry was crossed: a seat zone is authored on the table and travels with whoever claims the seat, while a trigger volume is authored on a model and travels with the entity it was placed on. A trigger volume never collides and never affects physics — raising these two events is the entire whole of what it does, and it does nothing at all unless a mod or a table script subscribes.

The fifteenth channel: your own hook names#

A button, checkbox or input whose props name a custom hook — onClick for a button, onChange for the other two, both falling back to a plain hook prop — dispatches that name too, immediately after onUiEvent, carrying the same ModUiEventPayload. Those names are yours, so they cannot be listed here.

await api.setUiElement({
  id: manifest.id + "-end-round",
  type: "button",
  props: { text: "End Round", onClick: "endRound" }
});
api.on("endRound", (payload) => { /* payload is a ModUiEventPayload */ });

Prefix your hook names with something specific to your mod. The name is used verbatim as a key, so a widget whose onClick is "onTurnStart" delivers a ModUiEventPayload to every handler registered for the real turn hook.

Three facts that apply to all of them#

Which peer raises a hook varies, and it matters. onTurnStart is dispatched only from host-only controls. onObjectDropped is written by the client that performed the drag. The rest are derived from replicated state, so every peer running a mod raises them from its own copy. Each hook's entry says which.

Nothing can be canceled. There is no veto hook, no try* variant, no preventDefault, and no return value a handler can use to refuse what triggered it. By the time any handler runs, the host has applied the change and broadcast it. React and correct instead — move the piece back, flip it again — or prevent the interaction with the host's own controls. See Known limitations.

The derived hooks swallow their first observation. onTurnChanged, onPeerJoined, onPeerLeft, onSeatChanged and onTeamChanged all compare current state against a remembered previous value, and the first value each client sees seeds that comparison silently. A mod loaded into a room that is already populated and already running turns therefore hears nothing until something next changes. Seed your own bookkeeping in setup.

Ids you cannot resolve#

A hook payload names an entity by id and nothing more, and every hook fires for every entity — a face-down card and an entity standing inside a hidden seat zone included. That was symmetrical with the pull reads until 2026-08-14. It is not any more: the six read-world reads now return the least-privileged view on every peer, so an id a hook hands you may be one you cannot look up.

  • api.getObject(objectId) resolves null for an entity a hidden zone conceals — the same null a deleted id gives you.
  • api.getZoneObjects and api.listObjects omit it from their arrays.
  • A face-down card does resolve, but as label: "Card" with metadata.__redacted === true and no metadata.cardId or secretMetadata.

So write a crossing handler to tolerate a null lookup rather than treating it as "the entity is gone" — it may equally mean "you are not entitled to it". If your game's rule genuinely turns on which entity crossed, that is the case read-hidden-information and api.getUnredactedSnapshot exist for.

Registration, ordering and errors#

api.on is append-only and there is no unsubscribe. Registering the same handler twice runs it twice. Handlers for one hook run in registration order, synchronously, and a handler that throws is reported as a hook-phase diagnostic naming the hook — it does not stop the others. An async handler returns a promise the frame does not await, so two async handlers on the same hook interleave after their first await.

Every selected mod's script is alive at once, each in its own frame. The runner holds one sandbox iframe per mod id, and a hook event is fanned out to all of them (apps/web/src/mods/SandboxedModRunner.ts, frames and dispatchEvent). Reloading one mod restarts only that mod's frame; the others keep running with their handlers intact. A UI event goes to exactly one mod — the one that owns the element — through dispatchEventForMod. The one exception to the fan-out is four crossing hooks: a frame that was not granted read-world is skipped, so a mod on the same table can be receiving them while yours is not.

Two consequences worth designing for: your handler is not the only handler for that hook on the table, and the order two different mods see an event in is the order their frames were created, which is the order the room selected them. Do not write a hook handler that assumes it is alone; namespace the ids and saved data you own with your mod id.

These are not the table-scripting events#

The mod hook is onTurnStart. The table-scripting delegate is globalEvents.onTurnStarted. They are different names on different surfaces with different payloads and no relationship: a mod cannot subscribe to onTurnStarted, and a table script cannot subscribe to onTurnStart. The same goes for every other near-miss between the two lists — never read them as one vocabulary. See Choosing a surface.

See also#

ModCapability#

Surface B — mod script · type

A capability a mod declares in manifest.capabilities.allowed.

declare type ModCapability =
  | "log" | "spawn-object" | "register-action" | "read-context" | "read-world"
  | "read-hidden-information"
  | "object-action" | "saved-data" | "subscribe-events" | "ui" | "play-sound"
  | "plugin-call" | "read-cards" | "read-decks" | "host-message";

The eleven slugs manifest.capabilities.allowed may contain: log, spawn-object, register-action, read-context, read-world, read-hidden-information, object-action, saved-data, subscribe-events, ui and play-sound. Each of the 23 api methods is gated on exactly one of them. The same eleven are declared as modCapabilitySchema (packages/shared/src/modManifest.ts), which is what a manifest is validated against, and this is the element type of ModSetupManifest.capabilities.allowed — so it is also the type you are reading when a script inspects its own grants.

How, why and when to use it#

Your mod paints a scoreboard with api.setUiElement, the game is playable without one, and you would rather the mod degrade than throw. Read manifest.capabilities.allowed in setup, keep a flag, and skip the feature — that is what this type is for. The alternative is to call the method and catch, which does not behave the way people expect here: every gated method throws Missing mod capability: <capability> synchronously, including the ones that return a promise, so a .catch() on the returned promise never sees it and only a try/catch wrapped around the call itself does. Check once at startup rather than at every call site.

Gotchas#

Omitting the capabilities block is not the same as granting nothing. The schema defaults it to { version: "1", allowed: ["log"] }, so a manifest that says nothing about capabilities can call api.log and nothing else.

One of the eleven is a genuine wall: read-hidden-information. It gates exactly one method, api.getUnredactedSnapshot, and it is the only way to the host's unredacted table state — every read-world read returns the least-privileged view on every peer, host included. It is never implied by read-world, never granted by default, re-checked host-side so a forged message cannot get past it, and visible to the publish scanner. Read it as "this mod can see hidden cards", because that is what a player reading the listing will read it as.

Nine of the eleven are re-checked by the host. read-context and subscribe-events are not. Those two gate no message: getMySeat, getMyTeam, getTurn and on are answered inside the frame, and the host pushes contextUpdate and hookEvent into every running frame unconditionally (apps/web/src/mods/SandboxedModRunner.ts, updateContext and dispatchEvent). A frame granted neither still holds seat, team and turn state in its own realm and still receives every hook payload. Read a capability list as least-privilege disclosure — what a reviewer or a player can know about a mod before opening its code — rather than as a wall, and do not design a mod on the assumption that a withheld read-context hides the seat map from another one. See Known limitations.

Declaring more than you use is never flagged. The validator walks the capabilities the script is detected as using and checks each one is declared; it never walks the declared list looking for entries nothing calls. Keeping the list minimal is a discipline you hold yourself to.

See also#

ModZoneType#

Surface B — mod script · type

What kind of seat zone this is. The first four have behaviour; the rest parse and are inert for now. Enter/leave events and api.getZoneObjects work for ALL of them.

declare type ModZoneType =
  | "hand" | "area" | "hidden" | "scripting"
  | "reveal" | "layout" | "randomize" | "fog-of-war" | "drop";

What kind of seat zone a crossing happened in — the value of ModZoneEventPayload.zoneType, and the only thing in a zone event that says what the zone is for.

How, why and when to use it#

onZoneEnter and onZoneLeave fire for every type, so branch on this before doing anything. Four types have behaviour: hand (the seat's private hand — an entity dropped here gains an ownerSeat), area (a play area that gates interaction but grants no ownership), hidden (contents concealed from anyone not entitled to the seat) and scripting (a trigger volume with no engine behaviour at all, which exists so a mod can give a region meaning).

Gotchas#

Five values do nothing yet. reveal, layout, randomize, fog-of-war and drop parse, render and raise crossings, but no engine rule reads them. They are listed so a scene authored on a newer client still loads on an older one — treat them as inert regions.

A mod does not choose a zone's type. Zones are authored in the scene, not created by scripts, and there is no API that adds, moves or retypes one. A zone never names code to run, either; the relationship only runs the other way, from the zone's geometry to your handler.

See also#

ModTableEventPayload#

Surface B — mod script · interface · 3 members

Payload of onTableEvent, onCardDrawn and onObjectDropped.

The object handed to a handler for three of the fourteen hooks: onTableEvent, onCardDrawn and onObjectDropped. All three carry the identical shape — the log line that fired the hook, the entity that line names, and the table as it stood at that instant — because the two narrow hooks are the broad one with a message-prefix test in front of them.

How, why and when to use it#

You are writing a handler that has to work for both draws and moves, or you want one function registered against all three hooks and a switch on the message inside it. Because the shape is shared, that function needs no overload and no type guard: read event.message for the verb, object for the entity, snapshot when you need context the entity alone does not give you. The alternative is three separate handlers with three separate shapes, which is what you would write if you assumed the narrow hooks carried narrow payloads — they do not, and a onCardDrawn handler that expects a card field will not find one. Reach for the three-hook-one-handler pattern when your rule is "something happened to a piece"; keep them separate when the draw rule and the move rule share no code.

Gotchas#

Every field is a copy, not a live view. The frame receives this object through postMessage, so it is a structured clone made when the hook fired. Mutating it changes nothing on the table, and holding on to it gives you a picture that is already stale by the next event.

snapshot and object are both redacted, on every client including the host. These three hooks cost only subscribe-events, so they do not hand out hidden information: card identities, deck order, other seats' hands and secretMetadata are neutralized before the payload crosses into your frame. See snapshot for what that means field by field, and declare read-world if your rule needs the true table.

See also#

Members#

Signature Description Returns
event TableEvent
object The event's subject, or null when the event names no object. TableObjectState | null
snapshot The table at the moment the event fired — redacted, on every peer including the host, to the narrowest view the entitlement model has (a spectator with no seat and no team). Hidden card identities, deck order, other seats' hands and secretMetadata are neutralized here even when the frame is running on the host, which holds them all. TableSnapshot | null

modtableeventpayload.event#

readonly event: TableEvent;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The event-log line that caused the hook to fire — the same record the table's own event feed shows a player. It is the only field of this payload that is always present, and the only one that tells you what happened rather than to what.

Returns

TableEvent, never null. Six fields: id (a fresh UUID per line), at (an ISO-8601 timestamp), actor (a peer id, or one of the literals "Host", "Script", "You", "System" and "Mod"), message (the human-readable line), an optional objectId, and an optional revealsIdentity flag.

How, why and when to use it

You want to score a shuffle, a flip or a lock, and none of those has a hook of its own — the log line is where the verb lives. The alternative is to diff two snapshots yourself on every onTableEvent, which is far more code and still cannot tell a flip from an undo of a flip. Test message with startsWith for the verb you care about, and take identity from objectId rather than from the text, because the text is built for a human reader. Use actor first in any handler that also writes to the table: a line your own mod wrote comes back through the same hook, and a handler that reacts to itself runs forever.

Gotchas

at comes from the clock of the client that wrote the line. The runtime stamps it with its own new Date().toISOString() when it appends to the log (apps/web/src/playcanvas/TabletopRuntime.ts, the log helper). Two clients' timestamps are not comparable to each other, and neither is authoritative — sort by arrival order in your handler if ordering matters.

objectId is optional, not nullable. A line with no subject omits the key entirely, so it reads undefined. Test typeof event.objectId === "string" rather than !== null.

revealsIdentity marks a line that names a card. A card's label is the card's identity, so the runtime tags any line whose message embeds one, and hidden-information redaction drops those lines for a viewer not entitled to them. That means the set of lines you see through this hook on a player's client is smaller than the set the host sees.

See also

  • TableEvent — the declared shape of this field.
  • object — resolving objectId to an entity.
  • onTableEvent — the hook that delivers every line.
  • api.log — writing a line yourself, and why it comes back.

modtableeventpayload.object#

readonly object: TableObjectState | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The event's subject, or null when the event names no object.

Resolved out of snapshot below, so it carries the same redaction: an event about a card you are not entitled to see resolves to a neutralized object.

The entity the log line is about, already looked up for you. The client that raises the hook takes event.objectId, finds the matching entry in its current snapshot, and hands you that entry — so you get the full replicated state without a round-trip.

Returns

TableObjectState | null. null in two different situations that look identical here: the line names no entity at all (event.objectId is absent — a chat line, a system message, a mod's own api.log), and the line names one that is no longer in the snapshot the lookup runs against, which is what a delete produces. Compare event.objectId separately when you need to tell them apart.

How, why and when to use it

Your rule is "when a card lands in the discard zone, score it", and you need the card's label, position and metadata to decide. Reading them here costs nothing, whereas the obvious alternative — api.getObject with event.objectId — is an await across the frame boundary that returns the same data one turn of the event loop later, and returns null for exactly the entity a delete line was about. Use this field for every read you can satisfy from it; fall back to api.getObject only when you need an entity the event did not name, such as the deck a drawn card came from.

Gotchas

Positions here are in feet. object.position is a world-space triple in feet, so [0, 1, 0] is one foot above the table origin. The same is true of every coordinate on the entity.

The lookup runs against the current snapshot, not the one the event was written from. For a fast sequence — draw, then immediately destroy — the entity can already be gone by the time the hook is delivered, and you get null for a line that plainly names something. Copy anything you need out of this object inside the handler.

Applies to: every object kind. card, deck, die, token, board, bag, custom and card-holder all resolve the same way; nothing about the lookup varies by kind. What varies is which kinds produce which log lines.

This is always the redacted entity, on every client including the host. It is resolved out of snapshot, which is redacted against the narrowest view the entitlement model has before it reaches your frame, so it inherits exactly the same neutralization: a face-down card carries label "Card", no metadata.cardId and metadata.__redacted === true. An entity concealed entirely by a hidden seat zone is not in the snapshot at all, so this resolves null for it — indistinguishable from an entity that has left the table. Declare read-world and use api.getObject when you need the true entity.

See also

modtableeventpayload.snapshot#

readonly snapshot: TableSnapshot | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The table at the moment the event fired — redacted, on every peer including the host, to the narrowest view the entitlement model has (a spectator with no seat and no team). Hidden card identities, deck order, other seats' hands and secretMetadata are neutralized here even when the frame is running on the host, which holds them all.

This hook needs only subscribe-events, so it is not the place to hand out table secrets — and neither are the read-world reads, which return this same least-privileged view. A mod that genuinely needs more declares read-hidden-information and calls api.getUnredactedSnapshot(): one capability in the manifest, one method name in the script, both visible to anyone auditing the mod.

The table as it stood when the line was logged — every entity, every zone, every snap point, the UI state and the event log — with hidden information removed. It is the one place on this surface where a table arrives without you asking for it, and the only one that arrives without read-world.

Returns

TableSnapshot | null. null when the client has no snapshot to give — the runtime has not produced one and no broadcast has arrived yet — and also when it cannot work out what to conceal, because withholding the table is the safe answer there and publishing it is not. Once a table is up and running, this field is populated on every event.

How, why and when to use it

Your rule needs the rest of the table, not just the entity that moved — "the round ends when the last card leaves the draw pile", "score a token by which zone it is sitting in". The alternative is api.getSnapshot, and the difference is not convenience but correctness: getSnapshot is a round-trip that resolves after your handler returns, so it can observe a table that has already changed again, while this field is the table at the moment of the event. Use this one inside a hook handler; use api.getSnapshot in setup, or anywhere you need the table and are not holding an event.

Gotchas

This is a full copy of the table on every single log line. A busy table logs several lines a second and each one clones the entire snapshot into your frame. Read what you need and let it go — retaining snapshots in an array is the fastest way to make a mod the reason a table stutters.

It is redacted on every client, including the host. This hook needs only subscribe-events — the narrowest capability there is, and one that reads as harmless on a registry listing — so it is not a place that hands out table secrets. The snapshot is put through packages/shared/src/tableObjects/redaction.ts against the narrowest view the entitlement model has: a spectator with no seat and no team. Hidden card identities, deck order, every seat's hand and secretMetadata are neutralized before the payload reaches your frame, and running on the host does not change that — a mod is granted nothing by living where the secrets are kept.

The practical consequences: a face-down card arrives with label "Card", no metadata.cardId and metadata.__redacted === true; a deck carries at most its publicly-visible top card, never its order; and an entity concealed by a hidden seat zone is missing from objects altogether. Counting by label or cardId will therefore under-report, and objects.length is not the entity count on a table with hidden zones. If your rule genuinely needs the true table, declare read-hidden-information and call api.getUnredactedSnapshot — that capability is in your manifest where a player can see it, which is exactly the point. read-world will not do it: since 2026-08-14 its six reads return the same least-privileged view this payload does.

It is a sibling of the event, not a wrapper around it. snapshot.eventLog already contains the line in event, at the front, so a handler that walks eventLog will process the current event a second time.

See also

ModTurnStartPayload#

Surface B — mod script · interface · 4 members

The object handed to a handler for onTurnStart, and to nothing else. It answers "whose turn is it, and how much can they do" in one shape: the peer, their seat, their team, and the table's per-turn action limit.

How, why and when to use it#

You are opening a turn — dealing a card, resetting a budget, prompting the active player — and you want the seat and team without asking for them. That is the reason to prefer this payload over ModTurnChangedPayload, which reports the same moment but carries only the active peer id and would leave you calling api.getMySeat or walking a snapshot to recover the rest. The trade is coverage: only the host raises onTurnStart, so a mod that has to work on a player's client reads the turn from ModTurnChangedPayload or from api.getTurn instead.

Gotchas#

Three of its four fields are null at an empty table. Starting turn order with nobody in the order still raises the hook, with peerId, seat and team all null. Guard on peerId before you deal anything.

See also#

  • peerId — whose turn it is.
  • actionLimit — the per-turn cap, and what null means there.
  • onTurnStart — the hook that delivers it.
  • ModTurnInfo — the same question asked on demand instead of pushed.

Members#

Signature Description Returns
peerId string | null
seat string | null
team string | null
actionLimit maxActionsPerTurn; null = unlimited. number | null

modturnstartpayload.peerId#

readonly peerId: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The peer whose turn has just begun. It is the first entry of the turn order when the host starts turns, and the next entry each time the turn advances — the same id every other hook and every roster uses for that person.

Returns

string | null. null means the turn order is empty: the host pressed Start Turns with nobody in the order, or advanced a turn on an order that has emptied out. The hook still fires in that case, so null here is a real state and not a failure.

How, why and when to use it

You keep a per-player total — score, cards played, actions taken — and the top of a turn is where you look that player up. Key the lookup on this id and never on seat, because a seat can be released and re-taken by somebody else inside one session while a peer id belongs to one connection for its whole life. Use the seat when your state belongs to the chair (the hand in front of it, the score marker on the table) and this id when it belongs to the person.

Gotchas

A peer id does not survive a reconnect. Someone who drops and rejoins comes back with a new id, so state keyed on it is orphaned. Persist per-player state under the seat with api.setSavedData if it has to outlive a disconnection.

It is never the string "none" or an empty string. The absence of an active peer is expressed as null, so a falsy test and a === null test agree here — which is not true of actionLimit.

See also

modturnstartpayload.seat#

readonly seat: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The seat the active peer occupied when the turn began. The host resolves it out of the room's seat assignment map at dispatch time, so it is the same value the seat UI shows and the same one that decides who may look at a hidden hand.

Returns

string | null. A seat identifier such as red or blue when that peer is seated. null means they hold no seat — which happens when the turn order contains a peer who never sat down, and also whenever peerId itself is null.

How, why and when to use it

Your game puts something in front of a chair — a hand, a score marker, a per-seat panel — and at the top of a turn you need to know which chair to act on. This field saves the lookup, and it is the right key for anything that belongs to the position rather than the person, because it survives the occupant leaving. The alternative is api.getMySeat, which answers a different question entirely: it tells the client running the mod about itself, not about whoever's turn it is.

Gotchas

It is a snapshot of the moment the turn began. A player who changes chairs mid-turn does not re-raise this hook, so the value you captured goes stale silently. Subscribe to onSeatChanged as well when mid-turn moves matter to your rules.

A seat and a team are independent assignments. Someone can hold a seat with no team, a team with no seat, both, or neither, so do not infer one from the other — read team for the other half.

See also

modturnstartpayload.team#

readonly team: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The team the active peer belonged to when the turn began. It comes out of the same assignment map as the seat, read at dispatch time, and it is the field a partnership game groups turns by.

Returns

string | null. A team identifier when that peer is on a side. null means they are on none — the ordinary state at a free-for-all table, where nobody is ever assigned a team, and also whenever peerId is null.

How, why and when to use it

You are running a partnership game — bridge, a two-versus-two skirmish — and a turn belongs to a side as much as to a person: the score goes to the team, and the reveal rules let a partner see the hand. Reading this field at the top of the turn tells you which side is on without a second lookup. The alternative is api.getMyTeam, which reports the team of the client running the mod rather than the team of whoever's turn it is; reach for that one when you are deciding what this client should be shown.

Gotchas

null is the normal answer, not an edge case. Teams are opt-in and most tables never assign one, so a handler that branches on team must treat null as "no partnership rules apply" rather than as missing data.

Turn order is a list of peers, not of teams. The host advances one peer at a time in roster order, so consecutive turns can belong to the same team or alternate — nothing in the platform interleaves sides for you. Sequence team turns yourself if your game needs them to alternate.

See also

modturnstartpayload.actionLimit#

readonly actionLimit: number | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

maxActionsPerTurn; null = unlimited.

How many actions the table will let the active player take this turn. It is the host's turnAutomation.maxActionsPerTurn setting, copied into the payload at dispatch time, so it reflects whatever the host had configured when the turn opened.

Returns

number | null. null means unlimited, not zero and not "unknown" — it is the value the setting holds when the host has not capped turns at all. A number is the cap the host counts against.

How, why and when to use it

Your game shows the player how many moves they have left, or refuses to deal a second card once they have spent their allowance. Reading the table's own limit here keeps your display honest when the host changes the setting mid-session, which a constant in your mod cannot do. Count the actions yourself against it — the table maintains its own per-turn counter, and no hook reports that counter, so a mod that wants a running total watches onTableEvent and resets on each onTurnStart.

Gotchas

Test it against null explicitly. if (!payload.actionLimit) treats unlimited and a cap of zero as the same thing, and they are opposites. Write payload.actionLimit === null for the unlimited branch.

Your count and the host's count are separate numbers. Enforcing a limit in a handler does not stop the host applying an eleventh action, and nothing a handler returns can refuse one. If the cap matters to your rules, correct after the fact — move the piece back, undo the draw — rather than trying to block. See Known limitations.

See also

ModTurnChangedPayload#

Surface B — mod script · interface · 3 members

The object handed to a handler for onTurnChanged. It describes the turn state rather than a turn beginning: whether turn order is running at all, who is active now, and who was active before. Every peer derives it from its own copy of the replicated turn state, so it is the shape a mod reads when it cannot assume it is running on the host.

How, why and when to use it#

You need to know the turn moved and your mod might be loaded on a player's client — a resumed room, a spectator, a client that took over after host migration. This payload is what you get there, because ModTurnStartPayload reaches the host alone. It also covers two moments the turn-start payload never reports: turn order being switched on and switched off, which arrive here as a change to enabled. Use this shape for anything that has to be right on every client, and the turn-start payload when you are on the host and want the seat and team resolved for you.

Gotchas#

The first observation raises nothing. Each client remembers the turn state it first sees and compares against it, so a mod loaded into a room where turns are already running hears nothing until the turn next moves. Seed your own state from api.getTurn in setup.

See also#

Members#

Signature Description Returns
enabled boolean
activePeerId string | null
previousActivePeerId string | null

modturnchangedpayload.enabled#

readonly enabled: boolean;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Whether the table is running turn order at all. true from the moment the host starts turns until it stops them; false at a table that has never used turns, and again after the host presses Stop.

How, why and when to use it

Your mod shows a turn indicator, or refuses a play out of turn, and both have to switch themselves off when the host abandons turn order mid-game. This flag is the only signal for that: turn order stopping raises onTurnChanged with enabled: false and nothing else — no onTurnStart, no closing event of any kind. Treat a false as "the concept of a turn no longer applies" and tear down whatever your true branch built, rather than leaving the last active player highlighted forever.

Gotchas

A change of enabled and a change of active peer arrive through the same hook. The host raises onTurnChanged when either differs from the previous observation, so a handler must read both fields rather than assuming a dispatch means the turn advanced.

Stopping turns clears the active peer in the same dispatch. The host writes enabled: false and activePeerId: null together, so you get one event carrying both, with previousActivePeerId naming whoever was cut off.

Starting turns with an empty order produces enabled: false. The host sets the flag from whether the order has any entries, so pressing Start Turns at an empty table leaves turns off.

See also

modturnchangedpayload.activePeerId#

readonly activePeerId: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Whose turn it is now, as the replicated turn state records it. Every client derives this field from its own copy of that state, so all of them agree on it without any of them asking the host.

Returns

string | null. A peer id when someone is on. null in two situations: the host has stopped turn order, in which case enabled is false in the same dispatch, and the order is empty while turns are nominally on.

How, why and when to use it

Your mod has to answer "is it my turn?" on whichever client it is running on, and it has to keep answering correctly after the turn moves. Compare this id against the client's own peer id — the one api.getTurn resolves isMyTurn from — and you have a live answer with no round-trip. The alternative is calling api.getTurn on every event, which is an await that returns the same value this field already gave you synchronously; keep api.getTurn for setup, where there is no event to read from.

Gotchas

This field alone cannot tell an advance from a stop. A turn moving and turns being switched off both change it, and the second also sets enabled to false. Read both fields together.

It does not carry the seat or the team. Only ModTurnStartPayload resolves those, and that payload reaches the host alone. On a player's client, map the id to a seat yourself from a roster you maintain with onSeatChanged.

A dispatch is not a turn boundary. The host raises this hook whenever the turn state differs from the last observation, including when turns start and when they stop, so counting dispatches over-counts turns. Count onTurnStart on the host, or count transitions where previousActivePeerId and activePeerId are both non-null.

See also

modturnchangedpayload.previousActivePeerId#

readonly previousActivePeerId: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Who was on before this change. The client keeps the last turn state it observed and reports that state's active peer here, which turns a bare "the turn moved" into a transition you can act on from both ends.

Returns

string | null. null on the first turn of a session, because the remembered state before turn order starts has no active peer — a first turn therefore arrives as previousActivePeerId: null with activePeerId set, and never as an empty string. It is also null for any later dispatch where nobody was on, such as the first advance after turns were restarted.

How, why and when to use it

Your rules have an end-of-turn step — discard down to five, hand the dice on, score what the player built — and the platform raises no "turn ended" hook on this surface. This field is how you get one: when it is non-null, the person it names has just finished, and you run your close-out for them before you run your open-up for activePeerId. The alternative is remembering the previous active peer in a variable of your own, which is the same thing done worse — your copy starts empty on every reload, while this one is derived from state the client already held.

Gotchas

Turns stopping reports the cut-off player here. Pressing Stop dispatches enabled: false, activePeerId: null and this field naming whoever was mid-turn. That is the one dispatch where an end-of-turn step should run with no matching start.

It reports the previous observation, not the previous turn. The comparison is against the last turn state this client saw, so a client that joined mid-game reports null for its first dispatch even though several turns have already been played.

No other payload carries it. ModTurnStartPayload has no previous-peer field and ModTurnInfo has none either, so a mod that wants both the outgoing player and the incoming player's seat has to listen to onTurnChanged and onTurnStart together.

See also

ModPeerPayload#

Surface B — mod script · interface · 4 members

The object handed to a handler for onPeerJoined and onPeerLeft — one shape for both directions. It describes a person at the table: their connection id, the name they announced, the role they held, and when they connected. It says nothing about seats, teams or hands.

How, why and when to use it#

You are maintaining your own roster because the platform gives a mod no method that returns one, and both hooks hand you this same shape so one function can add and remove from the same map. On departure the payload is the record the client remembered from when that peer arrived, so displayName, role and connectedAt describe them as they were — which is exactly what you want for a leave message and exactly wrong if you assume it was re-read at departure time. Use the seat hooks instead when what you care about is who is playing rather than who is present: a spectator is a peer and holds no seat.

Gotchas#

Neither hook fires for peers who were already there. Each client seeds its comparison from the first roster it sees, so a mod loaded into a populated room starts with an empty roster and never hears about the people in it. Seed yours in setup from api.getSnapshot, whose hands and zones name the seats that are already occupied.

See also#

  • peerId — the identity key.
  • role — host, player or spectator.
  • connectedAt — when they joined, and whose clock said so.
  • onSeatChanged — the hook about playing rather than presence.

Members#

Signature Description Returns
peerId string
displayName string
role "host" | "player" | "spectator"
connectedAt string

modpeerpayload.peerId#

readonly peerId: string;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The connection id of the peer who arrived or left. It is the id the whole platform keys on: the same string appears as activePeerId in the turn hooks, as peerId in the seat and team hooks, as actorPeerId on a UI event, and as the actor of a log line that a player caused.

Returns

string, always. Neither hook can fire without a peer to describe, so this field is never null and never empty.

How, why and when to use it

You are building the roster your mod reasons about — who can be dealt to, who a "waiting for players" message should count — and this is the key every entry hangs off. Key on it rather than on displayName, which two people can share, and rather than on the seat, which is null for a spectator and can change hands. The one thing it is wrong for is state that must outlive a disconnection: an id belongs to a connection, so use the seat as the key for anything you want the next occupant of that chair to inherit.

Gotchas

A rejoin is a new id. Someone who drops and comes back raises onPeerLeft with the old id and then onPeerJoined with a new one, and nothing in the payload links the two. Match on the seat if you need to recognize a returning player.

Your own client is in the roster. The comparison covers every entry the client sees, including itself, so a mod running on a peer that joins after your mod loaded elsewhere reports that peer like any other. The frame is not told which id is its own; ask api.getTurn for isMyTurn rather than trying to compare ids to work out who you are.

See also

modpeerpayload.displayName#

readonly displayName: string;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The name that peer announced when they joined the room, as it appears in the player list and in chat. It is here so a mod can write a readable message — "Ada joined" — without keeping a name table of its own.

Returns

string. It is the name the joining client supplied to the signaling server, so it is chosen by that player rather than attested by the platform.

How, why and when to use it

You are logging with api.log or labeling a UI element, and a peer id truncated to six characters tells a reader nothing. Put this field in the text and the id in your data structures — the split matters because the moment you use a name as a map key, two players called Player collapse into one entry and your scores merge. On departure the value is the name that client remembered from the arrival, which is the correct name to print in a leave message.

Gotchas

It is not unique and it is not a key. Nothing stops two peers at one table sharing a name. Every lookup goes through peerId.

Do not put it in a UI element without escaping it in your own mind first. It is free-form text from another player, so a mod that concatenates it into a widget's text prop is publishing whatever that player typed to everyone at the table.

The entity-facing names are different words. An entity's displayName is its optional human label and its label is the slug that identifies it; this field is a person's name and shares nothing with either.

See also

  • peerId — the thing to key on instead.
  • role — what that person is allowed to do.
  • api.log — where a name belongs.
  • api.setUiElement — the other place a name is shown, and the one to be careful with.

modpeerpayload.role#

readonly role: "host" | "player" | "spectator";
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

What that peer is at the table: the one host, a player, or a spectator. The signaling server decides the role when the connection is established, and this field carries the value the roster held at the moment the peer appeared or disappeared.

Returns

"host" | "player" | "spectator" — three values, no fourth, and never null. The "offline" value that appears on ModUiEventPayload.actorRole cannot occur here, because an offline table has no roster to join.

How, why and when to use it

Your game deals only to players and has to leave spectators out of the count, or it wants to know which peer is the host so it can address a message to them. Reading the role at arrival is how you classify a peer before they have taken a seat — the seat hooks tell you nothing until they sit down, and a spectator never does. Use the role for "should this person be in the game at all" and the seat for "which chair are they playing from".

Gotchas

The role you get is the role at that instant, and it can change without a hook. A spectator who claims a seat becomes a player, and host migration makes some other peer the host, and neither re-raises onPeerJoined. Watch onSeatChanged for the first and accept that your cached roles drift for the second.

Exactly one peer is the host, and that is where the table's authority lives. A role of "host" tells you which client owns the table state and which client runs the host-only paths; it is not a permission your mod grants or checks.

See also

modpeerpayload.connectedAt#

readonly connectedAt: string;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

When that peer's connection was established. The signaling server stamps itnew Date().toISOString() in apps/server/src/signaling/session.ts, as it builds the peer's presence record — so it is the server's clock, not the joining player's and not the host's, and it is the same string on every client.

Returns

string: an ISO-8601 timestamp with milliseconds and a Z suffix, such as 2026-07-27T14:03:11.482Z. Never null, and never a number — pass it through Date.parse if you need to do arithmetic on it.

How, why and when to use it

You want join order — seating people in the order they arrived, giving the first arrival the first turn, showing "at the table for 12 minutes". Because one clock produced every one of these values, comparing two of them is meaningful in a way that comparing timestamps taken on two different clients is not. The alternative is stamping your own Date.now() when the hook fires, which measures when your frame heard about the arrival rather than when it happened, and drifts by however long the mod took to load.

Gotchas

It survives the peer. onPeerLeft reports the value that was remembered from the arrival, so subtracting it from the current time on departure gives you a session length.

A rejoin gets a fresh timestamp. The server stamps a new presence record for the new connection, so "time at the table" resets for anyone who reconnects.

Do not compare it against a timestamp your own frame produced. The sandbox's clock is the player's device clock, which is not synchronized with the server's. Compare connectedAt values with each other, and nothing else.

See also

ModSeatChangedPayload#

Surface B — mod script · interface · 4 members

The object handed to a handler for onSeatChanged. It reports one peer's seat as a before-and-after pair, plus the team they hold after the change, so a claim, a move and a release are all the same shape read three different ways.

How, why and when to use it#

Your mod owns something per chair — a hand, a score marker, a per-seat panel — and it has to follow the people who move between chairs. Reading previousSeat and seat together tells you what to tear down and what to build in one handler, which is why the payload carries both rather than just the new value. The alternative, api.getMySeat, answers only for the client running the mod and answers it once; use it to find out who you are, and this payload to track everybody else.

Gotchas#

A team change does not arrive here. The host compares seat and team separately and raises a different hook for each, so a peer who changes both in one update produces one onSeatChanged and one onTeamChanged, in that order, with each payload carrying the other assignment's post-change value for convenience.

See also#

Members#

Signature Description Returns
peerId string
previousSeat string | null
seat string | null
team string | null

modseatchangedpayload.peerId#

readonly peerId: string;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The peer whose seat changed. The host walks the seat assignment map peer by peer and raises one hook per entry that differs, so this field names exactly one person and the rest of the payload describes only them.

Returns

string, always. The map is keyed by peer id, so there is no state in which a seat change has no peer to attribute it to.

How, why and when to use it

Two players swapping chairs produces two dispatches, and this field is the only thing that tells them apart — both carry a previousSeat and a seat, and reading them without the id gives you a swap with no idea who went where. Build your seat map as peer id to seat and update the one entry this field names, rather than rebuilding the whole map from a snapshot on every event, which costs a round-trip and still races the second half of the swap.

Gotchas

It appears in the release event too. A player who stands up or leaves the table produces a dispatch naming them with seat: null, so an entry keyed on this id has to be deleted as well as added.

The two halves of a swap arrive as two independent events with no ordering guarantee between them. A handler that assumes it sees both chairs settle in one call is wrong for one of the two dispatches. Recompute anything derived from the whole seating only after you have applied the single change this payload describes.

See also

modseatchangedpayload.previousSeat#

readonly previousSeat: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The seat this peer held before the change. It is read from the client's remembered copy of the assignment map, which is what makes a claim distinguishable from a move without your mod keeping its own history.

Returns

string | null. null on a first claim — a peer with no entry in the previous assignment map is treated as holding { seat: null, team: null }, so their first seat arrives as previousSeat: null and never as an empty string. A seat identifier means they were sitting somewhere and have now moved or stood up.

How, why and when to use it

Your mod puts a marker in front of a chair, and when someone moves you have to clear the old chair as well as set the new one. This field is the "clear this" half, and it is the reason you do not need to search your own map for the peer before updating it. Combine it with seat to classify the event: null here is a claim, null there is a release, and two identifiers is a move.

Gotchas

It is the previous value this client observed, not the previous value in the session. A client that loaded its mod after several seat changes had already happened compares against the map it first saw, so the first dispatch it hears reports that seeded state as the previous seat.

Entities do not follow the player out of the old seat. An entity's ownerSeat belongs to the chair, so the hand in front of the seat named here stays exactly where it is when its occupant moves. Whether that is right is your game's decision; move the contents yourself if it is not.

See also

modseatchangedpayload.seat#

readonly seat: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The seat this peer holds after the change — the value the table's seat UI now shows for them, and the value the hidden-hand rules now use to decide which hand they are allowed to look at.

Returns

string | null. A seat identifier such as red or blue when they are seated. null means they now hold no seat, which is how both a voluntary release and a departure from the table announce themselves.

How, why and when to use it

Your rules are about the chairs — "deal to every occupied seat", "a hand belongs to the seat in front of it" — so this is the field that decides where a piece goes. It is the right key for anything the next occupant should inherit, precisely because it is a property of the table rather than of a connection: a player who disconnects releases the seat, and whoever takes it next picks up whatever your mod parked there. Key on peerId instead when the state belongs to the person and should follow them if they move.

Gotchas

A release and a departure are indistinguishable here. Both produce seat: null. Subscribe to onPeerLeft when the difference changes what your mod does.

The seat identifiers come from the table's own seat list, not from your mod. You cannot invent one, and a mod that hard-codes a set of names will disagree with a table configured for a different number of players. Read the identifiers you are given rather than declaring them.

See also

modseatchangedpayload.team#

readonly team: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The team this peer is on after the seat change. It is carried for convenience — the seat hook reads the peer's whole assignment entry, so it hands you the other half rather than making you go looking for it.

Returns

string | null. A team identifier when they are on a side; null when they are on none, which is the ordinary state at any table that does not use teams.

How, why and when to use it

Your partnership game seats players in pairs and has to check that a claim is legal — "the red seat belongs to team A" — and this field gives you the side without a second lookup at the moment you need it. It is a post-change value only: there is no previous-team field here, because a team that changed would have raised onTeamChanged instead. Read this one when a seat move needs the side for context; read the team hook when the side itself is what changed.

Gotchas

A dispatch of this hook means the seat changed, not the team. The team value here is frequently identical to the one you already had, so a handler that treats every onSeatChanged as a team update will re-run work for nothing.

Seats and teams are independent. Taking a seat does not put anyone on a team and leaving one does not take them off, so this field can be non-null while seat is null.

See also

ModTeamChangedPayload#

Surface B — mod script · interface · 4 members

The object handed to a handler for onTeamChanged. It is the mirror image of the seat payload: one peer, their side before and after, and the seat they hold after the change. Field order differs from the seat payload — previousTeam and team come first, seat last — so read by name rather than by position if you write one function for both.

How, why and when to use it#

You are running a partnership game and a side changing matters on its own: the scoring pool moves, a partner's hand becomes visible, a team-scoped UI panel has to be rebuilt. Subscribing to this rather than to onSeatChanged is what stops you re-running that work every time somebody merely changes chairs. Use the seat hook when the chair is the subject; use this one when the side is.

Gotchas#

At a table with no teams this hook never fires. Teams are opt-in, and a table where nobody is ever assigned one produces no team changes at all — so a mod that only listens here hears nothing and must not treat that silence as "no players".

See also#

Members#

Signature Description Returns
peerId string
previousTeam string | null
team string | null
seat string | null

modteamchangedpayload.peerId#

readonly peerId: string;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The peer whose team changed. The host compares the assignment map entry by entry and raises one hook per peer whose team differs, so this names one person and the payload describes that person alone.

Returns

string, always. Team assignments are stored against a peer id, so there is no team change without a peer to attribute it to.

How, why and when to use it

You keep a side roster — who is on team A, who is on team B — so that a partnership rule can find the partner of whoever is playing. Update the single entry this id names on each dispatch, because a table that reassigns several players at once produces one dispatch per player and rebuilding the whole roster on each of them does the same work three times over and briefly sees an inconsistent middle state.

Gotchas

A peer with no team is still a peer. Somebody being taken off a side produces a dispatch naming them with team: null, so your roster has to remove entries as well as add them.

It is the same id the seat hook uses, and the same id the turn hooks use. One peer changing both seat and team produces two dispatches carrying this identical value, one through each hook, and neither is a duplicate of the other.

See also

modteamchangedpayload.previousTeam#

readonly previousTeam: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The side this peer was on before the change. It comes from the client's remembered copy of the assignment map, so you get the transition rather than just the destination.

Returns

string | null. null when they were on no side — including the very first time anyone assigns them one, because a peer with no entry in the previous map is read as { seat: null, team: null }. It is never an empty string. A team identifier means they were on that side and have now switched or been taken off.

How, why and when to use it

Your scoring is per side, and a player moving from team A to team B means A loses their contribution and B gains it — you need both ends of that to keep the totals right. This field is the "subtract from" half. The alternative is holding your own previous-team map, which is the same thing with an extra failure mode: your copy starts empty on every reload while this one is derived from state the client already had.

Gotchas

null here means "joined a side", and null in team means "left one". Both null cannot happen — the hook only fires when the two differ.

It reflects the last state this client observed. A mod loaded into a table where sides were already assigned compares against the map it first saw, so its first dispatch reports that seeded value as the previous team.

See also

modteamchangedpayload.team#

readonly team: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The side this peer is on after the change — the value the table's own team assignment now holds for them, and the one every other client derives from the same replicated map.

Returns

string | null. A team identifier when they are on a side. null means they are now on none, which is how being taken off a team announces itself.

How, why and when to use it

Your game scores by side, or reveals a partner's hand, or paints a per-team panel, and all of those key on the value here. Take it from the payload rather than calling api.getMyTeam, which reports only the side of the client running the mod and tells you nothing about the peer this event is about. Use api.getMyTeam for "what should this screen show"; use this field for "what has just changed at the table".

Gotchas

Team identifiers are the table's, not yours. Your mod cannot create a side, and the strings it receives come from the room's own assignment UI. Group on the values you are given rather than hard-coding a pair of names.

A team is not a seat and does not imply one. Someone can be put on a side while standing, so a handler that assumes a non-null team means an occupied chair will be wrong; read seat for that.

See also

modteamchangedpayload.seat#

readonly seat: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The chair this peer holds after the team change. The hook reads the peer's whole assignment entry, so it hands you the seat alongside the side rather than making you look it up separately.

Returns

string | null. A seat identifier when they are seated; null when they hold no chair — a spectator put on a side, or somebody who was taken off a seat and a team in the same update.

How, why and when to use it

Your partnership game needs to know where the new team member is sitting, because the pieces you move to follow a side change live in front of a chair. Reading the seat here saves a lookup at the exact moment you have the side. It is a post-change convenience value only: a chair that changed would have raised onSeatChanged, so if this differs from the seat you remembered, you have already had — or are about to get — that hook too.

Gotchas

This field changing is not what fired the hook. The dispatch means the team differed. Do not drive seat bookkeeping from here, or a player who changes sides without moving will look to your mod like a seat event that never happened.

null is common at a spectator-heavy table. Somebody can be assigned to a side without ever sitting down, so guard before you use this value to address a chair.

See also

ModZoneEventPayload#

Surface B — mod script · interface · 4 members

Payload of onZoneEnter and onZoneLeave — one entity crossing one seat zone.

Four strings and nothing else. Your world is the replicated snapshot: there is no pc.Entity, no engine guid and no live handle here. Call api.getObject(objectId) when you need the entity.

The object handed to a handler for onZoneEnter or onZoneLeave. It names the zone, its seat, its type and the entity that crossed the boundary — four strings, and deliberately nothing else.

How, why and when to use it#

It is the smallest payload that answers "who went where", and the shape is identical for both hooks so one helper can serve them. There is no entity on it because a mod's world is the replicated snapshot: call api.getObject(objectId) when you need the entity itself, and skip that round trip for the many rules — counters, gates, "has this area emptied yet" — that only need the ids.

Gotchas#

No engine handle is reachable from here, by design. There is no pc.Entity, no scene-graph node and no internal id anywhere in this payload or behind it. The four fields are the whole surface.

A zone is identified by a pair. An authored zone id is unique only within its seat, so key any map you build on seat and zoneId together.

You cannot read a zone's geometry. Seat zones live in the scene document and never cross the wire, so there is no position, size or rotation to ask for. Subscribe to the crossings, or ask api.getZoneObjects what a zone holds.

See also#

Members#

Signature Description Returns
zoneId The authored zone id. Unique only WITHIN its seat, so key on seat + zoneId together. string
zoneType ModZoneType
seat The seat that owns the zone, e.g. "red". string
objectId The entity that crossed the boundary. string

modzoneeventpayload.zoneId#

readonly zoneId: string;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

The authored zone id. Unique only WITHIN its seat, so key on seat + zoneId together.

The authored id of the zone box that was crossed, carried through from the scene unchanged so a zone stays the same zone across frames, seat claims and reloads. It is also the second argument api.getZoneObjects expects.

Returns

string. Scenes authored in Edit Mode name their boxes seat-zone-<seat>-<index>, but that is an editor convention rather than a rule — an imported scene may use anything.

How, why and when to use it

Use it when a seat owns more than one zone and your rule differs between them, and use it to ask that same zone what it now holds. For anything per-seat rather than per-zone, seat alone is the simpler key.

Gotchas

Unique only WITHIN its seat. Two seats routinely carry boxes with the same id, so a map keyed on zoneId alone merges every seat into one bucket. Key on seat and zoneId together.

Do not hard-code one. The ids belong to whichever scene the table loaded, not to your mod. A rule naming a literal id works on the scene it was written against and silently does nothing everywhere else — branch on zoneType where you can.

See also

modzoneeventpayload.zoneType#

readonly zoneType: ModZoneType;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

What kind of zone was crossed. The field almost every zone handler branches on first, because both hooks fire for all zone types and most rules only mean something for one of them.

Returns

A ModZoneType: hand, area, hidden or scripting for the four types with behaviour, or one of the five deferred ones that parse and fire but that no engine rule reads yet.

How, why and when to use it

Branching on the type is what separates "in play" from "in somebody's hand" without your mod knowing anything about the scene's layout. A rule written against zoneType survives an author renaming a box, adding a second discard pile, or applying the seat template to three more seats — none of which a rule written against zoneId survives.

Gotchas

It is the effective type. A seat whose boxes carry no explicit type has its first box treated as hand and the rest as area, so a scene authored before zone types existed still reports something sensible.

You will never see it empty. An inert box — an untyped leftover in a seat that does carry typed boxes — is not tracked at all, so it never fires.

See also

modzoneeventpayload.seat#

readonly seat: string;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

The seat that owns the zone, e.g. "red".

The seat that owns the zone — whose hand, area or pile was crossed. A seat identifier such as red or blue, the same vocabulary api.getMySeat and ModSeatChangedPayload use.

Returns

string. Always a real seat — a zone belongs to exactly one by construction, so unlike most seat-shaped fields on this surface it is never null.

How, why and when to use it

It answers "whose?", which is what most zone rules are really asking: scoring, turn gating and per-player tallies all key on it. It is also the first argument to api.getZoneObjects.

Gotchas

The seat that owns the zone is not the player who moved the entity. A zone event says where something ended up, not who put it there, and it carries no actor at all. Correlate with onObjectDropped when you need one.

At a live table only CLAIMED seats have zones. An empty chair's boxes are neither rendered nor tracked, so no crossings arrive for it — and releasing a seat owes a leave to everything standing in its zones.

See also

modzoneeventpayload.objectId#

readonly objectId: string;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

The entity that crossed the boundary.

The entity that crossed the boundary, as an id. Not an entity object and not a handle: a mod's world is the replicated snapshot, so a zone event names the entity and leaves resolving it to you.

Returns

string. Pass it to api.getObject, which resolves null when no such entity is on the table.

How, why and when to use it

Plenty of rules never need the entity at all — counting occupants, gating a turn, noticing that an area emptied — and for those the id is the whole answer with no round trip. Resolve it when the rule depends on what moved: the kind, the face it is showing, its tags, its metadata.

Gotchas

On a leave, the entity may already be gone. One of the three things a leave means is "this entity left the table" — drawn into a container, combined into a stack, or deleted. api.getObject resolving null is the signal, not an error. Ask api.getZoneObjects what remains instead of assuming the entity is around to inspect. null has a second meaning now: an entity a hidden seat zone conceals is erased from every mod read, and the two cases are deliberately indistinguishable.

Every entity is reported, furniture included. The host's membership pass samples everything on the table, so a locked board or a card holder standing inside a zone crosses like anything else. Filter on kind, or have the author give the zone a tagFilter — nothing is withheld here, precisely so that the hooks and getZoneObjects always describe the same set.

Resolving the id tells you less than the hook did. Zone hooks are only ever delivered to the host, and the host reports every entity that crossed — but api.getObject redacts what it returns on every peer, the host included. So a face-down card entering a zone fires the hook with its real id and then resolves to label: "Card" with metadata.__redacted set. Write the rule against the id, the kind and the position; a rule that needs the card's face declares read-hidden-information and reads api.getUnredactedSnapshot.

See also

ModTriggerEventPayload#

Surface B — mod script · interface · 6 members

Payload of onTriggerEnter and onTriggerLeave — one entity crossing one authored trigger volume on a model.

Plain data only. Your world is the replicated snapshot: there is no pc.Entity, no engine guid and no live handle here. Call api.getObject(objectId) when you need the entity.

The object handed to a handler for onTriggerEnter or onTriggerLeave. It names the authored trigger volume that was crossed, the entity carrying it, the entity that crossed it and which way — five strings and a two-value union, and deliberately nothing else.

How, why and when to use it#

It is the smallest payload that answers "what went where", and the shape is identical for both hooks so one helper can serve them. There is no entity on it because a mod's world is the replicated snapshot: call api.getObject(objectId) when you need the entity itself, and skip that round trip for the many rules — counters, gates, "is this slot filled yet" — that only need the ids.

It is the model-authored twin of ModZoneEventPayload, and the two are intentionally the same shape of thing: a crossing, reported once, by the host, as plain data.

Gotchas#

No engine handle is reachable from here, by design. There is no pc.Entity, no scene-graph node and no engine guid anywhere in this payload or behind it. The five fields are the whole surface.

Two of the five fields are entity ids, and they are different entities. ownerObjectId CARRIES the volume; objectId CROSSED it. Resolving the wrong one gets you state that looks valid and describes the wrong piece.

triggerId alone does not identify a volume. It is unique only within its model asset, so two copies of the same board both report "goal-slot". The unique key for one volume is ownerObjectId + triggerId — key every per-volume map, count and UI element id on the pair.

You cannot read a volume's geometry. A trigger volume is a local physics artefact rebuilt from the model's authored configuration; it never enters the replicated snapshot, so there is no position, rotation or size to ask for. Subscribe to the crossings instead.

Nothing here is redacted, and that is the design. A crossing by a face-down card or a hidden-zone occupant arrives with the same five fields as any other. The read-world gate on both hooks is what makes that safe — a mod holding read-world can already read those entities out of api.listObjects, so a redacted payload would buy no security and would only stop a legitimate mod correlating the event with the entity.

See also#

Members#

Signature Description Returns
triggerId The authored trigger volume's id. Unique only WITHIN its model asset, so it does NOT identify a volume on its own — two copies of the same model each carry a volume with this same id. Key on ownerObjectId + triggerId together, or on triggerTag for the coarser "any volume of this sort" match. string
triggerName The authored trigger volume's name. string
triggerTag The author's tag on the volume, when it set one. string
ownerObjectId The entity CARRYING the volume. With triggerId, the unique key for one volume. string
objectId The entity that CROSSED the boundary — a different entity from ownerObjectId. string
phase "enter" | "leave"

modtriggereventpayload.triggerId#

readonly triggerId: string;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

The authored trigger volume's id. Unique only WITHIN its model asset, so it does NOT identify a volume on its own — two copies of the same model each carry a volume with this same id. Key on ownerObjectId + triggerId together, or on triggerTag for the coarser "any volume of this sort" match.

The authored id of the trigger volume that was crossed — the identity the model's author gave it, carried through unchanged so a volume stays the same volume across samples, snapshot rebuilds and reloads.

Returns

string, 1–64 characters. It comes from the model's authored configuration (triggers[].id in the model's .meta.json sidecar, or a platform model's preset override), so its spelling is the author's choice and nothing about it is generated or engine-derived.

How, why and when to use it

Use it when one model carries several volumes and your rule differs between them — a board with a slot per seat, a track with a segment per space. Prefer triggerTag for matching when the author provided one: a tag is the identifier they added for a rule, so it survives the volume being renamed or re-created.

For keying — a map, a count, a UI element id — pair it with ownerObjectId: the two together are the unique identity of one volume on one entity.

Gotchas

It is unique only WITHIN its model asset, so it is not a key on its own. Two copies of the same model both report the same triggerId for the same volume. Key on ownerObjectId and triggerId together. A map keyed on triggerId alone appears to work with one copy of the model on the table and silently merges every copy into one bucket the moment an author adds a second.

Do not hard-code one against a model you do not ship. The ids come from whichever model an author dropped on the table. A rule naming a literal id works on the model it was written against and silently does nothing everywhere else.

See also

modtriggereventpayload.triggerName#

readonly triggerName: string;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

The authored trigger volume's name.

The authored volume's name — the human-readable label an author typed beside it, carried through so a log line or a UI label can say "reached Red Goal" instead of "reached trigger-3".

Returns

string, 1–64 characters, and always present: unlike triggerTag a name is required when authoring a volume.

How, why and when to use it

Print it. That is what it is for. It is the one field on this payload written for a person rather than for code, so it belongs in api.log or in the text of a api.setUiElement widget.

Gotchas

Do not key a rule on it. A name is editable at any time in the Model editor, and nothing warns an author that a mod matched on it. Match on triggerTag — which exists precisely so the matchable identifier and the printable one are different fields — or on triggerId if the author gave no tag.

It is not unique. Nothing stops two volumes on one model, or two different models, carrying the same name.

See also

modtriggereventpayload.triggerTag#

readonly triggerTag?: string;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

The author's tag on the volume, when it set one.

The author's tag on the volume, when they set one. This is the field to match on — a tag is the identifier a model author adds specifically so a mod can recognise the volume, which makes it the only one of the three that is safe to compare against a literal.

Returns

string | undefined. Present only when the author tagged the volume. It follows the platform's author-tag rules — 1–32 characters matching ^[a-z0-9_-]+$ — and the reserved dt: namespace is rejected at authoring time, so a triggerTag can never be a platform tag and a mod can never receive one.

How, why and when to use it

Branch on it first, and treat a volume with no tag as "not for me". A rule written as if (payload.triggerTag !== "goal") return; keeps working when the author renames the volume, re-creates it, or copies the model — none of which a rule written against triggerName survives.

Publish the tags your mod expects in its description. The tag is a contract between a model author and you, and nothing in the platform enforces it — no scanner check, no manifest field, no validation at load.

Gotchas

Optional means optional. Compare against your literal rather than testing for presence, so an untagged volume falls through instead of matching by accident. payload.triggerTag !== "goal" is right; payload.triggerTag && … invites a mod that fires on every tagged volume on the table, including another mod's.

It is a class, not an identity. The tag lives on the model asset, so every copy of the model reports it — which is exactly what makes it the right thing to match on and the wrong thing to key on. When you need to tell two volumes apart, key on ownerObjectId + triggerId.

A trigger tag is not an entity tag. It is authored on the volume, not on the entity, so it never appears in TableObjectState.tags and cannot be used in an api.listObjects filter.

See also

  • triggerName — the printable label, and why it is not this.
  • triggerId — the fallback when there is no tag.
  • Tags and groups — the author-tag rules and the reserved dt: namespace.

modtriggereventpayload.ownerObjectId#

readonly ownerObjectId: string;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

The entity CARRYING the volume. With triggerId, the unique key for one volume.

The entity carrying the trigger volume — the board with the slot, the cup, the track. Not the entity that crossed it; that is objectId, and confusing the two is the single easiest mistake to make with this payload.

Returns

string. An entity id like any other: pass it to api.getObject for its state.

How, why and when to use it

It is half of the only unique key a volume has. An authored triggerId is unique only within its model asset, so two copies of the same board both report "goal-slot". ownerObjectId plus triggerId names exactly one volume on exactly one entity, and is what any per-volume map, count or UI element should be keyed on.

It is also how a mod tells which board scored: resolve it when the message or the score needs to name the owner, and skip the lookup when the id alone is enough.

Gotchas

Two entities, two ids, one event. ownerObjectId and objectId are always different entities. A handler that resolves the wrong one gets state that looks valid and describes the wrong piece.

Do not key on triggerId alone. With one copy of a model on the table it appears to work; the day an author adds a second board every count silently merges into one bucket, and a mod that namespaced a UI element id on it starts fighting itself.

The owner may be gone on a leave. An entity leaving the table takes its volumes with it, so api.getObject(payload.ownerObjectId) can resolve null in a leave handler. Drop the key from your map rather than assuming the owner is around to inspect.

It is an entity id, never an engine handle. Like everything else on this payload it is a key into the replicated snapshot — there is no pc.Entity behind it.

See also

modtriggereventpayload.objectId#

readonly objectId: string;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

The entity that CROSSED the boundary — a different entity from ownerObjectId.

The entity that crossed the boundary, as an id. Not the entity that carries the volume — that is ownerObjectId, and mixing the two up is the single most common misreading of this payload. Not an entity object and not a handle either: a mod's world is the replicated snapshot, so the event names the entity and leaves resolving it to you.

Returns

string. Pass it to api.getObject, which resolves null when no such entity is on the table.

How, why and when to use it

Plenty of rules never need the entity at all — counting occupants, gating a turn, noticing that a slot filled — and for those the id is the whole answer with no round trip. Resolve it when the rule depends on what crossed: the kind, the face it is showing, its tags, its metadata.

Gotchas

On a leave, the entity may already be gone. One of the two things a leave means is "this entity left the table" — drawn into a container, combined into a stack, or deleted. api.getObject resolving null is the signal, not an error.

It is reported for hidden and face-down entities too, unredacted. A card in a hand or inside a hidden seat zone crosses a trigger volume like anything else and its id is handed to you, exactly as onZoneEnter already does for hidden zones. That is gated by read-world rather than by redacting the payload, because a mod holding read-world can already enumerate those entities from api.listObjects — the gate does the work, so the payload shape does not have to.

Every entity is reported, furniture included. The host's sampling pass tests everything on the table, so a locked board standing inside a volume crosses like a card does. Filter on kind yourself if the rule is only about game pieces.

A volume never reports its own owner as having crossed it. objectId and ownerObjectId are always two different entities, so you do not need to guard against the board triggering its own slot.

See also

modtriggereventpayload.phase#

readonly phase: "enter" | "leave";
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

Which way the entity crossed: "enter" on onTriggerEnter and "leave" on onTriggerLeave. It carries no information the hook name did not already give you — it is here so that one handler can serve both hooks, which is exactly what this entry's example does.

Returns

"enter" | "leave". There is no third value and no "still inside" phase; the host reports transitions only.

How, why and when to use it

Write the handler once, register it with api.on for both hook names, and branch on phase for the parts that differ. That is usually the clearer shape for a counter or a per-volume set, because the increment and the decrement stay next to each other where an inconsistency between them is visible.

Gotchas

Registering one handler for both hooks does not deduplicate anything. api.on is append-only with no unsubscribe: a handler registered for both names is called once per crossing per direction, which is what you want, and a handler registered twice for the same name is called twice, which is not.

Do not read it as physics state. A crossing is sampled at a fixed low rate against the crossing entity's origin point, so "enter" means "the pass saw it inside and had not before" — not "a collision began". A trigger volume never collides at all.

Do not build a log verb by concatenating it. "enter" and "leave" are not both regular in English; branch and pick the word.

See also

  • onTriggerEnter — where "enter" comes from, and the one handler that serves both.
  • onTriggerLeave — and "leave".
  • api.on — append-only registration, and why order matters.

ModUiEventPayload#

Surface B — mod script · interface · 10 members

Payload of onUiEvent AND of every custom widget hook.

The object handed to a handler for onUiEvent and to every custom widget hook. One interaction produces one of these and delivers it twice: first to onUiEvent, then — with the same object — to the hook name the widget's props chose, when they chose one.

Applies to: button, checkbox and input. Those three are the only widget types that dispatch anything at all; text, panel, canvas and layout render and nest correctly and fire nothing. See Known limitations.

How, why and when to use it#

This payload is the reason a UI element is the working way to give players a button. The obvious alternative, api.registerAction, records the action and renders no control for anyone to press (Known limitations) — so a button created with api.setUiElement is what an author reaches for instead, and this shape is what comes back. It carries the two things a rule needs and a registered action never delivers: which control was used, and who used it.

Gotchas#

Only the host's copy of your mod hears it. A player pressing a button sends the interaction to the host, which dispatches it into its own sandbox frame; the mod frame on the player's own client is never told. Any state a handler builds from these events therefore exists on the host alone.

See also#

Members#

Signature Description Returns
modId string
elementId string
widgetType TableUiWidgetType
interaction "click" | "change"
hook The custom hook name this interaction also dispatched, or null. string | null
value Present for input change events. string
checked Present for checkbox change events. boolean
actorPeerId string | null
actorRole "offline" | "host" | "player" | "spectator"
at string

moduieventpayload.modId#

readonly modId: string;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Which mod owns the widget that was interacted with. It is copied from the element's ownerModId, the value the host recorded when the element was created, and it is what the host routes the event by.

Returns

string, always — and always your own mod's id in a handler you registered. The host looks up the sandbox frame registered under this id and posts the event to that one frame only, so an event carrying a different id is not delivered to you at all.

How, why and when to use it

You want to build an element id or a saved-data key that cannot collide with another mod's, and this is the prefix to use — the same string your setup receives as manifest.id. Reading it from the payload rather than closing over the manifest is worth it in one place: a helper function shared between handlers that has no access to the setup scope. For a permission check it is worth nothing, because the routing already guarantees the answer.

Gotchas

It is not a filter you need to write. Comparing it against your own id in a handler is dead code: the host dispatches per mod through dispatchEventForMod (apps/web/src/mods/SandboxedModRunner.ts), unlike the other nine hooks, which are fanned out to every running frame.

An unknown id is dropped silently. If no frame is registered under the id — the mod was reloaded, or never started — the host discards the event rather than broadcasting it. A widget left behind by a mod that is no longer running is inert.

See also

moduieventpayload.elementId#

readonly elementId: string;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Which of your widgets was clicked or changed. It is the id you passed to api.setUiElement when you created the element, echoed back unchanged.

Returns

string, always. The host refuses to build an event without one, so a handler never sees an empty value here.

How, why and when to use it

You have five buttons and one onUiEvent handler, and this is what you switch on. The alternative is a separate custom hook name per widget, which is clearer when the five buttons do genuinely unrelated things and worse when they do not — five near-identical handlers that each need the same permission check is exactly the case for switching on this field instead. Use the id for routing, and a named hook when a control's logic has nothing in common with its neighbors.

Gotchas

It is your string, so make it collision-proof. Element ids share one namespace across every mod running at the table. Prefix them with your mod's id, as the platform's own examples do, or two mods will fight over the same element.

Re-creating an element with the same id replaces it. setUiElement is an upsert, so the id you branch on here survives a widget being rebuilt with different props — which is what makes "update the label after a click" work without changing your routing.

See also

moduieventpayload.widgetType#

readonly widgetType: TableUiWidgetType;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

What kind of control produced the event. It is the element's declared type, copied straight from the widget the player touched, and it is the field that tells you which of value and checked is populated.

Returns

TableUiWidgetType — the full seven-value union (text, button, checkbox, input, panel, canvas, layout) is what the type says, but only three of those values ever reach a handler: button, checkbox and input. The other four have no interaction to dispatch from.

How, why and when to use it

You are writing one handler for a whole panel of controls and want to read the right field without checking both. Branching on this is more direct than testing payload.value !== undefined and payload.checked !== undefined in turn, and it reads as the intent rather than as a probe. Branch on elementId when you care which control it was, and on this when you care what shape of answer it gave you.

Gotchas

The declared union is wider than the delivered set. Autocomplete offers you seven cases; four of them are unreachable in a handler, so a switch that covers all eight has four dead arms.

Known gap. Only button, checkbox and input read an interaction hook from a widget's props (apps/web/src/ui/App.tsx, the mod UI overlay renderer). text, panel, canvas and layout accept a hook prop, store it and replicate it, and dispatch nothing — none of them has an interaction to dispatch from. Every one of the eight types renders and nests correctly, and the four interactive types fire reliably. Put the hook on a button, checkbox or input inside the container rather than on the container. See Known limitations.

See also

moduieventpayload.interaction#

readonly interaction: "click" | "change";
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

What the player did: pressed something, or altered something. It is fixed by the widget type rather than chosen at runtime, so it carries no information that widgetType does not already give you — it is there so a handler can read the verb without knowing the control.

Returns

"click" | "change", always one of the two. "click" for a button and nothing else. "change" for a checkbox and for an input. The four non-interactive widget types produce neither, because they produce no event.

How, why and when to use it

You are writing an audit line — "who pressed what, when" — and want it to read naturally for every control without a lookup table of your own. Logging this field gives you click on a button and change on an input for free. When your handler has to do different work per control, branch on elementId instead: two buttons both report "click", so this field can never separate them.

Gotchas

An input reports a change per keystroke. The widget dispatches on every edit, not on blur and not on Enter, so typing red produces three events carrying r, re and red. Debounce in your handler, or key your rule off a neighboring button instead.

There is no "submit", no "focus" and no "hover". These two values are the whole vocabulary; a control's other DOM events are not forwarded to the sandbox at all.

See also

  • widgetType — what determines this value.
  • value — what a "change" from an input carries.
  • hook — the second delivery of the same event.
  • api.setUiElement — choosing the control, and therefore the verb.

moduieventpayload.hook#

readonly hook: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The custom hook name this interaction also dispatched, or null.

The custom hook name this interaction also dispatched. The host delivers the event to onUiEvent first and then, when this field is not null, posts the identical object a second time under the name it holds. It is the mechanism behind the one unbounded channel on this surface: hook names you invent.

Returns

string | null. null when the widget named no hook. Where the name comes from depends on the type, and each falls back to a plain hook prop:

Widget type Read from Falls back to
button props.onClick props.hook
checkbox props.onChange props.hook
input props.onChange props.hook

Applies to: button, checkbox and input. text, panel, canvas and layout accept the same props and dispatch nothing, so no handler ever observes their names — see Known limitations.

How, why and when to use it

You are already inside a named handler — api.on("endRound", …) — and you want to know whether the same event is also going to reach your onUiEvent logger, or you are in the logger and want to record which named handler is about to run. Reading it from onUiEvent is how you tell a widget that routes somewhere specific from one that only has the catch-all. If all you want is per-widget routing, branch on elementId and leave the props alone — a hook name is worth declaring when the handler is the unit you want to name, not when the widget is.

Gotchas

The name is used verbatim as a key, including the ten platform names. A widget whose onClick is "onTurnStart" delivers this ModUiEventPayload to every handler you registered for the real turn hook, which will then read fields that are not there. Prefix your hook names with your mod's id.

Both deliveries carry the same object. onUiEvent and the named hook receive one payload, in that order, so a handler registered on both runs twice for one press. Pick one, or make the work idempotent.

An empty or whitespace-only name becomes null. The host trims the string and treats a blank as no hook, so a widget with onClick: " " reaches onUiEvent alone.

See also

moduieventpayload.value#

readonly value?: string;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Present for input change events.

The text an input holds after the edit that raised the event. It is read straight off the field the player is typing in, so it is the whole current contents rather than the character that changed.

Returns

string | undefined — the property is optional. Populated for an input "change" and for nothing else. A button click and a checkbox change both leave it undefined, as does any event you receive through a custom hook name attached to one of those two. An empty field gives you "", which is a real value and not an absence.

Applies to: input. On button and checkbox the field is absent; text, panel, canvas and layout raise no event at all.

How, why and when to use it

You want a player to name their bid, their wager, their house rule, and an input is the only control that takes free text. Read the value here rather than calling api.getUiState afterwards: the state you would read back is the element's declared props.value, which is what you last set, not what the player just typed. This field is the only place the typed string appears.

Gotchas

Test !== undefined, not truthiness. A player clearing the field sends "", and a truthy test throws that away as if nothing happened — which is exactly the edit a "clear your bid" rule needs to see.

The field is controlled by the element's replicated props.value. Typing does not change what the element says it holds; it only tells your mod what was typed. Echo the new string back with api.setUiElement if the text is meant to stay in the box for everyone, or the next redraw of the overlay restores what the element declares.

One event per keystroke. The value arrives once per edit, so a five-character entry produces five events and five round-trips to the host if you echo each one back. Debounce before you write.

See also

moduieventpayload.checked#

readonly checked?: boolean;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Present for checkbox change events.

Whether a checkbox is ticked after the click that raised the event. It is the box's new state, not the old one and not a toggle instruction.

Returns

boolean | undefined — the property is optional. Populated for a checkbox "change" and for nothing else. A button click and an input change both leave it undefined. When it is present it is true or false, never null.

Applies to: checkbox. On button and input the field is absent; the four non-interactive widget types raise no event.

How, why and when to use it

You are exposing a per-table option — "auto-deal at the start of a turn", "play with the expansion" — and a checkbox is the control for it. Take the new state from this field rather than flipping a boolean you keep yourself: your copy and the box diverge the moment two players click at nearly the same time, and the value here is the one the widget actually settled on.

Gotchas

false is falsy, so if (payload.checked) silently ignores every un-tick. Test !== undefined to see whether the field applies, then read the boolean — this is the field where truthiness is most likely to lose you half the events.

The box is controlled by the element's replicated props.checked. Clicking tells your mod what the player chose; it does not change what the element declares. Write the new state back with api.setUiElement or the tick reverts to whatever the element says on the next redraw of the overlay.

See also

moduieventpayload.actorPeerId#

readonly actorPeerId: string | null;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Who used the control. This is the field that makes a table UI element usable as a game control at all: it is the only way a mod learns which person did something, and the reason a button is the working substitute for api.registerAction, which renders nothing to press (Known limitations).

Returns

string | null. A peer id — the same id the turn, seat, team and presence hooks use for that person. null at an offline table, where there is no room and therefore no peer id to report.

How, why and when to use it

Your rule is "only the active player may press End Turn", and it needs to compare the presser against ModTurnChangedPayload.activePeerId. This field is the left-hand side of that comparison, and there is no alternative source for it — a snapshot tells you what changed, never who changed it. Restrict visibility with the element's own visibility scope so the wrong people never see the control, and re-check identity here before you act, because visibility is not a lock.

Gotchas

It comes from the connection, not from the message body. When a player presses a control, their client relays the interaction to the host and the host attributes it to the peer the data channel belongs to (apps/web/src/ui/App.tsx, the onModEvent handler). A peer cannot claim to be a different peer here. That is not true of actorRole.

null and "the host" are different answers. An offline table reports null; a host at a live table reports their own peer id like anyone else. Do not read null as "the host did it".

See also

moduieventpayload.actorRole#

readonly actorRole: "offline" | "host" | "player" | "spectator";
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

What the person who used the control was at that moment. It is the role their own client reported for itself when it forwarded the interaction, so it describes them at the instant of the click rather than now.

Returns

"offline" | "host" | "player" | "spectator" — four values, always one of them, never null. "offline" appears at a table with no room, where actorPeerId is null too. The other three match the roles the presence hooks report; unlike ModPeerPayload.role, this union includes "offline", because a solo table has UI and no roster.

How, why and when to use it

You want a control that spectators can see but not act on — a scoreboard with an admin button on it — and this is the cheapest test for that. It reads better than resolving the peer id against a roster you maintain, and it is right for the common case. Where the decision is worth money, prefer the id: compare actorPeerId against a roster you built from onPeerJoined, whose roles come from the signaling server.

Gotchas

It travels in the message body, so it is self-reported. A remote interaction carries the role its own client asserted; the host validates that it is one of the four literals and substitutes "player" for anything else, but it does not cross-check the claim against the roster (apps/web/src/ui/App.tsx, normalizeUiWidgetInteractionEvent). The peer id beside it is attributed by the connection and is the stronger evidence of the two.

It is not a permission check. Nothing about a role stops a spectator pressing a control that is visible to them. Scope who can see a widget with the element's visibility field, and re-check the actor in the handler before you act.

See also

moduieventpayload.at#

readonly at: string;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

When the interaction was processed, as an ISO 8601 string from new Date().toISOString() — UTC, millisecond precision, of the form 2026-07-27T18:04:11.238Z. It is stamped from the host's clock, not the clicking player's: a click made by anyone other than the host arrives over the wire and normalizeUiWidgetInteractionEvent (apps/web/src/ui/App.tsx) rebuilds the payload with a fresh timestamp, discarding whatever the sender wrote.

Returns

string, always present.

How, why and when to use it

You want to stop a player double-firing an expensive action by hammering a button, so you record the timestamp of the last accepted click and ignore anything that follows within a second of it. This field is the right clock for that, because every handler at the table is comparing values from the same source. The alternative is Date.now() inside your handler, which is the clock of whichever machine your frame happens to be running on and drifts from the timestamps on the rest of the table's events; use it only for measuring how long your own code took.

Gotchas

It is when the host processed the click, not when the player made it. Network latency sits inside that gap for every remote player, and it varies per player, so this is not a fair measure of who pressed first in a race.

Same-millisecond collisions are real. Two clicks in the same millisecond produce identical strings, so this is not a unique key. Pair it with actorPeerId if you need one.

Parse it before you do arithmetic. Date.parse(payload.at) gives you milliseconds; subtracting two strings gives you NaN.

See also

ModHookEventMap#

Surface B — mod script · interface · 15 members

Every hook the host dispatches by name.

ONE further channel is unbounded: a button/checkbox/input whose props name a custom hook (onClick / onChange, falling back to hook) dispatches that name too, right after onUiEvent, with the same ModUiEventPayload. text, panel, canvas and layout accept a hook prop but never fire.

These names are NOT the table-scripting event names. The mod hook is onTurnStart; the table-script delegate is onTurnStarted.

The fourteen hook names the platform dispatches, each mapped to the payload it carries. It exists to type api.on: the typed overload looks the handler's argument up in this map, so api.on("onTurnStart", …) hands you a ModTurnStartPayload with no annotation of your own. MOD_HOOK_EVENT_NAMES (packages/shared/src/modScripting.ts) is the same fourteen as a runtime array, declared satisfies readonly ModHookEventName[] so the two cannot drift apart.

Four of the fourteen are narrowed: onZoneEnter, onZoneLeave, onTriggerEnter and onTriggerLeave are delivered only to a frame that was granted read-world, on top of the subscribe-events every hook needs. Being in this map is what makes a hook typed; being in MOD_HOOK_EVENT_CAPABILITIES is what makes one gated.

The map is not the complete set of names you can register. A button, checkbox or input whose props name a custom hook dispatches that name too, immediately after onUiEvent and with the same ModUiEventPayload. Those names are yours, so they cannot be listed here — api.on has a string overload for them.

How, why and when to use it#

You are deciding which hook to attach a rule to, and this map is the menu. onTableEvent is the tempting first choice because it fires for every line the table logs and therefore covers everything — which is exactly why it is usually the wrong one: your handler runs on every unrelated move at the table and you write the filtering by hand. onCardDrawn and onObjectDropped are onTableEvent with the message-prefix check already done, so prefer a narrow hook wherever one matches and keep onTableEvent for the actions no narrow hook covers — a shuffle, a flip, a lock.

Gotchas#

Three names share one payload type, so the compiler cannot catch a swapped name. onTableEvent, onCardDrawn and onObjectDropped all carry ModTableEventPayload; onPeerJoined and onPeerLeft both carry ModPeerPayload. A handler written for one compiles clean against its twin and runs at the wrong moment.

These are not the table-scripting event names. The mod hook is onTurnStart; the table-scripting delegate is globalEvents.onTurnStarted. Different surfaces, different payloads, no relationship — a mod cannot subscribe to onTurnStarted and a table script cannot subscribe to onTurnStart. Registering the table-scripting spelling here succeeds and never fires.

A custom hook name is used verbatim as a key. Giving a widget's onClick the name "onTurnStart" delivers a ModUiEventPayload to every handler registered for the real turn hook. Prefix your own names with your mod id.

See also#

Members#

Signature Description Returns
onTableEvent Any event-log line. The broadest hook — it fires for everything. ModTableEventPayload
onCardDrawn An event whose message starts with "draw ". ModTableEventPayload
onObjectDropped An event whose message starts with "moved ". ModTableEventPayload
onTurnStart A turn began (order started, or the turn advanced). ModTurnStartPayload
onTurnChanged The turn state changed (enabled flag or active peer). ModTurnChangedPayload
onPeerJoined ModPeerPayload
onPeerLeft ModPeerPayload
onSeatChanged ModSeatChangedPayload
onTeamChanged ModTeamChangedPayload
onUiEvent ModUiEventPayload
onHostMessage Another peer's copy of THIS mod called api.sendToHost. ModHostMessagePayload
onZoneEnter An entity entered a seat zone — ANY type, scripting included. Once per crossing. ModZoneEventPayload
onZoneLeave An entity left a seat zone: it moved out, it left the table, or the zone went away with a released seat. One event covers all three. Same authority and same two capabilities as onZoneEnter. ModZoneEventPayload
onTriggerEnter An entity entered a TRIGGER VOLUME authored on a model in the Model editor. Once per crossing. A trigger volume never collides and never affects physics — firing these events is the whole of what it does. ModTriggerEventPayload
onTriggerLeave An entity left a trigger volume: it moved out, or it left the table. Same authority and same two capabilities as onTriggerEnter. ModTriggerEventPayload

modhookeventmap.onTableEvent#

onTableEvent: ModTableEventPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Any event-log line. The broadest hook — it fires for everything.

Fires once for every line the table writes to its event log. It is the broadest hook on this surface — every flip, roll, shuffle, draw, deal, move, system message and mod diagnostic passes through it — and it is the only hook that also carries the table alongside the event — redacted, because it costs only subscribe-events to receive.

Parameters

The handler receives one ModTableEventPayload:

Field Type Notes
event TableEvent The log line itself: id, at (ISO-8601), actor, message, optional objectId, optional revealsIdentity.
object TableObjectState | null The event's subject, resolved out of the current snapshot by event.objectId. null when the event names no entity, and also when the entity has already left the table — a delete is the common case.
snapshot TableSnapshot | null The table at the moment the event fired, redacted on every client including the host. null before any snapshot exists, or when the client cannot work out what to conceal.

Which peer raises it: the client whose own runtime wrote the log line. The host writes a line for every action it applies, so on the host this hook sees the whole table's activity. A player's client writes lines only for what it did locally.

How many times it fires: exactly once per log line. A draw action produces one line and therefore one onTableEvent — plus one onCardDrawn, because the narrow hooks are dispatched in addition to this one, never instead of it.

Actor strings: actor is a peer id, or one of the literals "Host", "Script", "You" and "System". "Mod" is used for lines a mod itself wrote with api.log, so a mod that logs inside this handler will see its own line come back through it.

A private search arrives here, and nowhere else. There is no mod hook for the deck/bag search feature — a mod hears one the way it hears anything else public, as ordinary lines on this stream: searched <pile> (<n> cards), took a card from <pile>, and finished searching <pile>. Every one of them names the pile and a count, never a card, exactly like the chat line the players see. The identities the searching player is shown reach that player's own redacted snapshot and go no further, so payload.snapshot here is no wider during a search than it is at any other moment. Building a mod that tries to work out what was taken from these lines is building on information that is not there by design — see Deck and Bag Search.

How, why and when to use it

You need to react to something the narrow hooks do not cover — a shuffle, a flip, a lock — and the event log is the only place those are reported. The alternative is polling api.getSnapshot on a timer, which the sandbox has no primitive for and which would miss anything that happened and was undone between polls. Filter by message prefix, as the example does, and prefer onCardDrawn or onObjectDropped when one of them already matches — those two are literally this hook with the prefix check done for you.

Example

// content/scripting-api/examples/modhookeventmap.onTableEvent.js

// Mod script: onTableEvent is the catch-all. Every line the table logs arrives
// here, so this filters by message prefix rather than reacting to all of them.
// manifest capabilities.allowed: ["log", "subscribe-events"]

const INTERESTING = ["shuffle ", "flip ", "roll "];

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  let seen = 0;

  api.on("onTableEvent", (payload) => {
    seen += 1;
    const message = payload.event.message;
    if (!INTERESTING.some((prefix) => message.startsWith(prefix))) {
      return;
    }

    const subject = payload.object;
    api.log(manifest.name + ": " + payload.event.actor + " -> " + message
      + (subject ? " (" + subject.kind + " " + subject.id + ")" : " (entity gone)")
      + " [" + seen + " events seen]");

    if (payload.snapshot) {
      api.log(manifest.name + ": table now holds "
        + payload.snapshot.objects.length + " entities.");
    }
  });

  api.log(manifest.name + ": listening to every table event.");
};

Shuffling a deck prints You -> shuffle main-deck (deck obj-7c1a) [12 events seen] followed by the entity count.

Gotchas

Logging inside this handler feeds itself. api.log writes a line to the same feed, and on the client running the mod that line raises another onTableEvent. The example above avoids a loop by logging only for three message prefixes; a handler that logs unconditionally will run forever.

The message format is not a stable contract. Lines are human-readable strings built as <action> <label> or similar, and matching on them is what the two narrow hooks do internally. Prefer event.objectId and payload.object for identity, and use the message only for the verb.

object being null does not mean the event was unattached. An event about an entity that has since been destroyed resolves null too, because the lookup runs against the current snapshot rather than the one the event was written from. Check event.objectId separately if you need to tell them apart.

See also

modhookeventmap.onCardDrawn#

onCardDrawn: ModTableEventPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

An event whose message starts with "draw ".

Fires when a draw action is applied to an entity. It is onTableEvent with one filter applied — the host dispatches it for any log line whose message starts with "draw " — and it carries the identical payload.

Parameters

The handler receives one ModTableEventPayload:

Field Type Notes
event TableEvent The log line, whose message is draw <label> and whose objectId is the container the action targeted.
object TableObjectState | null ⚠ The container — the deck or bag that was drawn from — not the card that came out of it. null if that container has since been consumed.
snapshot TableSnapshot | null The table at the moment the event fired, redacted on every client including the host. null when no snapshot exists, or when the client cannot work out what to conceal.

Which peer raises it: the client whose runtime applied the draw. In a room that is the host, because a player's draw travels to the host as an intent and is applied there.

How many times it fires: once per applied draw action, and always after onTableEvent for the same line — the host dispatches the broad hook first, then this one.

Applies to: deck and bag. A draw addressed to any other kind does nothing at the table but still writes the log line, so the hook still fires with a container whose kind is something else.

How, why and when to use it

You want to score, count or react when cards leave a pile — a hand-size check, a "deck is nearly empty" warning, a rule that triggers on the fifth draw. The alternative is onTableEvent with your own startsWith("draw ") check, which is exactly what this hook is; use the narrow one so the prefix stays the platform's problem rather than yours. What this hook will not tell you is which card was drawn — for that, read the container's contents with api.getContainerContents before the draw, or the drawing player's hand with api.getHandObjects after it.

Example

// content/scripting-api/examples/modhookeventmap.onCardDrawn.js

// Mod script: count draws per deck. The payload's `object` is the CONTAINER the
// draw action targeted, not the card that came out of it.
// manifest capabilities.allowed: ["log", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  /** @type {Record<string, number>} */
  const drawsByDeck = {};

  api.on("onCardDrawn", (payload) => {
    const source = payload.object;
    if (!source) {
      api.log(manifest.name + ": a draw fired with no container attached.");
      return;
    }

    const key = source.label;
    drawsByDeck[key] = (drawsByDeck[key] || 0) + 1;
    api.log(manifest.name + ": " + payload.event.actor + " drew from " + key
      + " (" + drawsByDeck[key] + " draws, " + source.stackCount + " left).");

    if (source.stackCount <= 1) {
      api.log(manifest.name + ": " + key + " is down to its last card.");
    }
  });

  api.log(manifest.name + ": counting draws per deck.");
};

Each draw prints Host drew from main-deck (3 draws, 49 left)., and the last one adds main-deck is down to its last card.

Gotchas

payload.object is the deck, not the card. This is the single most likely misreading of this hook. The event is raised by the action, and the action targeted the container; the card the runtime created has a different id that this payload never carries.

stackCount in the payload is the state at dispatch time. It is read from the snapshot the host built when it wrote the line, so it already reflects the draw.

A one-card deck becomes the card itself. When the last card is drawn, the runtime converts the deck rather than leaving an empty one, so a following getObject on the container id can resolve a card — or null. Do not assume the container you saw is still a deck.

See also

modhookeventmap.onObjectDropped#

onObjectDropped: ModTableEventPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

An event whose message starts with "moved ".

Fires when a player finishes dragging an entity and it actually moved. Like onCardDrawn, it is onTableEvent with one filter: the host dispatches it for any log line whose message starts with "moved ".

Parameters

The handler receives one ModTableEventPayload:

Field Type Notes
event TableEvent The log line, whose message is moved <label> and whose actor is the literal "You" — the line is written by the client that performed the drag, about itself.
object TableObjectState | null The entity that was dropped, with its settled position, rotation and velocity. null if it has since left the table.
snapshot TableSnapshot | null The table at the moment the event fired, redacted on every client including the host. null when no snapshot exists, or when the client cannot work out what to conceal.

Which peer raises it: the client that performed the drag. The runtime writes the moved line in its own pointer-release path, so a mod running on the host does not see this hook for a drag another player performed — the host receives that drag as an intent and applies it without writing a moved line.

How many times it fires: once per completed drag, and only when the entity traveled further than the runtime's pickup threshold — a click that picks up and puts down in place raises nothing. Dragging a multi-selection raises one event for the entity that was grabbed, not one per selected entity.

Applies to: every object kind that a player can drag. Grabbing a member of a parented assembly escalates to the root ancestor, so the entity you get is the one that actually moved, which can be the assembly's ancestor rather than the piece the player clicked. See Known limitations.

How, why and when to use it

You want to know when a piece has been placed — did it land in a scoring zone, is it outside the board, has the player committed to a square? The alternative is a onTableEvent handler with your own prefix test, which is what this is. What it deliberately is not is a drag notification: it fires once at the end, never during, and there is no in-flight position stream on this surface. If you need to know a piece was picked up rather than put down, there is no hook for that on Surface B — model it as your own state instead.

Example

// content/scripting-api/examples/modhookeventmap.onObjectDropped.js

// Mod script: keep a board tidy by noticing when a piece is dragged outside the
// play area. The hook fires once per drag that actually moved the entity.
// manifest capabilities.allowed: ["log", "subscribe-events"]

const PLAY_AREA_HALF_WIDTH = 2.6;

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  api.on("onObjectDropped", (payload) => {
    const entity = payload.object;
    if (!entity) {
      api.log(manifest.name + ": a drop was logged for an entity that has left.");
      return;
    }

    const offX = Math.abs(entity.position.x) > PLAY_AREA_HALF_WIDTH;
    const offZ = Math.abs(entity.position.z) > PLAY_AREA_HALF_WIDTH;
    api.log(manifest.name + ": " + entity.label + " landed at "
      + entity.position.x.toFixed(2) + ", " + entity.position.z.toFixed(2) + " feet.");

    if (offX || offZ) {
      api.log(manifest.name + ": " + entity.label + " is outside the play area.");
    }
  });

  api.log(manifest.name + ": watching for pieces dropped off the board.");
};

Dropping a token near the edge prints red-knight landed at 3.10, 0.44 feet. and then red-knight is outside the play area.

Gotchas

The entity is reported where physics put it, which is not always where it stopped. The line is written at release, and a piece thrown with velocity keeps travelling afterwards. Read velocity in the payload, or re-read with api.getObject later, if you need the resting position.

actor is always "You", not a peer id. The line is written by the dragging client about itself, so it carries no identity you can attribute across the table. Use api.getMySeat on that client, or the entity's own ownerSeat.

A mod running only on the host misses other players' drags entirely. This is the practical consequence of the line being written locally: in a room where the script runs on the host, this hook reports the host's own drags. Watch onTableEvent for the actions that do travel through the host if you need table-wide coverage.

See also

modhookeventmap.onTurnStart#

onTurnStart: ModTurnStartPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

A turn began (order started, or the turn advanced).

Fires when a turn begins — either because the host started turn order, or because the turn advanced to the next player. It carries who the turn belongs to and how many actions the table will let them take.

This is not the table-scripting event of the same shape. The mod hook is onTurnStart; the table-scripting delegate is globalEvents.onTurnStarted. Different surfaces, different names, no relationship — a mod cannot subscribe to onTurnStarted and a table script cannot subscribe to onTurnStart.

Parameters

The handler receives one ModTurnStartPayload:

Field Type Notes
peerId string | null The peer whose turn it is. null when the turn order is empty — starting turns with nobody connected still fires the hook.
seat string | null That peer's seat at dispatch time. null when they hold no seat.
team string | null That peer's team at dispatch time. null when they are on no team.
actionLimit number | null The table's maxActionsPerTurn setting. null means unlimited, not zero.

Which peer raises it: the host, and only the host. Both dispatch sites are host-only controls — the Start Turns and Next Turn buttons are rendered behind an isHost check, and the /endturn chat command is handled on the host. A mod running on a player's client never receives this hook.

How many times it fires: once per turn beginning. Starting turn order fires it for the first player; each advance fires it once more. Stopping turn order fires it not at all — that raises onTurnChanged only.

Ordering: the host dispatches onTurnStart first and onTurnChanged afterwards, when the turn state it wrote settles. A mod listening to both sees them in that order for the same turn.

How, why and when to use it

You want to do something at the top of a player's turn: deal them a card, reset a per-turn budget, show them a prompt. The alternative is onTurnChanged, which fires for the same moment plus two more — turn order being switched on and switched off — and which reports the active peer without their seat or team. Use onTurnStart when you want the turn beginning and want the seat and team resolved for you; use onTurnChanged when you also care about turn order stopping, or when your mod might run somewhere other than the host.

Example

// content/scripting-api/examples/modhookeventmap.onTurnStart.js

// Mod script: announce the new turn and reset the per-turn budget this game
// tracks itself. `actionLimit` is the table's own limit, or null for unlimited.
// manifest capabilities.allowed: ["log", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  let turnNumber = 0;
  let actionsThisTurn = 0;

  api.on("onTurnStart", (payload) => {
    turnNumber += 1;
    actionsThisTurn = 0;
    const who = payload.seat || payload.peerId || "an empty seat";
    const limit = payload.actionLimit === null
      ? "no action limit"
      : payload.actionLimit + " actions";
    api.log(manifest.name + ": turn " + turnNumber + " belongs to " + who
      + " (team " + (payload.team || "none") + ", " + limit + ").");
  });

  api.on("onTableEvent", () => {
    actionsThisTurn += 1;
    if (actionsThisTurn === 1) {
      api.log(manifest.name + ": first action of turn " + turnNumber + ".");
    }
  });

  api.log(manifest.name + ": turn tracker ready.");
};

Starting turns prints turn 1 belongs to red (team A, 3 actions)., and the next action prints first action of turn 1.

Gotchas

Only the host raises this. If your mod might run on a player's client — a resumed room, a reconnect — it never sees this hook there. onTurnChanged is derived from replicated turn state and does reach every peer, so use it when coverage matters more than the extra fields.

actionLimit: null means unlimited. Testing it as falsy treats "unlimited" and "zero actions" identically. Compare against null explicitly, as the example does.

seat and team are snapshots of the moment the turn began. A player who changes seat mid-turn does not re-raise this hook; listen for onSeatChanged as well if that matters.

Nothing here can refuse a turn. There is no veto: by the time your handler runs, the turn has already advanced and been broadcast. React and correct instead. See Known limitations.

See also

modhookeventmap.onTurnChanged#

onTurnChanged: ModTurnChangedPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

The turn state changed (enabled flag or active peer).

Fires whenever the table's turn state changes: turn order switched on, switched off, or the active player rotated. It is derived by comparing the replicated turn state against the previous value, so it reports the change rather than the intent behind it.

Parameters

The handler receives one ModTurnChangedPayload:

Field Type Notes
enabled boolean Whether turn order is on after the change. false is how "turns stopped" arrives.
activePeerId string | null The peer whose turn it now is. null when turn order is off or the order is empty.
previousActivePeerId string | null Who it was before. null on the first turn of a session — that is how you recognize a start rather than an advance.

There is no seat and no team here; resolve them yourself with api.getHandObjects or from the onSeatChanged roster you keep.

Which peer raises it: every peer running a mod. The turn state is part of the replicated room state, so each client sees its own copy change and raises the hook locally. This is the difference that matters between it and onTurnStart, which only the host raises.

How many times it fires: once per change to either enabled or activePeerId. A change to both at once — starting turn order, which switches enabled on and sets the first active peer — fires it once, not twice. It does not fire for the very first state a client sees; the first observation seeds the comparison silently, so a mod that loads into a room where turns are already running gets nothing until the next change.

Ordering: on the host, onTurnStart is dispatched first and this hook follows.

How, why and when to use it

You want a banner, a highlight or a prompt that tracks whose turn it is — including taking it down when the host stops turn order altogether. onTurnStart is the alternative and it is the better hook for "do something at the top of a turn", because it resolves the seat and team for you. This one is what you use when your mod might run on a player's client, or when "turns have stopped" is a state you have to handle: onTurnStart never reports that.

Example

// content/scripting-api/examples/modhookeventmap.onTurnChanged.js

// Mod script: onTurnChanged reports the turn STATE, so it fires when turn order
// is switched on and off as well as when the active player rotates.
// manifest capabilities.allowed: ["log", "read-context", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  api.on("onTurnChanged", (payload) => {
    if (!payload.enabled) {
      api.log(manifest.name + ": turn order is off; free play resumes.");
      return;
    }

    if (payload.previousActivePeerId === null) {
      api.log(manifest.name + ": turn order started with "
        + (payload.activePeerId || "nobody") + ".");
    } else {
      api.log(manifest.name + ": turn moved from " + payload.previousActivePeerId
        + " to " + (payload.activePeerId || "nobody") + ".");
    }

    const turn = api.getTurn();
    api.log(manifest.name + ": " + (turn.isMyTurn
      ? "you are up."
      : "waiting for someone else."));
  });

  api.log(manifest.name + ": turn state watcher ready.");
};

Starting turns prints turn order started with peer-3f2a.; advancing prints turn moved from peer-3f2a to peer-9b40.; stopping prints turn order is off; free play resumes.

Gotchas

The first observation is swallowed. Each client seeds its comparison from the first turn state it sees and raises nothing for it, so a mod loaded into a game already in progress learns the turn state only when it next changes. Call api.getTurn in setup to establish where you are starting from.

api.getTurn() inside this handler can still be one update behind. The hook is raised from the state change, while getTurn reads a context object the host pushes separately. Prefer the payload's own activePeerId and use getTurn only for isMyTurn, which the payload does not carry.

enabled: false leaves the other two fields meaningless. When turn order stops, activePeerId is null and previousActivePeerId is whoever was last up. Do not read them as "nobody's turn"; there is no turn.

See also

modhookeventmap.onPeerJoined#

onPeerJoined: ModPeerPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Fires once for each peer that appears in the connected-peer list and was not there before. It is derived by comparing the peer roster against its previous value, so it reports arrivals — not connection events, and not reconnections of a peer whose id never left.

Parameters

The handler receives one ModPeerPayload:

Field Type Notes
peerId string The new peer's id. This is the id every other hook and activePeerId use.
displayName string The name that peer announced. Not unique, and not a stable key — always match on peerId.
role "host" | "player" | "spectator" Their role at the moment they appeared. A spectator who later claims a seat does not re-raise this hook; watch onSeatChanged.
connectedAt string ISO-8601 timestamp of when that peer connected.

Which peer raises it: every peer running a mod. The roster is replicated, so each client compares its own copy and raises the hook locally.

How many times it fires: once per peer id that appears. The first roster a client sees raises nothing — that observation seeds the comparison, so the peers already present when your mod loads are invisible to this hook. It also does not fire for the client itself in the roster it starts from.

How, why and when to use it

Your game needs a minimum number of players before it deals, or wants to greet arrivals, or has to add a piece for each new participant. The alternative is polling api.getHandObjects or the snapshot, which tells you about seats rather than people and misses a spectator entirely. Use this hook to maintain your own roster, and seed that roster in setup from whatever the table already has — because the peers who were there before your mod loaded never arrive through it.

Example

// content/scripting-api/examples/modhookeventmap.onPeerJoined.js

// Mod script: greet arrivals and refuse to start until enough players are in.
// The hook fires once per NEW peer id, never for peers already present.
// manifest capabilities.allowed: ["log", "subscribe-events"]

const MINIMUM_PLAYERS = 3;

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  /** @type {Set<string>} */
  const players = new Set();

  api.on("onPeerJoined", (payload) => {
    if (payload.role === "spectator") {
      api.log(manifest.name + ": " + payload.displayName + " is watching.");
      return;
    }

    players.add(payload.peerId);
    api.log(manifest.name + ": " + payload.displayName + " joined as "
      + payload.role + " at " + payload.connectedAt + ".");

    if (players.size >= MINIMUM_PLAYERS) {
      api.log(manifest.name + ": " + players.size + " players - ready to deal.");
    } else {
      api.log(manifest.name + ": waiting for "
        + (MINIMUM_PLAYERS - players.size) + " more.");
    }
  });

  api.log(manifest.name + ": needs " + MINIMUM_PLAYERS + " players.");
};

The third arrival prints Ada joined as player at … — with the ISO-8601 connectedAt — followed by 3 players - ready to deal.

Gotchas

Peers already in the room when your mod loads never fire this hook. The first roster seeds the comparison silently. A mod that only counts arrivals will undercount every time it is loaded into a room that is already populated — which includes every reconnect and every host migration.

displayName is not an identity. Two players can share one, and a player can change theirs. Key every map on peerId.

Role at arrival is not role forever. A spectator can become a player and a player can be promoted to host by migration, and neither re-raises this hook.

See also

modhookeventmap.onPeerLeft#

onPeerLeft: ModPeerPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Fires once for each peer that disappears from the connected-peer list. The payload describes that peer as it was last seen, because by the time the hook runs there is nothing left to ask.

Parameters

The handler receives one ModPeerPayload — the same shape onPeerJoined carries, filled in from the roster entry that has been removed:

Field Type Notes
peerId string The departed peer's id.
displayName string Their name as last known.
role "host" | "player" | "spectator" Their role as last known — the role they held when they left, not a special "gone" value.
connectedAt string ISO-8601 timestamp of when they originally connected, so Date.now() minus this is how long they stayed.

Which peer raises it: every peer running a mod, from its own copy of the replicated roster.

How many times it fires: once per peer id that disappears, whatever the reason — a clean leave, a dropped connection, a kick or a ban all look identical here. There is no separate hook and no reason code.

How, why and when to use it

A player leaving mid-game usually leaves something behind: cards in a hand nobody can now see, a piece locked to their seat, a turn that will never end. This hook is where you clean that up — turn their cards face up, release their pieces, advance past them. The alternative is to notice on the next onSeatChanged, which only fires if their seat assignment was cleared and tells you nothing about a spectator. Do the cleanup here, and make it idempotent — a reconnecting player raises onPeerJoined with a new id and none of your bookkeeping for the old one.

Example

// content/scripting-api/examples/modhookeventmap.onPeerLeft.js

// Mod script: hand a departing player's pieces back to the table. The payload
// describes the peer as it was last seen, because it is already gone.
// manifest capabilities.allowed: ["log", "object-action", "read-world", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  api.on("onPeerLeft", async (payload) => {
    api.log(manifest.name + ": " + payload.displayName + " (" + payload.role
      + ", joined " + payload.connectedAt + ") left.");

    const abandoned = await api.listObjects({ kind: "card", tag: "in-play" });
    const unlocked = abandoned.filter((card) => !card.locked);
    if (unlocked.length === 0) {
      api.log(manifest.name + ": nothing in play to reclaim.");
      return;
    }

    for (const card of unlocked) {
      api.objectAction(card.id, "flip");
    }
    api.log(manifest.name + ": turned " + unlocked.length + " cards face up.");
  });

  api.log(manifest.name + ": will reclaim cards when a player leaves.");
};

A departure prints Ada (player, joined …) left., with the ISO-8601 connectedAt, and then turned 4 cards face up.

Gotchas

The peer's entities are still on the table. Leaving does not delete anything a player owned; their ownerSeat cards stay where they are and remain hidden from everyone else. If your game needs them back, this handler has to do it — and a mod cannot use delete, so flip, combine and moving them are the tools you have.

Every departure looks the same. A network drop, a deliberate leave and a host kick are indistinguishable from the payload. Do not build "was this player kicked" logic on it.

A reconnect is a new peer id. The same person coming back raises onPeerJoined with a different peerId, so anything you keyed on the old id is orphaned. Key durable per-player state on the seat rather than the peer id, and persist it with api.setSavedData.

See also

modhookeventmap.onSeatChanged#

onSeatChanged: ModSeatChangedPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Fires when a peer's seat assignment changes — a claim, a swap or a release. It is derived by comparing the replicated seat map against its previous value, one hook per peer whose seat actually differs.

Parameters

The handler receives one ModSeatChangedPayload:

Field Type Notes
peerId string The peer whose seat changed.
previousSeat string | null The seat they held before. null means they held none, which is how a claim announces itself.
seat string | null The seat they hold now. null means they hold none, which is how a release announces itself.
team string | null Their team after the change. It is carried for convenience; a team change on its own raises onTeamChanged instead.

The three cases, read off those two fields:

previousSeat seat What happened
null a seat The peer claimed a seat.
a seat another seat The peer moved.
a seat null The peer released their seat, or left the table.

Which peer raises it: every peer running a mod, from its own copy of the replicated assignment map.

How many times it fires: once per peer whose seat differs. Two players swapping seats fires it twice — once for each of them — with no ordering guarantee between the two, so a handler that assumes it sees the whole swap in one call will be wrong half the time.

The first observation raises nothing. The initial assignment map seeds the comparison, so seats already taken when your mod loads never arrive through this hook.

How, why and when to use it

Your mod does something per seat — a scoring token in front of each player, a per-seat panel, a hand-limit rule — and it has to stay right when someone moves. The alternative is api.getMySeat checked at setup, which is correct for exactly as long as nobody moves. Use this hook to keep a seat map you own, seeded in setup from api.getHandObjects so you are not blind to whoever was already sitting down.

Example

// content/scripting-api/examples/modhookeventmap.onSeatChanged.js

// Mod script: keep a seat roster in step. The hook fires for a claim, a swap
// and a release, and `seat: null` is how a release announces itself.
// manifest capabilities.allowed: ["log", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  /** @type {Record<string, string>} */
  const seatByPeer = {};

  api.on("onSeatChanged", (payload) => {
    if (payload.seat === null) {
      delete seatByPeer[payload.peerId];
      api.log(manifest.name + ": " + payload.peerId + " released the "
        + (payload.previousSeat || "unknown") + " seat.");
    } else if (payload.previousSeat === null) {
      seatByPeer[payload.peerId] = payload.seat;
      api.log(manifest.name + ": " + payload.peerId + " claimed " + payload.seat + ".");
    } else {
      seatByPeer[payload.peerId] = payload.seat;
      api.log(manifest.name + ": " + payload.peerId + " moved from "
        + payload.previousSeat + " to " + payload.seat + ".");
    }

    api.log(manifest.name + ": " + Object.keys(seatByPeer).length + " seats taken.");
  });

  api.log(manifest.name + ": seat roster ready.");
};

Claiming a seat prints peer-3f2a claimed red. and then 1 seats taken.; moving prints peer-3f2a moved from red to blue.

Gotchas

A seat release and a departure look identical. A player leaving the table clears their assignment, which arrives here as seat: null — the same shape as someone voluntarily standing up. Use onPeerLeft when the difference matters.

Entities already owned by a seat do not follow the player. ownerSeat is a property of the entity, so a player who moves from red to blue leaves the red hand behind exactly as it was. Whether that is right is your game's decision; the table takes no position.

Seats already taken when your mod loads are invisible. The first assignment map seeds the comparison silently, so seed your own roster at setup rather than assuming this hook will tell you everything.

See also

modhookeventmap.onTeamChanged#

onTeamChanged: ModTeamChangedPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Fires when a peer's team assignment changes. It is the exact sibling of onSeatChanged — same derivation, same comparison, a different field — and the two are raised independently, so a player who changes seat and side at once raises both.

Parameters

The handler receives one ModTeamChangedPayload:

Field Type Notes
peerId string The peer whose team changed.
previousTeam string | null The team they were on before. null means none, which is how joining a team announces itself.
team string | null The team they are on now. null means none, which is how leaving one announces itself.
seat string | null Their seat after the change, carried for convenience. A seat change on its own raises onSeatChanged instead.

Which peer raises it: every peer running a mod, from its own copy of the replicated assignment map.

How many times it fires: once per peer whose team differs. A table that does not use teams never raises it at all, because every assignment's team stays null.

The first observation raises nothing. The initial assignment map seeds the comparison, so teams already assigned when your mod loads never arrive through this hook.

How, why and when to use it

Your game is played in sides, and something has to stay balanced or stay hidden per side: even teams before the deal, a per-team score, a panel only one side can see. The alternative is onSeatChanged, which is what most authors listen to first and which will not fire when a player switches sides without moving seat. Listen to both if either one can affect your rule; listen only to this one when the seat is irrelevant.

Example

// content/scripting-api/examples/modhookeventmap.onTeamChanged.js

// Mod script: a two-team game that refuses to start until both sides are even.
// The hook fires on the team change alone; a seat move raises onSeatChanged.
// manifest capabilities.allowed: ["log", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  /** @type {Record<string, string>} */
  const teamByPeer = {};

  api.on("onTeamChanged", (payload) => {
    if (payload.team === null) {
      delete teamByPeer[payload.peerId];
      api.log(manifest.name + ": " + payload.peerId + " left team "
        + (payload.previousTeam || "unknown") + ".");
    } else {
      teamByPeer[payload.peerId] = payload.team;
      api.log(manifest.name + ": " + payload.peerId + " joined team " + payload.team
        + " from seat " + (payload.seat || "none") + ".");
    }

    const sides = Object.values(teamByPeer);
    const a = sides.filter((team) => team === "A").length;
    const b = sides.filter((team) => team === "B").length;
    api.log(manifest.name + ": A " + a + " vs B " + b
      + (a === b && a > 0 ? " - even, ready to play." : " - not even yet."));
  });

  api.log(manifest.name + ": team balance watcher ready.");
};

Assigning the second player prints peer-9b40 joined team B from seat blue. followed by A 1 vs B 1 - even, ready to play.

Gotchas

A team is not a visibility boundary on its own. What a player can see is decided by hidden-information redaction on the host and, for UI, by an element's visibility scope. Putting two players on a team does not let them see each other's face-down cards, and a mod cannot arrange for it to.

Team names are whatever the table uses. The two-sided "A"/"B" convention in the example is the common case and not a platform guarantee; treat the value as an opaque string and compare, do not parse.

Teams already assigned when your mod loads are invisible. Seed your own map at setup instead of assuming this hook covers the starting state.

See also

modhookeventmap.onUiEvent#

onUiEvent: ModUiEventPayload;
Badge Value
Authority all-peers
Timing sync
Capability subscribe-events
Availability mod

Fires when someone interacts with a table UI element this mod owns. It is the single hook that covers every widget you created, and it is dispatched right before the widget's own custom hook name, with the identical payload.

Parameters

The handler receives one ModUiEventPayload:

Field Type Notes
modId string Always this mod's id — dispatch is filtered to the owning mod, so it never carries another's.
elementId string The element that was interacted with. This is how one handler serves several widgets.
widgetType TableUiWidgetType The element's type. In practice only button, checkbox and input ever appear here.
interaction "click" | "change" "click" for a button; "change" for a checkbox or an input.
hook string | null The custom hook name this interaction also dispatched, or null when the widget named none.
value string Present for an input change only. Absent — not empty — otherwise.
checked boolean Present for a checkbox change only. Absent otherwise.
actorPeerId string | null Who interacted. null at an offline table.
actorRole "offline" | "host" | "player" | "spectator" Their role at the moment of the interaction.
at string ISO-8601 timestamp.

Which peer raises it: the peer running the mod, and only when modId matches the mod currently loaded in the sandbox. A widget belonging to another mod never reaches your handler.

How many times it fires: once per interaction, always before the widget's custom hook. A button with onClick: "endRound" therefore runs your onUiEvent handler and then your endRound handler, in that order, with the same object.

Applies to: button, checkbox and input. text, panel, canvas and layout accept a hook prop and never dispatch anything — see Gotchas.

How, why and when to use it

You have several controls and want one place that logs them, checks whether the actor is allowed to press them, or routes on elementId. The alternative is a named hook per widget, subscribed with api.on, which is clearer when each control does something genuinely different — a button handler that has nothing in common with an input handler does not benefit from being merged. Use onUiEvent for the cross-cutting concerns (permission checks, audit lines, "something changed, re-render") and the named hooks for the per-control logic.

Example

// content/scripting-api/examples/modhookeventmap.onUiEvent.js

// Mod script: one handler for every widget this mod owns. onUiEvent fires for
// all of them; the named hook (here "wagerChanged") fires straight afterwards.
// manifest capabilities.allowed: ["log", "ui", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
  try {
    await api.setUiElement({
      id: manifest.id + "-wager",
      type: "input",
      presentation: { mode: "screen", anchor: "bottom-left", offsetX: 16, offsetY: -16 },
      props: { value: "1", placeholder: "Wager", onChange: "wagerChanged" }
    });
  } catch (error) {
    api.log(manifest.name + ": the host owns table UI - " + String(error));
  }

  api.on("onUiEvent", (payload) => {
    api.log(manifest.name + ": " + payload.interaction + " on a "
      + payload.widgetType + " (" + payload.elementId + ") by "
      + (payload.actorPeerId || "the host") + " as " + payload.actorRole + ".");
    if (payload.value !== undefined) {
      api.log(manifest.name + ": new value is " + payload.value + ".");
    }
    if (payload.checked !== undefined) {
      api.log(manifest.name + ": checkbox is now " + payload.checked + ".");
    }
  });

  api.on("wagerChanged", (payload) => {
    api.log(manifest.name + ": wager hook also ran for " + payload.elementId + ".");
  });
};

Typing in the input prints change on a input (my-mod-wager) by peer-3f2a as player., then new value is 5., then wager hook also ran for my-mod-wager.

Gotchas

Four of the eight widget types never reach this hook.

Known gap. Only button, checkbox and input read an interaction hook from a widget's props — a button from onClick falling back to hook, a checkbox and an input from onChange falling back to hook (apps/web/src/ui/App.tsx, the mod UI element renderer). text, panel, canvas and layout accept the prop, store it, 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. Attach your hook to the button, checkbox or input inside the container rather than to the container. See Known limitations.

value and checked are absent, not empty, when they do not apply. A button click carries neither. Test with !== undefined rather than truthiness, or an unchecked checkbox reads as "no value".

actorRole is not a permission check. It tells you what role the actor held; it does not stop a spectator pressing a button that is visible to them. Restrict who sees a control with the element's visibility scope, and re-check the role here before acting on it.

Registering the same custom hook name as a declared hook is possible and confusing. The name is used verbatim as a key, so a widget whose onClick is "onTurnStart" will deliver a ModUiEventPayload to every handler registered for the real turn hook. Prefix your own hook names.

See also

modhookeventmap.onHostMessage#

onHostMessage: ModHostMessagePayload;
Badge Value
Authority all-peers
Timing async
Capability subscribe-events
Availability mod

Another peer's copy of THIS mod called api.sendToHost.

HOST ONLY, and that is the point: it is the inbound half of the only channel that runs upwards. A handler registered on a player never fires, because a player is not the host.

The host's own sendToHost is delivered here too, in the same shape and by the same code path, so a handler never needs to know whether the sender was remote.

Fires on the host when any peer's copy of this mod calls api.sendToHost. The inbound half of the only channel a mod has that runs upwards.

How, why and when to use it

Register it once, switch on name, and treat data as untrusted input. Resolve actorPeerId to a seat from your own map and check the request is one that peer may make — then do the host-only work (spawn, act, draw) that the sender could not do itself.

Example

// content/scripting-api/examples/modhookeventmap.onHostMessage.js

// Mod script: a "ready" vote. Players press a button; the host tallies.
// Both halves live here because both halves are the same mod - they just run on
// different peers. The host is the only one that can draw the tally, so the
// players tell it, and it decides.
// manifest capabilities.allowed: ["host-message", "log", "subscribe-events", "ui"]

/** Peer ids that have declared themselves ready. Only ever populated on the host. */
const ready = new Set();

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = async function setup(api, manifest) {
  await api.setUiElement({
    id: "ready-button",
    type: "button",
    presentation: { mode: "screen", anchor: "bottom-center", offsetX: 0, offsetY: -40 },
    props: { text: "I'm ready", onClick: "declareReady" }
  });

  // Runs on the peer that clicked, host or player alike.
  api.on("declareReady", async () => {
    await api.sendToHost("ready", { at: Date.now() });
  });

  // Runs on the HOST only - for its own click and for every player's, by the
  // same path, so there is one handler rather than two.
  api.on("onHostMessage", async (message) => {
    if (message.name !== "ready") {
      return;
    }
    // A spectator can run this mod and can send. Refuse what they may not ask for.
    if (message.actorRole === "spectator") {
      return;
    }
    // The identity is the host's, stamped from the channel. Nothing in the
    // payload could have named a peer, and nothing in it is trusted.
    ready.add(message.actorPeerId);
    api.log(manifest.name + ": " + ready.size + " ready.");

    await api.setUiElement({
      id: "ready-tally",
      type: "text",
      presentation: { mode: "screen", anchor: "bottom-center", offsetX: 0, offsetY: -80 },
      props: { text: ready.size + " player(s) ready", variant: "caption" }
    });
  });

  // A peer that leaves is no longer ready. Without this the tally counts ghosts.
  api.on("onPeerLeft", (payload) => {
    ready.delete(payload.peerId);
  });
};

One file, two peers. The button and the declareReady handler run wherever the click happened; the tally only ever runs on the host.

Gotchas

HOST ONLY, and a handler on a player never fires. That is not a restriction to work around; it is the shape of the feature. The player's half of the flow is the code that called api.sendToHost.

The host's own messages arrive here too, by the same path and in the same shape, so one handler covers both. Do not special-case the host.

Nothing is validated for you but the envelope. The host bounds the name's length, the payload's size and the rate; it does not know what your message means. data is unknown because it is.

Messages can be dropped. Over the size or rate limit they never arrive, and you are told in the diagnostics panel rather than in the script. A flow that must not lose a step should be idempotent, or should confirm itself through replicated state.

See also

modhookeventmap.onZoneEnter#

onZoneEnter: ModZoneEventPayload;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

An entity entered a seat zone — ANY type, scripting included. Once per crossing.

HOST ONLY: the host is the only peer that evaluates zone membership, so a handler registered on a player or spectator peer never runs.

Needs read-world AS WELL AS subscribe-events. The host does not post this message into a frame without it — the handler is simply never called.

Fires when an entity comes to be inside a seat zone it was not inside a moment ago. The host recomputes which entities stand in which zones once per frame and reports the difference, so you get exactly one event per crossing — not one per frame the entity spends in there. It fires for every zone type, scripting included.

It needs two capabilities, not one. subscribe-events to call api.on at all, and read-world for the host to deliver the event: a mod without read-world is never sent the message, so its handler simply never runs. That second gate is enforced on the host, not in your frame.

Parameters

The handler receives one ModZoneEventPayload:

Field Type Notes
zoneId string The authored zone id, unique only within its seat.
zoneType ModZoneType Branch on this first.
seat string The seat that owns the zone.
objectId string The entity that entered. Not a handle.

How, why and when to use it

This is the hook for "something arrived somewhere that matters" — a card reaching a hand, a piece landing on a player's board, a token entering a region your rules care about. It beats watching onObjectDropped and testing coordinates yourself twice over: it uses the same containment maths every other zone rule uses, so your mod and the engine can never disagree about where the boundary is, and it also catches arrivals nobody dropped — pushed by physics, moved by a script, or spawned in place.

Use onObjectDropped instead when you specifically need to know who acted; a zone event carries no actor.

Example

// content/scripting-api/examples/modhookeventmap.onZoneEnter.js

// Mod script: react when something reaches a player's hand zone.
// This hook needs "read-world" AS WELL AS "subscribe-events" - the host does
// not post a zone event into a mod that was not granted it, so without the
// capability the handler simply never runs.
// manifest capabilities.allowed: ["log", "read-world", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  api.on("onZoneEnter", async (payload) => {
    // Every zone type fires this, so filter on the type you care about.
    if (payload.zoneType !== "hand") {
      return;
    }

    const entity = await api.getObject(payload.objectId);
    if (!entity) {
      return;
    }

    api.log(manifest.name + ": " + entity.label + " reached " + payload.seat
      + "'s hand zone (" + payload.zoneId + ").");
  });

  api.log(manifest.name + ": watching hand zones.");
};

The event log gains a line each time an entity reaches a hand zone, naming the entity, the seat and the zone.

Gotchas

HOST ONLY. Zone membership is evaluated on the authoritative peer and dispatched there and nowhere else, so a handler registered on a player's or spectator's client never runs. This is unlike almost every other hook, which reaches every peer. Do the work on the host and let the result replicate through the snapshot.

A zone's tagFilter gates occupancy itself. An entity whose tags the filter rejects is not in the zone as far as the engine is concerned, so it raises nothing — no enter, no leave, and it is absent from api.getZoneObjects. That is the cheapest way for an author to scope your rule to one class of piece.

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 inside it. Zones have no ceiling, and every rule that reads them agrees on that.

Nothing is filtered out for you. Locked furniture, boards and card holders standing inside a zone cross like anything else. Filter on kind yourself if your rule is only about game pieces.

A zone never names code to run. There is no "run this handler" setting on a zone, in the editor or in the manifest — the relationship only runs from the zone's geometry to a handler you registered.

See also

modhookeventmap.onZoneLeave#

onZoneLeave: ModZoneEventPayload;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

An entity left a seat zone: it moved out, it left the table, or the zone went away with a released seat. One event covers all three. Same authority and same two capabilities as onZoneEnter.

Fires when an entity that was inside a seat zone is no longer inside it — exactly one event per exit, off the same once-a-frame membership pass that raises onZoneEnter, and for every zone type.

Three different things produce it, deliberately as one event: the entity moved out, the entity left the table, or the zone itself went away because its seat was released.

Like onZoneEnter it needs both subscribe-events and read-world; without the second the host never sends it and the handler never runs.

Parameters

The handler receives one ModZoneEventPayload — the same four fields onZoneEnter carries, describing the zone that was left and the entity that left it.

How, why and when to use it

Pair it with onZoneEnter whenever you keep a count, a highlight or a per-zone summary: enter increments, leave decrements, and because the host diffs the membership set the two are guaranteed to balance with no bookkeeping of your own. It is also the moment to release whatever you attached on entry.

The one thing it is not is a deletion notice. Read it as "it is no longer here", whatever "here" stopped meaning, and watch the event log through onTableEvent if you specifically need to know an entity left the table.

Example

// content/scripting-api/examples/modhookeventmap.onZoneLeave.js

// Mod script: keep a running record of how full each play area is.
// Zone events reach the host and nobody else, so a handler here is always
// running on the one peer allowed to write saved data - which is what makes
// setSavedData safe to call straight from the handler.
// manifest capabilities.allowed: ["log", "read-world", "saved-data", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  api.on("onZoneLeave", async (payload) => {
    if (payload.zoneType !== "area") {
      return;
    }

    // Do NOT resolve payload.objectId here and assume it exists: a leave also
    // fires when the entity is removed from the table. Ask the zone instead.
    const remaining = await api.getZoneObjects(payload.seat, payload.zoneId);

    try {
      await api.setSavedData(JSON.stringify({
        seat: payload.seat,
        zoneId: payload.zoneId,
        count: remaining.length
      }));
    } catch (caught) {
      api.log(manifest.name + ": could not save the count - " + String(caught));
      return;
    }

    api.log(manifest.name + ": " + payload.seat + "'s play area now holds "
      + remaining.length + ".");
  });
};

The mod's saved data tracks the last area that changed, and the event log gains a line per exit.

Gotchas

HOST ONLY, like onZoneEnter. That is what makes calling api.setSavedData straight from the handler safe — saved-data writes are host-only too, so the two restrictions line up instead of fighting.

A released seat empties its zones all at once. A seat's zones exist only while the seat is claimed, so standing up raises a leave for every entity inside every one of that seat's zones, carrying the vanished zone's real type rather than a guess. A handler that assumes a leave means "somebody moved something" will see a burst here.

The entity may no longer exist. api.getObject resolves null when the leave was caused by the entity leaving the table — the normal case for a drawn card or an absorbed stack, not an error.

A tagFilter that stops matching also produces a leave. Occupancy is containment and the filter, so an entity whose tags change to no longer match has left the zone — correctly, but invisibly if you were only thinking about movement.

See also

modhookeventmap.onTriggerEnter#

onTriggerEnter: ModTriggerEventPayload;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

An entity entered a TRIGGER VOLUME authored on a model in the Model editor. Once per crossing. A trigger volume never collides and never affects physics — firing these events is the whole of what it does.

HOST ONLY: the host is the only peer that evaluates trigger membership, so a handler registered on a player or spectator peer never runs.

Needs read-world AS WELL AS subscribe-events. The host does not post this message into a frame without it — the handler is simply never called.

⚠ It fires for hidden and face-down pieces too, with the same payload — an id, two names and a phase. That is narrower than it sounds (no face, no label, no metadata), but it is currently WIDER than api.listObjects(), which since 2026-08-14 drops an entity a hidden seat zone conceals rather than returning it. So this hook can tell you an entity exists that the reads will not show you. read-world gates delivery, which is why the gap is small; closing it entirely is tracked as a follow-up.

Fires when an entity comes to be inside a trigger volume — a shape a model author placed on a model in the Model editor — that it was not inside a moment ago. The host samples which entities stand in which volumes on a fixed low-rate pass and reports the difference, so you get exactly one event per crossing, not one per sample the entity spends in there.

A trigger volume never collides and never affects physics. Raising this event is the entire whole of what it does — and it only does anything if a mod or a table script subscribes. An authored volume with no subscriber is inert.

It needs two capabilities, not one. subscribe-events to call api.on at all, and read-world for the host to deliver the event: a mod without read-world is never sent the message, so its handler simply never runs and nothing reports that. That second gate is enforced on the host, not in your frame. It is the existing read-world slug — no capability value was added for trigger volumes.

You will normally be told at publish time rather than discovering the silence: the scanner treats api.on("onTriggerEnter", …) as a use of read-world, so a manifest that omits it is rejected with undeclared-capability before the mod ever runs.

Parameters

The handler receives one ModTriggerEventPayload:

Field Type Notes
triggerId string The authored volume's id, unique only within its model asset.
triggerName string The authored volume's name. For printing, not for matching.
triggerTag string (optional) The author's tag, when they set one. The thing to match on.
ownerObjectId string The entity carrying the volume. With triggerId, the unique key for one volume.
objectId string The entity that crossed into the volume. A different entity, and not a handle.
phase "enter" | "leave" "enter" here, always. It exists so one handler can serve both hooks.

ownerObjectId and objectId are two different entities. The first is the board; the second is the piece that reached it.

How, why and when to use it

This is the hook for "a piece reached a place the model defines" — a slot on a board, a scoring cup, a segment of a track, a region an author drew rather than one a seat owns. It is the model-authored counterpart to onZoneEnter, and the choice between them is the choice between geometry that belongs to a seat and geometry that belongs to a model: a seat zone travels with whoever claims the seat, while a trigger volume travels with the entity it was authored on.

Match on triggerTag, not on triggerName. A tag is the identifier the author added for your rule; a name is a label they can rename at any time without realising anything depended on it. If your mod expects tagged volumes, say which tags in its description — the tag is a contract between a model author and you, and nothing in the platform enforces it.

Example

// content/scripting-api/examples/modhookeventmap.onTriggerEnter.js

// Mod script: react when a piece crosses a trigger volume that a model author
// placed and tagged "goal".
//
// Trigger events reach the HOST and nobody else, so this handler always runs on
// the authoritative peer. The same mod loaded on a player's client never sees a
// trigger event at all.
//
// The hook needs "read-world" AS WELL AS "subscribe-events". The host refuses to
// post a trigger event into a frame that was not granted "read-world", so
// without it the handler is simply never called - and nothing reports that.
// manifest capabilities.allowed: ["log", "read-world", "subscribe-events"]

/** @param {ModApi} api @param {ModSetupManifest} manifest */
exports.setup = function setup(api, manifest) {
  /**
   * `phase` is on the payload as well as in the hook name, so one handler can
   * serve both hooks. Register it twice; nothing merges them for you.
   *
   * @param {ModTriggerEventPayload} payload
   */
  const onGoalCrossing = async (payload) => {
    // An untagged volume carries no tag at all, so match on the tag rather than
    // on triggerName - a name is a label an author can rename without noticing.
    if (payload.triggerTag !== "goal") {
      return;
    }

    // triggerId is unique only WITHIN a model asset, so two copies of the same
    // board both call their volume "goal-slot". The OWNER and the volume id
    // together are the key for one volume on the table.
    const volumeKey = payload.ownerObjectId + "/" + payload.triggerId;

    // Two ids, two different entities: ownerObjectId CARRIES the volume and
    // objectId CROSSED it. This fires for face-down and hidden pieces too, with
    // the same payload - "read-world" is what gates that, not a redacted one.
    const crossed = await api.getObject(payload.objectId);
    const owner = await api.getObject(payload.ownerObjectId);
    const who = crossed === null ? payload.objectId : crossed.label;
    const where = owner === null ? volumeKey : owner.label + " / " + payload.triggerName;
    const verb = payload.phase === "enter" ? "reached" : "left";

    api.log(manifest.name + ": " + who + " " + verb + " " + where + ".");
  };

  api.on("onTriggerEnter", onGoalCrossing);
  api.on("onTriggerLeave", onGoalCrossing);
};

The event log gains a line each time something reaches or leaves a volume tagged goal, naming the entity and the volume.

Gotchas

No mod code runs on a player's or spectator's client at all, so a trigger handler is always host-side. That is the single most important sentence on this page. There is a second, independent reason as well: HOST ONLY — the host is the only peer that evaluates trigger membership, so a handler registered on a player or spectator peer never runs. Word for word what onZoneEnter already says, because the two hooks are deliberately identical in authority.

It fires for hidden and face-down entities, with the same payload. A card in a hand or inside a seat's hidden zone crosses a trigger volume like anything else, and you are handed its id. That is deliberate, it is exactly the treatment onZoneEnter already gives hidden zones, and it grants nothing: a mod holding read-world can already enumerate every face-down card and every hidden-zone occupant from api.listObjects. The capability gate is what makes the full payload safe, not payload hygiene — which is why the gate lives on the host, where an untrusted frame cannot opt itself back in.

triggerId is not a key on its own. It is authored on the model asset, so two copies of the same board both report "goal-slot". The unique identity of one volume is ownerObjectId plus triggerId. A map — or a UI element id — keyed on triggerId alone looks correct with one board on the table and silently collides the moment an author adds a second.

Containment is tested at the crossing entity's origin point, exactly as seat-zone membership is. A large piece is inside a volume when its origin is, not when its geometry overlaps.

Nothing can be cancelled. By the time your handler runs, the crossing has happened. React and correct — move the piece back — rather than trying to refuse it.

There is no onPrivateTriggerEnter. Trigger crossings are one hook family, and it is this one.

See also

modhookeventmap.onTriggerLeave#

onTriggerLeave: ModTriggerEventPayload;
Badge Value
Authority host-only
Timing sync
Capability read-world
Availability mod

An entity left a trigger volume: it moved out, or it left the table. Same authority and same two capabilities as onTriggerEnter.

Fires when an entity that was inside a trigger volume is no longer inside it — exactly one event per exit, off the same low-rate membership pass that raises onTriggerEnter.

Two different things produce it, deliberately as one event: the entity moved out of the volume, or the entity left the table altogether.

Like onTriggerEnter it needs both subscribe-events and the existing read-world; without the second the host never sends it and the handler never runs.

Parameters

The handler receives one ModTriggerEventPayload — the same five fields onTriggerEnter carries, describing the volume that was left and the entity that left it, with phase set to "leave".

How, why and when to use it

Pair it with onTriggerEnter whenever you keep a count, a highlight or a per-volume summary: enter increments, leave decrements, and because the host diffs the membership set the two balance with no bookkeeping of your own. It is also the moment to release whatever you attached on entry.

The one thing it is not is a deletion notice. Read it as "it is no longer in there", whatever stopped being true, and watch onTableEvent if you specifically need to know an entity left the table.

Example

The onTriggerEnter example registers one handler on both hooks and branches on phase — it is the example for this entry too. There is deliberately not a second, near-identical copy of it here: an enter/leave pair is only correct if both halves are written together, and two half-edited copies of the same script is the exact failure mode the examples gate exists to prevent.

Gotchas

Host only, exactly like the entry hook, and for two independent reasons: the host is the only peer that evaluates trigger membership, and no mod code runs on a player's or spectator's client at all.

The entity may no longer exist. api.getObject resolves null when the leave was caused by the entity leaving the table — the normal case for a drawn card or an absorbed stack, not an error. Decrement your count from the id and skip the lookup when you can.

An entity leaving the table raises the leave; a volume leaving the table may not. The membership pass reports transitions it can still see, so do not treat a balanced enter/leave pair as guaranteed across a model being deleted mid-game. Re-derive from the ids you hold rather than trusting a running count after a deletion.

It fires for hidden and face-down entities, with the same payload, for the same reason onTriggerEnter does — and gated by the same existing read-world.

See also

ModHookEventName#

Surface B — mod script · type

declare type ModHookEventName = keyof ModHookEventMap;

The ten platform hook names as a union, derived rather than written out: keyof ModHookEventMap. Because it is derived, it cannot fall out of step with the map it indexes — a hook added to ModHookEventMap joins this union in the same edit. It is the first parameter of api.on's typed overload, and the element type of the runtime array MOD_HOOK_EVENT_NAMES (packages/shared/src/modScripting.ts), which is declared satisfies readonly ModHookEventName[] against it.

How, why and when to use it#

You have grown past four api.on calls in a row and want a table of handlers you can iterate — a Record<ModHookEventName, Handler>, or a typed array you register in a loop. Annotating with this union is what makes that table exhaustive: leave a hook out of a Record keyed on it and the file stops compiling, which is the point. The alternative is a plain string, and it is the right choice for one case — a custom UI hook name, which is not a platform name and cannot be in this union.

Gotchas#

A custom hook name is not a member. A widget's onClick, onChange or hook prop can name anything, and api.on accepts it through a separate string overload that types the payload as ModUiEventPayload. Widening a variable to ModHookEventName | string collapses to string and loses the typed payloads for the ten, so keep the two registration paths apart.

The name is a key, not a checked identifier. The frame stores handlers in an object keyed by whatever string arrives. A typo caught by this union is a compile error; the same typo on the string overload registers a handler nothing will ever dispatch to, with no error at load or at run time.

See also#