Object Types
Every entity on the table is an ObjectHandle. Nine of them are also
something more specific, and this page is that surface: what a deck can do that a die cannot, and how a
script gets hold of it without a cast.
A handle's kind decides which of these types it is:
| Kind | Type | What it adds |
|---|---|---|
card |
CardObject |
tapped, cardId, and the three flip/tap delegates. |
deck |
DeckObject |
Everything on ContainerObject. |
bag |
BagObject |
Everything on ContainerObject; draws at random. |
card-holder |
CardHolderObject |
Everything on ContainerObject; hands out one at a time. |
die |
DieObject |
Nothing yet — onRolled is already on every handle. |
token |
TokenObject |
Nothing yet — stackCount is already on every handle. |
board |
BoardObject |
Nothing yet. |
button |
ButtonObject |
eventName, and the onPressed delegate. |
custom |
CustomObject |
Nothing yet. |
Five of the nine add nothing today, and they exist anyway. They give the editor a name to show for
refObject in a die script, they give this page somewhere to say what that kind is for, and they are where a
future die-specific member lands without every existing script's typing changing shape. An interface that is
ObjectHandle and nothing more is a promise about where to look, not a gap. The two that do add something
are the two containers (via ContainerObject) and ButtonObject, whose onPressed is the only way a table
script hears a press — its members are documented on Types.
How a script gets one#
There are two ways, and neither of them is a cast.
An object script gets refObject typed for its entity. When you press + New in an entity's Script
section, the editor records which kind it was created for (SceneScript.refKind) and declares refObject as
that kind's type — so a deck script completes on refObject.cards and a card script on refObject.onFlipped.
A script created from the asset bar or the scene's Scripts section is a global script: it has no entity, so
refObject stays the generic ObjectHandle and the starter body opens on globalEvents instead. See
Scripts.
A kind-filtered lookup narrows its result. world.getAllObjects({ kind: "deck" }) resolves DeckObject[],
because the filter has already excluded everything else — the narrowing is the filter's own guarantee, not an
assumption. That is ObjectHandleForKind doing the work, and it is the way a scene
script reaches kind-specific members.
// content/scripting-api/examples/concepts.typed-handles.ts
// Scene script: a kind filter narrows the result, so no cast is needed.
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() !== "!decks") {
return;
}
void reportDecks();
});
async function reportDecks(): Promise<void> {
const decks = await world.getAllObjects({ kind: "deck" });
for (const deck of decks) {
// `deck` is a DeckObject here — `cards` and `containerMode` are only on a container.
world.log(`${deck.name ?? deck.id}: ${deck.cards.length} card(s), draws from the ${deck.containerMode ?? "stack"}`);
}
const total = decks.reduce((sum, deck) => sum + deck.cards.length, 0);
world.broadcast(`${decks.length} deck(s) on the table, ${total} card(s) between them.`);
}
Nothing here changes what runs#
These types are declarations, not runtime objects. The sandbox builds one handle shape and wires every delegate onto it, including the kind-specific ones — a handle is created before its kind is known, so it could not do otherwise. What the types decide is what an author can see and what the editor will let them write.
Two consequences worth keeping straight:
- A delegate that cannot fire is a compile error, not a silent no-op.
refObject.onFlippedon a die script does not compile, becausetapandflipare refused for a die at the runtime anyway. That is the point of narrowing rather than putting all of it onObjectHandle. - A mismatched attachment still runs. Attaching a deck script to a card is allowed —
refKindis an authoring hint, never a gate. The Script section warns, the script executes, andrefObject.cardsis an empty array rather than an error. Nothing about attachment changed.
Kinds and their reserved actions#
The narrowing follows what the runtime already refuses. shuffle is a deck action; a bag has no order to
shuffle. tap and untap are card mechanics. draw and deal need a container. The full three-way table of
which action reaches which kind is Action vocabularies — this page's types
are that table expressed as declarations.
See also#
ObjectHandle— the fields, methods and eight delegates every one of these inherits.World—getAllObjects, whose kind filter produces these types.- Events — how delegates work and which peer runs them.
- Object kinds — what each kind is at the table, beyond scripting.
- Scripts — creating an object script from an entity's Script section.
- Action vocabularies — which actions each kind accepts.
ContainerCard#
Surface A — table script · interface · 2 members
One entry inside a container, as it is stored.
One entry inside a container, as the container stores it: which face it is, and whether it is stored face down.
It is a plain read-only record with no methods and no delegates - a card that has been dealt onto the table is an
entity with an ObjectHandle; a card still in a deck is one of these.
You meet it as the element type of
ContainerObject.cards and nowhere else.
See also#
ContainerObject.cards- the array these live in.CardObject.cardId- the same identity, once a card is on the table.
Members#
| Signature | Description | Returns |
|---|---|---|
cardId |
Which face this entry is — a standard code like "AS", or a custom deck's card id. |
string |
faceDown |
Whether the entry is stored face down. | boolean |
containercard.cardId#
readonly cardId: string;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Which face this entry is — a standard code like "AS", or a custom deck's card id.
Which face this entry is. For a standard deck it is a short code - "AS" for the ace of spades, "10H" for the
ten of hearts. For a custom deck it is the card id the deck's own definition gave it, which is whatever the
author chose.
How, why and when to use it
You are writing a rule that cares about specific cards: counting the aces left in the draw pile, checking that a deck was built with the cards you expect, dealing a scripted opening hand. The id is the only identity a card entry has - its position in the array is not stable across a shuffle, and there is no name.
Gotchas
The format is a convention, not a validated one. A custom deck can use any string, so a rule that assumes
"AS"-style codes silently matches nothing against a deck built elsewhere. Compare against ids you control, or
against ids you have read off this deck.
Reading it is a host-side read of hidden information. Scripts run on the host, which knows every card in every deck - so a script can see cards no player may see. Anything you print, broadcast or write into a label crosses that line deliberately.
See also
ContainerCard.faceDown- the other half of an entry.CardObject.cardId- the same identity on a card that is on the table.
containercard.faceDown#
readonly faceDown: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Whether the entry is stored face down.
Whether this entry is stored face down. It is the state the card will be in when it is drawn - a deck of face-down cards deals face-down cards - and it is per entry, so a container can hold a mix.
How, why and when to use it
You want to know whether drawing from this container reveals anything: a face-up discard pile behaves differently
from a face-down draw pile, and the difference is here rather than on the container. Reading it before a draw()
is how a script decides whether it needs to flip() afterwards.
Gotchas
It is the inverse of a handle's faceUp. An entry says faceDown: true; the card entity it becomes reports
faceUp: false. The two spellings sit one draw apart, so read the name carefully rather than the shape.
A container entry written as a bare card id inherits the container's own side. That is why a deck flipped as a whole deals differently afterwards.
See also
ObjectData.faceUp- the same idea, inverted, on an entity.ObjectHandle.flip- turning a drawn card over.
ContainerObject#
Surface A — table script · interface · 4 members
A container of cards or items: a deck, a bag, or a card-holder.
The shared surface of the three kinds that hold things: deck, bag and card-holder. A ContainerObject is
an ObjectHandle with four additions - what it holds
(cards), how it hands items out (containerMode),
how much it will take (capacityLimit), and a delegate for running out
(onDepleted).
You do not meet ContainerObject by name in a script. refObject in a deck script is a
DeckObject, and world.getAllObjects({ kind: "bag" }) resolves BagObject[] - both of which
are container objects, which is where these four members come from. The interface exists so the three kinds
share one set of members and one set of documentation rather than three copies that drift.
It is the card-lane surface. cards describes a pile of cards, and it is empty on a
container that stores pieces instead - a bag holds one lane or the other, never both. The piece-storing surface
(form, items, takeObject) is declared on
BagObject alone, because bag is the only kind that can be a container of pieces.
See also#
DeckObject,BagObject,CardHolderObject- the three kinds that are one.ContainerObject.capacityLimit- the limit every drop honours.ObjectHandle.draw- the action every container answers.ObjectHandle.shuffle- the action only a deck answers.
Members#
| Signature | Description | Returns |
|---|---|---|
cards |
What this container holds, in draw order — the top of the pile first. Empty for a container that stores no card entries. | readonly ContainerCard[] |
containerMode |
How this container hands out its next item - off the top ("stack"), off the bottom ("queue"), or at random. |
"stack" | "queue" | "random" | null |
capacityLimit |
How many pieces this container will accept before it starts refusing them, or null for no limit. |
number | null |
onDepleted |
Fires when the last item is drawn out and the empty container leaves the table. | ScriptDelegate<[EventContext]> |
containerobject.cards#
readonly cards: readonly ContainerCard[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
What this container holds, in draw order — the top of the pile first. Empty for a container that stores no card entries.
What this container holds, in draw order - the entry a draw() would take first is cards[0]. Each element is a
ContainerCard: a face id and whether it is stored face down. The array is a read-only copy of
the state the handle last received; assigning to it or pushing onto it changes nothing on the table.
It is empty for a container that stores no card entries, which includes a bag or a card-holder holding items
that are not cards. An empty array therefore means "nothing readable here", not necessarily "nothing here".
How, why and when to use it
This is how a script knows a deck's contents without dealing them out: verifying a deck was built correctly at
setup, counting how many of something is left, dealing a scripted hand by looking before you draw. The
alternative is to track every onCardDrawn from the start of the game and hope you have missed none.
Reach for ObjectData.stackCount instead when you only
need the number - it is the same information one field earlier, and it is meaningful for kinds that have no
cards at all.
Gotchas
It is a cached copy, not a live view. After a draw() or a shuffle() the handle still reports the previous
array until it is refreshed - await handle.refresh(), or read it off the handle the event delivered.
Reading it is a host-side read of hidden information. Scripts run on the host, so this array shows face-down cards that no player at the table may see. Printing, broadcasting or labelling anything from it is a deliberate reveal.
A random container's order is not its draw order. cards[0] is the top of the stored pile; a bag takes an
entry at random, so nothing about position predicts what comes out.
See also
ContainerCard- the shape of each element.ContainerObject.containerMode- which end the next draw comes from.ObjectData.stackCount- the count on its own.ObjectHandle.onCardDrawn- being told when the array changes.
containerobject.containerMode#
readonly containerMode: "stack" | "queue" | "random" | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
How this container hands out its next item - off the top ("stack"), off
the bottom ("queue"), or at random.
null when the entity declares no mode, and the default it then behaves as
depends on the kind: "random" for a bag, "stack" for everything
else. Write mode ?? (kind === "bag" ? "random" : "stack") rather than
assuming one of them - a bag is random by default, which is the whole point
of a bag.
How this container hands out its next item. "stack" takes from the top, "queue" takes from the bottom, and
"random" takes an arbitrary entry - a bag. null means the entity declares no mode at all, and the default
the host then applies is per kind: "random" for a bag, "stack" for a deck.
How, why and when to use it
A rule that reads the top card before drawing it is only correct for a "stack". Checking the mode first is how
a general "peek then draw" helper stays honest when someone points it at a bag. It is also the quickest way for a
setup script to assert that a container was configured the way the game needs.
Gotchas
null is a default, not an unknown - and the default is not the same for every kind. It means nothing was
declared, and resolveContainerConfig (packages/shared/src/tableContainers.ts) then answers "random" for a
bag and "stack" for a deck. So mode ?? "stack" is right on a deck and wrong on a bag; branch on the
entity's kind, or treat null on a bag as blind.
A value here is now the value the draw uses. The field used to be replicated but inert - the runtime read a
separate metadata.containerMode key - so this property read back as whatever an author declared while draws
ignored it. Both now resolve through the same helper, and a legacy metadata key is migrated onto the real field
on load.
The mode is not a promise about cards[0]. For "queue" the next draw is the last element, and for
"random" there is no answer at all - read the mode before you assume the array's first entry is next.
A script cannot change it. There is no setter and no action for it; the mode is authored on the entity in the editor, and a script only reads it.
See also
ContainerObject.cards- the entries the mode chooses between.ObjectHandle.draw- the call the mode governs.- Object kinds - which kinds are containers, and how each behaves.
containerobject.capacityLimit#
readonly capacityLimit: number | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
How many pieces this container will accept before it starts refusing them, or null
for no limit.
Authored on the entity in the editor; a script reads it and cannot set it. It bounds
putObject and the physical drop path alike — a full container rejects the piece and
sends it back where the hold started rather than swallowing it.
How many pieces this container will accept before it starts refusing them, or null for no limit. It bounds
putObject and the physical drop path alike: a full
container refuses the piece and sends it back to where the hold started rather than swallowing it.
Returns
number | null. A number is always a positive integer — the sandbox floors it and discards anything below 1
(apps/web/src/scripting/sandbox/tableScriptSandbox.html), so 0 and a negative are read as "no limit" rather
than "accepts nothing". null means the entity declares no limit.
How, why and when to use it
Read it when your script is about to fill a container and you want to say something useful before it starts
bouncing pieces — "the bowl only holds 60" is a better message than four silent refusals. It is also the honest
way to size a setup loop: stock up to capacityLimit rather than up to a number you hard-coded next to it.
Gotchas
It is authored, not scripted. A script reads it and cannot set it; there is no setter and no action for it. It is edited on the entity in the editor, alongside the container's form and search policy.
It is a limit on the count, not on the number of runs. A capacity of 60 admits 60 pieces however many sorts they are. The separate schema ceilings — 256 runs per bag, 10,000 copies per run — are structural and are not this field.
null is not a promise of infinity. The shared schema still caps a bag's stored state, so a container with
no authored limit will eventually refuse a drop for a reason this field does not describe. Read the result of
putObject either way.
It says nothing about a "holder". A finite holder is purely physical — pieces land in it under physics and
stay entities — so nothing consults this field for one. It bites on a "bag".
See also
BagObject.putObject— the call it refuses.BagObject.form— which containers it applies to.ContainerItem.count— what it is counting.
containerobject.onDepleted#
readonly onDepleted: ScriptDelegate<[EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when the last item is drawn out and the empty container leaves the table.
Fires when the last item is drawn out of this container and the empty container leaves the table. It is
onDestroyed narrowed to one of its four reasons -
the one a container can be written against - and fires immediately after it, on the same handle, with the same
EventContext.
Applies to: deck, bag and card-holder. It is declared on the container types only, because "ran out" is
not something that happens to a die.
Parameters
The handler receives one argument, transcribed from ScriptDelegate<[EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | EventContext |
Who drew the last item. context.reason is "depleted" - that is what selected this delegate. |
There is no handle argument, for the same reason onDestroyed has none: by the time it fires the entity is gone.
The handle you subscribed on is still in scope and still readable, but it now describes something that is no
longer on the table.
How, why and when to use it
"The draw pile ran out" is a rule in most card games - reshuffle the discards, end the round, deal a new hand -
and it is the one deck ending you can plan for. The alternative is onDestroyed with a context.reason check,
which is exactly what this is; use onDestroyed when you want all four endings, and this when you want the one
that means the game should do something.
Example
// content/scripting-api/examples/containerobject.onDepleted.ts
// Object script on a deck: the draw pile has run out. `onDepleted` fires only
// for the one removal a container can plan for - the last card being drawn.
refObject.onDepleted.add((context) => {
world.broadcast("The draw pile is empty.");
world.log(`Last card taken by ${context.actor}; deck ${refObject.id} is gone.`);
void recordRound();
});
async function recordRound(): Promise<void> {
const played = await world.getSavedData("rounds");
const next = Number(played ?? "0") + 1;
await world.setSavedData(String(next), "rounds");
world.broadcast(`Round ${next} is over - reshuffle the discards.`);
}
Drawing the last card broadcasts The draw pile is empty., logs who took it, and bumps a round counter that
survives a save.
Gotchas
The container is gone by the time you are told. Do not call refObject.shuffle() or draw() from here - the
entity no longer exists and the intents are refused. Spawn a replacement, or act on a different entity.
A deck that empties by conversion does not raise this. Drawing the second-to-last card can turn a one-card
deck into a plain card entity, which is reason: "converted" on onDestroyed and not a depletion. The card that
replaces the deck raises its own onCreated.
Deleting a container by hand does not raise it either. That is reason: "deleted". This delegate is
specifically "something drew the last item".
A handle stops receiving events after this. The sandbox forgets the id once the entity is destroyed, so handlers added afterwards never fire.
See also
ObjectHandle.onDestroyed- all four endings, this one included.ObjectDestroyedReason- what the other three mean.ContainerObject.cards- watching a container get low before it runs out.world.spawnObject- putting a fresh deck out in the handler.
DeckObject#
Surface A — table script · interface · 5 members
A deck of cards. Draws, shuffles, splits and combines; empties into nothing.
draw() (inherited) is fire-and-forget and tells you nothing about what came off.
drawCard() and dealTo() are the script-friendly pair: both resolve with a handle
to the card that actually left the deck, so a script can position, flip or record it.
A deck of cards. refObject is declared as this in a script created from a deck's Script section, and
world.getAllObjects({ kind: "deck" }) resolves an array of them.
It is a ContainerObject and adds nothing of its own, so everything it can do is either on
that interface - cards, containerMode,
onDepleted - or on ObjectHandle.
A deck is the only kind that answers shuffle, and the only container whose entries have a meaningful order.
See also#
ContainerObject- the three members it inherits.ObjectHandle.shuffle- the deck-only action.ObjectHandle.onCardDrawn- fired on the deck, not on the drawn card.
Members#
| Signature | Description | Returns |
|---|---|---|
drawCard() |
Take the top card off this deck and resolve with a handle to it. | Promise<CardObject | null> |
dealTo(options: DealToOptions) |
Draw the top card and place it in a seat's zone, in one host-authoritative step. | Promise<CardObject | null> |
onSearched |
Fires when a player opens a private SEARCH of this deck — browsing its whole contents, revealed by the host to that one player and to nobody else. | ScriptDelegate<[DeckObject, EventContext]> |
onSearchPulled |
Fires when a searching player takes a card out. Does NOT name the card — see onSearched. |
ScriptDelegate<[DeckObject, EventContext]> |
onSearchClosed |
Fires when the search session ends explicitly — the player closed the overlay. | ScriptDelegate<[DeckObject, EventContext]> |
deckobject.drawCard#
drawCard(): Promise<CardObject | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Take the top card off this deck and resolve with a handle to it.
Unlike draw(), this waits for the host to apply the draw and hands back the real
card — which is what lets a script place it. Resolves null when the deck is empty.
Take the top card off this deck and resolve with a handle to it.
How, why and when to use it
Use this instead of draw() whenever
you need to do something with the card. draw() is fire-and-forget and tells you nothing
about what came off, so a script cannot position, flip or record it. drawCard() waits for
the host to apply the draw and hands back the real card.
Reach for it when you need to place a card somewhere dealTo cannot express — a discard
pile, a market row, the middle of the table.
Gotchas
Resolves null when the deck is empty. It is host-authoritative and asynchronous: await
it, and do not assume the deck's cards array has already updated on the copy you were
holding.
Drawing the last card removes the deck (a one-card deck becomes a plain card), so a handle to the deck may be dead afterwards.
Example
// content/scripting-api/examples/deckobject.drawCard.ts
// Object script on a deck. `refObject` is a DeckObject.
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!turn") {
void turnOne();
}
});
async function turnOne(): Promise<void> {
const card = await refObject.drawCard();
if (!card) {
world.log("The deck is empty.");
return;
}
card.setPosition([0, 0.2, 0]);
world.log(`Turned up ${card.name ?? "a card"}`);
}
See also
dealTo()— when the destination is a seat's zone.
deckobject.dealTo#
dealTo(options: DealToOptions): Promise<CardObject | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Draw the top card and place it in a seat's zone, in one host-authoritative step.
Prefer this over drawCard() + setPosition() for dealing: the placement happens
on the host inside one operation, so a card is never briefly visible in the wrong
place, and a face-down deal is never briefly face up. Resolves null when the deck
is empty or the seat has no such zone.
Draw the top card and place it in a seat's zone, in one host-authoritative step.
How, why and when to use it
This is the primitive for dealing. Prefer it over
drawCard() plus setPosition(): the draw, the placement and the
facing all happen inside one host operation, so a dealt card is never briefly visible in the
wrong place and a face-down deal is never briefly face up on somebody's screen.
Pass zoneName to target a specific zone ("CardZone1"), or leave it out for the seat's
primary hand zone. Pass stack: true to add the card to what is
already in the zone rather than laying it beside it.
Gotchas
Resolves null when the deck is empty or the seat has no zone by that name — the two
are not distinguished, so check the result rather than assuming a card was dealt.
With stack: true onto a zone that already holds something, the result is the pile the
card was folded into, not the loose card — see stack.
faceDown defaults to the card's own facing in the deck. Be explicit when it matters.
Dealing to a seat nobody occupies is not an error; cards will simply pile up at an empty
place. Filter against world.getPlayers()
first.
Example
// content/scripting-api/examples/deckobject.dealTo.ts
// Object script on a deck. `refObject` is a DeckObject.
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!deal") {
void dealRound();
}
});
async function dealRound(): Promise<void> {
// One face-down card into every seated player's first card zone.
for (const player of world.getPlayers()) {
if (player.seat) {
await refObject.dealTo({ seat: player.seat, zoneName: "CardZone1", faceDown: true });
}
}
}
See also
world.getSeatZones()— finding the zones.drawCard()— when the destination is not a seat zone.
deckobject.onSearched#
readonly onSearched: ScriptDelegate<[DeckObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a player opens a private SEARCH of this deck — browsing its whole contents, revealed by the host to that one player and to nobody else.
Observe-only, in three deliberate ways:
-
A script cannot request a search.
searchis absent fromObjectAction. A script must not be able to force a player to look through a deck, nor to manufacture a private reveal for itself. -
The EVENT names no card.
onSearchPulledsays a card left the pile and who took it — never which card. Nothing is added to the payload that a player watching the public chat line could not already see, so subscribing to these events grants a script no reach it did not have.This is a statement about the EVENT, not a promise that a script cannot work the card out: a table script runs host-side, and
cardson this handle is the pile as the host holds it, so comparing it before and after a pull tells you what left. That access is pre-existing and unchanged — the point is that these events do not WIDEN it. -
The public facts are all here. The handle, its
cardsas your script is entitled to see them, andcontext.actor— the same information the public chat line carries, which is the intended ceiling.
Fires when a player opens a private search of this deck — a host-authoritative browse of the pile's whole
contents, revealed to that one player and to nobody else. It is
onAction narrowed to the search action, and fires
immediately after it on the same handle.
Applies to: deck and bag. It is declared on DeckObject and BagObject, the two
kinds a search can be opened on.
Two rules govern this delegate, and both are deliberate.
- A script can observe a search; it can never request one.
searchis absent fromObjectAction, there is nohandle.search(), and the script host's allowlist refuses the name. A script must not be able to make a player look through a deck, and must not be able to manufacture a private reveal for itself. - No card identity travels with the event. This delegate hands you the pile's handle and an
EventContext— the same public facts the identity-free chat line carries — andonSearchPullednever names the card that left. Card identities reach the searching player alone, through their own redacted snapshot, and never the shared event log. An event payload that named the card would be a reveal channel sitting next to the redaction layer instead of behind it.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[DeckObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | DeckObject |
The pile being searched — the same handle you subscribed on. |
| 2 | EventContext |
context.actor is the peer who opened the search. containerId and reason are undefined on this event. |
How, why and when to use it
Searching is the moment a tutor happens: a player goes looking for a specific card rather than taking whatever is on the pile. That is a rule-bearing moment in most card games — a search may cost something, may be limited to once a turn, may end a phase — and this is where a script hears about it without polling.
Use it to announce, to time, to count, or to start a step: broadcast that the table is waiting, stamp who looked
and when into saved data, or set a flag your other handlers read. Use
onSearchPulled for what came out and onSearchClosed
for the end of it.
What you cannot use it for is enforcement of what was taken. The script is told a card left; the players and the game log are told the same. If a rule depends on the card's identity, it has to be a rule a player can be seen to follow — that is the price of the pile staying hidden.
Example
// content/scripting-api/examples/deckobject.onSearched.ts
// Object script on a deck. `refObject` is a DeckObject, so the three search
// delegates are offered without a cast. A script may WATCH a search; it can
// never ask for one, and it is never told which card was seen.
refObject.onSearched.add((deck, context) => {
world.log(`${context.actor} opened a search of ${deck.cards.length} cards.`);
world.broadcast("Someone is searching the library - please wait.");
void countSearch(context.actor);
});
refObject.onSearchClosed.add(() => {
world.broadcast("The library is closed again.");
});
async function countSearch(actor: string): Promise<void> {
const seen = await world.getSavedData(`searches:${actor}`);
const next = Number(seen ?? "0") + 1;
await world.setSavedData(String(next), `searches:${actor}`);
world.log(`${actor} has searched this pile ${next} time(s).`);
}
Opening the search announces it to the table and keeps a per-player count that survives a save; closing it announces the end.
Gotchas
A refused search raises nothing at all. When the authored audience does not include the actor, the host drops the intent silently — no event, no log line, no snapshot. That is on purpose: announcing that someone tried to look leaks the intent the feature exists to keep private. So the absence of this event is not evidence that nobody tried.
Who may search is authored, not scripted. The audience lives on the pile
(metadata.search) and on the surrounding Area zone, and the host re-checks it on every intent. A script cannot
widen it, narrow it, or read it as a typed field — see
Deck and Bag Search.
onAction fires first, on the same handle, with "search" as its action, so a script subscribed to both
hears the same moment twice, in that order.
A session can end without onSearchClosed. The reveal expires on its own after five minutes if the searching
peer never closes it — that expiry revokes the grant but raises no action, so a state machine that waits for the
close event should have its own timeout.
See also
DeckObject.onSearchPulled— a card left the pile, and who took it.DeckObject.onSearchClosed— the session ended.BagObject.onSearched— the same event on a bag, which defaults to no search at all.ObjectHandle.onAction— every action, this one included.ObservedObjectAction— the names an action handler can receive but not request.- Deck and Bag Search — authoring who may look, and what happens afterwards.
deckobject.onSearchPulled#
readonly onSearchPulled: ScriptDelegate<[DeckObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a searching player takes a card out. Does NOT name the card — see onSearched.
Fires when a player who is searching this deck takes a card out of it. It is
onAction narrowed to the search-pull action.
It does not name the card. The payload is the pile and the actor — nothing else — exactly like the public chat line, which says took a card from Library and never which one. The searcher holds the identity because the host revealed the pile to them alone; the event log, the other players and this delegate do not. A script that could read the pulled card here would be a bypass of the whole hidden-information layer, reachable by any scene script, so the payload deliberately stops where the public facts stop.
Applies to: deck and bag.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[DeckObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | DeckObject |
The pile the card came out of, already one card shorter — read cards for what is left, never for what left. |
| 2 | EventContext |
context.actor is the searching peer. containerId is undefined here; it is set on cardDrawn, not on an action event. |
How, why and when to use it
This is the "a card has been tutored out" moment. Count it, charge for it, announce it, or end the step: a pull
is the thing a search costs, so a limit of one card per search is a rule you can enforce with what this event
gives you. Pair it with onSearched to reset your counter when a new session opens.
Reach for globalEvents.onCardDrawn instead when what you
need is the card that appeared rather than the fact that a pull happened — the pull spawns the card through the
ordinary draw path, so that event fires too, with a handle to it.
Example
// content/scripting-api/examples/deckobject.onSearchPulled.ts
// Object script on a deck. The pull event says a card LEFT the pile and who
// took it - never which card. Counting pulls is the kind of rule a script can
// enforce with what it is given.
let pulls = 0;
refObject.onSearched.add(() => {
pulls = 0;
});
refObject.onSearchPulled.add((deck, context) => {
pulls += 1;
world.broadcast(`${context.actor} has taken ${pulls} card(s); ${deck.cards.length} left.`);
if (pulls > 1) {
world.broadcast("That is one card more than this tutor allows - put one back.");
}
});
The counter resets when a search opens, and a second pull in the same session is called out to the whole table.
Gotchas
The session stays open. A pull does not end the search — the player is still browsing, and the reveal
survives minus the card that left. Do not treat this as the end of anything;
onSearchClosed is that.
Pulling the last item destroys the pile. The removal runs through the normal draw path, so an emptied deck
converts or is removed exactly as a drawn-out deck is, and the handle you subscribed on stops receiving events.
Watch onDepleted for that ending rather than inferring it from a pull.
A pull cannot be scripted, and cannot be refused by a script. There is no search-pull in
ObjectAction, and this delegate is a notification, not a veto — the
host has already validated the pull (the pile holds the card, and the actor's live reveal covers it) and applied
it before you are told.
onAction fires first, with "search-pull" as its action.
See also
DeckObject.onSearched— the session opening, and the two security rules in full.DeckObject.onSearchClosed— the session ending.globalEvents.onCardDrawn— the card itself, as a handle.ContainerObject.cards— what is left in the pile.- Deck and Bag Search — who may pull, and from what.
deckobject.onSearchClosed#
readonly onSearchClosed: ScriptDelegate<[DeckObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when the search session ends explicitly — the player closed the overlay.
NOT raised when a session simply times out: an abandoned grant is dropped silently the next time the host builds that peer's snapshot, with no event. So treat this as "the player finished", never as "no search is open"; a script that must know the latter should pair it with its own timeout.
Fires when a search session on this deck is closed — the private reveal is revoked and the pile goes back to
being hidden. It is onAction narrowed to the
search-close action, and it is the only one of the three search events that reports an ending.
By the time it fires, the authored After search policy has already been applied: a pile set to shuffle has
been shuffled by the host, and one set to keep its order has been left exactly as it is. Read
cards here and you are reading the order play continues from.
Applies to: deck and bag.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[DeckObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | DeckObject |
The pile, after the order policy has been applied. |
| 2 | EventContext |
context.actor is the peer whose session ended. |
How, why and when to use it
Use it to close whatever onSearched opened: lift the "please wait" notice, resume the
turn timer, advance the phase, clear the per-session counter. It is also the honest place to record that a search
happened at all, because by now the table has seen every line the search will ever produce.
Example
// content/scripting-api/examples/deckobject.onSearchClosed.ts
// Object script on a deck: the search session has ended. The authored
// `After search` policy has already been applied by the host, so `cards` here
// is the order play continues from.
refObject.onSearchClosed.add((deck, context) => {
world.broadcast(`${context.actor} finished searching (${deck.cards.length} cards remain).`);
void stamp(context.actor);
});
refObject.onShuffled.add((deck) => {
world.log(`${deck.name ?? "deck"} was shuffled - the search order is no longer known.`);
});
async function stamp(actor: string): Promise<void> {
await world.setSavedData(actor, "lastSearcher");
const who = await world.getSavedData("lastSearcher");
world.log(`Last search of this pile: ${who ?? "nobody"}.`);
}
Closing the search announces the remaining count and records who looked last; the shuffle that a
shuffle-on-close pile performs arrives as an ordinary shuffle event just before it.
Gotchas
An expired session does not raise this. A reveal that simply times out — the searching peer disconnected, or walked away — is dropped without an action event. Anything that must run when a search ends needs its own fallback rather than waiting here forever.
A shuffle-on-close pile raises the shuffle separately. The host performs an ordinary host-authoritative
shuffle, so onShuffled fires with its own public log
line before this event. A handler that reacts to shuffles will hear that one too.
Closing is not gated the way opening is. A player whose permission was revoked mid-session can still close their own session — revoking your own reveal only ever shows you less — so do not read this event as proof the actor is still allowed to search.
Only an open session closes. A search-close with no live session is dropped and raises nothing, so this
event never fires twice for the same session.
See also
DeckObject.onSearched— the session opening, and the two security rules in full.DeckObject.onSearchPulled— a card leaving mid-session.ObjectHandle.onShuffled— the shuffle a closing search can cause.- Deck and Bag Search — where After search is authored.
BagObject#
Surface A — table script · interface · 10 members
A bag. Draws at random rather than in order, and has no order to shuffle.
It can be searched like a deck when an author opts in, but does not default to it: drawing
blind is what a bag is FOR, so search on a bag is off unless the author says otherwise.
A container. refObject is declared as this in a script created from a bag's Script section, and
world.getAllObjects({ kind: "bag" }) resolves an array of them.
It is a ContainerObject — so it has cards, a
containerMode, a capacityLimit and
onDepleted — and it adds the whole of the piece-storing surface on top:
form, infinite, items,
takeObject, putObject and the two delegates
onObjectEntered / onObjectLeft.
bag is the wire kind for every sort of container the platform has. Two fields say which sort a given one is:
infinite: false |
infinite: true |
|
|---|---|---|
form: "holder" |
An open bowl or tray. Pieces fall in and stay real entities. No stored contents. | A bowl that looks full and never empties; a draw spawns a copy of one source piece. |
form: "bag" |
Closed. A piece dropped in stops being an entity and becomes a run in items. |
Closed and endless: one source piece, nothing visible, nothing consumed. |
How, why and when to use it#
Read form and infinite before anything else, because they decide
where the contents even live. A bag's pieces are in items; a holder's are ordinary entities that
world.getAllObjects finds; an infinite container has none at
all.
What makes a bag a bag rather than a deck is its
containerMode: it draws at random by default, which means it has no order
worth reading and nothing to shuffle — a shuffle addressed to one is refused before anything runs.
Gotchas#
A bag holds cards OR pieces, never both. That is the one-lane rule, enforced host-side at every drop. A card
bag reports through cards with an empty items; a piece bag is the other way round.
A container is normally locked scenery, and lock does not mean disabled. A locked container keeps its context menu and still draws, deals, is searched and is tipped out; what lock refuses is moving, flipping and deleting it.
BagObject is not a kind of its own for spawning. You spawn kind: "bag" and pass
container to say which sort it is.
See also#
BagObject.form— the field everything else depends on.BagObject.items— what a piece bag holds.BagObject.takeObject/putObject— out and in.ContainerObject— the members it inherits.ObjectHandle.draw— the fire-and-forget action.SpawnObjectOptions.container— creating one.
Members#
| Signature | Description | Returns |
|---|---|---|
form |
Which sort of container this is. | "holder" | "bag" |
infinite |
True for a container that never runs out: every draw spawns another copy of one authored source piece, and nothing is consumed. An infinite container stores no items. |
boolean |
items |
What this bag holds, grouped by sort of piece. Empty for a holder, for an infinite container, and for a bag that stores cards instead (read cards for those). |
readonly ContainerItem[] |
takeObject(options?: { key?: string; position?: Vec3 }) |
Take one piece out and resolve with a handle to it — the piece version of DeckObject.drawCard(). |
Promise<ObjectHandle | null> |
putObject(object: ObjectHandle) |
Put an entity INTO this container, exactly as a player dropping it in would. | Promise<boolean> |
onObjectEntered |
Fires when a piece goes INTO this container — a player dropped it in, a script called putObject, or a tipped-out piece came back. |
ScriptDelegate<[ObjectHandle, EventContext]> |
onObjectLeft |
Fires when a piece comes OUT of this container — a draw, a tip-out, or a player lifting one out of a bowl. The handle argument is the piece now on the table, which for a bag is a newly created entity with a new id. | ScriptDelegate<[ObjectHandle, EventContext]> |
onSearched |
Fires when a player opens a private search of this bag. Observe-only — see DeckObject.onSearched. |
ScriptDelegate<[BagObject, EventContext]> |
onSearchPulled |
Fires when a searching player takes an item out. Does NOT name the item. | ScriptDelegate<[BagObject, EventContext]> |
onSearchClosed |
Fires when the search session ends explicitly. Not raised on a timeout — see DeckObject. |
ScriptDelegate<[BagObject, EventContext]> |
bagobject.form#
readonly form: "holder" | "bag";
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Which sort of container this is.
"bag"— CLOSED and virtual. A piece dropped on it stops being a body on the table and becomes an entry initems."holder"— OPEN and physical: a bowl, a tray. Its pieces stay real entities resting inside it, soitemsis always empty andworld.getAllObjectsstill finds them.
An entity authored before containers existed reads "bag".
Which sort of container this is — the single field that decides whether the things inside it are still entities on the table.
form |
What it is | Where its contents are |
|---|---|---|
"bag" |
Closed and virtual. A piece dropped on it stops being a body on the table. | items — run-length encoded, no ids. |
"holder" |
Open and physical: a bowl, a tray. Pieces fall in and rest there. | On the table. items is always empty and world.getAllObjects finds them. |
Returns
"holder" | "bag", never null. An entity authored before containers existed reads "bag", matching
resolveContainerConfig's own default (packages/shared/src/tableContainers.ts).
How, why and when to use it
Branch on it before you go looking for contents, because the two forms answer the question in completely
different places. "How many stones are in the bowl?" is a
world.getAllObjects call on a holder and an items sum on a
bag; a helper that only knows one of those is silently wrong half the time.
It is also the quickest way for a setup script to assert that a container was configured the way the game needs —
a game whose rules depend on nobody seeing what is in the pot needs a "bag", and finding a "holder" there is
worth a loud log line during setup rather than a mystery mid-game.
Gotchas
A holder's pieces are ordinary entities, with everything that follows. They are pickable, they are counted by
world.getAllObjects, they each cost a rigidbody, and they are visible to every peer. A bowl is scenery with
physics, not storage.
A holder still refuses drops when it is infinite. A finite holder makes no decision at all — the piece just lands in it. An infinite holder still adopts, destroys and refuses, which is what stops an infinite bowl quietly accumulating every stone put back into it.
A script cannot change it. There is no setter. The form is authored on the entity in the editor, or set once
at spawn through
SpawnObjectOptions.container.
See also
BagObject.items— empty on a holder, by definition.BagObject.infinite— the other axis of what a container is.SpawnObjectOptions.container— setting it at spawn.- Object kinds — where
bagsits among the kinds.
bagobject.infinite#
readonly infinite: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
True for a container that never runs out: every draw spawns another copy of one authored
source piece, and nothing is consumed. An infinite container stores no items.
true for a container that never runs out. Every draw spawns another copy of one authored source piece and
nothing is consumed, so an infinite container stores no items and its count never falls.
Returns
boolean, never null. An entity authored before containers existed falls back to its legacy
metadata.infinite flag, so an old bag-infinite reads true here rather than silently becoming finite.
How, why and when to use it
It is the branch a "how much is left?" rule needs. Counting the runs in items is meaningless on an infinite
container — the answer is always zero and the supply is always endless — so check this first and skip the count
entirely. The same goes for depletion: there is no last piece and
onDepleted will not fire, so a rule that waits for a bag to empty will wait
forever on one of these.
Use it in setup to confirm the table was built the way the game expects: a supply bowl that is not infinite will run dry mid-game, and finding that out during setup is much cheaper than finding out in round four.
Gotchas
An infinite container can still refuse a drop. It takes only its own source piece; anything else glides back to where the hold started with a message naming what it wants. Colour and material variants count as different pieces, so a blue cube is refused by a bowl of red ones rather than silently recoloured.
A matching piece dropped in is destroyed, not stored. That is the point — the supply is the source
definition, not a tally — so items stays empty however many pieces go back in.
With no source set it draws nothing. An infinite container that has never been given a source adopts the
first piece dropped into it; until then
takeObject resolves null, indistinguishably from an empty bag.
Draws are governed. Because a single held pointer or three lines of script could otherwise mint objects in a
loop, every infinite draw passes through the host's spawn governor
(apps/web/src/playcanvas/containers/spawnGovernor.ts). See
takeObject for what that means for a loop.
See also
BagObject.takeObject— drawing from one, and the throttle.BagObject.form— the other axis of what a container is.SpawnObjectOptions.container— spawning one with a source.ContainerObject.onDepleted— the event an infinite container never raises.
bagobject.items#
readonly items: readonly ContainerItem[];
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
What this bag holds, grouped by sort of piece. Empty for a holder, for an infinite
container, and for a bag that stores cards instead (read cards for those).
⚠ The order of this array is NOT the draw order. A bag draws at random by default, and
even in "stack"/"queue" mode a run is a group rather than a position. Use it to
answer "what is in here, and how many of each"; use takeObject to take one.
⚠ This reads the HOST's contents, including a bag whose author marked its contents
secret — table scripts run host-side, where that state lives, exactly as cards already
reports a deck's real order. It is not a way to show players what they are not entitled
to see: what a script prints with broadcast or log reaches everyone.
What this bag holds, grouped by sort of piece. Each entry is a
ContainerItem — a run of identical copies with a key, a
name, a kind and a count — so twenty identical black stones are one entry with count: 20, not twenty
entries.
Returns
readonly ContainerItem[], never null. It is empty in four cases, and they are worth knowing apart because
three of them are not "this container is empty":
| Empty because | How to tell |
|---|---|
| The bag really is empty | form is "bag", infinite is false |
It is a "holder" — its pieces are loose entities on the table |
form === "holder" |
| It is infinite — the supply is a source definition, not a tally | infinite === true |
| It holds cards instead | cards is non-empty |
How, why and when to use it
This is the read behind every "what can I draw?" menu: iterate the runs, show
name and
count, and pass the chosen
key to
takeObject. It is also the cheap way to total a bag —
items.reduce((sum, item) => sum + item.count, 0) — which is the true count, where the container's
stackCount is the public one and is clamped.
Gotchas
⚠ The order is NOT the draw order. A bag draws at random by default, and even in "stack"/"queue" mode a
run is a group rather than a position in a pile. items[0] is not "next". Use it to answer what is in here;
use takeObject to take one.
It is a snapshot, not a live view. The array you are holding does not update when a draw or a drop changes
the bag. Re-read refObject.items after any await.
At most 256 runs travel. The sandbox copies that many onto a handle, matching the shared schema's ceiling on stored runs — a bound on how many sorts of piece a bag can hold, not on how many pieces.
This reads the HOST's contents, including a bag marked secret. Table scripts run on the host, where the real
contents live, so items reports the runs of a secretContents bag exactly as
cards already reports a deck's real order. That is not a way to show players what they
are not entitled to see: anything your script puts in
world.broadcast or
world.log reaches the whole table. A mod is a different surface and is
redacted — see api.getContainerContents.
A holder's contents are not hidden, they are elsewhere. An empty items on a "holder" does not mean the
bowl is empty; it means its pieces are ordinary entities, and
world.getAllObjects is where you count them.
See also
ContainerItem— the shape of one entry.BagObject.takeObject— turning a run into an entity.BagObject.form— why a holder's array is empty.ContainerObject.cards— the other lane.
bagobject.takeObject#
takeObject(options?: { key?: string; position?: Vec3 }): Promise<ObjectHandle | null>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Take one piece out and resolve with a handle to it — the piece version of
DeckObject.drawCard().
Pass key (from an items entry) to take a PARTICULAR sort of piece; omit it to take
one the way a player would, which for a bag means at random. Pass position to place
the piece somewhere specific; omit it and it appears just above the container.
Resolves null when there is nothing to take — an empty bag, an infinite container with
no source piece set yet, a key that names no run — and when the table is drawing faster
than it is allowed to. Draws are rate-limited per container and per actor, so a loop that
asks for a thousand pieces gets some of them and then null; treat a null as "not
now" and stop, rather than retrying in a tight loop.
Take one piece out of this container and resolve with a handle to it — the piece version of
DeckObject.drawCard(). The piece that comes out is a real entity on the table with an id
of its own; the run it came from loses one copy, unless the container is infinite, in which case nothing is
consumed at all.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
options.key |
string |
no | A ContainerItem.key from items. Takes a particular sort of piece. Omit it to take one the way a player would, which for a bag means at random. |
options.position |
Vec3 |
no | Where to put the piece. Omit it and the piece appears just above the container. |
Returns
Promise<ObjectHandle | null>. A handle is the piece, already on the table.
How, why and when to use it
Use it whenever the script needs to do something with what came out — place it in a zone, tag it, record it,
roll it. The plain draw() action is fire-and-forget and
tells you nothing about the piece, which makes it useless for anything past the animation.
key is what turns a blind bag into a chooser: read items, present the runs, and take the
one the player picked. Without a key a bag is honest about being a bag.
Example
// content/scripting-api/examples/bagobject.takeObject.ts
// Object script on a bag. `refObject` is a BagObject.
//
// Draws are governed on the host, so a loop asking for many pieces gets some of
// them and then `null`. Treat `null` as "not now" and stop asking - retrying in
// a tight loop only keeps the bucket drained.
globalEvents.onChatMessage.add((message) => {
const text = message.text.trim();
if (text === "!draw5") {
void drawFive();
} else if (text === "!black") {
void takeNamed("Black Stone");
}
});
async function drawFive(): Promise<void> {
const drawn: string[] = [];
for (let i = 0; i < 5; i += 1) {
const piece = await refObject.takeObject();
if (!piece) {
// An empty bag, an infinite container with no source piece set, an
// unknown `key`, and a throttled draw all answer `null` - deliberately
// indistinguishable, so there is exactly one sensible response to it.
break;
}
drawn.push(piece.name ?? piece.id);
}
world.broadcast(`Drew ${drawn.length} of 5: ${drawn.join(", ") || "nothing"}`);
}
async function takeNamed(name: string): Promise<void> {
// `items` answers "what is in here"; `key` is how you ask for one sort of it.
const run = refObject.items.find((item) => item.name === name);
if (!run) {
world.log(`No ${name} in this bag.`);
return;
}
const piece = await refObject.takeObject({ key: run.key, position: [0, 1, 0] });
world.log(piece ? `Took a ${name}.` : `Could not take a ${name} right now.`);
}
Typing !draw5 pulls up to five pieces out and names them; !black takes one Black Stone specifically, or logs
that the bag has none.
Gotchas
⚠ It is rate-limited by the host's spawn governor, so a loop plateaus at null. Draws are throttled per
container and per actor (apps/web/src/playcanvas/containers/spawnGovernor.ts), because a script loop or a
stuck auto-repeat could otherwise mint objects until the snapshot stops validating and replication wedges. A loop
asking for a thousand pieces gets some of them and then null. Treat null as "not now" and stop — retrying
hard only keeps the bucket empty, and a refusal is side-effect-free precisely so a spammer cannot hold a
legitimate draw out indefinitely.
null means "nothing came out", for every reason, and the reasons are deliberately indistinguishable. An
empty bag, an infinite container with no source piece set yet, a key that names no run, and a throttled draw
all answer the same way. There is no error to catch and no code to branch on — which is the point: the one
correct response to all four is to stop asking.
It is a host-authoritative mutation. await it, and do not assume the items array you
read a line earlier still describes the bag.
A key is never a substitute for chance. Taking by key hands out exactly what was asked for. If the game's
randomness matters, draw without one — the piece is chosen by the host's private RNG at draw time, which is what
makes the next draw unguessable on a peer even when the contents are public.
On a holder there is nothing stored to take. A "holder" keeps its pieces as ordinary entities, so pick one
up with world.getAllObjects and move it instead.
See also
BagObject.items— the runs, and where akeycomes from.BagObject.putObject— the other direction.BagObject.onObjectLeft— the event this raises.DeckObject.drawCard— the card-lane equivalent.- Host authority — why this is asynchronous.
bagobject.putObject#
putObject(object: ObjectHandle): Promise<boolean>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | async |
| Capability | none |
| Availability | both |
Put an entity INTO this container, exactly as a player dropping it in would.
Resolves true when the container took it. false means it was refused and the piece
is still on the table — the container is full, it is infinite, it holds cards rather than
pieces, or the piece is not the sort this container accepts (an infinite container and a
source-typed bowl only take their own piece).
For a bag the entity ceases to exist on the table and becomes an items entry; for a
holder it is simply moved inside. Either way this is a host-authoritative mutation, so
read the result rather than assuming it worked.
Put an entity into this container, exactly as a player dropping it in would. For a "bag" the entity ceases
to exist on the table and becomes an items entry; for a "holder" it is simply moved
inside and stays an entity.
Parameters
| Name | Type | Required | Notes |
|---|---|---|---|
object |
ObjectHandle |
yes | The piece to put in. A bare id string is accepted too, so a plausible mistake behaves sensibly rather than failing silently. |
Returns
Promise<boolean>. true means the container took it. false means it was refused and the piece is still on
the table, untouched and exactly where it was.
How, why and when to use it
This is how a script stocks a bag. There is deliberately no way to spawn one with pieces already inside it —
SpawnObjectOptions.container has no contents — so
setup spawns the pieces and puts them in, one call each. That is not a detour: it is what makes a script's
stocking take the same checks a player's drop takes.
It is also the "put it back" half of a rule — returning a spent tile to the supply, clearing the board into the bowl at end of round.
Example
// content/scripting-api/examples/bagobject.putObject.ts
// Object script on a bag. `refObject` is a BagObject.
//
// `putObject` goes through the same host-side acceptance check a player's drop
// takes - capacity, the one-lane rule, and piece-type matching on an infinite
// container - so read the result rather than assuming it worked.
globalEvents.onChatMessage.add((message) => {
if (message.text.trim() === "!tidy") {
void sweepStones();
}
});
async function sweepStones(): Promise<void> {
const loose = await world.getAllObjects({ tag: "stone" });
let stored = 0;
let refused = 0;
for (const piece of loose) {
if (await refObject.putObject(piece)) {
stored += 1;
} else {
// Refused: the bag is full, it holds cards rather than pieces, it is
// infinite, or this is not the sort of piece it accepts. Either way the
// entity is still on the table and nothing has changed.
refused += 1;
}
}
world.broadcast(`Stored ${stored} stone(s); ${refused} would not go in.`);
}
Typing !tidy sweeps every entity tagged stone into the bag and reports how many would not go.
Gotchas
⚠ It takes the same host-side acceptance check a player's drop takes, so a refusal is normal, not exceptional.
The decision is containerAcceptsItem (packages/shared/src/tableContainers.ts), the one function the drop path
also calls, and it refuses for four reasons a script will meet:
- Capacity — the bag already holds
capacityLimitpieces. - The one-lane rule — a bag holds cards or pieces, never both. A card offered to a bag of pieces is refused, and so is a piece offered to a bag of cards.
- Piece type — an infinite container, and a source-typed bowl, take only their own piece. Colour and material variants count as different pieces, so a blue cube is refused by a bowl of red ones rather than silently recoloured.
- Nesting — a container cannot go inside another container.
false is not an error, and there is nothing to catch. Read the return value; a script that ignores it will
believe it stocked a bag it did not.
It is host-authoritative and asynchronous. await it, and re-read items afterwards
rather than assuming what you had is current.
On an infinite container a matching piece is destroyed, not stored. That is what infinite means — the supply
is the source definition, not a tally — so items stays empty however many pieces go back in.
See also
BagObject.takeObject— the other direction.BagObject.onObjectEntered— the event this raises.ContainerObject.capacityLimit— the limit it honours.SpawnObjectOptions.container— and why it has nocontents.
bagobject.onObjectEntered#
readonly onObjectEntered: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a piece goes INTO this container — a player dropped it in, a script called
putObject, or a tipped-out piece came back.
The handle argument is the PIECE, captured as it was immediately before the container
took it; context.containerId is this container's id. For a bag that entity no longer
exists by the time your handler runs, so read what you need off the handle rather than
calling refresh() on it.
Fires when a piece goes into this container — a player dropped it in, a script called
putObject, or a tipped-out piece came back. It is the container-scoped half of
globalEvents.onObjectEnteredContainer.
⚠ The delegate belongs to the CONTAINER, not to the piece. You subscribe on the bag and the handler receives
the piece. That is the same rule onCardDrawn
follows, for the same reason: for a "bag" the piece stops existing the moment it goes in, so a subscriber could
never have been attached to it.
Parameters
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The piece, captured as it was immediately before the container took it. |
| 2 | EventContext |
context.actor is who put it in; context.containerId is this container's id, and on this event it is always a real id. |
How, why and when to use it
Subscribe here when the rule is about this container: a bowl that scores when the last stone goes in, a
discard bag that announces what was thrown away, a supply that has to stay in step with a counter your script
keeps. When the rule is about containers in general — "any tile that goes out of play" — subscribe to
globalEvents.onObjectEnteredContainer
instead and read context.containerId.
Example
// content/scripting-api/examples/bagobject.onObjectEntered.ts
// Object script on a bag. `refObject` is a BagObject.
//
// The delegate belongs to the CONTAINER, not to the piece. The handle argument
// is the piece as it was immediately before the bag took it, and for a bag that
// entity no longer exists by the time this runs - so read what you need off the
// handle here and now.
let storedSoFar = 0;
refObject.onObjectEntered.add((piece, context) => {
storedSoFar += 1;
world.log(
`${context.actor} put ${piece.name ?? piece.id} (${piece.kind}) into ${context.containerId}.`
);
const limit = refObject.capacityLimit;
if (limit !== null && storedSoFar >= limit) {
world.broadcast("That bag will not take any more.");
}
});
Each piece dropped into the bag logs who put what in, and the table is told once the authored capacity is reached.
Gotchas
⚠ For a "bag" the handle is already dead. The entity ceased to exist on the table as it went in, so read
what you need off the handle synchronously. refresh() on it will not answer, and passing its id to
world.getObjectById resolves null.
A "holder" is the opposite, and quieter. Its pieces stay real entities resting in the bowl, so the handle
remains live — but a finite holder makes no acceptance decision at all, which is why this fires far less on one
than the visible traffic suggests.
A refused drop raises nothing. A piece the container would not take never entered it, so there is no event — the piece simply glides back to where the hold started.
Cards are a different lane. A card going into a card bag is a card action, not this. A container holds one lane or the other, never both.
See also
BagObject.onObjectLeft— the other direction.globalEvents.onObjectEnteredContainer— the table-wide version.BagObject.putObject— the scripted way to raise it.EventContext.containerId— the id it carries.
bagobject.onObjectLeft#
readonly onObjectLeft: ScriptDelegate<[ObjectHandle, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a piece comes OUT of this container — a draw, a tip-out, or a player lifting one out of a bowl. The handle argument is the piece now on the table, which for a bag is a newly created entity with a new id.
Fires when a piece comes out of this container — a draw, a tip-out, or a player lifting one out of a bowl.
The handle argument is the piece now on the table, which for a "bag" is a newly created entity with a new
id.
⚠ The delegate belongs to the CONTAINER, not to the piece. You subscribe on the bag and the handler receives
the piece, exactly as
onObjectEntered does. The reason is the mirror image: a piece coming out of a bag
is born during this very fan-out, so nothing could have been subscribed to it beforehand.
Parameters
| Position | Type | Notes |
|---|---|---|
| 1 | ObjectHandle |
The piece, live and on the table. |
| 2 | EventContext |
context.actor is who took it; context.containerId is this container's id, and on this event it is always a real id. |
How, why and when to use it
This is the one hook that sees every way a piece leaves — a menu draw, a drag-away, a tip-out, a script's
takeObject — so it is the right place for a rule that must not be bypassed by the route
a player happened to use. Tag the piece here, place it, record it against the player who drew it.
It pairs naturally with takeObject: the call tells your own code what came out, and this delegate tells your
code what came out however it came out.
Example
// content/scripting-api/examples/bagobject.onObjectLeft.ts
// Object script on a bag. `refObject` is a BagObject.
//
// Fires for every way a piece comes out - a draw, a tip-out, or a player
// lifting one out of a bowl. For a bag the handle is a NEWLY created entity
// with an id of its own, so this is the place to position or tag it.
refObject.onObjectLeft.add((piece, context) => {
world.log(`${context.actor} took ${piece.name ?? piece.id} out of ${context.containerId}.`);
if (refObject.infinite) {
// An infinite container never depletes: every draw is another copy of one
// source piece, and its stored `items` stay empty.
return;
}
const remaining = refObject.items.reduce((sum, item) => sum + item.count, 0);
if (remaining === 0) {
world.broadcast("That was the last piece in the bag.");
}
});
Every piece out of the bag is logged, and emptying a finite bag announces it once.
Gotchas
The id is new, every time. A piece taken out of a "bag" is a fresh entity — the one that went in no longer
exists. An id you stored when it entered will never match the one that comes back out; match on
ContainerItem.key or a tag, not on identity.
An infinite container raises it forever and never depletes. items stays empty and
onDepleted never fires, so a rule that counts down to an empty bag will wait
indefinitely on one. Check infinite first.
A holder raises it when a player lifts a piece out. That is genuinely a piece leaving the container, even though nothing was stored — a bowl's membership is positional.
Cards are a different lane. A card leaving a deck or a card bag raises
onCardDrawn, not this. A container holds one lane
or the other, never both, so the two never fire for the same removal.
See also
BagObject.onObjectEntered— the other direction.globalEvents.onObjectLeftContainer— the table-wide version.BagObject.takeObject— the scripted way to raise it.ContainerObject.onDepleted— the container running out.
bagobject.onSearched#
readonly onSearched: ScriptDelegate<[BagObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a player opens a private search of this bag. Observe-only — see DeckObject.onSearched.
Fires when a player opens a private search of this bag — a host-authoritative browse of everything in it,
revealed to that one player and to nobody else. It is the bag's copy of
DeckObject.onSearched, with the same payload and the same two rules: a script may
observe a search, never request one, and no card or item identity travels with the event.
A bag defaults to no search at all. Drawing blind is what a bag is for — its contents come out at random —
so browsing one and picking is against its nature, and the unauthored audience for a bag is No one. This
delegate therefore only ever fires on a bag whose author deliberately turned search on, which makes it a
meaningful signal rather than routine traffic.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[BagObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | BagObject |
The bag being searched — the same handle you subscribed on. |
| 2 | EventContext |
context.actor is the peer who opened the search. |
How, why and when to use it
Use it where picking from a bag is a designed exception: a "rummage" ability, a scripted setup step that lets one player choose their starting tile, a scenario in which the referee looks inside. Announcing it matters more here than on a deck, precisely because a bag is normally random — the rest of the table should be able to tell that somebody chose rather than drew.
Example
// content/scripting-api/examples/bagobject.onSearched.ts
// Object script on a bag. Searching a bag is OFF unless an author turns it on -
// drawing blind is what a bag is for - so this delegate firing at all is worth
// announcing to the table.
refObject.onSearched.add((bag, context) => {
world.broadcast(`${context.actor} is rummaging through ${bag.cards.length} items.`);
world.log(`Search opened on bag ${bag.id} by ${context.actor}.`);
});
Opening a search on the bag announces it to everyone and records it in the script console.
Gotchas
It usually never fires. With the default audience of No one, a bag refuses every search silently. If your handler seems dead, check the bag's Search setting in the Inspector before suspecting the script.
Shuffling on close does nothing here. A bag has no order to restore, so the After search policy is irrelevant to it — the randomness is in how it draws, not in how its contents are stored.
A refused search raises nothing, deliberately: announcing that someone tried to look would leak the intent the feature exists to keep private.
See also
DeckObject.onSearched— the full account of the two security rules.BagObject.onSearchPulled— an item leaving mid-session.BagObject.onSearchClosed— the session ending.ContainerObject.containerMode— why a bag draws at random.- Deck and Bag Search — turning search on for a bag.
bagobject.onSearchPulled#
readonly onSearchPulled: ScriptDelegate<[BagObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when a searching player takes an item out. Does NOT name the item.
Fires when a player searching this bag takes an item out of it. It is the bag's copy of
DeckObject.onSearchPulled, and like it, it never names the item — the payload
is the bag and the actor, the same public facts the identity-free chat line carries. The searcher knows what they
took because the host revealed the bag to them alone; the shared event log and this delegate do not.
Applies to: deck and bag.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[BagObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | BagObject |
The bag, already one item shorter. |
| 2 | EventContext |
context.actor is the searching peer. |
How, why and when to use it
A pull from a bag is a chosen item where the game normally supplies a random one, so it is worth recording even when the rules allow it. Use it to count picks against a per-turn limit, to announce that the bag was picked from rather than drawn from, or to end a rummage step after the first item.
Example
// content/scripting-api/examples/bagobject.onSearchPulled.ts
// Object script on a bag: someone chose an item instead of drawing blind. The
// event names the bag and the actor, never the item - so record the fact and
// leave "which one" to the players who can see the table.
refObject.onSearchPulled.add((bag, context) => {
world.broadcast(`${context.actor} picked an item out of ${bag.name ?? "the bag"}.`);
void audit(context.actor, bag.cards.length);
});
async function audit(actor: string, remaining: number): Promise<void> {
const previous = await world.getSavedData("picks");
await world.setSavedData(`${previous ?? ""}${actor}:${remaining};`, "picks");
}
Each pick is announced and appended to a saved audit string that survives a save and load.
Gotchas
The session stays open after a pull, exactly as it does on a deck.
Taking the last item destroys the bag. The pull runs through the ordinary draw path, so the emptied bag
leaves the table and the handle stops receiving events — watch onDepleted.
This is a notification, not a veto. The host validated and applied the pull before the event was raised, and
there is no search-pull in ObjectAction for a script to send.
See also
DeckObject.onSearchPulled— the same event on a deck, documented in full.BagObject.onSearched— the session opening.BagObject.onSearchClosed— the session ending.globalEvents.onCardDrawn— the item itself, as a handle.
bagobject.onSearchClosed#
readonly onSearchClosed: ScriptDelegate<[BagObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when the search session ends explicitly. Not raised on a timeout — see DeckObject.
Fires when a search session on this bag is closed and the private reveal is revoked. It is the bag's copy of
DeckObject.onSearchClosed, and the only one of the three search events that
reports an ending.
The After search order policy has no effect on a bag: there is no order to shuffle or preserve, because a bag hands out its contents at random however they are stored. The setting is still authorable on a bag and is simply inert there — the useful fact this event carries is that the private view has closed again.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[BagObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | BagObject |
The bag, with whatever is left in it. |
| 2 | EventContext |
context.actor is the peer whose session ended. |
How, why and when to use it
Use it to undo whatever onSearched set up: clear a "someone is rummaging" notice,
release a turn timer, or advance the step now that the choosing is over.
Example
// content/scripting-api/examples/bagobject.onSearchClosed.ts
// Object script on a bag: the rummage is over. A bag has no order to restore,
// so `After search` changes nothing here - the useful moment is simply that the
// private view has closed again.
refObject.onSearchClosed.add((bag, context) => {
world.log(`${context.actor} closed the bag search; ${bag.cards.length} items remain.`);
world.broadcast("The bag is closed.");
});
Closing the session logs the remaining count and tells the table the bag is back to being blind.
Gotchas
An expired session raises nothing. A reveal that times out after five minutes, or dies with a disconnecting peer, revokes silently — give any state machine that waits on this event its own fallback.
No shuffle event follows. Unlike a deck set to shuffle on close, a bag performs no reordering, so this is the last event of the session.
Only an open session closes, so this never fires twice for one session.
See also
DeckObject.onSearchClosed— the same event on a deck, documented in full.BagObject.onSearched— the session opening.BagObject.onSearchPulled— an item leaving mid-session.- Deck and Bag Search — where the two settings are authored.
CardHolderObject#
Surface A — table script · interface
A card holder — a rack that keeps cards in a row and hands them out one at a time.
A card holder - a rack that keeps cards in a row in front of a seat and hands them out one at a time. refObject
is declared as this in a script created from a holder's Script section.
It is a ContainerObject and adds nothing of its own. A holder is a named pile location
with a proximity capture radius: it stamps metadata.holderId on a card or deck dropped close enough to it, and
that is the whole of its job.
⚠ A holder does not make the cards it captures belong to a seat. That implicit behavior was removed - a
holder standing inside a seat's area used to stamp its owning seat onto every card it caught, competing with the
seat's own hand zone. Seat membership now comes from the seat's hand zone alone, unless an author explicitly
sets metadata.holderOwnerSeat on the holder to say "this rack is that seat's". A holder saved before that
opt-in existed has its own ownerSeat copied into metadata.holderOwnerSeat once, when the table loads, so an
already-published rack keeps its seat. Neither the handle nor ObjectData publishes ownerSeat, so a rule about
whose holder this is still has to come from the entity's configuration in the editor rather than from the
handle.
If you want "cards here are mine and private", that is a hand zone, not a holder - and the two compose: a holder standing inside a hand zone is a private pile.
See also#
ContainerObject- the three members it inherits.ObjectHandle.onCardDrawn- fired on the holder when a card leaves it.- Object kinds - what a holder does at the table.
- Player Zones and seat templates - hand zone vs. card holder, and how a seat template can own holders.
CardObject#
Surface A — table script · interface · 5 members
A single card, or a face-up/face-down stack of them.
A single card, or a face-up/face-down stack of them. refObject is declared as this in a script created from a
card's Script section, and world.getAllObjects({ kind: "card" }) resolves an array of them.
On top of ObjectHandle it adds the two things that are only true of a card -
tapped and cardId - and the three delegates for the actions only a
card answers: onFlipped, onTapped and
onUntapped.
The three delegates are onAction narrowed to one
action each. They add no engine event and no network traffic: the sandbox fans one action event out to
onAction and then to the matching one of these.
See also#
ObjectHandle.onAction- the wider event the three are derived from.ObjectHandle.flip- the call that raisesonFlipped.ContainerCard- the same card while it is still inside a deck.
Members#
| Signature | Description | Returns |
|---|---|---|
tapped |
Whether the card is currently tapped (rotated to mark it as used). | boolean |
cardId |
Which face this card is, when it has one — a standard code like "AS", or a custom deck's card id. |
string | null |
onFlipped |
Fires when this card is turned over. Read faceUp on the handle for the new side. |
ScriptDelegate<[CardObject, EventContext]> |
onTapped |
Fires when this card is tapped. | ScriptDelegate<[CardObject, EventContext]> |
onUntapped |
Fires when this card is untapped. | ScriptDelegate<[CardObject, EventContext]> |
cardobject.tapped#
readonly tapped: boolean;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Whether the card is currently tapped (rotated to mark it as used).
Whether this card is currently tapped - rotated to mark it as used, the way a tapped land or an exhausted
character is marked in the games that use the mechanic. false for a card that has never been tapped.
How, why and when to use it
Tapping is a gesture at the table but a rule in a game: "you may not use a tapped card", "untap everything at the start of your turn". Both need to read the state rather than track it, because a player can tap a card by hand at any moment and a script that kept its own flag would be wrong from then on.
Gotchas
It is a cached value. Immediately after a tap the handle still reports the old value - an action posts an
intent and returns. Read it off the handle onTapped delivers, or await refresh().
Untapping a card that is not tapped is not an error, and it still raises onUntapped with actor: "Script".
If that matters to your rule, check this field first.
ObjectHandle has no tap() or untap() method - they are in the action vocabulary and the sandbox's
allowlist, but there is no typed call for them. See
Action vocabularies.
See also
CardObject.onTapped- being told when it changes.ObjectData.locked- the other per-card boolean, and a different idea.- Action vocabularies - what
tapdoes on each kind.
cardobject.cardId#
readonly cardId: string | null;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Which face this card is, when it has one — a standard code like "AS", or a custom deck's card id.
Which face this card is: a short code like "AS" for a standard deck, or the author's own id for a custom one.
null when the entity carries no card identity - a blank card, or a handle that has never held real state.
It is the same identity a ContainerCard entry carries inside a deck, so a card keeps its id
across being drawn, dealt, stacked and split.
How, why and when to use it
Any rule about which card this is starts here: scoring a hand, matching a played card against a requirement, recognising the one card your game treats specially. Names are for players and are not unique; ids are the identity.
Gotchas
null has two causes and they are not the same. A card with no id, and a handle that has never been filled
from a snapshot (a fresh spawnObject result). await refresh() tells them apart.
A stack reports one id. A face-up stack of five cards is one entity with one identity - the entries below the top are not readable from a card handle. Read them off the container they came from, or split the stack.
Reading it can be a reveal. A face-down card's id is visible to a script because scripts run on the host. Broadcasting it shows every player something the table was hiding.
See also
ContainerCard.cardId- the same identity while the card is in a deck.ObjectData.faceUp- whether anyone else can see it.ObjectData.metadata- where the raw value is read from.
cardobject.onFlipped#
readonly onFlipped: ScriptDelegate<[CardObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when this card is turned over. Read faceUp on the handle for the new side.
Fires when this card is turned over. It is
onAction narrowed to the flip action, and fires
immediately after it on the same handle - with the card's state already updated, so
faceUp on the handler's argument is the new side.
Applies to: card. It is declared on CardObject only. Other kinds accept a flip and raise onAction for
it; they do not raise this.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[CardObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | CardObject |
The same handle you subscribed on, refilled from the event's post-flip state. |
| 2 | EventContext |
"Script" for a flip() call, otherwise the peer who flipped it. |
How, why and when to use it
Turning a card over is the moment a game usually cares about: a face-up card scores, triggers, or becomes public
knowledge. Attaching the rule to the card means no id comparison and no filter to keep in step with the table -
the delegate fires for this card and no other. Use
globalEvents.onObjectAction with an action check
instead when the rule belongs to the table rather than to one card, or when the cards do not exist yet.
Example
// content/scripting-api/examples/cardobject.onFlipped.ts
// Object script on a card. `refObject` is a CardObject, so onFlipped is
// offered without a cast - and refuses to compile on a kind that cannot flip.
refObject.onFlipped.add((card, context) => {
const side = card.faceUp === true ? "face up" : "face down";
world.log(`${card.name ?? card.cardId ?? "card"} is now ${side} (${context.actor})`);
});
refObject.onFlipped.add((card) => {
if (card.faceUp === true && card.cardId !== null) {
world.broadcast(`Revealed: ${card.cardId}.`);
void remember(card.cardId);
}
});
async function remember(cardId: string): Promise<void> {
const seen = await refObject.getSavedData("revealed");
const count = Number(seen ?? "0") + 1;
await refObject.setSavedData(String(count), "revealed");
world.log(`${cardId} has been revealed ${count} time(s).`);
}
Flipping the card face up logs the new side, announces the id, and keeps a per-card count that survives a save.
Gotchas
Calling flip() from inside the handler recurses. The second flip raises this delegate again with
actor: "Script" and never stops. Guard on context.actor if you have to flip from here.
onAction fires first, on this same handle, with "flip" as its action - so a script that subscribes to
both is told twice, in that order.
A stack flips as one entity. Turning a stack of five over is one event, not five.
See also
ObjectHandle.onAction- every action, this one included.ObjectHandle.flip- the call that raises it from a script.ObjectData.faceUp- the side it just became.- Events and delegates - the per-action ordering table.
cardobject.onTapped#
readonly onTapped: ScriptDelegate<[CardObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when this card is tapped.
Fires when this card is tapped. It is
onAction narrowed to the tap action, and fires
immediately after it on the same handle, with tapped already true on the handler's
argument.
Applies to: card.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[CardObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | CardObject |
The same handle you subscribed on, refilled from the event's post-tap state. |
| 2 | EventContext |
Who tapped it - "Script", or the peer's id. |
How, why and when to use it
Tapping is how players mark a card as used, and most games that use it have a rule attached: this card may not be
used again until it is readied, tapping it costs something, tapping it triggers an effect. Putting that on the
card itself keeps the rule with the entity it belongs to. The table-wide alternative is
globalEvents.onObjectAction filtered on "tap", which
is what you want when the rule covers every card in play.
Example
// content/scripting-api/examples/cardobject.onTapped.ts
// Object script on a card: notice when it is used, and say so at the start of
// the next turn if it is still tapped.
let tappedThisTurn = false;
refObject.onTapped.add((card, context) => {
tappedThisTurn = true;
world.broadcast(`${context.actor} tapped ${card.name ?? "a card"}.`);
});
globalEvents.onTurnStarted.add((turn) => {
if (tappedThisTurn && refObject.tapped) {
world.log(`${refObject.name ?? refObject.id} is still tapped as ${turn.peerId} begins.`);
}
tappedThisTurn = false;
});
Tapping the card announces it; if it is still tapped when the next turn starts, the script console says so.
Gotchas
Tapping an already-tapped card still fires this. The action is applied rather than toggled, so nothing
deduplicates a second tap. Read tapped first if the difference matters.
There is no tap() method on a handle. The action exists and the sandbox accepts it, but the typed calling
surface omits both tap and untap - see Action vocabularies.
onAction fires first on this same handle, with "tap".
See also
CardObject.onUntapped- the other half of the mechanic.CardObject.tapped- the state this event reports a change in.ObjectHandle.onAction- the wider event.
cardobject.onUntapped#
readonly onUntapped: ScriptDelegate<[CardObject, EventContext]>;
| Badge | Value |
|---|---|
| Authority | host-only |
| Timing | sync |
| Capability | none |
| Availability | both |
Fires when this card is untapped.
Fires when this card is untapped - readied again after being tapped. It is
onAction narrowed to the untap action, and fires
immediately after it on the same handle, with tapped already false.
Applies to: card.
Parameters
The handler receives two arguments, transcribed from ScriptDelegate<[CardObject, EventContext]>:
| Position | Type | Notes |
|---|---|---|
| 1 | CardObject |
The same handle you subscribed on, refilled from the event's post-untap state. |
| 2 | EventContext |
Who readied it - "Script", or the peer's id. |
How, why and when to use it
Readying is the other half of the tap mechanic, and it is usually a turn boundary: everything untaps, and
something should happen when it does. Subscribing on the card keeps that rule local. Note that a script can
observe readying but not perform it - there is no untap() method on a handle - so a rule that wants to ready
a card has to ask a player to, or be moved to a surface that can.
Example
// content/scripting-api/examples/cardobject.onUntapped.ts
// Object script on a card: count how often it is readied, and object when a
// locked card is readied. A script cannot tap it back, so it says so instead.
let readied = 0;
refObject.onUntapped.add((card, context) => {
readied += 1;
world.log(`${card.name ?? "card"} readied by ${context.actor} (${readied}x)`);
if (card.locked) {
world.broadcast(`${card.name ?? "That card"} is locked and should not be readied yet.`);
}
});
Readying the card logs it with a running count, and readying a locked one puts an objection in the chat.
Gotchas
A script cannot ready or unready a card. There is no tap() or untap() method on a handle, so this
delegate is observe-only: the example objects in chat rather than tapping the card back. See
Action vocabularies.
Untapping a card that is not tapped still fires this, for the same reason a repeat tap does: actions are applied, not toggled.
See also
CardObject.onTapped- the other half of the mechanic.CardObject.tapped- the state this event reports a change in.ObjectHandle.onAction- the wider event.
DieObject#
Surface A — table script · interface
A die. Raises onRolled when it settles.
A die. refObject is declared as this in a script created from a die's Script section.
It adds nothing to ObjectHandle today: the two members a die script wants -
roll() and
onRolled - are already on every handle, because
rolling is expressed as an action like any other. The type exists so the editor can name what refObject is, and
so a die-specific member has somewhere to land later without reshaping anything.
See also#
ObjectHandle.onRolled- the settle event, and the face value it carries.ObjectHandle.roll- rolling from a script.
TokenObject#
Surface A — table script · interface
A token, counter or meeple — the general stackable piece.
A token, counter or meeple - the general stackable entity. refObject is declared as this in a script created
from a token's Script section.
It adds nothing to ObjectHandle today. The member a token script usually
wants, stackCount, is on every handle because decks
and card stacks count too.
See also#
ObjectData.stackCount- how many a pile represents.ObjectHandle.onPickedUp- the event most token rules start from.
BoardObject#
Surface A — table script · interface
A board. Usually a fixture: locked, parented to, and dropped on.
A board. refObject is declared as this in a script created from a board's Script section.
It adds nothing to ObjectHandle. A board is usually a fixture - locked,
parented to, and dropped on - so a board script is normally written about other entities, through
globalEvents, with refObject used only for the board's own position and identity.
See also#
globalEvents.onObjectDropped- what most board rules watch.ObjectData.position- the board's own placement, in feet.
CustomObject#
Surface A — table script · interface
An entity with an author-supplied model and no built-in behaviour.
An entity with an author-supplied model and no built-in behaviour. refObject is declared as this in a script
created from a custom entity's Script section.
It adds nothing to ObjectHandle, which is the point: a custom entity has no
kind-specific mechanic to expose, so everything it does is either generic or written by you.
See also#
ObjectHandle- the whole of what a custom entity can do.ObjectData.metadata- where an author's own fields live.
ObjectHandleForKind#
Surface A — table script · type
The handle interface for a given kind — what refObject is declared as in an
object script, and what a kind-filtered world lookup resolves to. Any kind
with no interface of its own resolves to ObjectHandle.
declare type ObjectHandleForKind<K extends ObjectKind> =
K extends "card" ? CardObject :
K extends "deck" ? DeckObject :
K extends "die" ? DieObject :
K extends "token" ? TokenObject :
K extends "board" ? BoardObject :
K extends "bag" ? BagObject :
K extends "card-holder" ? CardHolderObject :
K extends "custom" ? CustomObject :
K extends "button" ? ButtonObject :
ObjectHandle;
The handle type for a given kind, as a mapping you can apply rather than a list you have to remember:
ObjectHandleForKind<"deck"> is DeckObject, ObjectHandleForKind<"card"> is
CardObject. Any kind with no type of its own - including a string the union does not name -
resolves to ObjectHandle, so applying it is always safe.
You rarely write it yourself. It is what makes
world.getAllObjects narrow when you pass a single kind, which is
the one place a scene script gets a typed handle without a cast:
world.getAllObjects({ kind: "deck" })resolvesDeckObject[]world.getAllObjects({ tag: "scoring" })resolvesObjectHandle[], because no kind was namedworld.getObjectById(id)resolvesObjectHandle | null- an id says nothing about a kind
Write it explicitly when a helper of your own has to stay generic over the kind it is handed.
See also#
world.getAllObjects- the call that applies it.ObjectKind- the union it maps from.ObjectHandle- the fallback, and what every result still is.
