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)resolvesnullfor an entity ahiddenzone conceals — the samenulla deleted id gives you.api.getZoneObjectsandapi.listObjectsomit it from their arrays.- A face-down card does resolve, but as
label: "Card"withmetadata.__redacted === trueand nometadata.cardIdorsecretMetadata.
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#
- The mod api object — the 25 methods, including
api.onitself. - Sidecars — the
triggerskey a model author uses to create the volumes the last two hooks report on. api.setUiElement— where custom hook names come from.- Mod capabilities — declaring
subscribe-events, and the other ten slugs. - ModCapability — the eleven slugs as a value table.
- Events and delegates — ordering and cancellation across both surfaces.
- Host authority — which peer raises what, and why.
- Known limitations — every documented gap, in one list.
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#
- ModCapability — the eleven values, with the methods each unlocks.
- Mod capabilities — the matrix, the detectors and the exact rejection text.
ModSetupManifest— where a script reads its own grants.- Sandbox limits — what the capability model is and is not for.
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#
ModZoneEventPayload.zoneType— where you read it.onZoneEnter— the hook that carries it.- Zones and seats — how zones and their types are authored.
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#
event— the log line itself.object— the entity the line names.snapshot— the whole table at that instant.api.on— registering the handler.- Mod hooks and capabilities — which hook fires when, and on which peer.
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— resolvingobjectIdto 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
event— whereobjectIdcomes from.TableObjectState— every field on the entity.api.getObject— fetching an entity this payload did not name.- Object state — the replicated shape, field by field.
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
TableSnapshot— every collection a snapshot carries.api.getSnapshot— asking for the table outside a handler.object— the one entity, already resolved (and redacted the same way).- Async and snapshots — why a round-trip sees a later table.
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 whatnullmeans 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
seat— the chair, which is the other candidate key.ModTurnChangedPayload.activePeerId— the same person, reported on every peer.api.getTurn— asking whether it is this client's turn.ModPeerPayload.peerId— the same id in the arrival and departure hooks.
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
team— the sibling assignment.onSeatChanged— the hook that changes this value.api.getMySeat— this client's own seat, which is a different question.api.getHandObjects— what is in front of a seat.
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
seat— the other assignment carried here.onTeamChanged— the hook that changes this value.api.getMyTeam— this client's own team.ModTeamChangedPayload— the full before-and-after shape for a side change.
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
onTurnStart— where this value arrives, once per turn.onTableEvent— the feed you count actions from.ModTurnInfo— the turn state you can ask for at any time.- Limits and caps — the platform's other numeric ceilings.
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#
enabled— whether turn order is running.activePeerId— who is on now.previousActivePeerId— who was on before.ModTurnStartPayload— the richer, host-only alternative.- Host authority — which peer raises what.
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
activePeerId— the field that changes on every advance.ModTurnInfo— the same flag, read on demand.api.getTurn— seeding your state atsetup.onTurnStart— the hook that does not fire when turns stop.
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
previousActivePeerId— the other half of the transition.enabled— the flag you must read alongside it.api.getTurn— the same state, pulled instead of pushed.ModTurnStartPayload.peerId— the host-only equivalent, with seat and team.
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
activePeerId— who is on now.enabled— telling a stop from an advance.ModTurnInfo— the pulled turn state, which reports no previous peer.onTurnStart— the opening half, on the host.
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
displayName— the human name, which is not a key.ModSeatChangedPayload.peerId— the same id in the seat hook.ModUiEventPayload.actorPeerId— the same id on a widget interaction.api.setSavedData— persisting per-player state that must survive a rejoin.
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
peerId— the peer this role belongs to.ModUiEventPayload.actorRole— the four-value version, on widget interactions.onSeatChanged— a spectator becoming a player.- Host authority — what the host role actually owns.
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 it — new 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
peerId— the peer this timestamp belongs to.ModUiEventPayload.at— the other timestamp on this surface, from a different clock.ModTableEventPayload.event— the log line's ownat, from a third clock.onPeerJoined— the hook that first reports it.
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#
previousSeat— the chair they left.seat— the chair they took.ModTeamChangedPayload— the mirror-image shape for sides.onSeatChanged— the hook, and how many times it fires.
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
seat— the chair this peer now holds.ModPeerPayload.peerId— the same id at arrival and departure.ModTeamChangedPayload.peerId— the same id in the team hook.onSeatChanged— the hook's multiplicity, spelled out.
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
seat— the other half of the transition.ModTeamChangedPayload.previousTeam— the same idea for sides.api.getHandObjects— reading what is in front of the seat being vacated.onPeerLeft— telling a departure from a voluntary release.
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
previousSeat— the chair being vacated.team— the side they are on after the change.api.getMySeat— this client's own seat.- Zones and seats — where the seat list is defined.
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
seat— the field that actually changed.ModTeamChangedPayload.team— the same value when the side is what moved.api.getMyTeam— this client's own team.onTeamChanged— the hook to use when the side is the subject.
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#
previousTeam— the side they left.team— the side they joined.ModSeatChangedPayload— the same shape for chairs.api.getMyTeam— this client's own side.
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
team— the side this peer is now on.ModSeatChangedPayload.peerId— the same id in the seat hook.ModTurnStartPayload.peerId— the same id at the top of a turn.onTeamChanged— the hook, and when it fires.
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
team— the other end of the transition.ModSeatChangedPayload.previousSeat— the same idea for chairs.onTeamChanged— the hook that delivers it.api.setSavedData— persisting a per-side total across a reload.
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
previousTeam— the side being left.seat— the chair they hold after the change.api.getMyTeam— this client's own side.ModTurnStartPayload.team— the same value reported at the top of a turn.
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
team— the field that actually changed.ModSeatChangedPayload.seat— the authoritative seat event.api.getHandObjects— what sits in front of the chair this names.onSeatChanged— the hook to drive seat changes from.
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#
ModZoneType— the nine kinds of zone.onZoneEnter— when it arrives, and the two capabilities it needs.api.getZoneObjects— the pull half of the same surface.api.getObject— resolvingobjectId.
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
seat— the other half of the key.api.getZoneObjects— takes both halves.- Zones and seats — where the ids come from.
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
ModZoneType— what each value means.zoneId— when the type is not specific enough.- Zones and seats — choosing a type when authoring.
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
zoneId— the other half of a zone's identity.api.getZoneObjects— takes this as its first argument.onSeatChanged— who is sitting where.
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
api.getObject— resolving it.onZoneLeave— why the lookup can fail.api.getZoneObjects— the whole set, without a lookup per id.
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#
onTriggerEnter— when it arrives, and the two capabilities it needs.onTriggerLeave— the two ways it ends.ModZoneEventPayload— the same shape, for seat-owned geometry.api.getObject— resolvingobjectId.
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
ownerObjectId— the other half of the key.triggerTag— the identifier meant for matching.triggerName— the human label, and why it is not a key.- Sidecars — where a volume's
idis authored.
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
triggerTag— what to match on instead.triggerId— the authored identity.api.log— where this usually ends up.
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
objectId— the other id, and the other entity.triggerId— the other half of the key.api.getObject— resolving it.
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
api.getObject— resolving it.ownerObjectId— the other id on this payload, and the other entity.onTriggerLeave— why the lookup can fail.
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#
elementId— which control was used.actorPeerId— who used it.api.setUiElement— creating the widgets that produce this.- Table UI widget types — all eight types, and which four dispatch.
- Mod hooks and capabilities — the custom-hook channel this payload also travels on.
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
elementId— the value you do have to branch on.ModSetupManifest— where the same id arrives atsetup.api.setUiElement— the call that stamps the owner.- Mod hooks and capabilities — per-mod routing versus fan-out.
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
modId— the owner, which you do not have to check.api.setUiElement— where the id is chosen.api.deleteUiElement— removing the widget this names.api.getUiState— reading back every element you own.
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,checkboxandinputread an interaction hook from a widget's props (apps/web/src/ui/App.tsx, the mod UI overlay renderer).text,panel,canvasandlayoutaccept ahookprop, 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 abutton,checkboxorinputinside the container rather than on the container. See Known limitations.
See also
interaction— the verb that pairs with each type.value— populated forinputonly.checked— populated forcheckboxonly.- Table UI widget types — all eight, with what each renders.
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 aninputcarries.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
onUiEvent— the delivery that always happens.api.on— registering a handler for a name of your own.api.setUiElement— whereonClick,onChangeandhookare set.- Mod hooks and capabilities — the ten platform names this must not collide with.
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
checked— the same idea for acheckbox.widgetType— how to know which of the two to read.api.setUiElement— writing the value back so it sticks.api.getUiState— what the element declares, which is a different thing.
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
value— the same idea for aninput.interaction— why a checkbox reports"change"and not"click".api.setUiElement— committing the new state.- Table UI widget types — the eight types and their props.
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
actorRole— what they were, and why it is weaker evidence.ModTurnChangedPayload.activePeerId— the id to compare against for a turn rule.api.setUiElement— thevisibilityscope that decides who sees the control.api.registerAction— the alternative, and why it is not one yet.
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
actorPeerId— the identity that is attributed rather than claimed.ModPeerPayload.role— the three-value roster role.onPeerJoined— building a roster you can trust.- Host authority — what each role can actually do at the table.
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
ModUiEventPayload— the shape this belongs to.actorPeerId— who clicked.onUiEvent— the hook that delivers this payload.TableEvent.at— the same clock on the table's own log lines.
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#
- Mod hooks and capabilities — each hook's exact firing condition and which peer raises it.
ModHookEventName— the keys of this map, as a union.api.on— registration, ordering, and the absence of unsubscribe.api.setUiElement— where the fifteenth, unbounded channel comes from.- Events and delegates — multiplicity and cancellation across both surfaces.
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
onCardDrawn— the same payload, filtered to draws.onObjectDropped— the same payload, filtered to moves.api.on— registering the handler, and the ordering rules.TableEvent— the log line's own fields.- Deck and Bag Search — the three search lines, and why they name no card.
- Events and delegates — ordering, multiplicity and cancellation.
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
onTableEvent— the unfiltered hook this one is derived from.api.getContainerContents— what is left in the pile.api.getHandObjects— where the drawn card went.api.objectAction— how a mod raises adrawitself.
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
onTableEvent— the unfiltered hook this one is derived from.api.getObject— re-reading the entity once it has settled.- Host authority — which peer writes what.
- Known limitations — parented assemblies and the entity you get.
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
onTurnChanged— the turn state changing, on every peer.api.getTurn— asking whether it is your turn right now.ModTurnStartPayload— the payload shape.- Choosing a surface — why
onTurnStartandonTurnStartedare unrelated.
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
onTurnStart— the host-only hook with seat and team.api.getTurn— the current state, includingisMyTurn.api.deleteUiElement— the worked example of a banner driven by this hook.ModTurnChangedPayload— the payload shape.
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
onPeerLeft— the matching departure hook.onSeatChanged— when someone takes a seat.api.getHandObjects— seeding your roster from what the table already holds.ModPeerPayload— the payload shape.
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
onPeerJoined— the matching arrival hook.api.objectAction— the ten actions available for cleanup.api.setSavedData— surviving a reconnect.ModPeerPayload— the payload shape.
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
onTeamChanged— the sibling hook for sides.onPeerLeft— telling a release from a departure.api.getMySeat— this client's own seat.ModSeatChangedPayload— the payload shape.
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
onSeatChanged— the sibling hook for seats.api.getMyTeam— this client's own team.api.setUiElement— thevisibilityscope that does restrict who sees a panel.ModTeamChangedPayload— the payload shape.
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,checkboxandinputread an interaction hook from a widget's props — abuttonfromonClickfalling back tohook, acheckboxand aninputfromonChangefalling back tohook(apps/web/src/ui/App.tsx, the mod UI element renderer).text,panel,canvasandlayoutaccept the prop, store it, 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 thebutton,checkboxorinputinside 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
api.setUiElement— creating the widgets this hook reports on.api.on— subscribing to a custom hook name.- Table UI widget types — which types dispatch and which do not.
ModUiEventPayload— the payload shape.
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
api.sendToHost— the outbound half.ModHostMessagePayload— the payload's fields, and which of them are the sender's.onUiEvent— usually what triggers the send.
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
onZoneLeave— the matching exit.ModZoneEventPayload— the payload, field by field.api.getZoneObjects— what a zone holds right now.- Capabilities — declaring
read-worldandsubscribe-events.
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
onZoneEnter— the matching entry, and the capability rule in full.ModZoneEventPayload— the payload.api.getZoneObjects— what the zone holds after the exit.api.setSavedData— why host-only matters here.
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
onTriggerLeave— the matching exit.ModTriggerEventPayload— the payload, field by field.onZoneEnter— the same shape of hook for seat-owned geometry, with the same authority.api.getObject— resolvingobjectId.- Mod capabilities — declaring
read-worldandsubscribe-events. - Sidecars — the
triggerskey that authors these volumes.
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
onTriggerEnter— the matching entry, the worked example, and the capability rule in full.ModTriggerEventPayload— the payload.onZoneLeave— the seat-owned equivalent, which has a third cause this one does not.api.getObject— and why it can resolvenullhere.
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#
ModHookEventMap— the map this indexes, and each name's payload.api.on— the two overloads, and why there is no unsubscribe.- Mod hooks and capabilities — the unbounded channel this union deliberately excludes.
- Events and delegates — how the two surfaces' event models differ.
