Dicey Table

Mod Capabilities

A mod's entry.script runs with exports.setup(api, manifest) inside a sandboxed iframe. The api object it receives is capability-gated: manifest.capabilities.allowed is a list drawn from 12 fixed slugs, and every gated method throws unless its capability is on that list. Declaring fewer capabilities than a mod's script uses fails validation. Declaring more than it uses does not.

If capabilities is omitted from the manifest entirely, it defaults to { version: "1", allowed: ["log"] } — a mod with no capabilities block can only call api.log().

The capability → method matrix#

Capability api methods it gates
log log(message)
spawn-object createObject(object)
register-action registerAction(action)
read-context getMySeat(), getMyTeam(), getTurn()
read-world getSnapshot(), getObject(objectId), listObjects(filter?), getContainerContents(objectId), getHandObjects(seat?), getZoneObjects(seat, zoneId)all six least-privileged, on every peer including the host — and it is additionally demanded by an onZoneEnter/onZoneLeave/onTriggerEnter/onTriggerLeave subscription
read-hidden-information getUnredactedSnapshot() — the elevated read, and the only route to real card faces, pile order and secretMetadata. Never implied by read-world, never granted by default.
object-action objectAction(objectId, action)
saved-data getSavedData(scope?), setSavedData(data, scope?)
subscribe-events on(eventName, handler)
ui getUiState(), listUiElements(), setUiElement(element), deleteUiElement(elementId)
play-sound playSound(params), setObjectSound(objectId, action, ref)
plugin-call listPlugins(), callPlugin(pluginId, functionName, params?) — naming a declared function on an installed plugin your manifest also declares in plugins. Not network access; see below.

See the api reference for full parameter and return details on every method above.

plugin-call is not a network grant, and describing it as one is wrong. A mod names a function; the platform performs the request from its own servers, to an origin the plugin declared, under that plugin's quota and circuit breaker. A mod cannot express a destination or a payload the plugin did not declare a schema for, and every network pattern in the scanner still rejects the script at publish. It also requires a matching plugins declaration — see Calling a plugin from a mod.

How a declaration is checked#

