Dicey Table

Setup JSON

entry.setup (conventionally setup.json) names the file that describes what's on the table when a mod loads: every entity, zone, snap point, and — since the ECS editor work — the scene's own attached scripts. It is authored entirely through Edit Mode; hand-writing one from scratch isn't the expected workflow, though understanding its shape helps when debugging a validation failure or reading a diff.

Two setup-file formats exist in the codebase today, and this page documents only the current one. Every mod saved through Edit Mode since the ECS editor work produces the modern edit-scene format described below. An older mod-setup format (templatesVersion/templates/objects) predates it — see "If you have an older mod" at the bottom of this page for what it looked like and what to do with one. Its removal is tracked separately and is out of scope for this reference.

The modern format: edit-scene#

An edit-scene document is an EditSceneSnapshot (packages/shared/src/sceneEditor.ts, editSceneSnapshotSchema) — the same document shape Edit Mode saves for a scene under active development, serialized to setup.json when you publish. Its top-level fields:

Field Required What it holds
schemaVersion yes 1 or 2. v2 uses repo-relative asset paths; v1 used opaque server ids (asset_<hash>) and is upgraded to v2 on load when an id→path map is available. Both parse.
sceneId yes Stable scene identifier.
name yes Scene display name.
environment yes Table surface color/texture, ambient color, fog. See the editor guide's room/lighting pages for the authoring side.
room no The editable room model — floor/ceiling/wall materials, wall posters, lights.
roomSettings no Per-scene debug-overlay and player-count session settings; backfilled with defaults on load if absent.
materials no Up to 256 project materials.
textures no Up to 512 project textures.
prefabs no Reusable object prefabs — configure a piece once, then place any number of copies. Structurally identical to the legacy mod-setup format's templates[], so one validator serves both. Absent on scenes authored before prefabs existed; read it as prefabs ?? [].
objects yes Every TableObjectState in the scene — the entities themselves.
zones no Legacy authored zones with an ownerSeat; superseded by seatZones and folded into it on load.
seatZones no The current per-seat configuration: a group transform, typed zone boxes and one name label per seat color, plus the seat's template binding. Backfilled with defaults if absent.
seatTemplate no The scene's single seat template — one seat-local name label, typed zones and card-holder definitions, and the uniform scale every linked seat inherits. Absent means every seat is detached, which is exactly the pre-template behavior.
snapPoints no Table-level snap points.
scripts no Up to 64 scene scripts — Table Scripting, not mod scripting. See the generated field table below.
sceneScriptIds no Which of scripts[] are attached at the scene level (vs. attached to an individual object via metadata.scriptId). Pruned on load to drop any id with no matching script.

schemaVersion and environment together are also the exact heuristic ("schemaVersion" in setupJson && "environment" in setupJson) the validator uses to decide "is this edit-scene or the legacy format?" before picking which schema to parse against — see What gets rejected for what happens when a document matches neither.

objects[] has no schemaVersion-scoped array-level cap of its own — see Limits and caps for the caps that do apply (the overall TableSnapshot collection caps, and the draft-level 5 MB setupText size limit).

One field intentionally has no home here or anywhere else: entityPatches, which used to sit alongside objects, was deleted (2026-07-25) because it was write-only — nothing ever read it back on load. An older save containing it still parses; Zod strips the unknown key silently. Do not author it; nothing consumes it.

Object prefabs#

A prefab is a named, reusable object definition: kind, label, scale, color, tags, material, sound overrides and metadata (including an attached script). Placing one copies those fields onto a new object and links it back via metadata.prefabId, so editing the prefab is the one place you change every copy.

{
  "prefabs": [
    { "id": "counter-red", "kind": "token", "label": "Red Counter",
      "metadata": { "tokenShape": "box", "tokenFaceImage": "assets/textures/counter-red.webp" } }
  ],
  "objects": [
    { "id": "s:counter-red:1", "kind": "token", "label": "Red Counter",
      "position": { "x": -0.4, "y": 0, "z": 0 }, "metadata": { "prefabId": "counter-red" } }
  ]
}

A prefab carries no position/rotation — those belong to each placement.

Prefab vs. the model sidecar. A <stem>.meta.json sidecar gives one mesh file spawn defaults, and explicitly cannot give it a name. A prefab names a whole object and works for any kind, mesh or not — so one counter.glb plus one sidecar can back twenty prefabs, each with its own label, art and script. See Required and conventional files.

prefabs is optional with no default: a scene saved before prefabs existed keeps parsing untouched, and the migration deliberately does not add the key to a document that never had one.

Seat zones and the seat template#

seatZones[] and seatTemplate are the authored player-seat layout. Neither ever crosses the data channel — every peer loads the setup document from the mod's own repository, so seat geometry costs zero bandwidth and is identical on every peer by construction. See Player Zones and seat templates for the authoring side.

