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#
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,splitandcombineare declared inObjectActionand are on the script host's allowlist (apps/web/src/scripting/TableScriptHost.ts,SCRIPT_SAFE_OBJECT_ACTIONS), butObjectHandleexposes 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.deletelooks like the same problem and is not: it is reachable, asdestroy(). Until a method exists, model tapping as your own state (a tag, or saved data) and read it back withhandle.refresh(). A mod can callsplitandcombine, 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 fortaporuntap, so both fall through to itsdefault: return falsefor 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
ObjectKindunion names all nine engine kinds —card,deck,die,token,board,bag,custom,card-holderandbutton— 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 itsKNOWN_KINDSlist 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. Readhandle.kindback whenever the kind comes from data rather than a literal. (The union has grown as kinds were added —card-holderwith the per-kind handle types,buttonwith 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.
refObjectis declared asdeclare const refObject: ObjectHandle;— never optional — and the sandbox passesundefinedfor 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 scriptrefObjectis populated before the body runs and every method on it works. In a scene script, reach entities throughworld.getObjectByIdorworld.getAllObjectsinstead.
A table script cannot read displayName#
Surface: Table Scripting. ObjectData.name returns red-die, not Red Die.
Known gap. Entities have three names —
idaddresses,labelis the slug and machine key,displayNameis the optional human name.ObjectData.nameis filled fromlabel(apps/web/src/scripting/sandbox/tableScriptSandbox.html,stateToData), andSpawnObjectOptions.namewriteslabelas 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 acardit is the card's identity and it drives hidden-information redaction. If a table script needs a human label, store it yourself inmetadataand read it back throughObjectData.metadata.Mod scripting reads it, subject to redaction. The six
read-worldreads answer as the least-privileged viewer — a spectator with no seat and no team — on every peer including the host, so for anycardwhose face is not public the reply haslabelrewritten toCard,metadata.cardIddropped anddisplayNamedeleted (packages/shared/src/tableObjects/redaction.ts,redactObjectForRestrictedViewer). Author a readableNamefreely — 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:secretMetadatafollows the very same entitlement, so aread-worldread carries it only for a card whose face is already public, and never for any other kind. A mod that needs the real name declaresread-hidden-informationand callsapi.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.
TableObjectStatecarriesparentIdandcomponents[], and neither appears in the table-scripting declarations —ObjectDatahas 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-outgrabbableWhileParentedridesmetadatarather than the schema root (packages/shared/src/objectParenting.ts), so a script reads it throughObjectData.metadata— and it is ignored while the assembly is welded. Mod scripting is not affected:parentId,components,physics,tapped,displayNameandsoundSetOverridesare all readable there — with the caveat thatdisplayNameandsecretMetadataare 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 comparehandle.idagainst the ids you care about rather than assuming, and usegrabbableWhileParentedon 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.listObjectsaccepts{ kind, tag, tags, match }, withtagstruncated to 32 entries andmatchcoerced to"any"or"all".world.getAllObjectsaccepts{ 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'sspawnbranch, andcreateGameplayObject, 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 fromcreateObjectitself, and its own comment says why:applySnapshotrebuilds 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 fromworld.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
postMessageboundary 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. UseonTickfor 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, theregisterActioncallback 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 abuttonwithapi.setUiElement, give its props anonClickhook name, and subscribe to that name withapi.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). Reassigningmodule.exportsreplaces 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'sloaded.log line and no diagnostic. All three supported forms work correctly. Writeexports.setup = function setup(api, manifest) { … };, orexports.default = …, and assign ontomodule.exportsrather 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 aPickof 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,inputandselectread an interaction hook from a widget's props — abuttonfromonClickfalling back tohook, the other three fromonChangefalling back tohook(apps/web/src/ui/App.tsx, the mod UI element renderer).text,panel,canvasandlayoutaccept the prop, store it, replicate it and never dispatch anything, because none of them has an interaction to dispatch from. Every widget type renders and nests correctly, and the four interactive types fire reliably. Attach your hook to thebutton,checkbox,inputorselectinside the container rather than to the container.One container prop DOES dispatch:
onDismiss, fired by the close button the chrome draws on an element whosepresentation.modeis"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.
sidewaysis declared oncustomDeckDefinitionSchema(packages/shared/src/customDeck.ts), persisted with the deck, and copied onto every spawned deck bybuildCustomDeckObjectMetadata, so it reaches every peer insidemetadata.customDeck. Nothing reads it back: no path underapps/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 fromface.columns,face.rowsand the sheet's pixel dimensions. To ship a landscape deck today, author the cells landscape (cardWidthPxgreater thancardHeightPx) 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":
slideandcounter-fallare looping sets (loop: truein the catalog), and the host-authoritative sound protocol cannot express a stop.soundEventSchema(packages/shared/src/protocol.ts) carries no loop flag,DataChannelMessagehas no stop/cancel message, andplayRemoteSoundEventcallsplay()withoutloop— 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-rummageis 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, sinceshufflebecame deck-only, does nothing). Whatever unblocksslideunblocksbag-rummage, so the two are one decision. The other two unemitted bound actions,collectandboard-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-landandcounter-fallare additionally unreachable by the resolver. Their catalog ids arecounter.landandcounter.fall, andresolveBuiltinSetIdonly ever probes<material>.<action>,dice.<action>andgeneric.<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 playsplace, and a token falling already playsfall.A mod that wants a sliding or counter sound plays it explicitly and controls its own timing. A mod cannot stop a looping
playSoundeither, 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 inapps/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 —validateSceneScriptSafetyruns the same patterns over the compiled body, and the editor's transpile does not strip comments, so// draw the top cardrejects a whole scene. This is a security boundary and it is not expected to loosen.\bneeds a non-word character on both sides, sotopCard,deckTop,parentZone,parentIdandfetchCountare 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 thedom-accessrule for the same reasonself["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, andpresentation.anchornow also acceptsupper-left,upper-centerandupper-right(packages/shared/src/tableObjects.ts,TABLE_UI_SCREEN_ANCHOR_ALIASES). They are input-only: every parse site normalizes them to the canonicaltop-*, sogetUiState()and every snapshot still report one spelling per anchor. Use theupper-*form in a mod script; expecttop-*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.
requireCapabilitythrowsMissing 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 sharedMOD_API_METHOD_CAPABILITIESmap (apps/web/src/mods/SandboxedModRunner.ts, therequireCapabilityhelper inrun). 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 arequestWorldwith aworldResponsecarryingMissing mod capability: <capability>. That coverslog,spawn-object,register-action,read-world,read-hidden-information,object-action,play-sound,saved-data,uiandplugin-call.
read-contextandsubscribe-eventsare the two that are not. They gate no message to the host:getMySeat,getMyTeam,getTurnandonare answered entirely inside the frame, from state the host has already pushed there.updateContextposts acontextUpdateanddispatchEventposts ahookEventto 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 — andselfis not one of the five scanner patterns, which rejectdocument,window,parent,topandopener. Do not design a mod on the assumption that a withheldread-contexthides 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
bagwhose authoredmetadata.cardsholds bare card-id strings — no per-entryfaceDown, nometadata.stackModelVersion— is read and drawn by two different defaults. The reader falls back to the container's ownfaceDown(packages/shared/src/tableObjects.ts,getContainerContentsFromObject); the draw path falls back throughdeckEntryDefaultFaceDown(apps/web/src/playcanvas/TabletopRuntime.ts), which inverts for unversioned data. So the two disagree for exactly the entries that specify nothing.A
deckis unaffected — the runtime stamps the current stack-model version onto every deck as it creates it — and so is any container whose entries namefaceDownoutright.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, andGET /api/decks/:idstill 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
breakingVersionunless a stored card identity genuinely stopped meaning what it meant — seebreakingVersionand migration.
See also#
- Scripting concepts — the model these limitations sit inside.
- Action vocabularies — the three action lists, and which asymmetries are deliberate.
- Events and delegates — ordering, multiplicity and cancellation.
- Sandbox limits — the language subset, the scanner and the capability model.
- Script safety — every scanner rule with its message and workaround.
- Plugins — the plugin contract these deck keys hang off.
