Dicey Table

Known limitations

Every page in these docs that names a gap links here. This is the whole list, in one place, so that "what else is like this?" has a single answer.

How to read this page#

Each entry is one of two things, and telling them apart is the point of the page:

By design Known gap
What it is Someone decided this and the decision still holds. Nobody intended this. It is a bug or an unfinished piece.
What to do Design around it. It is not expected to change. Do not build on its current shape.

An API with no gaps listed is not a better API; it is a less honest one. Every entry below names the file and the symbol it comes from, so you can check the claim yourself — and every one of them says what still works, because in almost every case most of the feature does.

Verified against source on 2026-07-27, with entry 18 re-verified on 2026-07-29 and the dice-value entry removed on 2026-08-26 (a settled die now reports its face). Where an entry applies to only one script surface, it says so. The two surfaces are Table Scripting and Mod Scripting; they are separate, and a limitation on one is not automatically a limitation on the other.

Entries are removed from this page when the behavior they describe is fixed, so the list is shorter than it was. Nothing is kept for history: if it is here, it is still true today.

Index#

# Limitation Surface Kind
1 Actions with no ObjectHandle method Table Known gap
2 tap and untap are refused for every participant The table Known gap
3 An unknown ObjectKind becomes custom, silently Table Known gap
4 refObject is undefined in scene scripts Table Known gap
5 A table script cannot read displayName Table Known gap
6 Parenting is invisible to table scripts Table Known gap
7 Grab events report the assembly root Table By design
8 Tag validation is asymmetric Table Known gap
9 Object filters are not symmetrical Both Known gap
10 objectCreated does not fire for a snapshot rebuild Table By design
11 onTick is throttled and opt-in Table By design
12 Nothing can cancel an action Both By design, deferred
13 registerAction renders no button Mod Known gap
14 The exports object cannot be replaced Mod Known gap
15 setup receives four manifest fields Mod By design
16 The hook prop is dead on four widget types Mod Known gap
17 A custom deck's sideways flag changes nothing Mod Known gap
18 Three sound actions are mod-only Both By design
19 The scanner has no lexer Both By design
20 Capabilities are enforced in two places Mod By design
21 An unversioned bag reports the wrong faceDown Both Known gap
22 A deck stranded by a breakingVersion bump is unlabelled Plugins Known gap

Object actions#

Actions with no ObjectHandle method#

Surface: Table Scripting. You want to tap a card from a script, find tap in the ObjectAction type union, and then find no way to call it.

Known gap. tap, untap, split and combine are declared in ObjectAction and are on the script host's allowlist (apps/web/src/scripting/TableScriptHost.ts, SCRIPT_SAFE_OBJECT_ACTIONS), but ObjectHandle exposes no method for any of them (packages/shared/src/scripting.ts). Every layer below the typed API is ready for them — the allowlist passes them and the runtime applies them correctly — and only the calling surface is missing. delete looks like the same problem and is not: it is reachable, as destroy(). Until a method exists, model tapping as your own state (a tag, or saved data) and read it back with handle.refresh(). A mod can call split and combine, because mod scripting passes the action name as a string.

tap and untap are refused for every participant#

Surface: the table itself, not either script surface. A player right-clicks a card and finds no tap option; a script's tap works.

Known gap. The per-kind gate that decides whether a player or spectator may send an action (packages/shared/src/tableObjects.ts, isObjectActionAllowedForTarget) has no case for tap or untap, so both fall through to its default: return false for every one of the nine kinds. The runtime applies both actions correctly whenever they arrive, and the host — including a table script and the host's own UI — bypasses this gate entirely, so the feature works; only the participant path is missing. If your game needs players to tap, have a table script do it in response to something they can send.

Table scripting — types and data#

An unknown ObjectKind becomes custom, silently#

Surface: Table Scripting. A misspelled kind spawns a plain entity instead of failing.

Known gap. The ObjectKind union names all nine engine kinds — card, deck, die, token, board, bag, custom, card-holder and button — but it also carries an open (string & {}) escape hatch (packages/shared/src/scripting.ts), so a typo is not a type error. The sandbox then coerces anything outside its KNOWN_KINDS list to 'custom' (apps/web/src/scripting/sandbox/tableScriptSandbox.html): spawnObject({ kind: "dice" }) produces a plain custom entity that never rolls, with no error and no log line. Read handle.kind back whenever the kind comes from data rather than a literal. (The union has grown as kinds were added — card-holder with the per-kind handle types, button with the button object — see Object Types.)

refObject is undefined in scene scripts#

Surface: Table Scripting. The editor offers refObject.flip() inside a scene script and the script throws the moment it runs.