Three things about the on-disk shape are worth knowing when you are reading a diff:

  • Zone boxes are typed, and the type is a discriminated union. A box carries an optional type (hand, area, hidden, scripting, and the parse-only reveal, layout, randomize, fog-of-war, drop), the shared options name / interaction / showBoundary / tagFilter, and only the options its own type defines — hand its layout/privacy keys, hidden its hides, and area its centerSnapPoint and auto-arrange keys (arrange, arrangeColumns / arrangeRows, arrangeCellSize, arrangeGutter). Putting primary on an area zone or hides on a hand zone is a parse error, not a silently-stripped key, which is what stops an author believing an option is doing something.
  • type is optional with no default, and that is deliberate. A box saved before types existed has no type at all, and the scene migration uses exactly that to promote a legacy seat's first box to hand and the rest to area — once. Defaulting the field would make every already-published scene look "already typed" and the promotion would never run.
  • A linked seat's zones and label are a derived cache. For a seat whose templateLink is "linked", the template is the source of truth and the seat's own stored geometry is re-materialized from it on every load and save. That is why an older client, this file's publish-time parse, and any mod reading the scene all still see correct world geometry without knowing seatTemplate exists — and why hand-editing a linked seat's zones is pointless: the next migrate overwrites them, idempotently.

A seat may define at most one hand zone, and the refine that enforces it runs on publish. editSceneSnapshotSchema is parsed by the scanner (apps/server/src/githubScanner.ts via modManifest.ts), so a document carrying two hand zones for one seat is rejected at registration, not merely warned about in the editor. The same rule applies to seatTemplate.zones.

Table Scripting's place in a setup file#

scripts[]/sceneScriptIds are how a scene's own Table Scripting (the world/globalEvents/ refObject TypeScript surface, distinct from mod scripting's entry.script) rides along inside the same setup document a mod publishes. A mod that has both a setup.json with scene scripts and an entry.script mod script is legal and common — they are two independent surfaces that happen to travel in the same repo. See Choosing a surface if you're deciding which one a given piece of behavior belongs in.

How a setup file is validated#

Local draft validation (validateLocalModProjectDraft, packages/shared/src/modManifest.ts) parses your entry.setup text against editSceneSnapshotSchema first when the schema-version/environment heuristic above says edit-scene, falling back to the legacy schema only if that parse fails — and vice versa when the heuristic says legacy. This is genuinely format-agnostic in both directions, which is why an older mod's setup file still validates without you doing anything. See What gets rejected for the exact error codes this path can produce, and Fixing a rejection for template-instantiation-failed specifically.

Any scripts[] present in an edit-scene document are additionally run through the same static safety scan mod scripts get (validateSceneScriptSafety — see Sandbox limits), and re-checked at serve time alongside the mod script itself.

If you have an older mod#

Before the edit-scene format, a setup file used mod-setup: a flat templatesVersion: "1" document with a templates[] array (reusable object definitions, up to 500) and an objects[] array (up to 500, each either a full object definition or a { templateId, position, … } reference that expands against a declared template at load time). The generated field tables for modSetupSchema, modSetupObjectSchema and tableObjectTemplateSchema below describe this legacy shape in full, for anyone reading an existing mod's file or debugging a template-instantiation-failed error.

This format is not the recommended way to author a new mod. It predates Edit Mode's visual authoring entirely — there was no scene editor for it, only hand-written JSON — and its removal is tracked by a separate plan. There is no in-product "convert this mod" button today: the practical migration path is to open the mod in Edit Mode, let it load and render from the legacy file, and Save — Edit Mode always writes the modern edit-scene format on save, regardless of which format it read.

A standalone, legacy-only validation route, POST /api/mods/validate/setup (apps/server/src/modValidation.ts), exists purely for this older format — it parses only modSetupSchema and reports instantiatedObjectCount, the count after template expansion. It has no edit-scene awareness and is unrelated to the local-draft validation path described above; do not use it to validate a modern setup file.

Generated field tables#

The four schemas below cover, in order: the legacy modSetupSchema envelope, the legacy modSetupObjectSchema union (a definition or a template reference), the legacy tableObjectTemplateSchema — and, the one modern piece among the four, sceneScriptSchema for scripts[].

See also#

modSetupSchema#

Exported from @diceytable/shared as modSetupSchema. 3 fields across 1 table.

Field Type Required Default Min / Max Pattern Rule Description
templatesVersion "1" no "1"
templates tableObjectTemplateSchema[] no [] <= 500 items
objects modSetupObjectSchema[] no [] <= 500 items

templatesVersion#

Always "1" — there has only ever been one version of this legacy format. Its presence (without a sibling environment key) is one half of the heuristic that distinguishes a mod-setup document from an edit-scene one; see Setup JSON above.

templates#

Reusable object definitions an entry in objects[] can reference by id via templateId, so the same die or token doesn't need to be redefined for every placement. Legacy — a new mod authored through Edit Mode never produces this array; every object it saves is a full, self-contained definition.

objects#

Each entry is either a full tableObjectDefinitionSchema object or a { templateId, position, … } reference into templates[], expanded at load time by instantiateSetupObjects(). A reference to a template id that doesn't exist is what produces the template-instantiation-failed scanner error — see Fixing a rejection.

modSetupObjectSchema#

Exported from @diceytable/shared as modSetupObjectSchema. 18 fields across 3 tables.