Every capability is detected by matching literal api.<method>( call syntax in the entry script's source — a whole-word, unlexed regex, the same style as the scanner's 5 banned-pattern rules. There is no AST parse: matching happens against raw text.

Capability Detector
spawn-object /\bapi\.createObject\s*\(/
register-action /\bapi\.registerAction\s*\(/
read-context /\bapi\.(getMySeat|getMyTeam|getTurn)\s*\(/
read-world /\bapi\.(getSnapshot|getObject|listObjects|getContainerContents|getHandObjects|getZoneObjects)\s*\(/
read-world (zone/trigger hook) An api.on("onZoneEnter" | "onZoneLeave" | "onTriggerEnter" | "onTriggerLeave", …) subscription demands read-world too, because those hooks are only delivered to a frame that was granted it.
read-hidden-information /\bapi\.getUnredactedSnapshot\s*\(/
object-action /\bapi\.objectAction\s*\(/
saved-data /\bapi\.(getSavedData|setSavedData)\s*\(/
ui /\bapi\.(getUiState|listUiElements|setUiElement|deleteUiElement)\s*\(/
subscribe-events /\bapi\.on\s*\(/
play-sound /\bapi\.(playSound|setObjectSound)\s*\(/
plugin-call /\bapi\.(listPlugins|callPlugin)\s*\(/ — plus a second, finer pass that reads each api.callPlugin("id", "fn" pair out of the source and rejects any the manifest's plugins array does not declare, or any call site whose two targets are not plain string literals
log /\bapi\.log\s*\(/

Because the detectors match a literal api. prefix, a call reached through a renamed binding (const a = api; a.createObject(...)) or computed property access (api["createObject"](...)) is not detected — and will then fail at run time with a missing-capability throw instead. Call the methods the plain way.

This check runs at three points, all calling the same validateManifestCapabilities():

  1. GitHub registration / scan — an undeclared capability is a severity: "error" issue, which sets the mod's scan status to incompatible. An incompatible mod cannot be registered or selected for a room.
  2. Serve time — re-run every time an already-registered mod is loaded into a room. If the script changed since the last scan and now uses a capability the manifest does not declare, the mod fails to load with "Mod script no longer passes sandbox compatibility checks." — even though it was compatible at registration.
  3. Local editor draft — the same check runs while you are authoring, before anything is published.

The exact rejection#

Script uses capability "<capability>" but it is not declared in manifest.capabilities.allowed.

with code: "undeclared-capability", severity: "error". For a script that calls api.createObject(...) while capabilities.allowed is ["log"]:

Script uses capability "spawn-object" but it is not declared in manifest.capabilities.allowed.

Declaring a capability you do not use — not flagged#

This is the single most useful fact on this page. The validator walks only the capabilities the script uses and checks each is declared; it never walks the declared list looking for entries the script never calls. A manifest can declare all 12 capabilities against a script that only calls api.log(), and nothing rejects it — no warning, no error, at any of the three checkpoints above. Least privilege here is a discipline you keep yourself; the scanner will not enforce it for you.

plugin-call is the one partial exception, and only in one direction: a plugins entry naming at least one function without plugin-call is rejected as undeclared-capability, because that manifest contradicts itself. A resource-only entry ("functions": []) names no call and needs no capability. Declaring plugin-call with no plugins entry and no call site is still not flagged.

Declaring read-hidden-information you do not use is not flagged either, and it is the one entry where an idle declaration costs you something real: it is what a reviewer and a player read as "this mod can see hidden cards". Take it off the list when the call goes.

What a declared capability list actually guarantees#

At run time 10 of the 12 capabilities are enforced in two places, and only one of them counts:

  1. In the frame. Each gated api method begins with requireCapability, which throws Missing mod capability: <capability> synchronously (apps/web/src/mods/sandbox/modSandbox.html). This is the error you will see while developing — and it executes in the same sandboxed JavaScript realm as the mod script, so it is a courtesy, not a boundary: a script that posts a message straight at the host never runs it.
  2. On the host — the authoritative one. Before acting on any gated message from a mod frame, the host re-validates it against that mod's granted capabilities using the shared MOD_API_METHOD_CAPABILITIES map (apps/web/src/mods/SandboxedModRunner.ts, the requireCapability helper in run). A fire-and-forget message — log, createObject, registerAction, objectAction, playSound, setObjectSound — is dropped and reported as a runtime mod diagnostic reading Missing mod capability: <capability> (blocked <method> from sandbox). A read or write that expects an answer (requestWorld) is answered with a worldResponse carrying error: "Missing mod capability: <capability>", which surfaces in the mod as a rejected promise.

That second check covers log, spawn-object, register-action, read-world, read-hidden-information, object-action, play-sound, saved-data, ui and plugin-call.

The one capability that is a wall rather than a disclosure#

read-hidden-information is the exception to the framing in this whole section. For the other eleven, the host check stops a message — it does not, on its own, decide what the mod could have learned some other way. For this one it decides exactly that: the six read-world reads are redacted to the least-privileged view on every peer including the host, so hidden card faces, pile order and secretMetadata are simply not in any answer a mod can obtain without this slug. Its host-side check runs before the state is served, using the same MOD_API_METHOD_CAPABILITIES map, so a frame that forges the requestWorld message is refused rather than served. It is the only grant whose absence withholds data rather than declaring an intent.

The two capabilities the host cannot re-check#

read-context and subscribe-events gate no message to the host, so there is nothing for the host to re-validate. getMySeat, getMyTeam, getTurn and on are all answered inside the frame, from state the host has already delivered — and it delivers that state unconditionally: updateContext posts a contextUpdate and dispatchEvent posts a hookEvent to every running mod frame regardless of what that mod declared (apps/web/src/mods/SandboxedModRunner.ts). A frame granted neither capability still holds the seat, team and turn context and still receives every hook payload, in its own realm, reachable by ordinary property access — and self is not one of the five banned script patterns, which reject document, window, parent, top and opener.

For these two, a declaration is disclosure, not enforcement. Withholding read-context from a mod does not hide the seat map from it; withholding subscribe-events does not stop hook payloads reaching its frame. Both remain worth declaring accurately — that is what the list is for — but do not design a game's hidden information around either one.

What none of this changes is what a capability list means. It is still a scanned declaration, verified against literal api.<method>( call sites in the script that was scanned — least-privilege disclosure, and the thing a reviewer or a player can read before opening a mod's code. It is not a statement about a mod's intentions, and, as the section above says, a manifest may declare more than the script uses without being flagged.

The boundaries that hold regardless of anything a manifest declares are unchanged and independent of all of this: the opaque-origin iframe and its CSP, the neutered DOM, network and storage globals, the five banned script patterns, the ten-item object-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 object action outside the ten, whatever it declares.

Reading a manifest's declared capabilities#

mods/example's manifest declares:

"capabilities": {
  "version": "1",
  "allowed": ["log", "spawn-object", "register-action", "read-world", "object-action", "saved-data", "subscribe-events"]
}

Seven of the twelve. A reviewer reading only this list — before opening the script — can correctly infer: this mod writes diagnostic log lines, spawns objects, adds a custom action button, reads the table state as a spectator sees it, dispatches one of the 10 safe object actions, persists small per-mod values, and reacts to lifecycle events. It does not read seat/team/turn context, does not touch the table UI overlay, does not play or override sound, and — because read-hidden-information is absent — cannot see a face-down card, a pile's order or any secretMetadata, on any peer. That absence is exactly as informative as the presence — a mod with ui declared is one whose author says it draws its own on-table panel; a mod without it cannot, because its api.setUiElement call would fail scanning.

What players are told, at the table#

A capability list is not only read by reviewers. Since the room-level disclosure landed, the capabilities of every mod a table runs are shown to everyone at that table — not only to whoever loaded them.

Two independent sources produce that disclosure and the client unions them, so neither can shrink the other:

Source Derived from Cannot be suppressed by
Registry room.selectedModIds and the stored manifests, served by GET /api/rooms/:roomId/capabilities the host — the joining client fetches it directly, before any peer connection exists
Host the mods whose sandbox frames actually started, published on the replicated room state the registry — it is the only source that can see a mod loaded outside the room's selection

What that produces, in the product:

  • Before joining. A player who opens a table whose capability set includes read-hidden-information gets a blocking prompt naming every mod and its capabilities, and nothing connects until they accept. This is fetched from the server, so a host cannot skip it.
  • While playing. A persistent indicator — a "recording light" — stays visible for as long as such a mod is loaded. It is platform-owned chrome: no mod UI, panel, overlay or fullscreen mode can cover it, and it has its own world-space surface inside an immersive-VR session, where none of the page's 2D chrome renders at all.
  • When it changes. The disclosure is identified by a fingerprint over (mod id, version, capabilities). If a mod is added, a capability appears, or a version changes under a player who already accepted, they are prompted again. A room that installed a package at 1.0.0 stays pinned to 1.0.0 rather than drifting to whatever was published since.

Only read-hidden-information blocks. Every other capability is disclosed in the same places without a modal — making a log-only mod interrupt a join would train players to click through the one prompt that matters. Players can re-read the full list at any time from the player menu.

Two things this does not claim. It is a disclosure layer, not an enforcement one: nothing here narrows what a mod may do, and everything in the sections above still decides that. And a host that runs a mod outside the room's selection and misreports its own live set is not detectable from another peer — the host is the party running the code. What the disclosure closes is the silent case (nobody was told) and the drift case (the set changed after people agreed to it).

For authors, the practical consequence is the one already stated above: an idle read-hidden-information declaration is not just a reviewer's impression any more. It turns a blocking prompt on for every player at every table that loads your mod, and lights a permanent indicator while it runs. Take it off the list when the call goes.

Capability reference#

log#

Gates log(message). The only capability granted by omitting capabilities from the manifest entirely. Declaring only log is a statement that a mod does nothing observable at the table — useful while scaffolding, or for a diagnostics-only mod.

spawn-object#

Gates createObject(object). Fire-and-forget — the call returns nothing, not even the new object's id. A mod that needs to act on what it just spawned also needs read-world to look the object back up (by a label or tag it chose) after the fact.

register-action#

Gates registerAction(action), which adds a labeled entry to the app UI. It grants no ability to do anything when that entry is used — whatever it triggers is gated by whatever other capability that code path uses.

read-context#

Gates getMySeat(), getMyTeam(), getTurn(). The narrowest read capability: only this peer's own seat, team and turn state, from a locally cached value pushed by the host. It exposes no roster of other peers.

read-world#

Gates getSnapshot(), getObject(objectId), listObjects(filter?), getContainerContents(objectId), getHandObjects(seat?) and getZoneObjects(seat, zoneId). listObjects's tags filter is capped at 32 entries and match is coerced to "any" or "all" on the frame side and the host side independently.

All six return the least-privileged view, on every peer including the host. They are passed through the same redactors a spectator with no seat and no team gets, so a face-down card arrives as label: "Card" with metadata.__redacted === true and no displayName, metadata.cardId or secretMetadata; a deck or bag reports at most its single publicly visible face-up first card and never its order; an entity a hidden seat zone conceals is dropped from lists and resolves null from getObject; and a hand whose entries are all concealed is dropped rather than returned empty. A mod is granted nothing extra by the peer it happens to run on, and these six never change behaviour based on what else the manifest declares — one call site, one meaning. Reading a hidden face needs read-hidden-information below.

read-world is also what the four zone/trigger hooks need: onZoneEnter, onZoneLeave, onTriggerEnter and onTriggerLeave are delivered only to a frame granted it, so subscribing to one without declaring read-world is a severity: "error" at publish time even though api.on itself is gated by subscribe-events.

read-hidden-information#

Gates getUnredactedSnapshot(), and nothing else. It resolves the host's table state with no redaction at all: real card faces, a deck's and a bag's ordered metadata.cards, every seat's hand, secretMetadata on every kind, and the entities a hidden seat zone conceals. It exists because the six read-world reads above stopped returning any of that.

Four things make it different from every other slug on this page. It is never implied by read-world and never granted by default. It is re-checked host-side before the state is served, so forging the underlying postMessage does not get past it. It is visible to the publish scannerdetectScriptCapabilities matches api.getUnredactedSnapshot( by name, so calling it without declaring it is an undeclared-capability rejection, which means "this mod reads hidden information" is a fact a reviewer can establish from the script rather than only the manifest. And a capability cannot grant what a peer does not hold: on a player or spectator client this resolves that client's own received snapshot, which the host redacted at the wire boundary before broadcasting, so only on the host is it the full table. Write anything that adjudicates secrets to run on the host.

object-action#

Gates objectAction(objectId, action). action is independently re-checked host-side against a fixed 10-item allowlist (flip, rotate, lock, unlock, shuffle, draw, deal, split, combine, roll). That re-check happens regardless of capability declaration, so tap, untap, delete, lift, flick, press and the reveal-* actions are unreachable from a mod under any capability. See Action vocabularies.

saved-data#

Gates getSavedData(scope?), setSavedData(data, scope?). scope.objectId is the only thing the mod supplies — the host injects the mod's own id before storage, so one mod can never read or write another mod's saved data regardless of what scope it passes.

subscribe-events#

Gates on(eventName, handler). Unlike every other gated method, this one does not message the host per call — it only registers a local handler. The host pushes lifecycle events into the frame independently; declaring subscribe-events is what lets the mod's api.on(...) registration succeed, not what lets events reach the frame.

ui#

Gates getUiState(), listUiElements(), setUiElement(element), deleteUiElement(elementId). setUiElement and deleteUiElement scope to the calling mod's own id, injected host-side — a mod cannot create or delete another mod's UI elements.

play-sound#

Gates playSound(params), setObjectSound(objectId, action, ref). Semantic-only by design: a mod names a material × action event, an object id and action pair, or one of its own declared sounds from manifest.soundSets — never a raw clip id. setObjectSound's ref is host-validated against the mod's own declared sound names, so a mod cannot set another mod's sound as an override.

plugin-call#

Gates listPlugins() and callPlugin(pluginId, functionName, params?). It is the only capability that also requires a second manifest field: plugins, naming each plugin id and each function on it your script calls. Both targets of every api.callPlugin( must be plain string literals so the scanner can read them, an undeclared pair is rejected at publish, and the mod-visible surface deliberately never includes a plugin's endpoints, origins or auth shape. Full rules on Calling a plugin from a mod.

See also#

modCapabilitySchema#

15 values. Declared in manifest.capabilities.allowed. Using one without declaring it is an undeclared-capability rejection.

Capability
log
spawn-object
register-action
read-context
read-world
read-hidden-information
object-action
saved-data
subscribe-events
ui
play-sound
plugin-call
read-cards
read-decks
host-message

Eleven slugs, listed in manifest.capabilities.allowed, each gating a group of api methods. Omit the block and a mod gets ["log"] and nothing more. The scanner infers what a script uses from literal api.<method>( call sites, so declaring fewer than you call is an undeclared-capability rejection while declaring more than you call is not flagged at all — least privilege here is your discipline, not a check.

Nine of the eleven are re-checked host-side, and that is the enforcement that counts. Before acting on a message from a mod frame the host revalidates it against that mod's granted set through MOD_API_METHOD_CAPABILITIES (apps/web/src/mods/SandboxedModRunner.ts), covering log, spawn-object, register-action, read-world, read-hidden-information, object-action, play-sound, saved-data and ui.

read-hidden-information is the one slug that withholds data rather than declaring an intent. It gates api.getUnredactedSnapshot() alone. Every read-world read is redacted to the least-privileged view on every peer, the host included, so hidden card faces, a pile's order and secretMetadata are not in any answer a mod can obtain without it — running on the host does not help. Its host-side check runs before the state is served, it is never implied by read-world, it is never granted by default, and detectScriptCapabilities matches api.getUnredactedSnapshot( by name so an undeclared call is rejected at publish time.

read-context and subscribe-events send the host nothing to re-check. getMySeat, getMyTeam, getTurn and on are answered inside the frame from state the host pushes to every running mod — updateContext posts a contextUpdate, and dispatchEvent posts a hookEvent whatever the mod declared for every hook except the four narrowed ones below — and that state rests on self.__diceytableCtx and self.__diceytableHooks, reachable by ordinary property access, with self absent from the five banned script patterns. For those two a declaration is disclosure rather than enforcement: withholding either hides nothing from the frame. Declare them accurately anyway, and do not rest a game's hidden information on them.

read-world gates four hook deliveries as well as six pull methods, and this is the one narrowing of its kind. MOD_HOOK_EVENT_CAPABILITIES maps onZoneEnter, onZoneLeave, onTriggerEnter and onTriggerLeave to read-world, and SandboxedModRunner.dispatchEvent never posts one of those messages into a frame that was not granted it — so the handler is simply never called, with nothing reported. It is genuinely host-side: requireCapability inside modSandbox.html runs in the untrusted frame's own realm, whereas this check does not, so a frame cannot opt itself back into a hook it was not granted. Everything else is fanned out to every running mod.

Those four payloads are sent in full — they carry an objectId (and, for a trigger, an ownerObjectId) for every entity that crosses, face-down and hidden-zone ones included, and the host-side capability gate is the only control on them. Note the asymmetry this now creates: since the read-world pull reads became least-privileged, a hook can name an id that api.getObject then resolves to null and api.getZoneObjects omits. The push tells you that something crossed; it does not tell you what, and the pull will not fill the gap in. The trigger hooks added no new capability value — neither subscribe-events nor the sandbox was widened for them.

The scanner covers all four on the way in: subscribing with api.on("onZoneEnter" | "onZoneLeave" | "onTriggerEnter" | "onTriggerLeave", …) counts as a use of read-world, so a manifest that omits it is rejected at publish time with undeclared-capability rather than shipping a handler that silently never runs. That detection only ever demands a declaration; it never grants one. See ModCapability.