Known gap. refObject is declared as declare const refObject: ObjectHandle; — never optional — and the sandbox passes undefined for it in a scene script (packages/shared/src/scripting.ts; apps/web/src/scripting/sandbox/tableScriptSandbox.html, runScript). The declaration's own comment says so: "Defined ONLY in object scripts … undefined in scene scripts." In an object script refObject is populated before the body runs and every method on it works. In a scene script, reach entities through world.getObjectById or world.getAllObjects instead.

A table script cannot read displayName#

Surface: Table Scripting. ObjectData.name returns red-die, not Red Die.

Known gap. Entities have three names — id addresses, label is the slug and machine key, displayName is the optional human name. ObjectData.name is filled from label (apps/web/src/scripting/sandbox/tableScriptSandbox.html, stateToData), and SpawnObjectOptions.name writes label as well, so a script can neither read nor set the human name. Reading and writing the slug both work correctly, and the slug is the right thing to key game logic on — for a card it is the card's identity and it drives hidden-information redaction. If a table script needs a human label, store it yourself in metadata and read it back through ObjectData.metadata.

Mod scripting reads it, subject to redaction. The six read-world reads answer as the least-privileged viewer — a spectator with no seat and no team — on every peer including the host, so for any card whose face is not public the reply has label rewritten to Card, metadata.cardId dropped and displayName deleted (packages/shared/src/tableObjects/redaction.ts, redactObjectForRestrictedViewer). Author a readable Name freely — it is neutralized exactly when the card is — but do not treat it as a hiding place, because it is public again the moment the card is face-up. Secrets that must survive a face-up card belong nowhere on the entity: secretMetadata follows the very same entitlement, so a read-world read carries it only for a card whose face is already public, and never for any other kind. A mod that needs the real name declares read-hidden-information and calls api.getUnredactedSnapshot. See Object state.

Parenting is invisible to table scripts#

Surface: Table Scripting. You parent a token to its base in the editor, then find no way to discover that relationship from a script.

Known gap. TableObjectState carries parentId and components[], and neither appears in the table-scripting declarations — ObjectData has exactly nine readonly properties and none of them is either one (packages/shared/src/scripting.ts). Parenting itself works completely; it is an editor and runtime concept the script declarations were never extended to cover. One part of it is visible: the per-child opt-out grabbableWhileParented rides metadata rather than the schema root (packages/shared/src/objectParenting.ts), so a script reads it through ObjectData.metadata — and it is ignored while the assembly is welded. Mod scripting is not affected: parentId, components, physics, tapped, displayName and soundSetOverrides are all readable there — with the caveat that displayName and secretMetadata are stripped from a card whose face is not public, on every peer including the host. In a table script, tag the members of an assembly and query the tag.

Grab events report the assembly root#

Surface: Table Scripting. A player picks up a token that is parented to a board, and onObjectPickedUp hands you the board.

By design. Grabbing any member of a parented assembly escalates to the root ancestor (apps/web/src/playcanvas/TabletopRuntime.ts, resolveGrabTarget), because "I glued this token to its base, so moving it should move the base" is what an author means by parenting. Escalation stops at the first ancestor that is locked or that the actor may not drag, so a restricted middle node halts it; Alt+grab targets the clicked child directly, and a VR grab escalates unconditionally. This is not expected to change. Design for it: a script that attributes a drop to the piece the player visually touched will be wrong for any parented assembly, so compare handle.id against the ids you care about rather than assuming, and use grabbableWhileParented on a child that genuinely should move on its own.

Tag validation is asymmetric#

Surface: Table Scripting. A spawn with a long tag produces no entity, no error and no obvious reason.

Known gap. The sandbox filters tags with /^[a-z0-9_-]+$/ and no length cap (apps/web/src/scripting/sandbox/tableScriptSandbox.html, spawnObject), so an over-long tag passes the frame. The host then parses the spawn definition with the shared schema, which also requires .max(32) per tag (packages/shared/src/tableObjects.ts). The parse fails, and it fails for the whole spawn rather than for the offending tag — the entity is never created and the only signal is a diagnostic in the script console. Both filters are correct in isolation; they are drifted apart. Keep tags at 32 characters or fewer, and read the script console first when a spawn silently produces nothing.

Object filters are not symmetrical#

Surface: both. You write a multi-tag filter that works in a mod and does not exist in a table script.