A value matching any one of the variants below.

Variant Documented at
tableObjectDefinitionSchema see tableObjectDefinitionSchema
tableObjectTemplateReferenceSchema below

modSetupObjectSchema.tableObjectTemplateReferenceSchema#

Field Type Required Default Min / Max Pattern Rule Description
templateId string yes 1–96 chars ^[a-z0-9][a-z0-9._-]*[a-z0-9]$
position vector3TupleSchema yes
rotation vector3TupleSchema <br>same shape as tableObjectTemplateReferenceSchema.position no
scale vector3TupleSchema <br>same shape as tableObjectTemplateReferenceSchema.position no
label string no 1–80 chars
color string no ^#[0-9a-f]{6}$
ownerSeat string | null no <= 40 chars
faceDown boolean no
locked boolean no
stackCount integer no >= 1, <= 1000
containerMode "random" | "stack" | "queue" no
capacityLimit integer no >= 1, <= 1000
container containerStateSchema no 1 further cross-field rule (message built at validation time).
tags string[] no <= 100 items; each 1–32 chars ^[a-z0-9_-]+$
metadata Record<string, unknown> no

tableObjectTemplateReferenceSchema.templateId

The join into templates[], matched by exact string equality against a template's id. Nothing falls back to a template's label or to positional order, and instantiateSetupObjects() resolves the lookup once, at load, before the host builds anything.

A reference to an id that no templates[] entry declares throws during expansion, and that throw is what surfaces as the template-instantiation-failed error — see Fixing a rejection. Templates live in the same document, so this is always a typo you can find without leaving the file.

tableObjectTemplateReferenceSchema.position

The one field a reference must carry, and the one thing a template never holds. A template says what an entity is; a reference says where it sits. That split is the point of the format — ten identical tokens are one template and ten positions.

Because there is nothing to inherit, a placement with no position fails to parse rather than quietly landing at the table center.

tableObjectTemplateReferenceSchema.rotation

Euler angles in degrees, applied by the host as it creates the entity. Omit the field and the template's rotation applies; with neither set the entity spawns axis-aligned.

Rotate on the reference rather than in the template when two placements of the same shape face different ways — a board square to one seat and edge-on to another is one template and two rotations, not two templates.

tableObjectTemplateReferenceSchema.scale

A per-axis multiplier over the kind's default size rather than an absolute measurement, so the same value means the same thing whichever template it lands on. The reference's value replaces the template's outright; the two are never blended axis by axis.

Scale reaches the collision shape the host builds, not only the mesh, so an entity scaled here collides at the size a player sees. Anything you scale far from 1 is usually better authored as its own template.

tableObjectTemplateReferenceSchema.label

Replaces the template's label for this one placement. label is the slug and the machine-facing identity, not a caption — the human-readable name is displayName, and a reference has no field for it.

For a card template this carries more weight than it looks. A card's label is its identity whenever metadata.cardId is absent, and it is the field the host overwrites when it redacts a snapshot for a peer who isn't allowed to see that card. Ten references to one card template that never set a label are ten copies of the same card.

tableObjectTemplateReferenceSchema.color

A #rrggbb tint for the entity, overriding the template's. With neither set the host falls back to the default color for that kind.

Keep the mod's palette in the templates and reach for this only when one placement has to read differently from its siblings — a marked starting space, or one player's token in a set that shares a single shape.

tableObjectTemplateReferenceSchema.ownerSeat

The seat that owns this placement — a seat id such as red or blue, from the same palette the table seats players into. It is what makes per-seat furniture work: a player board, a reference card, a token that starts in front of one chair.

Watch the fallback direction. Expansion resolves this as "the reference's value, or else the template's", and null counts as absent — so writing ownerSeat: null on a reference does not clear an owner the template set. Author the unowned case as a separate template.

tableObjectTemplateReferenceSchema.faceDown

Spawns the entity showing its back. Applies to card and deck templates; against a template of any other kind, expansion throws and the mod fails with template-instantiation-failed.

The reference schema can't see the template's kind, so a misplaced faceDown parses cleanly and only breaks when the setup file is expanded — which is why the error names the template rather than the field. See Fixing a rejection.

tableObjectTemplateReferenceSchema.locked

Pins the entity in place. The host gives it a static rigidbody, so it doesn't fall, doesn't shift when something lands on it, and refuses drags. Boards, mats and scenery want this; anything a player picks up does not.

Locking also stops grab escalation: grabbing a child of a locked entity moves the child instead of walking up to the locked ancestor. A locked board therefore keeps the pieces parented to it draggable, which is usually exactly what you want.

tableObjectTemplateReferenceSchema.stackCount

Deck thickness at spawn. Applies to deck templates only; against any other kind, expansion throws as template-instantiation-failed.

It is not a way to deal a short deck. The host materializes the card list first — from metadata.cards, from a custom-deck definition, or as a standard 52-card set — and then overwrites stackCount with that list's length. Author ten entries in metadata.cards if you want ten cards; setting stackCount: 10 alone gets you a 52-card deck.

tableObjectTemplateReferenceSchema.containerMode

