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-onlyreveal,layout,randomize,fog-of-war,drop), the shared optionsname/interaction/showBoundary/tagFilter, and only the options its own type defines —handits layout/privacy keys,hiddenitshides, andareaitscenterSnapPointand auto-arrange keys (arrange,arrangeColumns/arrangeRows,arrangeCellSize,arrangeGutter). Puttingprimaryon anareazone orhideson ahandzone is a parse error, not a silently-stripped key, which is what stops an author believing an option is doing something. typeis optional with no default, and that is deliberate. A box saved before types existed has notypeat all, and the scene migration uses exactly that to promote a legacy seat's first box tohandand the rest toarea— once. Defaulting the field would make every already-published scene look "already typed" and the promotion would never run.- A linked seat's
zonesandlabelare a derived cache. For a seat whosetemplateLinkis"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 knowingseatTemplateexists — and why hand-editing a linked seat'szonesis 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#
- Object state — the full
TableObjectStateshape everyobjects[]entry has. - Required and conventional files — where
setup.jsonsits in a mod's file tree. - What gets rejected — every setup-file validation error code.
- Sandbox limits — the safety scan
scripts[]is run through. - Limits and caps — every size cap touching a setup file, in one table.
- Choosing a surface — Table Scripting vs. Mod Scripting.
- Mod scripting API —
TableObjectDefinition, the same object shape asapi.createObjectaccepts it at run time.
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 id — instantiateSetupObjects() 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
unlock — flip, 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.