Known gap. api.listObjects accepts { kind, tag, tags, match }, with tags truncated to 32 entries and match coerced to "any" or "all". world.getAllObjects accepts { kind, tag } and nothing else (packages/shared/src/scripting.ts; apps/web/src/scripting/sandbox/tableScriptSandbox.html, getAllObjects). The extension shipped to the mod sandbox only. Both filters work correctly for what they declare, and both filter the replicated snapshot rather than the scene graph. In a table script, fetch once with the single tag that narrows most and filter the returned array yourself.

Table scripting — events and lifecycle#

objectCreated does not fire for a snapshot rebuild#

Surface: Table Scripting. You reconnect, or the host migrates, and the index your onObjectCreated handler was building is empty for everything that was already on the table.

By design. The event is raised from exactly two places (apps/web/src/playcanvas/TabletopRuntime.ts): applyIntent's spawn branch, and createGameplayObject, which every gameplay path that produces an entity goes through — a draw, a deal, either half of a split, both combine paths, and the one-card deck that becomes a plain card. It is deliberately not raised from createObject itself, and its own comment says why: applySnapshot rebuilds every entity through that same function, so announcing there would fire "created" for the whole table on every snapshot apply. The event therefore means "this entity has just come into existence", never "this entity is now present". Seed your index from world.getAllObjects() when your script starts, then keep it current with the event — which is also what makes it correct after a reconnect, a load from a save, or a host migration.

onTick is throttled and opt-in#

Surface: Table Scripting. onTick fires roughly ten times a second, not once per frame, and not at all until something registers a handler.

By design. Every tick crosses a postMessage boundary into the sandbox, and a per-frame event on that path would cost more than any gameplay it enables. Registering the first handler switches delivery on and removing the last switches it off, so a table with no tick handler pays nothing (apps/web/src/scripting/TableScriptHost.ts, wantsTick). This is not expected to change. Use onTick for coarse-grained work — a countdown, a periodic tidy-up — and drive anything that has to line up with a specific moment from the event for that moment.

Nothing can cancel an action#

Surface: both. There is no veto hook, no try* delegate, no preventDefault, and no return value a handler can use to refuse what triggered it. By the time any handler runs, the host has applied the change and broadcast it.

This is a deferral rather than an omission: the design note records that veto hooks "need a sync-over-async design decision" and holds them for a follow-up (docs/feature-plans/scripting.md). It is the biggest single difference between this event model and the one Tabletop Simulator authors expect, so it is stated here plainly rather than left to be discovered. React and correct instead — move the piece back, flip it again, destroy what should not be there — or prevent the interaction with the host's own controls (lock the entity, put it in an owned zone, turn on turn order) rather than with script logic.

Mod scripting#

registerAction renders no button#

Surface: Mod Scripting. You call api.registerAction({ id, label }), no control appears, and nothing ever calls you back.

Known gap. The host's handler appends one line to the table's event log — registered action <label> — and does nothing else (apps/web/src/ui/App.tsx, the registerAction callback passed to the mod runner). No UI is rendered and there is no callback channel, so the capability grants the ability to write a log line. The mechanism that does work is a table UI element: create a button with api.setUiElement, give its props an onClick hook name, and subscribe to that name with api.on. That path is wired end to end and carries the actor's peer id and role in its payload.

The exports object cannot be replaced#

Surface: Mod Scripting. You write module.exports = { setup };, the mod loads without an error, and setup never runs.

Known gap. The sandbox resolves the entry point as module.exports.default || exports.default || exports.setup, evaluated against the objects it created before running your file (apps/web/src/mods/sandbox/modSandbox.html). Reassigning module.exports replaces the object the first path reads from, so none of the three expressions resolve and the mod loads with no setup having run — and because loading succeeded, you get the mod's loaded. log line and no diagnostic. All three supported forms work correctly. Write exports.setup = function setup(api, manifest) { … };, or exports.default = …, and assign onto module.exports rather than replacing it.

setup receives four manifest fields#

Surface: Mod Scripting. manifest.entry and manifest.assets are undefined inside setup.

By design. The host builds the second argument as exactly { id, name, capabilities, soundSets } (apps/web/src/mods/SandboxedModRunner.ts, run, typed as a Pick of the manifest). A script has no use for its own file paths or its store listing, and handing them over would widen the surface for nothing. This is not expected to change. The four fields you do get are the four a script needs — its own id for namespacing, its name for log lines, its granted capabilities, and the sound sets it declared. Put anything else your script needs in the script, as a constant.

The hook prop is dead on four widget types#

Surface: Mod Scripting. You put a hook on a panel and no event ever arrives.