Which card a container hands out when something draws from it: stack takes the one on top, queue takes from the bottom of the pile, random draws blind. Declared here it overrides the template's own value for this one placement, rides the object definition into the snapshot, and is what mods reading table state see.

It is also what the host draws by. resolveContainerConfig (packages/shared/src/tableContainers.ts) takes this field first, falls back to the legacy metadata.containerMode key, and only then to the per-kind default — random for a bag, stack for everything else. Set the behaviour here rather than in metadata: a legacy metadata key is moved onto the field and deleted the first time the table is loaded.

tableObjectTemplateReferenceSchema.capacityLimit

How many cards this placement's container may hold, overriding the template's own figure. Like containerMode, it is replicated state a mod can read back — and it is the number the host enforces.

The enforcement is one check: a combine that would push the merged pile past the target container's capacity is refused outright, leaving both piles as they were. containerCapacityFor (apps/web/src/playcanvas/TabletopRuntime.ts) resolves the figure through resolveContainerConfig — this field first, then the legacy metadata.containerCapacity key, then unlimited. Nothing checks it on any other return route, so set it for the hand or slot you want to stop filling by merge, not as a hard inventory cap.

tableObjectTemplateReferenceSchema.container

Override the template's container configuration for this one placement. Applies to bag templates; the five keys are documented on containerStateSchema.

It replaces the template's container wholesale — it does not merge into it. Expansion resolves this field as "the reference's value, or else the template's", the same way label and locked resolve, and unlike metadata, which is the one field that merges key by key. So a reference that supplies { "secretContents": true } and nothing else gets a container with no form at all and fails validation, not a secret copy of the template's bag. Restate the whole object, form included.

The common use is one bowl template placed several times with different starting contents — one bag per player, each with its own pieces, sharing a model, a collider and an interior. When every placement wants the same contents, author them on the template and leave this out.

tableObjectTemplateReferenceSchema.tags

Author-owned classification for this placement. The pattern leaves no room for :, which is what keeps the platform's reserved dt: namespace unwritable from a setup file — a mod can't mint a tag the platform treats as its own.

Expansion drops the field. instantiateSetupObjects() copies the label, transform, color, seat, container and metadata onto the instantiated definition and carries no tags, so tags authored on a legacy reference or its template validate and then never reach the table. Tag entities in Edit Mode instead — see Tags and groups.

tableObjectTemplateReferenceSchema.metadata

The freeform bag, shallow-merged over the template's: the reference's keys win, one top-level key at a time. There is no deep merge, so overriding metadata.cards replaces the whole card list rather than patching one entry in it.

This is also where the host reads settings that have no column of their own — a deck's card list and custom deck art, a container's draw mode and capacity, an object script's scriptId. When a behavior seems to have no field, it lives here.

modSetupObjectSchema.tableObjectTemplateReferenceSchema.position#

Field Type Required Default Min / Max Pattern Rule Description
x number yes
y number yes
z number yes

tableObjectTemplateReferenceSchema.position.x

One of the two table-plane axes, in feet, with 0 at the table's center. Stepping x by 1 moves an entity one foot sideways, which is the unit to think in when you lay out a row of spaces or space tokens evenly apart.

tableObjectTemplateReferenceSchema.position.y

Height, in feet, as an absolute world Y - measured from the room, not from the table. So it means "above the table" only relative to whichever table your game assigns:

Table Play surface y
No table assigned (the default table) -0.02 - think of it as 0
The built-in Dining Table (what new games start on) 1.5
A published table pack its own tablePack.surface.surfaceY

On the default table y: 0.5 is half a foot of air; on the Dining Table the same half foot is y: 2. A y below the assigned surface starts the entity inside the table, where physics will push it back out. Nothing records which table a position was written for, so changing your game's table does not move anything by itself - the Game Editor offers to move your pieces by the height difference when you pick the new table (see Assigning a Room or Table).

Author a little above the surface and let the entity settle rather than trying to land it exactly. Two placements that differ only in y are stacked on each other, not side by side.

tableObjectTemplateReferenceSchema.position.z

The second table-plane axis, in feet, again measured from the table's center. A board's grid of spaces varies in x and z together while y stays flat across all of them, and per-seat furniture is mostly a matter of pushing z out toward one edge.

tableObjectTemplateSchema#

Exported from @diceytable/shared as tableObjectTemplateSchema. 20 fields across 2 tables.

Field Type Required Default Min / Max Pattern Rule Description
id string yes 1–96 chars ^[a-z0-9][a-z0-9._-]*[a-z0-9]$
kind "card" | "deck" | "die" | "token" | "board" | "bag" | "custom" | "card-holder" | "button" yes
label string yes 1–80 chars
rotation vector3TupleSchema no
scale vector3TupleSchema <br>same shape as rotation no
color string no ^#[0-9a-f]{6}$
ownerSeat string | null no <= 40 chars
faceDown boolean no
locked boolean no
stackCount integer no >= 1, <= 1000
containerMode "random" | "stack" | "queue" no
capacityLimit integer no >= 1, <= 1000
container containerStateSchema no 1 further cross-field rule (message built at validation time).
tags string[] no <= 100 items; each 1–32 chars ^[a-z0-9_-]+$
material soundMaterialSchema no
soundSetOverrides soundSetOverridesSchema no
metadata Record<string, unknown> no

