Dicey Table

Object state

TableObjectState is a Scene Entity's full serialized state: what a TableSnapshot carries per entity, what replicates to every peer, and what the persistence layer saves. Its schema is tableObjectStateSchema in packages/shared/src/tableObjects.ts. TableSnapshot also carries 7 sub-collections for things that are not table objects — zones, snap points, vector lines, decals, text labels, joints, and world-space UI — covered in the second half of this page.

packages/shared type names were not renamed when the editor's user-facing labels changed (D-3 of the ECS work). TableObjectState, label, displayName and every other identifier below keeps its schema name even where the Inspector shows different English — see ECS terminology.

The three names, at the schema level#

Every entity carries three distinct name-shaped fields. Full treatment, including the slug-normalization rules and the Name/Slug field split, lives at IDs, names and tags; this section is the field-by-field summary.

Field Schema type Required Addresses the entity?
id string, 1–96 chars yes Yes — the only one that does.
label string, 1–80 chars yes No. The slug: unique in the scene, machine-facing, and for kind: "card" the card's identity — it is what drives hidden-information redaction.
displayName string, 1–80 chars, optional no No. The human name. Falls back to label when absent; objectDisplayName() is the one resolver every surface (Hierarchy row, entity name, status line, context menu) uses so they can never disagree. Redacted with the card: deleted from any copy — on the wire or from a mod read — whose viewer is not entitled to that card's identity.

Nothing — no script, no mod, no runtime lookup — resolves an entity by label or displayName. id is assigned on creation and never edited by hand.

secretMetadata — the place for a secret#

metadata is public: apart from the two identity keys (cardId on a card, cards on a container) it is identical in every copy of the snapshot. secretMetadata is the same shape (Record<string, unknown>, any JSON value per key) with one difference — the host removes it outright from any copy whose viewer is not entitled to the entity's identity. It exists so a card's real identity has a home that is not the human-readable Name.

Kind Who receives it
card Exactly whoever may see the card's face: it is face-up, or in the viewer's own hand, or revealed to the viewer's team.
deck The host only, always.
every other kind The host only, always.

A mod is not "whoever". Since 2026-08-14 the six read-world reads answer as the least-privileged viewer — a spectator with no seat and no team — on every peer including the host, so a mod sees secretMetadata on exactly one thing: a card whose face is already public. Read the table above as the wire rule; the mod rule is one line, and it is "no".

The card rule is the same predicate that governs label and metadata.cardId (isCardIdentityVisibleToViewer) — there is deliberately no second notion of visibility to drift out of sync with it. The deck and other-kind rule is the closed one on purpose: a deck has no per-viewer identity concept at all (its ordered contents are withheld from every peer, D2), so there is no entitlement to derive an answer from, and "host only" is the answer that cannot be wrong. That also means the field can never be accidentally public on a kind nobody thought about.

Four things to know before you use it:

  • Capped at 2 048 bytes of JSON per entity (MAX_SECRET_METADATA_BYTES), measured on the UTF-8 encoding of JSON.stringify(value). Over the cap the object fails schema validation. It rides on every entity of a snapshot capped at 5 000, which is why it is an order of magnitude tighter than the 16 KiB mod saved-data cap; anything bulky belongs in modObjectSavedData.
  • Absent, never {}. An entity without a secret has no such field, so an empty bag can never enter a snapshot delta as a spurious change.
  • It is a replication boundary, not encryption. The host's runtime holds it in full, and it is written to the save unredacted — a saved game keeps its full hidden state, so treat a save file as you would the host's own memory. Redaction is applied on the way out of the host: at the wire boundary, and — since 2026-08-14 — at the mod read boundary as well.
  • A mod running on the host does not read it. That was true until 2026-08-14 and is the single most likely thing to trip up an existing mod. read-world no longer grants a frame the peer's own view; the elevated read is api.getUnredactedSnapshot, gated by the read-hidden-information capability, which a player can read in the mod's manifest.
  • A face-up card's secret is public. The gate is the identity entitlement, not a separate switch. Anything that must stay hidden after a reveal does not belong here.

Absence is ambiguous: it means either "the author set none" or "you were not entitled", and nothing distinguishes those. Write every mod rule to tolerate its absence, wherever the mod is running.

migrateTableSnapshot() prunes an over-cap or non-object secretMetadata on load rather than rejecting the snapshot — the same policy as parentId below, for the same reason: a hand-edited save must never be able to wedge a room.

Field reference#

Fields not already covered by Object kinds (which covers the kind-specific meaning of stackCount, containerMode, capacityLimit, faceDown, metadata) or the three-names table above:

Field Type Notes
kind TableObjectKind See Object kinds.
position, rotation, scale Vector3Tuple Always world-absolute on the wire, even for a parented entity — every consumer (peers, mods, listObjects, the TTS/TTP bridges, saved games) already reads them as world, so keeping them local would make reconstruction order-dependent.
color string, #rrggbb Hex color; interpretation is kind-dependent (tint vs. accent).
ownerSeat string | null, ≤40 chars The seat holding this entity in-hand. Any object with a non-empty ownerSeat counts as a hand card in getHandObjectsFromSnapshot, regardless of kind.
locked boolean Governs the locked-entity action gate. Switches the rigidbody to static while locked; unlock restores the authored body type.
tapped boolean, optional Set by the tap/untap actions. Not gated by isObjectActionAllowedForTarget for any kind today — see Object actions.
tags string[], ≤100, each [a-z0-9_-] 1–32 chars Author tags. The dt: prefix is reserved and unauthorable — see below.
parentId string | null, ≤96 chars, optional The parent Scene Entity. Transform/organization only, never a physics constraint. null explicitly means "detached"; absent means "never parented" — the distinction matters for delta comparison (strictEq, which treats null and undefined as different values here).
physics ObjectPhysics, optional Per-object overrides (bodyType, mass, friction, restitution, linearDamping, angularDamping, collisionShape, collisionSize, collisionOffset, rigidbodyEnabled, collisionEnabled, weldChildren). Every field absent = use the runtime's per-kind default (see Object kinds).
components ObjectComponentState[], optional Optional engine components — see below.
material SoundMaterial, optional One of the 8 sound materials; drives both the default sound set and (via defaultObjectPhysicsForKind's suggestion path) editor placeholder physics.
soundSetOverrides Partial<Record<SoundAction, SoundRef>>, optional Per-SoundAction overrides; each value is a license-safe SoundRef, never a raw clip id.
metadata Record<string, unknown> The structured extension point, and public apart from the two identity keys — cardId is deleted and cards reduced to the publicly-visible front entry for a viewer not entitled to them, with __redacted: true set. Kind-specific data lives here — see Object kinds for what each kind actually reads. Keep it small: it rides in every snapshot.
secretMetadata Record<string, unknown>, optional, ≤2 KiB of JSON The non-public bag: stripped from any viewer not entitled to the entity's identity, which for a mod read means everyone. See above.
velocity, angularVelocity Vector3Tuple Live physics state, present only on TableObjectState (not TableObjectDefinition — you cannot spawn an entity with a starting velocity).

Optional engine components#

components[] is capped at 8 entries (MAX_OBJECT_COMPONENTS) with at most one per type. The addable set today is light and camera (OBJECT_COMPONENT_TYPES) — not script, which stays on metadata.scriptId with its own section, and not render, collision or rigidbody, which are intrinsic to every entity and are configured through physics/metadata.materialId rather than listed here. See ECS terminology §4 for the two-tier reasoning.

  • light{ type: "directional" | "omni" | "spot", color, intensity, range, innerConeAngle, outerConeAngle, castShadows }, defaults to enabled.
  • camera{ clearColor, fov, nearClip, farClip, projection, orthoHeight, priority }, defaults to disabled on add and its priority is clamped by the runtime to stay below the table camera — a second enabled camera changes what the whole table renders through, so enabling one is a deliberate second action.

A component payload with a type this client does not recognize is rejected at the schema boundary, not silently dropped — silently dropping it would let an older client resave the entity and permanently discard a newer client's authoring.

The reserved dt: tag namespace#

Tags beginning dt: are platform-owned and cannot be authored: OBJECT_TAG_PATTERN (^[a-z0-9_-]+$, case-insensitive) excludes :, so a setup file declaring "dt:internal" fails schema validation outright rather than being accepted and ignored. They still appear in anything that reads an entity's tags. Full list and rationale at IDs, names and tags.

parentId and persistence — read this before assuming data loss#

TableSnapshot itself carries no explicit schema-version field — the "v2" label lives only in code comments and the migrateTableSnapshot() function name; everything ECS added to TableObjectState (parentId, components, displayName, physics.rigidbodyEnabled/collisionEnabled/weldChildren) is optional and additive on top of that unversioned wire shape, so no migration bump was needed. secretMetadata landed the same way — optional and additive, so an older save parses unchanged and there was again no version bump.

migrateTableSnapshot() (packages/shared/src/tableObjects/migrations.ts) does one thing to parentId on every load: it prunes, never backfills. Self-parents, dangling parent references, cycles, and chains deeper than the configured maximum are all rewritten to null before the snapshot is even parsed, so a hand-edited or corrupted save can never wedge the runtime in an infinite parent walk.

On native (DiceyTable-format) export/import, parentId is lossless — the full schema parses it like any other field. It is only the older, format-agnostic TTS/TTP bridge that has a narrower field set, and that importer was recently widened (not narrowed) to also carry parentId, displayName, tags, tapped, physics, material, soundSetOverrides, components, containerMode, capacityLimit and secretMetadata where the source data has them. Export is unredacted by design — a save keeps its full hidden state — so an exported file carries every entity's secretMetadata in the clear. Treat a save file as you would the host's own memory.

The 7 sub-collections#

TableSnapshot carries seven optional arrays beyond objects[], each with a matching *DefinitionSchema (spawn-time input) and *StateSchema (full serialized form) pair — mirroring the TableObjectDefinition / TableObjectState split. All seven schemas live in packages/shared/src/tableObjects.ts.

Zones#

A seat/interaction rectangle on the table (tableZoneDefinitionSchema / tableZoneStateSchema). id, label, position, size: { x, z } (each 0 < value ≤ 24), rotationY, ownerSeat, interaction (allow / owner-seat-only / blocked), visibility (all / owner-seat-only), metadata.

Snap points#

A point that pulls a dropped entity into alignment (tableSnapPointDefinitionSchema / tableSnapPointStateSchema). id, label (≤80, optional), position, rotationY, snapRadius (0 < value ≤ 2), ownerSeat, metadata.

Vector lines#

A drawn/annotation line (tableVectorLineDefinitionSchema / tableVectorLineStateSchema). id, points (2–1000 Vector3Tuples), color (#rrggbb), thickness (0 < value ≤ 10), rotation, metadata.

Decals#

A projected image on a surface (tableDecalDefinitionSchema / tableDecalStateSchema). id, name (≤80), url (≤2048), position, rotation, scale, metadata.

Text labels#

Floating 3D text (tableTextLabelDefinitionSchema / tableTextLabelStateSchema). id, text (≤256), position, scale (0 < value ≤ 5), color, fontSize (0 < value ≤ 100), metadata.

Joints#

A physics constraint between two independently dynamic bodies (tableJointDefinitionSchema / tableJointStateSchema) — never parenting; see the parenting/joint/weld split. id, type (fixed / hinge / spring), objectAId, objectBId, enableCollision, breakForce (0 < value ≤ 1,000,000), metadata.

UI#

World-space or screen-anchored mod UI (tableUiElementDefinitionSchema / tableUiElementStateSchema, collected under tableUiStateSchema with a revision counter). id, parentId (nesting within the UI tree, unrelated to entity parentId), type (text / button / checkbox / input / panel / canvas / layout), order, ownerSeat, visibility (all / seat / team / players, each scoped to a list), presentation (world, or screen with an anchor + pixel offset), layout, props (shape validated per type via a discriminated union), metadata, ownerModId.

See also#

  • Limits and caps — the size cap on every array on this page.
  • Object kinds — kind-specific field meaning.
  • IDs, names and tags — the full treatment of id / label / displayName.
  • Mod scripting APITableObjectState, the same fields as the mod declarations state them.
  • Mod hooks and capabilitiesModTableEventPayload.object, where a hook hands you one of these.
  • ObjectHandleObjectData, the table-script view of the same entity, which is a different and smaller shape.
  • TypesSpawnObjectOptions, the table-script side of creating one.

tableObjectDefinitionSchema#

Exported from @diceytable/shared as tableObjectDefinitionSchema. 54 fields across 6 tables.

Field Type Required Default Min / Max Pattern Rule Description
id string no 1–96 chars
kind "card" | "deck" | "die" | "token" | "board" | "bag" | "custom" | "card-holder" | "button" yes
label string yes 1–80 chars
position vector3TupleSchema yes
rotation vector3TupleSchema <br>same shape as position no
scale vector3TupleSchema <br>same shape as position no
color string no ^#[0-9a-f]{6}$
faceDown boolean no
locked boolean no
stackCount integer no >= 1, <= 1000
containerMode "random" | "stack" | "queue" no
capacityLimit integer no >= 1, <= 1000
tags string[] no <= 100 items; each 1–32 chars ^[a-z0-9_-]+$
components objectComponentSchema[] no <= 8 items 1 further cross-field rule (message built at validation time).
displayName string no 1–80 chars
physics objectPhysicsSchema no
castShadows boolean no
receiveShadows boolean no
material soundMaterialSchema no
soundSetOverrides soundSetOverridesSchema no
materialSlots materialSlotsSchema no materialSlots may not hold more than 64 entries.
metadata Record<string, unknown> no
faceValue integer no >= -9999, <= 9999
overlay tableObjectOverlayStateSchema no
ownerSeat string | null no <= 40 chars
parentId string | null no 1–96 chars
diceTray diceTrayCustodySchema no
secretMetadata secretMetadataSchema no secretMetadata exceeds 2048 bytes when JSON-encoded.
container containerStateSchema no 1 further cross-field rule (message built at validation time).

id#

The address, and the only thing that resolves an entity — neither label nor displayName does. Leave it out and the host mints a UUID as it creates the entity (createObject, apps/web/src/playcanvas/TabletopRuntime.ts), which is right for most entries: a piece nothing points at never needs a name you chose.

Write one when something has to reach this entity — another entry's parentId, or a script that looks it up. Uniqueness is then yours to keep. The runtime indexes objects by id and nothing rejects a repeat, so a second entry claiming an id takes the address over and strands the first entity with nothing able to name it. Ids, names and tags has the full three-name split.

kind#

The one field with no default and no way back. kind fixes the collider and the mass, the fallback size and color, the sound material, and which actions the entity answers — nothing in the intent vocabulary changes it afterwards, so a different kind is a different entity.

Most of the rest of this schema is scoped by it: stackCount means something only on deck, faceDown only on card and deck, scale and color are ignored on both. custom is the escape hatch for an imported model. Object kinds walks all nine and what each is for.

label#

The slug — the machine key, unique in the scene, and the field Edit Mode's Inspector shows as Slug. It is what a table script reads as ObjectData.name, and for kind: "card" it is also the card's identity: customDeckCardIdForObject falls back to label when metadata.cardId is absent, and redactObjectForViewer (packages/shared/src/tableObjects/redaction.ts) overwrites it with Card when a viewer isn't entitled to see that card. Renaming a card for readability changes which card it is.

The schema checks only the length, so Chess Board parses here. Keep it lowercase, digits and dashes anyway — that is what the editor writes and what every machine reader expects. displayName is where the capitals and the spaces belong.

position#

Where the entity starts, in feet, and the only geometry field you must supply — rotation and scale both fall back to per-kind defaults. The three numbers are world-absolute even when parentId is set: parenting preserves the world transform rather than reinterpreting them as an offset from the parent, so a child written at { x: 0, y: 1, z: 0 } is one foot above the table origin, not one foot above its parent.

Spacing is yours to get right. Nothing de-overlaps a setup for you, so two dynamic entries sharing a spot spawn interpenetrating and the solver pushes them apart on the first frame — a starting layout that looks tidy in the file and scatters the moment the table loads.

rotation#

Euler angles in degrees, world-absolute, applied as the entity is created. Absent means { x: 0, y: 0, z: 0 }.

y is the yaw, and it is the one you almost always want: it turns a board to face a seat, or fans tokens around the table without tipping them. x and z tip a piece off its resting face, which for a dynamic body is a starting pose and not a resting one — a die authored at 30° on x falls flat as soon as the simulation runs. Use them for furniture you have also marked locked, where nothing is going to knock the angle back out.

scale#

The entity's local scale. Every built-in shape is a unit box or cylinder, so for those the three numbers are the piece's full size in feet; on an imported model they multiply the model's own dimensions instead. Absent means the per-kind default (normalizeObjectScale, apps/web/src/playcanvas/physics/PhysicsEngine.ts) — a die is 0.05 ft on a side, a board 1.2 × 0.9 ft.

Applies to: die, token, board, bag, custom, card-holder. On card and deck the host derives the scale from the poker-card footprint and the stack height, at creation and again on every stack change (syncStackScale), so whatever you write is discarded. A deck gets taller by holding more cards, not by being scaled.

color#

The base tint of the primitive shape the entity spawns with. The pattern in the table above is matched case-insensitively — a flag the column cannot show — so #4F7CFF parses as readily as #4f7cff, though the editor always writes lowercase.

Applies to: die, token, bag, custom, card-holder, and board — except that a board carrying metadata.boardImageUrl or metadata.boardStyle replaces its material outright and stops using color, and so does any entity carrying a metadata.materialId that resolves to a project material. On card and deck it is ignored entirely: those render through the shared card materials, so their faces come from the deck art and their edges from the card body.

faceDown#

Which way up the entity starts. Absent means false everywhere except kind: "deck", where the host defaults it to true — a deck you say nothing about arrives backs-up, which is what a draw pile wants and what a face-up discard pile has to opt out of.

Applies to: card, deck. On any other kind a full definition accepts the field and nothing reads it, while a template or template reference that sets it is rejected outright. It is the other half of the hidden-information rule: a face-up card is public to every peer regardless of ownerSeat, and only a face-down one is redacted on the wire.

locked#

Freezes the entity. The host gives it a static body at creation and the pick, drag and transform paths all skip it, so nothing a player does moves it. Absent means false.

This is the field for furniture — a board, a card rack, a scoreboard prop — that should stay exactly where you placed it while the game happens around it. It also propagates upward through parenting: a grab on a child stops escalating at a locked ancestor instead of dragging the whole assembly by it. The authored physics.bodyType is left untouched, so unlocking restores the body the entity would otherwise have had.

stackCount#

How many cards a pile starts with, and the number its body height comes from — a deck is stackCount sheets of 0.2 mm card thick, so a full pack stands about as tall as a real one.

Applies to: deck. This schema carries no cross-field refinement (the template schema does), so a token or a board with a stackCount parses here and nothing ever reads it.

It is not the field that fills the deck. The host builds the contents from metadata.cards, or from metadata.customDeck, or — given neither — from a standard 52-card French deck, then overwrites stackCount with however many entries it built. Ask for 10 and name no cards and you get 52. Name the cards and let the count follow.

containerMode#

Which end of a container a draw comes off: stack takes the card showing on the pile's face, queue takes the one at the back, random picks any of them. Applies to: deck and bag, the two kinds that answer draw and deal. On the other six the field parses and nothing consumes it.

This is the control, and writing it here is enough. resolveContainerConfig (packages/shared/src/tableContainers.ts) owns the precedence — this field first, then the legacy metadata.containerMode key, then the per-kind default (random for a bag, stack otherwise) — and both draw paths reach it through containerDrawModeFor in apps/web/src/playcanvas/TabletopRuntime.ts, so the host and every peer resolve the same answer. A metadata.containerMode on an older object still works, but it is legacy: migrateTableSnapshot moves it onto this field and deletes the key on load, so after one save cycle there is exactly one spelling of the draw order.

capacityLimit#

The ceiling on a container's contents, 1–1000. Applies to: deck and bag.

This is the number the host enforces. containerCapacityFor (apps/web/src/playcanvas/TabletopRuntime.ts) resolves it through resolveContainerConfig — this field first, then the legacy metadata.containerCapacity key, then unlimited — and refuses a combine whose result would exceed it. A legacy metadata.containerCapacity still works but is migrated onto this field and deleted from metadata on load, so write the field.

The check runs on the merge target, and it is the only capacity check in the runtime: nothing tests it when a card returns to a pile by any other route, so a container can end up holding more than it declares. A refused merge is silent — both piles simply stay as they were.

tags#

Author tags — the cheap grouping mechanism, and what a script's tag / tags filters match against. They cost no schema and they are many-to-many, which makes them the right way to say "these twelve tokens are the red army" when id can only say "this one token."

The character class is lowercase letters, digits, _ and -. : sits deliberately outside it, and that omission is the entire reason the platform's reserved dt: namespace — dt:object, dt:internal, dt:kind:card — cannot be written from a mod (packages/shared/src/objectTags.ts). Uppercase fails the parse here rather than being folded down for you, so Red rejects the file where red passes; the runtime's normalizeObjectTags lowercases and de-duplicates only what reaches it later. See Tags and groups.

components#

Optional engine components on this entity, and the addable set is exactly two: light and camera. The intrinsic three — render, collision, rigidbody — are on every entity already and are configured through the entity's physics state and metadata.materialId, never listed here and never removable. Scripts are not in this array either: an object script attaches through metadata.scriptId.

At most one entry per type — that is the cross-field rule the table refers to, and a duplicate light rejects the file rather than being ignored. A camera entry defaults to enabled: false on purpose: the engine renders through every enabled camera, so switching a second one on puts the whole table inside your prop. Add component covers the same two from the Inspector side.

displayName#

The human name — free-form, capitals and spaces welcome (Chess Board). It fills the Hierarchy row, the Inspector's Name field and pc.Entity.name, and when it is absent every one of those falls back to label (objectDisplayName, packages/shared/src/tableObjects.ts), so an entity is never nameless.

Nothing resolves an entity by it; that is id's job. A mod can read it back off TableObjectState, but a table script cannot see it at all — ObjectData.name is the label. Set it whenever the slug you need for machines is not the phrase you want a player to read.

physics#

Per-object physics, overriding the per-kind defaults field by field. Absent fields keep the default, so { "collisionShape": "sphere" } changes the collider and nothing else. An absent physics object entirely means the entity is whatever its kind says it is: see defaultObjectPhysicsForKind (packages/shared/src/tableObjects.ts) for the per-kind starting point.

The reason this is on the definition and not only on live state is that a spawn has to be able to carry it. A setup file that places a modelled piece usually needs the collider matching its silhouette, and until it could be written here the only way to get one was to edit the object after it existed. The built-in checkers, go stones and Chinese-checkers marble use exactly this: each names the analytic shape its mesh actually is, instead of taking the box a token would otherwise get.

castShadows#

Whether this entity casts a shadow. Absent means "inherit", not "off".

Three levels resolve it, most specific first:

  1. castShadows on the entity, here — an explicit true or false.
  2. The model's per-asset default: castShadows in its .meta.json sidecar. That one is authoring config for the whole model, read locally by every peer from the mod tree, and never appears in a snapshot.
  3. true. Every entity kind casts by default, so an existing table looks exactly as it did.

Because absent and false are different answers, write the key only when you mean to override the model. Leaving it out is how an entity keeps following its model's default; setting it to false opts that one entity out even when the model casts.

Shadow rendering is the single largest per-frame cost on a busy table — the shadow pass redraws every casting mesh from the light's point of view — so this is the field to reach for on entities that cannot visibly benefit: a flat playmat lying on the table, a backdrop, a large low decorative prop. Turning it off on the pieces players actually look at is a visual downgrade for very little, and the runtime already skips the shadow pass entirely while nothing is moving.

Replicated per entity, so changing it reaches every player, survives a save, and can be cleared back to "inherit".

receiveShadows#

Whether shadows are drawn onto this entity. Absent means "inherit", not "off".

The mirror of castShadows and resolved the same way: the value here, else the model's per-asset default (receiveShadows in its .meta.json), else true.

The two are independent, and the useful configurations are not symmetric. A table surface or playmat usually wants castShadows: false with receiving left on — it is what the pieces cast onto, so losing that is the one shadow players actually read. An entity that glows or is meant to look self-lit is the opposite case: leave casting alone and set receiveShadows: false so nothing darkens it.

Turning receiving off is cosmetic, not a performance lever. The cost of the shadow pass is driven by what casts, not by what receives.

Replicated per entity, so changing it reaches every player, survives a save, and can be cleared back to "inherit".

material#

The entity's physical material, and despite the name this is a sound field: it decides which built-in clip set every interaction resolves to. silent is the sentinel for "make no noise at all". The visual material is metadata.materialId — different key, different system, easy to reach for by mistake.

Absent means the per-kind default: card for card and deck, plastic for die and token, wood for board and card-holder, tile for bag, generic for custom. Setting it in a setup file changes sound only — the matching weight and friction live on physics, so a metal token becomes heavier as well as louder only if you set both. See Sound sets.

soundSetOverrides#

Per-action sound replacements for this one entity, keyed by sound action (place, pickup, drop, slide, shuffle, roll, fall, topple and the rest). Each value is either { kind: "builtin", material } — borrow another material's clips for that action — or { kind: "mod", modId, name }, naming a set your manifest declares. A raw clip id, path or URL is not expressible here, and that is a licensing boundary rather than an omission.

Resolution runs override first, then your mod's default mapping, then the built-in set for material or the kind (resolveSoundRef, apps/web/src/playcanvas/audio/). Reach for an override when one entity should sound unlike everything else of its material: a bell token that rings, a coin that lands soft. See Sound sets.

materialSlots#

Per-slot visual material assignment for an entity with a multi-material model: a map from a source material name to the material to use in its place. The key is the name the material carries inside the model file"TimerScreen_mat", "d20_mat" — which is also the name the Edit Mode slot list shows you. The value is a material id: a builtin: preset or a project material's guid.

{
  "kind": "custom",
  "label": "timer",
  "position": { "x": 0, "y": 0.2, "z": 0 },
  "materialSlots": {
    "TimerScreen_mat": "builtin:felt",
    "TimerCase_mat": "builtin:metal"
  }
}

Keyed by name, never by mesh index. Indices are an artifact of how the model was exported: adding a mesh, reordering the export, or re-running an optimizer renumbers them, and an index-keyed assignment then quietly points at a different part of the model with nothing to show for it. The material name survives all of that. If a model genuinely uses one name for two materials they share a slot — the honest trade against silent mis-assignment.

Three levels resolve what a given part of the model renders with, most specific first:

  1. materialSlots["<that part's source material name>"], here — an explicit assignment always wins.
  2. metadata.materialId — the all-slots shorthand, unchanged and still fully supported. It is the fallback for every slot with no explicit entry, so a single-material entity needs nothing else and anything already setting it keeps behaving exactly as it did.
  3. The material embedded in the model.

A name that matches nothing in the model is inert, not an error, and a material id that resolves to nothing leaves that slot on its model material — so a stale entry after a re-export degrades to the original look rather than to a missing-texture entity.

At most 64 entries, keys up to 96 characters and ids up to 128. Replicated per entity, so an assignment reaches every player, survives a save and an export, and can be cleared back to the model's own materials.

metadata#

The unvalidated escape hatch, and in practice where a good deal of an entity lives. It replicates and persists like every other field, and nothing checks its shape, so a misspelled key is silent rather than a scan error.

The runtime reads real behavior out of it: cards and customDeck (a deck's contents and its art), cardId (a card's identity when label is not it), standardPresetId, tokenShape, boardImageUrl, materialId (the visual material), scriptId (the object script to attach) and grabbableWhileParented. containerMode, containerCapacity and infinite are legacy container keys: they are still read as a fallback, but the first-class containerMode / capacityLimit / container fields outrank them and a load moves them across and deletes them. __redacted is the host's own marker, written onto the wire copy of an entity a viewer isn't entitled to see — never author it yourself.

faceValue#

The number printed on the face a settled die is showing, exactly as printed. The host computes it when the die comes to rest and replicates it, so every peer reads the same number instead of each client guessing at the geometry itself.

Absent means no value — never null, never 0. A die that is tumbling, held or cocked (leaning on a piece, wedged against a wall, or resting past half the angle between two of its faces) has no face to read and the field is dropped rather than left stale; a die imported as a custom model carries no face table and never gets one, which is why a tooltip on it shows the name and nothing else. There is deliberately no default. The field is bounded to whole numbers from -9999 to 9999 rather than assumed positive, so a die that prints 0 or a negative is representable — and a default of 0 would then make "settled on zero" indistinguishable from "not settled".

Applies to: die. Every other kind accepts the field in a full definition and nothing ever reads or writes it. The dice shipped as standard presets print 1sides, including the d10, which is numbered 110 here rather than 09.

It is on the definition, not only on the state, because the runtime rebuilds an entity field by field from whatever definition it is handed — on first sight of an object, and for every entity on the table after a host migration. A field that lived only on the state would be dropped on exactly those paths, and a die at rest never settles again, so its number would be lost for good.

⚠ Redacted with identity. A die inside a hidden seat zone does not publish what it landed on: the field is stripped from the wire copy sent to any viewer not entitled to that entity's identity, the same way label and secretMetadata are. Nothing in the schema itself makes it secret.

overlay#

Live overlay values and counter counts — the only part of the card overlay / counter feature that is ever replicated.

A card overlay has two halves and the split is the whole design. Everything presentational — the region rectangle a value is drawn into, its font size and colour, a counter's art, anchor and stacking mode, and the layout template that decided which regions apply to this card at all — lives in the mod's data/cardSchema.json and is resolved locally by every client from the mod's own repo. None of it is expressible here. What crosses the wire is numbers:

{ "values": { "pwr": 2, "tou": 2 }, "counters": { "plus-one": 3 } }

values is keyed by the mapped field key your schema already declares in fields[], so a card's live power really is "the pwr field, plus a delta" and a script reading it needs no translation table. counters is keyed by counter definition id.

A spawn may carry this, so a mod's setup.json can place a card that starts with counters on it. Carrying it on the definition is also what makes it survive an entity being rebuilt — first sight of an entity on a joining peer, an editor Type change, and every entity on the table after host migration.

Changing a value on an entity that already exists is a different operation with a different gate: send a card-overlay-set intent. The host resolves the region or counter definition from its own copy of data/cardSchema.json, refuses any key no definition declares, and checks the per-definition mutation permission (host / controller / seated / script). None of that depends on your UI having hidden a button.

Per-entry visibility is redaction, not rendering. Each region and counter declares visibility: "hidden" | "count-only" | "full", defaulting to hidden, and the host strips concealed entries from the snapshot it sends to a peer who is not entitled to that card's identity. Nothing in this bag makes a value secret by itself; the declaration in the card schema does.

ownerSeat#

Which seat holds this entity. null — the default — means the shared table. The eight seats are named red, blue, green, yellow, purple, orange, pink and cyan; the schema only checks the length, so a misspelled seat parses and then matches nobody.

It does two jobs. On a card it makes the body kinematic, which is what keeps a hand card from falling. And on card and deck it is half the hidden-information rule: a face-down card with an owner is visible to that seat and that seat's team, while a face-down card without one is host-only. Treat an authored value as a starting state rather than a binding: the host recomputes ownerSeat from the entity's position every time a player drops it, so a seat you wrote but did not place inside that seat's zone is cleared on the first move.

parentId#

The entity this one is attached to; absent or null makes it a root. Parenting is transform and organization: the child is held kinematic so it cannot fight its parent's body, and position stays world-absolute because the host preserves the world transform when it attaches. Chains cap at eight deep, and a parentId naming a missing entity, itself, or anything that closes a cycle is pruned to null on load rather than trusted.

Grabbing a child moves the whole assembly. That is deliberate — "I glued this token to its base, so moving it should move the base" — and the per-child opt-out is metadata.grabbableWhileParented. For two pieces that should both keep simulating while staying connected you want a joint instead. A mod reads this field back off TableObjectState; a table script cannot see it at all. See Parenting and Welding.

diceTray#

Marks a die as still belonging to the player who rolled it. Present on a die that is sitting where it landed after a roll; absent on every other entity and on every die a player has picked up and placed.

Three fields, all required when the record is present: seat, peerId and rollId. You do not author it — the host writes it when it spawns the dice for a roll and unsets it when a player takes one — but it is in the snapshot, so a script or a save inspector will meet it.

Two behaviours hang off its presence and nothing else. Clearing a roll removes the dice the roller still holds, which is exactly the dice that still carry this field; and a die in custody sits in a dedicated collision group whose mask admits only the table surface, the roll region's walls and other rolling dice, so a roll cannot shove the board around. Picking a die up and placing it unsets the field, which promotes it back to the ordinary dynamic group — a kept die is an ordinary die again, collides with everything, and survives every subsequent clear. That promotion is the feature: "roll, then keep some dice" is why rolled dice are real entities rather than an animation.

Applies to: die. A custody record on any other kind is dropped on load, the same way faceValue is — nothing writes it there, nothing would ever clear it, and a stray record would leave a permanent entity that a clear can delete and that other entities pass through. The same normalisation drops a malformed record rather than failing the whole snapshot, so a hand-edited save cannot wedge a room.

It is public. Dice are not hidden information: whose roll a die came from is already visible from where it is sitting, so this field is not redacted for any viewer. It also cannot carry an identity leak onto a card, because the load-time normalisation deletes it from anything that is not a die.

⚠ If you are writing a tool that edits snapshots, an unset that fails to arrive has two visible faces — a kept die that a later clear deletes, and a kept die that other entities pass through. Clear the field by removing it, never by writing a placeholder record.

secretMetadata#

The property bag that does not reach every player. It is shaped exactly like metadata — a Record<string, unknown>, any JSON value per key — and the one difference is the only thing that matters: the host strips it from the snapshot it sends to any peer who is not entitled to that entity's identity.

The per-kind rule, enforced in redactObjectForViewer (packages/shared/src/tableObjects/redaction.ts):

Kind Who receives secretMetadata
card Exactly whoever may see the card's identity — a face-up card, a card in the viewer's own hand, or one revealed to the viewer's team. A face-down card on the table, or in someone else's hand, is stripped.
deck The host only, always. A deck has no per-viewer identity notion at all — its ordered contents are already withheld from everyone — so there is no entitlement to grant one from.
every other kind The host only, always. Same reason: no entitlement exists, so "visible" cannot be justified.

That table is the wire rule. A mod script is judged against it as a spectator with no seat and no team, on every peer including the host, so the only secretMetadata a read-world read ever carries is a face-up card's — which by then is not a secret. To read your own secret back while the card is still face down, declare read-hidden-information and use api.getUnredactedSnapshot.

This is the sanctioned home for "which card is this really". label is a card's identity and is rewritten to Card when hidden; displayName is the human name and is removed when hidden — but both are visible the moment the card is face-up, and both are the fields the editor puts in front of an author. Put the answer here instead of encoding it in a name.

Capped at 2 048 bytes of JSON per entity (MAX_SECRET_METADATA_BYTES), measured on the UTF-8 encoding of JSON.stringify(value). Over the cap, the whole definition fails validation and the spawn is rejected — the field rides in every snapshot on an entity list capped at 5 000, so it is deliberately an order of magnitude tighter than the 16 KiB mod saved-data cap. Anything bulky belongs in saved data, keyed by entity id.

It is a replication boundary, not a cryptographic one. The host's runtime holds the value in full and it is written to the save unredacted, so treat a save file as you would the host's own memory. It protects a secret from the other players at the table; it does not protect one from whoever is hosting. It does withhold the value from a mod running on the host, which has been true since 2026-08-14 and is the assumption most likely to break an existing mod.

container#

The container contract: what sort of container this entity is, whether it ever runs out, what it dispenses, and what it is holding. Applies to kind: "bag" — the wire kind behind everything the editor calls a Container, bowls and trays included.

It is one nested object with five keys, each documented in containerStateSchema:

Key What it settles
form holder (open, physical, stores nothing) or bag (closed, virtual, stores its contents). Required.
infinite Never depletes; dispenses copies of source.
source What an infinite container hands out. Absent means it adopts the first piece put in.
contents A finite bag's stored pieces, insertion-ordered and run-length encoded.
secretContents Opt-in hidden information, with a real bandwidth cost.

It is a first-class replicated field, not metadata, and that is the point. metadata.infinite sat on the bag-infinite preset for a long time as a label no code read. A real field is validated by Zod at every boundary — spawn intent, setup.json, save load, import, a mod's createObject — compared field by field for delta replication, and carried through the snapshot migration. Write it here.

Two rules worth knowing before you author one:

  • A container on any other kind is dropped on load. Nothing writes it there and nothing would read it, so the loader removes it rather than leaving a key that quietly means nothing.
  • Every bag gets one. A bag loaded without a container is backfilled with { "form": "bag" }, which is exactly the behaviour a bag had before this field existed. You never have to add it to an old file to keep that file working — you add it to change the behaviour.

Containers do not nest: a stored piece's definition has no container key at all.

What reads it today. The field is validated at every boundary, replicated, migrated and preserved across save and load, and the container's form and infinite flag already decide which actions its menu offers. The behaviour the rest of this page describes — putting a piece in, taking one out, and the "looks full" fill — arrives with the container runtime; author against the contract, and expect a container you write now to be carried intact until it does.

tableObjectDefinitionSchema.position#

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

position.x

The lateral axis, in feet, measured from the table center. Positive x runs to one side, negative to the other, and nothing about a piece changes when you mirror it across zero.

The table is not square, so equal offsets on x and z are not equally far out: the default seat ring is an ellipse about 6 ft along x against 4 ft along z. A row of tokens laid out on x has much more room before it reaches a seat than the same row laid out on z.

position.y

Height, in feet, as an absolute world Y. Where the tabletop is depends on the table the game assigns: -0.02 on the default table (close enough to zero to think of the surface as y = 0), 1.5 on the built-in Dining Table that new games start on, or a table pack's own tablePack.surface.surfaceY. Measure from that surface.

This is the axis that decides whether a piece rests or drops, and it is a center, not a base. A body rests when its center is half its own height above the surface: about 0.025 for a 0.05 ft die, about 0.25 for a 0.5 ft bag. Lower and it starts inside the table and the solver pushes it back out; higher and it falls, which is what you want for a die and not for a board.

position.z

The remaining horizontal axis, in feet, from the table center — x and z together are the tabletop plane, and y is up.

z is the short axis of the default seat ring, so it is the coordinate that separates the seats facing each other: the red seat's zone is centered at x: 0, z: 4 and measures 1.6 by 0.5 ft, with the other seven spaced around from there. Placing a deck inside a zone does not itself give it an ownerSeat — that is assigned from position when a player drops something — but it does put a shared pile inside one player's reach, which is rarely what a starting layout meant.

tableObjectDefinitionSchema.physics#

Field Type Required Default Min / Max Pattern Rule Description
bodyType "static" | "dynamic" | "kinematic" no
mass number no > 0
friction number no >= 0, <= 1
restitution number no >= 0, <= 1
linearDamping number no >= 0, <= 1
angularDamping number no >= 0, <= 1
collisionShape "auto" | "box" | "sphere" | "capsule" | "cylinder" | "convexHull" | "mesh" no
collisionSize positiveVector3TupleSchema no
collisionOffset vector3TupleSchema <br>same shape as position no
rigidbodyEnabled boolean no
collisionEnabled boolean no
weldChildren boolean no

physics.bodyType

How the entity participates in simulation. dynamic is moved by forces and collisions, kinematic is moved only by you but still pushes dynamic bodies, and static never moves at all and is the cheapest of the three.

Absent means the per-kind default, which is dynamic for everything except board and card-holder. Note that locked also produces a static body without touching this field, and the two are independent: an unlocked entity with bodyType: "static" stays put and still responds to picks and transforms. Body type also constrains the collider — see collisionShape, where mesh is invalid on a dynamic body.

physics.mass

Mass in kilograms; must be greater than zero. Only dynamic bodies have one — the field is accepted and ignored on a static or kinematic entity, because in Ammo a zero-mass body is precisely what "static" means.

Absent means the per-kind default: 0.02 kg for a die, 0.05 for a card, 0.1 for a token, 0.2 for a custom model, 0.3 for a deck or bag. These are real-ish figures and worth keeping so: mass is what decides whether a thrown die scatters a stack of chips or bounces off it, and a piece an order of magnitude out of scale with its neighbours behaves like a bug even though nothing is broken.

physics.friction

Coulomb friction, 0 to 1. Low values slide; high values grip. Absent means the per-kind default, which runs from 0.5 on a die (it needs to tumble) through 0.6 on a card to 0.8 on a board.

This is the field that decides whether a piece stays where it lands on a crowded surface, and it is worth setting alongside a smooth collider rather than after one misbehaves. A sphere is the clear case: give a marble a true sphere collider and low friction and it will slide as well as roll, so the built-in Chinese-checkers marble pairs its sphere with 0.45 here and leans on angularDamping to settle it.

physics.restitution

Bounciness, 0 to 1 — the fraction of speed kept through an impact. 0 is a dead drop, 1 would bounce back to the height it fell from. Absent means the per-kind default: 0.05 for a card or deck, 0.1 for a token, 0.3 for a die, 0 for a board.

Keep it low for anything that should settle quickly. High restitution on a small light piece is the usual cause of a spawn that will not come to rest, because each bounce also re-wakes the neighbours it lands on. Glass and stone sit higher than wood or card — the built-in marble uses 0.25 — but even those stay well under half.

physics.linearDamping

Drag on straight-line motion, 0 to 1: how quickly a moving entity loses speed with nothing touching it. Absent means the per-kind default, 0.05 on a die up to 0.2 on a deck or bag.

Damping is the blunt instrument for a piece that travels too far after a flick. It is not friction — it applies in the air as well as on the cloth — so raising it a long way makes throws feel syrupy. Prefer friction when the problem is sliding across the table, and this when the problem is a piece that keeps going after it should have run out of energy.

physics.angularDamping

Drag on rotation, 0 to 1 — the rotational twin of linearDamping. Absent means the per-kind default, 0.05 on a die (which must be free to tumble or rolls stop looking random) through 0.2 on a token to 0.3 on a bag.

This field matters most to round pieces, and for a reason that is easy to miss: Ammo's rolling friction is not exposed through PlayCanvas, so angular damping is the only lever that stops a sphere rolling. A box collider hides the problem by catching on its own corners; swap in a true sphere and the entity will roll until it leaves the table unless this comes up with it. The built-in marble uses 0.55.

physics.collisionShape

The collision geometry, which is usually the only field here worth setting. auto keeps whatever the kind already configured, box, sphere, capsule and cylinder are analytic primitives fitted to the model's measured bounds, convexHull wraps the mesh in its convex hull, and mesh is the full triangle mesh.

Pick the primitive that IS the shape rather than the most detailed option. For a disc, a cylinder is exact and costs less than a hull of the same disc; for a sphere, sphere is exact and a hull is strictly worse; a convex body such as a biconvex go stone is described exactly by convexHull, so nothing is approximated there either. Reach for mesh only for genuinely concave static geometry — a btBvhTriangleMeshShape is invalid on a dynamic body, and the host quietly substitutes convexHull when you ask for one anyway. Both mesh-based shapes need loaded render geometry to build from, so on an entity whose model is still downloading the change is deferred rather than lost.

Changing this rebuilds the Ammo shape and re-creates the body, which is not free; setting the same value twice is a no-op.

physics.collisionSize

Explicit full dimensions for a box collider, in object-local units — that is, measured as though scale were 1, because the host multiplies scale back in when it builds the shape. Absent means the collider is auto-fitted to the model's measured bounds, which is the right answer nearly always.

This exists for imported models whose bounds are not what you want to collide with: one with a wide flat base, or one whose exported bounding box includes empty space. It applies to box colliders only — the analytic primitives and the mesh shapes take their size from the geometry and ignore it. Pair it with collisionOffset when the box also needs to sit somewhere other than the model's centre.

physics.collisionOffset

Where the collision box sits relative to the entity origin, in object-local units. Absent means the auto-fitted centre.

Paired with collisionSize, and like it, box colliders only. The case it solves is an imported model whose origin is not in the middle of it — a piece authored standing on the world origin, say, whose auto-fitted box is therefore centred half a model above where it should be.

physics.rigidbodyEnabled

Whether the entity's rigidbody component is switched on. Absent means enabled, so every existing setup file keeps its behaviour.

Disabling freezes the entity without discarding what you authored: mass, friction and the rest stay on the component and come back when it is re-enabled. That is deliberate, and it is why this is a flag rather than a removal — render, collision and rigidbody are intrinsic to a table entity and can never be taken off it.

physics.collisionEnabled

Whether the entity's collision component is switched on. Absent means enabled.

Disabling makes the entity non-colliding while leaving it fully visible — it will pass through everything and everything will pass through it. Useful for a decorative prop that should not get in the way of play. As with rigidbodyEnabled this disables rather than removes, so the authored shape survives and returns intact.

physics.weldChildren

Welds this entity's children into it as a single rigid body, so an assembly collides as one shape instead of as a loose pile of parts that can be knocked out of alignment.

This is a property of the parent, not of the children, and it changes what parenting means: ordinary parenting is transform and organisation only, with a parented child held kinematic so it cannot fight its parent. Welding goes further and merges the collision geometry. Unwelding restores each child's own collider from the shape it had authored.

tableObjectDefinitionSchema.overlay#

Field Type Required Default Min / Max Pattern Rule Description
values Record<string, cardOverlayValueSchema> no An object may carry at most 24 overlay values.
counters Record<string, number> no An object may carry at most 24 counters.
concealedValues integer no >= 1, <= 24
concealedCounters integer no >= 1, <= 239976

overlay.values

Mutable values, keyed by the mapped field key from your schema's fields[] — not by an id space of its own. { "pwr": 2 } means "the pwr field, modified".

How the number combines with the card's printed value is a property of the region, not of this bag: a delta region adds it to the catalogue value (base 2 power + 2 shows as 4), an override region replaces it. That is why the same stored 2 can render as 4 on one template and 2 on another, and why the combining rule never has to be replicated.

A value is number or boolean. There is deliberately no string: free-text entry at the table would need host-side validation against the field's declared type, and every string a region can draw is either static text from the definition or a catalogue value both sides already resolve locally.

At most 24 entries per entity, each within ±9,999. Both limits are enforced when the intent arrives, so a peer cannot grow the snapshot with them.

overlay.counters

Counter counts, keyed by counter definition id (counters[].id in data/cardSchema.json). At most 24 entries per entity, each 0–9,999 and additionally clamped to that counter's own declared max.

Zero parses — a hand-edited save is not rejected for it — but no mutation ever produces a zero: an entry that reaches zero is removed. "Absent" and "zero" must not both be reachable through play, or the snapshot delta churns between the two encodings forever.

A counter's art, colour, anchor, offset and stacking mode are definition data and resolve locally. So is its flip lifecycle: on a flip the host applies persist (nothing happens — the default), discharge (the count is cleared) or transfer (the count moves into another counter). A chain is impossible by construction — a transferTo target may not itself transfer — so one flip is always one hop.

overlay.concealedValues

Redaction output only. How many overlay values were withheld from this recipient because their region declared visibility: "count-only".

It is never written by the host's own authoritative state, never persisted, and stripped on load — a saved game is unredacted by design, so a persisted tally would be a lie the next session inherited. If you are reading it on the host, or in a save file, something is wrong.

count-only is the deliberate middle ground between hidden and full: the entry's identity is removed but its magnitude survives, so a client can draw "this card has something on it" without learning what.

overlay.concealedCounters

Redaction output only — the summed count of every counter withheld from this recipient by a count-only declaration, so a client can draw N anonymous chips without learning which counters they were.

Same contract as concealedValues: produced on the wire, never by the host's own state, never persisted, stripped on load.

tableObjectDefinitionSchema.diceTray#

Field Type Required Default Min / Max Pattern Rule Description
seat string | null yes <= 40 chars
peerId string yes 1–96 chars
rollId string yes 1–64 chars

diceTray.seat

The seat the roller was sitting in, or null when they held none. Required inside the record — null is a value here, not an omission.

It is the label half of custody, and it is what lets a roll be shown as red's roll rather than as a peer id. A spectating host who rolls produces a record with seat: null; that is an ordinary roll, not a broken one.

Custody is keyed to peerId, not to this. Seats change hands — a player leaves, someone else sits down — and a clear must still remove the dice of the person who rolled them rather than the dice of whoever happens to be sitting there now. Read seat for display and grouping; read peerId for ownership.

Capped at 40 characters, matching every other seat identifier in the schema.

diceTray.peerId

The player whose roll this die came from. Required, 1–96 characters, and the identity custody is actually keyed to.

A peer, not a seat, because a seat can change hands mid-session while a roll cannot: clearing a roll must remove the dice of the person who rolled them, and a seatless spectator who rolls still owns their dice. This is the field a clear compares against.

It is a peer id, so it dies with the connection — the same person rejoining is a different peer, and dice left behind by a departed player are no longer matched by any live roller's clear. They are ordinary entities on the table at that point; delete them like any other entity if your game wants them gone.

Not redacted: dice are public, and so is whose roll they came from.

diceTray.rollId

Which roll this die belongs to. Required, 1–64 characters, and shared by every die spawned for the same throw.

It is the same id the roll summary carries, which is what lets a summary panel highlight the dice it is describing, and what lets a script that stored a DiceRollSummary find those dice again later: compare this field rather than searching by position or by time.

A player who rolls twice in quick succession produces two batches with two ids, and both sets of dice stay in their custody until a clear. Grouping dice by roller alone would merge them; this is the field that keeps them apart.

It identifies a throw, not an entity — the die's own id is id. Nothing persists a roll id beyond the session, so it is not a useful key for saved data across loads.

tableObjectDefinitionSchema.physics.collisionSize#

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

physics.collisionSize.x

Full width of the collision box in object-local units, before scale. Must be greater than zero.

physics.collisionSize.y

Full height of the collision box in object-local units, before scale. Must be greater than zero. This is the one to check first when an imported model floats above the cloth or sinks into it.

physics.collisionSize.z

Full depth of the collision box in object-local units, before scale. Must be greater than zero.

containerStateSchema#

Exported from @diceytable/shared as containerStateSchema. 12 fields across 4 tables.

Field Type Required Default Min / Max Pattern Rule Description
form "holder" | "bag" yes
infinite boolean no
source containerSourceSchema no
contents containerContentRunSchema[] no <= 256 items
secretContents boolean no

Whole-object rules:

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

form#

What sort of container this is — the one required key, and the one that decides whether the container stores anything at all.

form What it is Where its contents live
"holder" Open and physical — a bowl, a tray. Pieces fall in and sit there under physics, visible to everyone. Nowhere. A holder stores no contents data; the pieces inside it are ordinary entities on the table with their own ids.
"bag" Closed and virtual — the contents are not on the table. A piece put into one stops existing as a body and is appended to contents. contents, on this field.

That difference is the whole model, so pick it first. A holder's count is however many entities happen to be resting inside it right now — the schema refuses a contents array on a holder rather than letting you author one that nothing would ever read. A bag's count is the sum of its runs.

"bag" is a wire value, not a label: both forms are Containers in the editor and in anything you show a player.

The field is required because there is no safe default. A container whose form was guessed would either swallow pieces that were meant to rest in it or leave pieces loose that were meant to be stored, and neither mistake announces itself.

infinite#

The container never depletes. Default false.

An infinite container dispenses copies of one authored piece — its source — and its state does not change when it is drawn from. Fifty draws leave the object's container field byte-identical, which is exactly the point: nothing to replicate, nothing to run out of, and a supply pile that cannot quietly be emptied by one player.

It is mutually exclusive with stored contents. The schema refuses infinite: true alongside a non-empty contents array, because an infinite container has nothing to hold: everything it hands out comes from source, and a matching piece put back into it is destroyed rather than accumulated.

Both forms can be infinite. An infinite holder is the bottomless bowl — it shows a "looks full" fill that never shrinks; an infinite bag is the same supply with nothing visible inside it.

This replaces metadata.infinite, which the bag-infinite preset carried for a long time and which no code ever read. A snapshot loaded from the old key is migrated onto this field, but write the real field in a hand-authored file: the point of the change is that a flag nothing reads is worse than no flag at all.

source#

What an infinite container dispenses — one piece definition, plus optional provenance for the editor.

source is meaningful only when infinite is true, and the schema refuses it otherwise rather than letting a finite bag carry a source nothing would consult.

Absent means "not chosen yet", not "nothing". An infinite container with no source adopts the first piece put into it and dispenses that from then on. This is deliberate parity with Tabletop Simulator, and it is what lets a mod ship an empty bowl an author fills by dropping one stone in rather than by hand-writing a definition. Changing or clearing an adopted source afterwards is host-only.

Once a source is set, the container is single-type: a piece matching it is destroyed when it is put back in, and a piece that does not match is refused and returned to where it was picked up. Matching is by identity key — see contents.key for what that covers, and note that a colour or material variant counts as a different piece.

contents#

What a finite bag is holding: an insertion-ordered, run-length encoded list of piece definitions.

Each entry is a runcount copies of one definition — so a bag of 200 identical go stones is one entry of a few hundred bytes rather than 200 copies of the same object. A piece put in merges into the run in front of it when it is the same piece (see contents.key); a different piece starts a new run. Order is preserved because the draw modes depend on it: stack takes from the end, queue from the start, and random picks a piece uniformly rather than a run — so 199 black stones and one white one hand out white about once in 200 draws, not half the time.

Finite bags only. The schema rejects a non-empty contents on a holder, whose pieces are loose entities resting on the table with their own ids, and on an infinite container, which dispenses from source and stores nothing.

One lane per bag. A bag holds either cards — metadata.cards, the existing deck machinery, with draw, peek, search and hidden-information redaction already working on it — or pieces, here. Once it holds one sort it refuses the other. Mixing the two would need a cross-lane order for stack/queue and a second redaction story, and no game has asked for one.

Three caps bound the field, and hitting any of them is a refusal reported to whoever put the piece in — "this container is full" — never a silent drop: 256 runs, 10,000 pieces per run, and 48 KiB for the serialised container field as a whole. The size cap is the one that bites a bag of many distinct pieces; roughly 250 authored definitions fit.

Contents are not hidden information by default — read secretContents before assuming they are.

secretContents#

Opt-in hidden information: hide the bag's contents from every peer but the host. Off by default, and the default is the right answer for almost every game.

Why contents are public by default. A piece bag's contents are unseen, not secret — everyone watched each piece go in, and authored starting contents sit in the mod's public GitHub repo where anyone can read them. The thing that must stay secret, which piece the next draw yields, is chosen by the host's private RNG at draw time and is not derivable from the contents at all. So the contents ride the wire in clear, the interface simply does not show them, and the public number is the count. That costs nothing: delta compression stays on and a draw patches one object.

What turning it on costs. For as long as a secret bag is non-empty, the whole table switches to per-peer redacted full snapshots with delta compression disabled — the host re-serialises the entire table once per peer on every broadcast. This is the same scaling cliff a deck of face-down cards already puts a table on, and it is why the flag is per bag rather than the default. On a card table that cost is unavoidable; on a bag of wooden pieces it usually buys no secrecy at all.

Turn it on when pieces are added privately (a blind bid, a hidden draft pool), or when stack/queue order would tell anyone reading the snapshot what the next draw is. Leave it off otherwise.

A bag holding cards is hidden information already, through the existing card machinery, and needs nothing here.

containerStateSchema.source#

Field Type Required Default Min / Max Pattern Rule Description
ref object no
definition containerItemDefinitionSchema yes

source.ref

Where the source piece came from — provenance for the editor only.

The runtime never resolves it. A draw spawns source.definition and nothing else; ref exists so the editor can offer "refresh from source" after you edit the preset, model or template the piece was built from, and so a panel can name what the container dispenses.

That split is on purpose. If the runtime resolved ref at draw time, a container whose model had since been deleted from the mod would spawn a placeholder box — an asset that silently degrades into a grey cube is worse than one that refuses the draw and says the source is missing.

Optional, and safe to omit in a hand-written file. A source with no ref still dispenses; you lose only the editor's ability to re-resolve it.

source.definition

The piece an infinite container dispenses — a full spawn definition minus everything positional.

It is a containerItemDefinitionSchema: what the piece is (kind, label, its look, metadata, physics, components), with the fields that say where an instance currently sits removed — no id, position, ownerSeat or parentId — and no container of its own, which is what makes "containers do not nest" structural rather than a rule to remember.

Every draw spawns a fresh copy of exactly this, and the container's own state does not change. It is also what an incoming piece is matched against: its identity key (see contents.key) decides whether a piece put back in is destroyed as a returning copy or refused as the wrong piece — so a definition that is nearly right, differing by a tint or a scale, produces a container that rejects the pieces it hands out.

Required whenever source is present. A ref without a definition would leave the runtime resolving an asset path at draw time, which is precisely what source.ref exists not to do.

containerStateSchema.contents#

Field Type Required Default Min / Max Pattern Rule Description
key string yes 1–512 chars
count integer yes >= 1, <= 10000
definition containerItemDefinitionSchema yes

contents.key

The run's identity key — the answer to "is this the same piece?", cached so the host does not recompute it on every insertion.

It is built from what a player can see and what a draw would reproduce: the kind, the model reference (metadata.customModelAssetId, else metadata.standardPresetId, else the primitive shape, plus the die type and side count for a die), the look (color lower-cased, materialSlots key-sorted, metadata.materialId) and scale rounded to four decimal places. Deliberately not part of it: id, label, displayName, transform, faceDown, locked, ownerSeat, tags, physics and sound overrides — two pieces differing only in those are the same piece to a player, and folding them together is what keeps a bowl of stones at one run.

Colour and material variants are different pieces. A blue cube is refused by a bowl of red cubes rather than accepted. The rule errs toward never destroying a piece that differs from what would come back out: if the blue cube were accepted it would be silently recoloured.

Never trusted from a file. Every key is recomputed from its own definition when a save or an incoming snapshot is loaded, and a wrong one is corrected in place. So a hand-edited save cannot forge a key that makes the wrong piece match, and you do not need to compute one when authoring — write the definition and let the platform key it.

contents.count

How many copies of this run's definition the bag holds, from 1 to 10,000.

This is the run-length half of the encoding: 200 stones are count: 200 on one run, not 200 entries. A run that falls to zero is removed rather than kept as an empty entry, which is why the minimum is 1.

The per-run ceiling is not the bag's ceiling. 256 runs and the 48 KiB size cap bound the field as a whole, and the container's own capacityLimit, when set, bounds the piece count further. An insertion that would bust any of them is refused and reported, never truncated.

contents.definition

The piece this run is made of — a full spawn definition minus everything positional.

It is a containerItemDefinitionSchema: the fields that describe what the piece is (kind, label, its look, metadata, physics, components) with the fields that describe where it currently is removed — no id, position, ownerSeat or parentId — and no container of its own, which is what makes "containers do not nest" structural rather than a rule someone has to remember.

Taking a piece out spawns this definition at the container's mouth. Because a run merges pieces that differ only in the fields the identity key ignores, the copy that comes out is this definition and not a record of any particular instance that went in — treat it as the canonical piece.

containerStateSchema.source.ref#

Field Type Required Default Min / Max Pattern Rule Description
type "preset" | "model" | "template" yes
id string yes 1–160 chars

source.ref.type

Which catalogue source.ref.id names: a built-in preset, a model asset in the mod's own file tree, or a template from setup.json.

Editor metadata, like the rest of ref — it changes nothing about what a draw spawns.

source.ref.id

The identifier within source.ref.type's catalogue — a preset id, a repo-relative model path, or a template id from setup.json.

Editor metadata. It is not resolved at draw time, so an id that has gone stale costs you the "refresh from source" button and nothing else.

containerItemDefinitionSchema#

Exported from @diceytable/shared as containerItemDefinitionSchema. 44 fields across 5 tables.

Field Type Required Default Min / Max Pattern Rule Description
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}$
faceDown boolean no
locked boolean no
stackCount integer no >= 1, <= 1000
containerMode "random" | "stack" | "queue" no
capacityLimit integer no >= 1, <= 1000
tags string[] no <= 100 items; each 1–32 chars ^[a-z0-9_-]+$
components objectComponentSchema[] no <= 8 items 1 further cross-field rule (message built at validation time).
displayName string no 1–80 chars
physics objectPhysicsSchema no
castShadows boolean no
receiveShadows boolean no
material soundMaterialSchema no
soundSetOverrides soundSetOverridesSchema no
materialSlots materialSlotsSchema no materialSlots may not hold more than 64 entries.
metadata Record<string, unknown> no
faceValue integer no >= -9999, <= 9999
overlay tableObjectOverlayStateSchema no

kind#

What the stored piece is — the one required field, and the one that cannot be changed later: a different kind is a different piece. See tableObjectDefinitionSchema.kind for the nine kinds and Object kinds for what each is for.

It is also the first component of the run's identity key, so a token and a die never share a run even if everything else about them matches.

label#

The stored piece's slug — the machine key a script reads as its name. Same field as tableObjectDefinitionSchema.label; it is not unique here, because a run of 200 stones is one definition with one label.

Not part of the identity key. Two pieces differing only in label merge into the same run, and the label that survives is the one the run was created with — so do not use label to tell stored pieces apart.

rotation#

The resting orientation a drawn copy is spawned with, Euler XYZ in degrees. Same field as tableObjectDefinitionSchema.rotation.

Not part of the identity key — a piece stored while lying on its side comes back out at this orientation, not at the one it happened to be in. Set it to how the piece should look when it arrives on the table.

scale#

The stored piece's size multiplier. Same field as tableObjectDefinitionSchema.scale.

Part of the identity key, rounded to four decimal places: a large stone and a small one of the same model are different pieces, kept in separate runs, and an infinite container set up with one refuses the other.

color#

The stored piece's tint, #rrggbb. Same field as tableObjectDefinitionSchema.color.

Part of the identity key (compared lower-cased). A blue cube is a different piece from a red one — it starts its own run in a finite bag, and an infinite container holding red cubes refuses it rather than absorbing it and quietly turning it red.

faceDown#

Whether a drawn copy arrives face down. Same field as tableObjectDefinitionSchema.faceDown.

Not part of the identity key — a card put in face down and one put in face up are the same card, which is the answer a player expects.

locked#

Whether a drawn copy arrives locked in place. Same field as tableObjectDefinitionSchema.locked.

Not part of the identity key. Rarely what you want on a stored piece: a piece that spawns locked cannot be picked up until someone unlocks it.

stackCount#

How many cards a stored deck piece holds. Same field as tableObjectDefinitionSchema.stackCount, and accepted only on a deck.

Note the one-lane rule before reaching for it: a bag holds either cards or pieces, so a bag whose contents are pieces refuses a deck, and a bag holding cards keeps them in metadata.cards rather than here.

containerMode#

Which end a draw comes off — stack, queue or random — carried on the stored piece so a drawn deck or bag keeps its draw order. Same field as tableObjectDefinitionSchema.containerMode.

Not part of the identity key.

capacityLimit#

The ceiling a drawn deck or bag carries. Same field as tableObjectDefinitionSchema.capacityLimit. It is the stored piece's capacity, not the capacity of the container holding it — that one lives on the container's own object.

Not part of the identity key.

tags#

The stored piece's author tags, lowercase and dash-separated, up to 100 of them. Same field as tableObjectDefinitionSchema.tags.

Not part of the identity key, so pieces differing only in tags merge and the run keeps the first one's tags. Tags are how a script finds a drawn copy afterwards, so put them on the definition rather than expecting a tag added to one instance to survive storage.

components#

Engine components authored on the stored piece — render, rigidbody, collision, light, camera, script — up to eight. Same field as tableObjectDefinitionSchema.components.

Not part of the identity key. They are rebuilt on the copy a draw spawns, so a light or a script attached here comes back with every piece taken out.

displayName#

The human-readable name shown for the stored piece — in a search overlay, in a container's menu header, and on whatever comes out of it. Same field as tableObjectDefinitionSchema.displayName.

Not part of the identity key, so it merges away like label. Worth setting anyway: it is the name a player reads when an infinite container refuses a piece ("Only Black Stones go in this container").

physics#

Physics overrides for the stored piece: body type, mass, friction, restitution, damping and the collider. Same shape as tableObjectDefinitionSchema.physics, and absent means the per-kind defaults.

Not part of the identity key — two pieces differing only in mass are the same piece to a player, and the run keeps the first one's values.

castShadows#

Whether the drawn copy casts a shadow. Same field as tableObjectDefinitionSchema.castShadows.

Not part of the identity key. Worth a thought for a piece stored in hundreds: shadow casting is per instance, and a bowl that empties onto the table all at once multiplies whatever you set here.

receiveShadows#

Whether the drawn copy receives shadows. Same field as tableObjectDefinitionSchema.receiveShadows.

material#

The stored piece's physical surface — wood, cardboard, metal — driving impact sounds and the physics baseline. Same field as tableObjectDefinitionSchema.material.

Not part of the identity key. Absent means the kind's default, resolved when the copy spawns.

soundSetOverrides#

Per-object sound set overrides carried on the stored piece, so a drawn copy sounds right from its first contact. Same field as tableObjectDefinitionSchema.soundSetOverrides.

Not part of the identity key: two pieces differing only in their sounds merge into one run, and the surviving run keeps the first one's overrides.

materialSlots#

Per-slot material overrides for the stored piece. Same field as tableObjectDefinitionSchema.materialSlots; at most 64 entries.

Part of the identity key, compared key-sorted. Two copies of one model finished differently are different pieces, for the same reason as color.

metadata#

The stored piece's free-form metadata — and the field that carries most of what makes it a particular piece: the model reference (customModelAssetId, standardPresetId, tokenShape), the die type and side count, and materialId. Same field as tableObjectDefinitionSchema.metadata.

Those keys are part of the identity key; the rest of metadata is not. So two tokens carrying different game state under their own keys merge into one run, and whichever state the run was created with is what every copy taken out of it carries. If a piece's metadata must survive being put into a container and taken back out, that is a per-instance value, and a container does not preserve per-instance values.

faceValue#

The stored piece's face value — the number a die or token is showing. Same field as tableObjectDefinitionSchema.faceValue.

Not part of the identity key, and this is the one people expect to be: a d6 stored showing 4 and one stored showing 1 merge into the same run, and what comes out is whatever the run was created with. Dice are rolled after they leave a container, so the stored value is a starting state, not a memory.

overlay#

Overlay values and counters carried on the stored piece — the badges drawn on top of it. Same shape as tableObjectDefinitionSchema.overlay.

Not part of the identity key. A counter incremented on one instance does not survive storage: the run keeps the definition it was created with, so treat an overlay here as the state a piece starts with.

containerItemDefinitionSchema.rotation#

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

rotation.x

Pitch in degrees, applied to the copy a draw spawns.

rotation.y

Yaw in degrees, applied to the copy a draw spawns. The usual one to set — a piece with a front, such as a printed tile, needs a facing when it arrives.

rotation.z

Roll in degrees, applied to the copy a draw spawns.

containerItemDefinitionSchema.physics#

Field Type Required Default Min / Max Pattern Rule Description
bodyType "static" | "dynamic" | "kinematic" no
mass number no > 0
friction number no >= 0, <= 1
restitution number no >= 0, <= 1
linearDamping number no >= 0, <= 1
angularDamping number no >= 0, <= 1
collisionShape "auto" | "box" | "sphere" | "capsule" | "cylinder" | "convexHull" | "mesh" no
collisionSize positiveVector3TupleSchema no
collisionOffset vector3TupleSchema <br>same shape as rotation no
rigidbodyEnabled boolean no
collisionEnabled boolean no
weldChildren boolean no

physics.bodyType

dynamic, kinematic or static for the copy a draw spawns. See tableObjectDefinitionSchema.physics.bodyType.

Leave it dynamic unless the piece is scenery. A static piece taken out of a container cannot be picked up or pushed.

physics.mass

The drawn copy's mass in kilograms, greater than zero, and meaningful only on a dynamic body. See tableObjectDefinitionSchema.physics.mass for the per-kind defaults.

physics.friction

Surface friction for the drawn copy. See tableObjectDefinitionSchema.physics.friction. Worth setting on a piece that lives in a bowl: too little and a heap of them slides apart.

physics.restitution

Bounciness for the drawn copy. See tableObjectDefinitionSchema.physics.restitution. Keep it low for anything dropped into a container repeatedly — a bouncy piece leaves the bowl it was aimed at.

physics.linearDamping

How quickly the drawn copy loses linear velocity. See tableObjectDefinitionSchema.physics.linearDamping.

physics.angularDamping

How quickly the drawn copy loses spin. See tableObjectDefinitionSchema.physics.angularDamping.

physics.collisionShape

The collider the drawn copy is given. See tableObjectDefinitionSchema.physics.collisionShape.

Not part of the identity key — the collider is not something a player can see, so it never splits a run.

physics.collisionSize

The collider's full dimensions in feet, at the piece's identity scale. See tableObjectDefinitionSchema.physics.collisionSize.

physics.collisionOffset

The collider's centre offset from the piece's origin, in object-local feet. See tableObjectDefinitionSchema.physics.collisionOffset.

physics.rigidbodyEnabled

Whether the drawn copy gets a rigidbody at all. See tableObjectDefinitionSchema.physics.rigidbodyEnabled. A piece taken out of a container with no rigidbody does not fall, settle or collide.

physics.collisionEnabled

Whether the drawn copy collides. See tableObjectDefinitionSchema.physics.collisionEnabled.

physics.weldChildren

Whether the drawn copy's parented children are welded into one compound body rather than simulated separately. See tableObjectDefinitionSchema.physics.weldChildren.

containerItemDefinitionSchema.overlay#

Field Type Required Default Min / Max Pattern Rule Description
values Record<string, cardOverlayValueSchema> no An object may carry at most 24 overlay values.
counters Record<string, number> no An object may carry at most 24 counters.
concealedValues integer no >= 1, <= 24
concealedCounters integer no >= 1, <= 239976

overlay.values

The overlay's labelled values as stored on the piece. See tableObjectDefinitionSchema.overlay.values.

overlay.counters

The overlay's counters as stored on the piece — the starting numbers a drawn copy arrives with, not a record of what any instance reached. See tableObjectDefinitionSchema.overlay.counters.

overlay.concealedValues

Redaction output only, and it has no business in an authored definition: it counts the overlay values withheld from one recipient. See tableObjectDefinitionSchema.overlay.concealedValues.

If you are reading a non-zero value here in a save file or on the host, something is wrong.

overlay.concealedCounters

Redaction output only, like concealedValues — the number of counters withheld from one recipient. See tableObjectDefinitionSchema.overlay.concealedCounters.

Never authored, never persisted.

containerItemDefinitionSchema.physics.collisionSize#

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

physics.collisionSize.x

Collider width in feet — a full dimension, not a half-extent.

physics.collisionSize.y

Collider height in feet — a full dimension, not a half-extent.

physics.collisionSize.z

Collider depth in feet — a full dimension, not a half-extent.