Known gap. Only button, checkbox, input and select read an interaction hook from a widget's props — a button from onClick falling back to hook, the other three from onChange falling back to hook (apps/web/src/ui/App.tsx, the mod UI element renderer). text, panel, canvas and layout accept the prop, store it, replicate it and never dispatch anything, because none of them has an interaction to dispatch from. Every widget type renders and nests correctly, and the four interactive types fire reliably. Attach your hook to the button, checkbox, input or select inside the container rather than to the container.

One container prop DOES dispatch: onDismiss, fired by the close button the chrome draws on an element whose presentation.mode is "modal". That is a dialog affordance, not the container itself becoming clickable — and it only deletes the dialog if your hook does.

A custom deck's sideways flag changes nothing#

Surface: Mod Scripting. You tick Sideways in the deck editor, expecting landscape cards, and every card still renders in the proportions its sheet cells give it.

Known gap. sideways is declared on customDeckDefinitionSchema (packages/shared/src/customDeck.ts), persisted with the deck, and copied onto every spawned deck by buildCustomDeckObjectMetadata, so it reaches every peer inside metadata.customDeck. Nothing reads it back: no path under apps/web/src/playcanvas/ rotates, resizes or re-crops a card because of it. The flag itself round-trips correctly and is safe to set as a record of intent — the deck it describes is built entirely from face.columns, face.rows and the sheet's pixel dimensions. To ship a landscape deck today, author the cells landscape (cardWidthPx greater than cardHeightPx) and scale the placed entity to match.

Sound#

Three sound actions are mod-only#

Surface: both. SoundAction has 17 values and the table only ever triggers 14 of them on its own. slide, counter-land and counter-fall never happen unless a mod asks for them.

By design. OBJECT_SOUND_EVENT_MAP (packages/shared/src/soundSets.ts) binds no kind to any of the three, so nothing at the table fires them; api.playSound({ event: { material, action } }) still does, and the licensed clips are real. Each is left out for its own reason, and none of them is "nobody got round to it":

  • slide and counter-fall are looping sets (loop: true in the catalog), and the host-authoritative sound protocol cannot express a stop. soundEventSchema (packages/shared/src/protocol.ts) carries no loop flag, DataChannelMessage has no stop/cancel message, and playRemoteSoundEvent calls play() without loop — so a peer receiving a slide would play a loop-mastered clip as a one-shot, which sounds worse than silence. Starting one host-side only would be unreplicated, which the whole sound model forbids. bag-rummage is the standing precedent and the closest sibling: also looping, also in the map, also never emitted — and it is the one of the three unemitted bound actions whose gesture already exists (shaking a held bag is detected today and, since shuffle became deck-only, does nothing). Whatever unblocks slide unblocks bag-rummage, so the two are one decision. The other two unemitted bound actions, collect and board-clear, are a different problem entirely: the runtime has no gather-the-dice and no sweep-the-board operation to attach a sound to.
  • counter-land and counter-fall are additionally unreachable by the resolver. Their catalog ids are counter.land and counter.fall, and resolveBuiltinSetId only ever probes <material>.<action>, dice.<action> and generic.<action> — so every material resolves both to nothing. Binding them today would change exactly nothing. They also describe a Connect-4 counter dropping through a slot rail, which the table does not model: a token landing already plays place, and a token falling already plays fall.

A mod that wants a sliding or counter sound plays it explicitly and controls its own timing. A mod cannot stop a looping playSound either, so keep looping refs for short, self-limiting moments.

Both surfaces — the security model#

Neither entry below is a gap, and neither should be worked around. They are here because they surprise people, and a reader who runs into one without an explanation reasonably concludes the platform is arbitrary.

The scanner has no lexer#

Surface: both. A comment rejects your script.

By design. Five whole-word regular expressions are run over raw script text (packages/shared/src/modManifest.ts, bannedScriptPatterns; mirrored in apps/server/src/githubScanner.ts, SANDBOX_SCANNER_RULES). They cannot tell a comment from code, a string from an identifier, or a property from a global, and the scanner's own note states the trade: "false positives are acceptable (mod authors can adjust scripts), while false negatives are treated as boundary risks and should trigger a rule update." Table scripts are scanned too — validateSceneScriptSafety runs the same patterns over the compiled body, and the editor's transpile does not strip comments, so // draw the top card rejects a whole scene. This is a security boundary and it is not expected to loosen. \b needs a non-word character on both sides, so topCard, deckTop, parentZone, parentId and fetchCount are all safe — rename or rephrase. Per-rule workarounds are on Script safety.