Whole-object rules:

  • 1 further cross-field rule (message built at validation time).

id#

The template's address inside templates[], and the only thing a { templateId, position, … } entry in objects[] can resolve. It is not the entity's idinstantiateSetupObjects() copies no id onto what it builds, so the runtime assigns each instance a fresh one.

Everything else a template declares is a default the reference may replace: label, rotation, scale, color, ownerSeat, faceDown, locked, stackCount, containerMode, capacityLimit and metadata are all re-declarable there and win when present, while kind, material and soundSetOverrides are template-only. That override relationship is the whole reason to declare a template: one shape, many placements that differ in a field or two.

kind#

Fixes which of the nine kinds every instance is — card, deck, die, token, board, bag, custom, card-holder or button — and it is the one template field a reference cannot override.

Choosing it also decides which siblings are legal. stackCount parses only on a deck template and faceDown only on card and deck; either on the wrong kind fails the schema's refinement before the mod loads. Changing a shipped template's kind changes every reference to it at once, which is rarely what you want — declare a second template instead. See Object kinds.

label#

The slug: the machine-readable identity every instance carries. A template has no displayName field and instantiateSetupObjects() writes none, so this is also the text a player reads on the pieces it produces.

Every reference that doesn't re-declare label yields an entity with the same one, so give each placement its own unless the pieces really are interchangeable. And on a card template the label is the card's identity — the host overwrites it with the placeholder Card when it redacts a hidden card for a peer who isn't entitled to see it (redactObjectForViewer, packages/shared/src/tableObjects/redaction.ts) — so two different cards must never share one.

rotation#

The resting orientation every instance starts at, as Euler angles in degrees; createObject hands the three components straight to setEulerAngles.

This field is what explains the template's shape. A template says how an entity sits but never where it sits, because position is required on the reference and supplied once per placement. Declare the angle here — a rack tilted toward the seats, a board turned to face north — and each of the twelve references that use it needs nothing but coordinates. A reference that needs a different angle re-declares rotation and replaces the whole vector, not one axis of it.

scale#

The entity's size in world units — feet — written onto its world scale and its collider together. It is a size, not a multiplier on the kind's default: normalizeObjectScale supplies that default only when you omit this field entirely.

Applies to: die, token, board, bag, custom, card-holder. On card and deck the value is discarded at creation. syncStackScale (apps/web/src/playcanvas/TabletopRuntime.ts) overwrites both kinds with the standard card footprint, and derives a deck's height from stackCount at one 0.2 mm sheet per card, so a deck visibly thins as it is drawn from. Resize a card through its deck sidecar rather than here.

color#

A six-digit hex tint for the entity's body. Omit it and the runtime uses its per-kind default, so you only need this when a piece must read as different from its neighbors — the red set against the blue set, the one die that scores.

That is also the usual argument for overriding it on the reference instead of setting it here: the template holds the shape both sides share, and each side's references bring their own color. The pattern is case-insensitive, so #A1B2C3 parses; the three-digit shorthand #fff and named colors do not.

ownerSeat#

The seat that owns the entity. Together with faceDown it is the hidden-information entitlement the host evaluates before it sends a snapshot: a face-down card owned by a seat is legible to that seat and, in team play, to that seat's team, and to nobody else. A face-down card with ownerSeat unset or null is legible to no peer at all (isCardIdentityVisibleToViewer, packages/shared/src/tableObjects/redaction.ts).

Nothing validates the string against the room's seat map. A seat id the room never assigns parses fine and then matches no viewer, which shows up as a hand its owner cannot read. Set ownership on the reference: one template serves every seat, and only the placements differ.

faceDown#

Whether instances start face-down. Applies to: card and deck. On the other six kinds the schema's refinement rejects the template, and a reference that supplies one against a non-card/deck template makes instantiateSetupObjects() throw Template <id> (<kind>) cannot define faceDown.

It is not a cosmetic flag. Face-up is public — the host sends a face-up card's identity to every peer no matter who owns it — so faceDown is what gives ownerSeat anything to do. Deal a hand face-down and owned; lay a market row face-up and unowned.

locked#

Pins instances in place. Applies to: every object kind. A locked entity is static to the physics simulation, and canApplyObjectAction (packages/shared/src/tableObjects.ts) refuses every action on it except unlockflip, roll, draw, deal and delete included — so locking a container also stops anyone drawing from it.

Set it on a template for furniture that never moves: the board, a play mat, a scoring track. Leave it off anything a rule has to act on. If a piece only needs to be immobile for part of a game, ship it unlocked and lock it from a script, rather than authoring a lock you then have to work around.

stackCount#

How many cards a deck starts holding. Applies to: deck, and only deck — the schema's refinement rejects it on the other eight kinds, and a reference that supplies one against a non-deck template makes instantiateSetupObjects() throw Template <id> (<kind>) cannot define stackCount.

