Dicey Table

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.onFlipped on a die script does not compile, because tap and flip are refused for a die at the runtime anyway. That is the point of narrowing rather than putting all of it on ObjectHandle.
  • A mismatched attachment still runs. Attaching a deck script to a card is allowed — refKind is an authoring hint, never a gate. The Script section warns, the script executes, and refObject.cards is 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.
  • WorldgetAllObjects, 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#

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#

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

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#

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

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.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

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

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#

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

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:

  1. A script cannot request a search. search is absent from ObjectAction. A script must not be able to force a player to look through a deck, nor to manufacture a private reveal for itself.

  2. The EVENT names no card. onSearchPulled says 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 cards on 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.

  3. The public facts are all here. The handle, its cards as your script is entitled to see them, and context.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.

  1. A script can observe a search; it can never request one. search is absent from ObjectAction, there is no handle.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.
  2. 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 — and onSearchPulled never 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#

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.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

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#

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 in items.
  • "holder" — OPEN and physical: a bowl, a tray. Its pieces stay real entities resting inside it, so items is always empty and world.getAllObjects still 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.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.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

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.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 capacityLimit pieces.
  • 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.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#

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.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

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

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

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#

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#

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.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

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

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#

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

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#

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#

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#

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#

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" }) resolves DeckObject[]
  • world.getAllObjects({ tag: "scoring" }) resolves ObjectHandle[], because no kind was named
  • world.getObjectById(id) resolves ObjectHandle | 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#