The one place this cost a real API value, the value grew an alias instead of the rule being loosened. A quoted "top-left" matches the dom-access rule for the same reason self["top"] does — " and - are both non-word — so the three top-row screen anchors could not appear in a publishable mod script. The regex was left exactly as it is, and presentation.anchor now also accepts upper-left, upper-center and upper-right (packages/shared/src/tableObjects.ts, TABLE_UI_SCREEN_ANCHOR_ALIASES). They are input-only: every parse site normalizes them to the canonical top-*, so getUiState() and every snapshot still report one spelling per anchor. Use the upper-* form in a mod script; expect top-* back. Details on Table UI widget types.

Capabilities are enforced in two places#

Surface: Mod Scripting. A capability list is checked both inside the sandbox and again by the host, and it is still a least-privilege declaration rather than a statement about what a mod intends to do.

By design. Ten of the twelve capabilities are checked twice. requireCapability throws Missing mod capability: <capability> inside the frame (apps/web/src/mods/sandbox/modSandbox.html), and the host re-validates every gated message against the mod's granted capabilities before acting on it, using the shared MOD_API_METHOD_CAPABILITIES map (apps/web/src/mods/SandboxedModRunner.ts, the requireCapability helper in run). The host check is the authoritative one: the in-frame throw runs in the same untrusted JavaScript realm as the mod script, so a script that posts to the parent window directly bypasses it — and the host refuses anyway, dropping a fire-and-forget message with a diagnostic and answering a requestWorld with a worldResponse carrying Missing mod capability: <capability>. That covers log, spawn-object, register-action, read-world, read-hidden-information, object-action, play-sound, saved-data, ui and plugin-call.

read-context and subscribe-events are the two that are not. They gate no message to the host: getMySeat, getMyTeam, getTurn and on are answered entirely inside the frame, from state the host has already pushed there. updateContext posts a contextUpdate and dispatchEvent posts a hookEvent to every running frame unconditionally (apps/web/src/mods/SandboxedModRunner.ts), so a frame that was never granted either capability still receives seat, team, turn and every hook payload in its own realm, reachable by direct property access. For those two, a declaration is disclosure and nothing more — and self is not one of the five scanner patterns, which reject document, window, parent, top and opener. Do not design a mod on the assumption that a withheld read-context hides the seat map from it.

What a capability list means is unchanged by any of that, which is why this entry is still here. It is least-privilege disclosure — what the platform's detectors found in the script, and what a reviewer or a player can know about a mod before opening its code — layered on the boundaries that hold regardless of what any manifest declares: the opaque-origin iframe and its CSP, the neutered DOM, network and storage globals, the five scanner patterns, the host-side 10-action allowlist, the per-mod namespacing of saved data, and the ownership check on sound references. A mod cannot reach fetch, localStorage, eval, another mod's saved data, or an action outside the allowlist, whatever its manifest says. The same statement, with the detector regexes and where each check runs, is on Mod capabilities.

An unversioned bag reports the wrong faceDown#

Surface: Both. You read a bag's contents, act on each entry's faceDown, and then the card you actually draw is the other way up.

Known gap. A bag whose authored metadata.cards holds bare card-id strings — no per-entry faceDown, no metadata.stackModelVersion — is read and drawn by two different defaults. The reader falls back to the container's own faceDown (packages/shared/src/tableObjects.ts, getContainerContentsFromObject); the draw path falls back through deckEntryDefaultFaceDown (apps/web/src/playcanvas/TabletopRuntime.ts), which inverts for unversioned data. So the two disagree for exactly the entries that specify nothing.

A deck is unaffected — the runtime stamps the current stack-model version onto every deck as it creates it — and so is any container whose entries name faceDown outright.

Workaround: author bag contents as objects, { cardId, faceDown }, and the two agree.

Plugins and decks#

A deck stranded by a breakingVersion bump is unlabelled#

Surface: Plugins and decks. A game's card model bumps its breakingVersion, and a deck you built under the previous one stops appearing on that game's Decks tab.

Known gap — in the surfacing, not the storage. The data floor holds: a saved decklist is never deleted, rewritten or re-pointed by a bump. Deck rows are keyed by (source kind, source id, breaking version), nothing in any publish path writes to them, and GET /api/decks/:id still returns the deck in full, so its own link still opens it.

What is missing is everything that would tell you so. ModDecksTab (apps/web/src/ui/decks/ModDecksTab.tsx) scopes both the public list and your own list to the card model's current breaking version, so a deck built for the previous one silently drops out of both — with no "built for an earlier version" marker anywhere, and no first-class deck export to take it elsewhere.

What to do: keep the deck's own link. And if you author a plugin or a card schema, treat this as one more reason not to bump breakingVersion unless a stored card identity genuinely stopped meaning what it meant — see breakingVersion and migration.

See also#