The count is load-bearing, not decorative: the deck's physical height is recomputed from it whenever it changes, and split is offered only while it is above 1. It gives you an anonymous pile of that many cards. Name the actual cards in metadata.cards when the identities matter — which for anything but a face-down draw pile, they do.

containerMode#

The order a container hands out its contents: stack takes the card a player can see on the pile's face, queue takes from the back of it, random takes any one. Applies to: deck and bag, the two kinds that answer draw and deal. On the other six the schema accepts the field and nothing consumes it.

What you declare here is what the host obeys. Every object built from this template carries the value onto the table, and the draw and deal paths resolve it through resolveContainerConfig (packages/shared/src/tableContainers.ts): this field first, then the legacy metadata.containerMode key, then the per-kind default — random for a bag, stack otherwise. Leave metadata out of it; a legacy metadata.containerMode is moved onto the real field and deleted on the next load.

capacityLimit#

The most items a container is meant to hold, 1–1000. Applies to: deck and bag.

It is enforced, not merely recorded. The merge path asks containerCapacityFor (apps/web/src/playcanvas/TabletopRuntime.ts), which resolves this field first, then the legacy metadata.containerCapacity key, then unlimited — so a capacityLimit of 200 declared here does refuse the merge that would make 300. Declare it on the template and every placement inherits it; a reference may re-declare its own.

Two limits on what that buys you: the ceiling is read off the pile being merged into, and combine is the only route that checks it, so a container can still be filled past its limit by any other means. A refusal is silent.

container#

The container contract, on a template. Applies to kind: "bag" templates — see tableObjectDefinitionSchema.container for the five keys and containerStateSchema for each one in detail.

Authoring it here is the usual place for it: a template is how a mod ships a bowl, a tray or a supply bag with its form, its source piece and its starting contents already set, so every reference to the template spawns a container that is ready to use.

A reference can override it wholesale — the reference's own container replaces this one rather than merging into it, so an override must restate form. That is what you want for the common case of one template with several starting fills; it is a trap if you expected { "secretContents": true } on the reference to leave the template's contents alone.

tags#

Author tags for grouping and for a script's tag filters. The character class is lowercase letters, digits, _ and -; : is deliberately outside it, and that omission is what makes the reserved dt: platform namespace — dt:object, dt:internal, dt:kind:card — unforgeable by a mod (packages/shared/src/objectTags.ts). Uppercase is rejected rather than lowercased for you.

Tags declared on a template do not reach the table: instantiateSetupObjects() (packages/shared/src/tableObjects.ts) assembles each instance field by field and omits tags from both the template and the reference, so anything placed through templateId arrives untagged. Tag a full tableObjectDefinitionSchema entry in objects[] instead. See Known limitations and Tags and groups.

material#

What every instance is made of — wood, cardboard, metal, plastic, card, tile, generic or silent. It picks the clip set a collision or a pickup plays, and it also scales mass: each material carries a density multiplier relative to the kind's baseline (MATERIAL_PHYSICS, packages/shared/src/tableObjects.ts), so a metal token lands heavier than a cardboard one and both keep their kind's other physics.

Template-only — tableObjectTemplateReferenceSchema has no material — so every instance shares this one value, and silent here silences all of them. See Sound sets.

soundSetOverrides#

Per-action clip choices layered over whatever material would otherwise pick — a partial map from a sound action (place, pickup, roll, shuffle, and the rest of that vocabulary) to a sound reference, either a first-party semantic name or one of your own names declared in manifest.soundSets. Never a raw clip id.

Reach for it when one entity is supposed to sound wrong: a cursed die that clatters like metal on an otherwise wooden set, a lid that thumps. Anything broader belongs in material, which is one field instead of a dozen entries. Template-only, like material — a reference cannot override it. See Sound sets.

metadata#

The open bag of per-kind detail the schema does not model, and where most of what actually drives behavior lives: cards for a container's ordered contents, customModelAssetId for an imported model, tokenShape. (Draw order and capacity are not among them — declare those as the template's own containerMode and capacityLimit; the matching metadata keys are legacy and a load migrates them away.) A template with an empty metadata produces a plain primitive whatever its kind says.

It is the one field merged rather than replaced. instantiateSetupObjects() spreads the template's keys and then the reference's over them, one level deep — a reference key of the same name wins outright and a nested value is swapped whole, not merged. Re-declare the entire cards array if you change any part of it.

tableObjectTemplateSchema.rotation#

Field Type Required Default Min / Max Pattern Rule Description
x number yes
y number yes
z number yes

rotation.x

Pitch, in degrees, about the world X axis — the value that tips an instance forward or back.

This is what stands a flat piece up. A board or a card near 90 leans on its edge instead of lying down, which is what a display rack or a propped-up player aid wants. Nothing clamps or normalizes the number, so 370 and 10 orient identically and both round-trip through the manifest unchanged.

rotation.y

Yaw, in degrees, about the vertical axis, and the one of the three you set nine times out of ten: it turns an instance to face a different seat without changing what it is resting on.

Because a template carries no position, whatever you put here is the shared facing for every placement of it — 180 for the seat opposite, 90 and 270 for the two sides. When the pieces should each look inward at their own seat, give each reference its own rotation and leave the template's out.

rotation.z

Roll, in degrees, about the world Z axis: the lean to one side that leaves an instance facing where it was.

It is the least-used of the three and the one to set deliberately rather than by feel. A dynamic piece authored at an angle it cannot rest at — a die at z: 45, balanced on a corner — hands the physics simulation an unstable pose and it settles somewhere you did not pick. Angled scenery is what this axis is for; turning a gameplay piece toward a seat is y.

sceneScriptSchema#

Exported from @diceytable/shared as sceneScriptSchema. 18 fields across 2 tables.

Field Type Required Default Min / Max Pattern Rule Description
id string yes 1–96 chars
name string yes 1–120 chars
description string no <= 500 chars
language "typescript" no "typescript"
source string yes <= 262144 chars
compiled string | null no null <= 524288 chars
refKind ("card" | "deck" | "die" | "token" | "board" | "bag" | "custom" | "card-holder" | "button") | null no null
variables scriptVariableSchema[] no [] <= 32 items 1 further cross-field rule (message built at validation time).
updatedAt string no

id#

The address, and the only thing that attaches a script to anything. The scene attaches by listing an id in sceneScriptIds; an entity attaches by putting the same id in its metadata.scriptId. Neither side ever looks at name.

The editor mints these as script-<uuid> and rewrites both reference sites when you delete a script, so ids stay opaque and you never need to type one. An id listed in sceneScriptIds with no matching entry in scripts[] is pruned on load rather than failing the file.

name#

The file name shown in the editor's script list and code-editor tab, .ts extension included. It is a label for you, not an attachment key — renaming a script breaks nothing, because everything that points at a script points at its id.

The editor keeps the name git- and URL-safe on your behalf: renaming slugifies the stem, re-adds .ts, and de-duplicates against the other scripts, so two scripts in one scene never end up sharing a name.

description#

A short note about what the script is for. Nothing in the editor writes it and nothing displays it — a value you put here by hand parses, survives a save, and is read by no one.

Put the explanation in a comment at the head of the script instead. That travels with the code a reader is already looking at, and comments survive transpilation into the body the host runs.

language#

Always "typescript" — Table Scripting has exactly one authoring language, and the field exists to name what source holds rather than to offer a choice.

Do not read it as a switch you could set to JavaScript to skip the build. JavaScript is the other surface's language: a mod script is a .js file in the repo that the sandbox loads directly. See Choosing a surface.

source#

The author-facing TypeScript, exactly as the editor's code panel shows it. Never executed directly — see compiled below.

compiled#

The transpiled JavaScript actually run by the sandbox host — produced by the editor's TypeScript worker on save, and scanned with the same static safety patterns a mod script gets. null means this script has never successfully compiled and will not run. Comments survive transpilation, which matters for the scanner — see Sandbox limits § the static scanner.

refKind#

Which object kind this script was written for, recorded when it is created from an entity's Script section. It is an authoring hint and never a runtime gate: the editor declares refObject as that kind's handle type ("deck" gives DeckObject) and seeds a kind-specific starter body, and the Script section warns when the script is attached to a different kind — but the host still attaches on metadata.scriptId alone, and a mismatched script runs exactly as it always did.

null means the script is not written for one kind: a global (scene) script, or any script authored before this field existed. Those keep the generic ObjectHandle typing, which is why the field is safe to add to a save that has never carried it.

See Object Types for the handle types this selects between.

variables#

The typed slots this script needs filled in, lifted out of source at save time and stored here so the entity inspector can render a row per variable without running anything. This array is the script's declaration — never its values. What each slot is actually wired to is a binding, and bindings live per attachment: on the attaching entity's metadata.scriptVars, or in the scene document beside sceneScriptIds. The same script on two entities therefore points at two different pieces while sharing this one array.

Entries are produced by a strict literal reader, not an evaluator: exactly one top-level declareVariables({ ... }) call whose argument is an object literal of object literals. Anything the reader cannot read without executing it — a spread, a computed key, a function call, a second call — is a save-time diagnostic, and the previous variables array is kept, so a transient typo never wipes out the bindings authored against it.

At most 32 entries, and name must be unique across the array. The cap is a replication budget rather than an ergonomic one: bindings ride an entity's metadata, which travels in every spawn intent and snapshot delta, so one pathological script must not be able to inflate the whole table's wire traffic. Defaults to [], which is what every script saved before this field existed reads as.

See declareVariables for the authoring side.

updatedAt#

An ISO timestamp the code editor stamps each time you save, alongside the new source and compiled. Absent means the script has not been saved since it was created — which also means it has no compiled body yet and will not run.

It records authoring history and nothing else. Scripts start in sceneScriptIds order, not in save order, so a newer timestamp never means a script runs later.

sceneScriptSchema.variables#

Field Type Required Default Min / Max Pattern Rule Description
name string yes 1–64 chars ^[A-Za-z_$][A-Za-z0-9_$]*$
type "object" | "objectList" | "spawnable" | "zone" | "number" | "string" | "boolean" | "vector3" | "color" | "seat" yes
label string no <= 80 chars
description string no <= 240 chars
kind "card" | "deck" | "die" | "token" | "board" | "bag" | "custom" | "card-holder" | "button" no
tag string no <= 32 chars
min number no
max number no
default scriptVariableLiteralSchema no

Whole-object rules:

  • 1 further cross-field rule (message built at validation time).

variables.name

The property name in the declaration literal — the key a script reads off the object declareVariables returns (vars.landingBox), and the key a binding is stored under. It is the variable's identity: renaming it in the source is a new variable as far as bindings are concerned. The old binding is never re-pointed at the new name: it is simply never looked up again, and it is dropped for good the next time the attachment is rewritten.

Also the inspector row's fallback caption when no label is given, which is why a readable camelCase name is worth more here than in ordinary code. 1–64 characters.

variables.type

What kind of thing may be bound to the slot, and therefore what the script reads back. Ten values, in two groups.

The reference types name something already on the table or in the project: object (one entity), objectList (several), spawnable (a standard-library preset or a prefab authored in this project, fed to world.spawnObject), and zone (one seat zone). A reference resolves lazily, at read time, and reads null — or, for objectList, simply omits the missing entries — whenever what it names is not on the table.

The literal types carry an authored value inline: number, string, boolean, vector3 (an [x, y, z] array), color (a full #rrggbb string) and seat (a seat id). Only these accept a default, and only number accepts min/max.

Changing a variable's type invalidates any binding already authored against it. A binding whose shape no longer matches the declaration resolves to nothing — null, [], or the declared default — rather than being coerced into the new type, and it is dropped outright the next time the attachment is rewritten.

variables.label

The caption the inspector puts on this variable's row. Falls back to name when absent, so it exists purely to say something friendlier than an identifier — "Landing box" rather than landingBox.

Presentational only: nothing addresses a variable by its label, and changing one never disturbs a binding. At most 80 characters.

variables.description

The hint shown under the row in the inspector — a sentence telling whoever is wiring the script up what belongs in this slot, and what happens if it is left empty.

Worth filling in for any variable whose correct binding is not obvious from its label, because the inspector is the only place a non-programmer meets this script. Presentational only, at most 240 characters.

variables.kind

Restricts an object or objectList slot to entities of one kind. Two things follow from it, and both matter.

At authoring time the inspector greys out non-matching entities in the picker tree and refuses a non-matching drop, so the author sees why a piece cannot go in the slot rather than finding an empty row. At run time the filter travels to the sandbox beside the bound id and is re-checked on every read, so an entity that arrives under that id as the wrong kind reads null instead of handing the script a card where it expected a deck — and starts resolving if a matching entity later takes the id.

Declaring it also narrows the type the script sees: { type: "object", kind: "deck" } reads as DeckObject | null, so a deck's methods are offered and a typo is caught in the editor.

Validated against the nine real object kinds (card, deck, die, token, board, bag, custom, card-holder, button), so a misspelling is a save-time diagnostic rather than a filter that quietly never matches. Accepted only on object and objectList; on any other type it rejects the declaration.

variables.tag

Restricts an object or objectList slot to entities carrying this tag. Combines with kind — both must pass — and is checked in exactly the same two places: the inspector's picker at authoring time, and the sandbox on every read at run time. Re-tagging a piece can therefore empty a slot the inspector still shows as bound, and tagging one can fill it again without the script restarting.

Unlike kind, a tag is authored freeform and cannot be validated against anything, so a tag nobody applies is a filter that never matches rather than a diagnostic. At most 32 characters. Accepted only on object and objectList.

variables.min

The inclusive lower bound of the inspector's numeric editor for a number variable. Accepted only on number; on any other type it rejects the declaration, and a min greater than max rejects it too.

It bounds the editor, not the read. Nothing re-applies it when the value reaches the script, and a binding that arrives from an imported save or a hand-edited document is never clamped against it — so a script about to loop or allocate on this number should clamp it itself.

variables.max

The inclusive upper bound of the inspector's numeric editor for a number variable, and the natural companion to min. Accepted only on number, and rejected when it is below min.

Carries the same caveat as min: it constrains what the inspector will let somebody type, and nothing else. The value the script reads is never clamped against it, so treat it as guidance to the author rather than a guarantee to the code.

variables.default

The value a literal variable reads when nothing is bound to it — the number, string, boolean, [x, y, z] array or #rrggbb colour a script starts life with before anybody configures it. Validated against the declared type at save time, so { type: "color", default: "#fff" } is a diagnostic rather than a colour that fails somewhere later.

Meaningless on the reference types. object, objectList, spawnable and zone have nothing to substitute when unbound — they read null (or []) — so a default on one of them is rejected rather than honoured.

Omitting it does not leave a literal variable undefined: an unbound number reads 0, a string reads "", a boolean reads false, a vector3 reads [0, 0, 0], a color reads "#ffffff", and a seat reads null. A default is how a script says something more useful than the type's empty value, and it is what lets an unconfigured script still run.