Dicey Table

Object kinds

TableObjectKind is a Scene Entity's Platform kind — shown as TYPE in the Inspector's PIECE panel. There are 9 values, defined as plain string constants in packages/shared/src/tableObjects/kinds/index.ts and collected into TABLE_OBJECT_KINDS:

card · deck · die · token · board · bag · custom · card-holder · button

The kind is fixed at spawn time (an existing entity's kind is never changed in place) and drives three things: which physics defaults the runtime applies when the entity has no physics override, the collision geometry the runtime builds, and which of the optional TableObjectState fields are meaningful. It does not gate which actions can target the entity — that is a related but separate table.

Physics defaults, at a glance#

These are the values apps/web/src/playcanvas/physics/collisionHelpers.ts (createRigidbodyConfig, called from TabletopRuntime.createObject) reads from apps/web/src/playcanvas/physics/PhysicsEngine.ts's per-kind functions (massForKind, frictionForKind, restitutionForKind, linearDampingForKind, angularDampingForKind) when building an entity's rigidbody and it has no explicit physics.mass / physics.friction / … override. A kind missing from one of those switch statements falls through to that function's default case, called out below.

Kind Mass Friction Restitution Linear damping Angular damping Default sound material
card 0.05 0.72 0.1 (default) 0.07 0.08 (default) card
deck 1.6 0.76 0.1 (default) 0.05 (default) 0.08 (default) card
die 0.35 0.82 0.52 0.02 0.015 plastic
token 0.18 0.78 0.14 0.04 0.06 plastic
board 4 0.92 0.1 (default) 0.05 (default) 0.08 (default) wood
bag 0.8 (default) 0.8 0.1 (default) 0.05 (default) 0.08 (default) tile
custom 0.8 (default) 0.82 0.1 (default) 0.05 (default) 0.08 (default) generic
card-holder 0.8 (default) 0.8 (default) 0.1 (default) 0.05 (default) 0.08 (default) wood
button 0.8 (default) 0.95 0.04 0.12 0.3 plastic

die's friction (0.82) is deliberately high — it converts a thrown die's slide into roll/tumble rather than letting it skate flat, and pairs with the highest restitution (0.52) of any kind so it actually bounces.

A second, unrelated set of "default physics" exists — do not confuse the two. packages/shared/src/tableObjects.ts also exports defaultObjectPhysicsForKind(), whose numbers do not match the table above (e.g. it gives deck a mass of 0.3 against the runtime's 1.6, and die a mass of 0.02 against the runtime's 0.35). That function is explicitly documented as "suggestions only" for the editor's physics inspector placeholders (TableEditModeShell.tsx) — it is never written onto object state and never applied by the runtime. The table above, from PhysicsEngine.ts, is what a spawned object actually gets.

Default scale and color#

When a spawn omits scale / color (and does not come from a standard preset, which supplies its own), the runtime falls back to DEFAULT_SCALE / DEFAULT_COLOR in TabletopRuntime.ts:

Kind Default scale (ft) Default color
card 0.24 × 0.0016 × 0.335 (poker card) #f6f1df
deck 0.24 × (52 × 0.0016) × 0.335 #a23c3c
die 0.05 × 0.05 × 0.05 #f4f6f8
token 0.12 × 0.04 × 0.12 #4c8c6a
board 1.2 × (board thickness) × 0.9 #2c6e6a
bag 0.5 × 0.5 × 0.5 #7257a6
custom 0.6 × 0.2 × 0.6 #60758f
card-holder 0.6 × 0.04 × 0.36 #3a5f7a

A deck's default scale is the one exception that is not static: absent an explicit scale, normalizeObjectScale() derives the Y dimension from stackCount (a card thickness per card, clamped), so a 10-card deck is thinner than a 52-card one even with no scale authored.

Card#

A single playing card. label is the card's identity for hidden-information redaction — see IDs, names and tags.

  • faceDown — whether the card is showing its back. flip toggles it.
  • ownerSeat — when set, the card is in that seat's hand (getHandObjectsFromSnapshot treats any object with a non-empty ownerSeat as a hand card, regardless of kind).
  • metadata.cardCornerRadius — corner rounding for the rendered card mesh; backfilled to a default if missing or non-numeric.
  • metadata.customDeck — when the card belongs to a custom deck sprite sheet, the sheet slice used to render its face.
  • metadata.faceUrl — a direct image URL for the card's front face (F4): an absolute http(s) URL or a repo-relative asset path (resolved the same way any other mod asset is). It textures the whole card face — no sprite sheet, no catalogue — and is the simplest way for a mod script to spawn a card with custom art: createObject({ kind: "card", metadata: { faceUrl: "https://…/anakin.jpg" } }). It renders on the 3D table and in the hand drawer. A face URL identifies the card, so it is redacted exactly like cardId: a face-down card in another player's hand (or an unowned face-down card on the table) never sends its faceUrl to other peers — it shows the back. Only face-up (or owner/team-visible) cards expose it.
  • metadata.backUrl — a direct image URL for the card's back. Public (a back is what a hidden card is shown as); omit it to use the standard card back.
  • Collision: a thin box collider, half-height max(scaleY / 2, 0.01) — the smallest clearance of any kind (collisionHalfHeightForObject).

Reachable actions: lift, flip, rotate, lock/unlock, combine, flick, reveal-all/reveal-team-a/reveal-team-b, delete. Not shuffle, draw, deal, split, roll — see the per-kind action matrix.

Deck#

A container of cards that behaves as one physical stack.

  • stackCount — how many cards the deck holds; also feeds the default-scale derivation above. 1–1000.
  • faceDown — the deck's face; flip flips every card entry too and re-derives the visible face.
  • metadata.cards — the ordered container entries (cardId + per-card faceDown). Absent on a deck, the runtime synthesizes a standard 52-card deck on first read (buildStandardDeckCardEntries).
  • metadata.customDeck — as for card, when the deck's faces come from a custom sprite sheet rather than the built-in playing-card art.
  • metadata.faceUrls — a direct-URL face map { [cardId]: url } (F4), one entry per distinct cardId in metadata.cards. The runtime textures the deck's top/bottom card from the matching URL; a card drawn from the deck copies the map (and any shared metadata.backUrl) so it stays self-describing. It is the deck-scale sibling of a single card's metadata.faceUrl. The whole map is host-only. Its keys are the deck's ordered card ids, which no peer may learn (a deck's order is secret), so redaction rebuilds it to at most the single publicly-visible top card's entry — in lockstep with how it strips the ordered metadata.cards to the visible top. The one exception is a private peek: a player who runs the peek action receives the top-N entries (and their face URLs) un-redacted in their own snapshot only, for as long as the host holds the reveal.
  • metadata.backUrl — a shared direct-URL back for every card the deck holds. Public.
  • split requires stackCount > 1; draw/deal/shuffle operate on metadata.cards.

Reachable actions: everything a card can do, plus shuffle, draw, deal, split, peek.

Die#

A single die. label defaults to the generic name "D6" regardless of the die's actual face count — labelForKind() (apps/web/src/playcanvas/objectHelpers.ts) has one case for die and does not branch on metadata.dieSides. The face count itself lives in metadata, not the schema:

  • metadata.dieSides — 4/6/8/10/12/20 in the standard presets. It is written by the presets (packages/shared/src/standardObjects.ts) and read by nothing: the model and its face numbering are both resolved from metadata.standardPresetId, not from this. Treat it as informational.
  • metadata.dieFaceStyle — e.g. "dots" for a pipped d6.
  • Collision: a convex hull by default (basePhysicsForKind's suggestion), so bevels and faces tumble faithfully; the runtime's actual applied physics (table above) uses the generic collider path since die has no case in collisionHalfHeightForObject.
  • Highest restitution and friction of any kind — see the physics table above.
  • faceValue — a schema field, not metadata: the number printed on the face a settled die is showing. The host computes it on settle and replicates it, and drops it while the die is tumbling, held or cocked. A die imported as a custom model has no face table and never gets one. See faceValue.

Reachable actions: lift, rotate, lock/unlock, roll, flick, delete. Not flip, shuffle, draw, deal, split, combine, or any reveal-*.

Token#

A small marker or piece — checkers, chips, pawns of a generic shape.

  • metadata.tokenShape"cylinder" or "box", selecting the rendered primitive.
  • metadata.chipValue — casino-chip presets attach a numeric value here; purely cosmetic/informational, not read by any gameplay path in packages/shared.
  • metadata.dualSided — e.g. the Reversi chip, which renders two colors on opposite faces.

Printed faces#

A token can carry art on its flat faces — a punchboard counter, a printed chip, a damage marker — while keeping color as the body.

  • metadata.tokenFaceImage — art printed on the up-facing side. A repo-relative asset path, a public path (/mods/…), or an http(s)/data:/blob: URL.
  • metadata.tokenFaceBackImage — art for the down-facing side. Omit it for a double-sided counter: the face art is used on both sides rather than leaving a blank back.
  • metadata.tokenFaceFit"cover" crops the art to fill the face; anything else (the default) maps it to the face as authored.

The art is alpha-blended over the body colour, so a PNG/WebP with transparency reads as an overlay printed on a coloured chip and an opaque image simply hides the colour. Only the two flat faces are printed — the rim stays color, which is what keeps a picked-up token looking like a physical counter instead of a cube wrapped in one image. (That wrapping is what metadata.customTextureAssetId does, and it is the right choice for a flat tile viewed from above.)

Printed faces require metadata.tokenShape: "box". The face is a rectangular plane matching the token's footprint, so on a "cylinder" token its corners would overhang the round body; a cylinder token therefore ignores these keys and keeps its plain colour. Set the token's scale.x/scale.z to the art's aspect ratio — the face is mapped to the footprint, so a square image on a non-square token stretches unless you use "cover".

{
  "kind": "token",
  "label": "Force",
  "position": { "x": 0, "y": 0.0, "z": 0 },
  "scale": { "x": 0.105, "y": 0.018, "z": 0.105 },
  "color": "#b32222",
  "metadata": {
    "tokenShape": "box",
    "tokenFaceImage": "assets/textures/counter-force.webp"
  }
}

An unresolved path leaves the token its plain body colour rather than erroring — a repo-relative path whose asset resolver has not primed yet prints as soon as it does.

Reachable actions: lift, rotate, lock/unlock, flick, delete. The narrowest action set of any kind alongside board and card-holder — no flip, container actions, roll, combine, or reveal-*.

Board#

A static playing surface — a game board, mat, or other furniture-like piece.

  • Nearly every board preset ships locked: true — boards are meant to stay put once placed.
  • metadata.boardStyle — selects the rendered board art/grid (e.g. "chess-wood", "go-grid").
  • metadata.boardImageUrl — a board face image, which takes precedence over boardStyle. Accepts a repo-relative asset path (assets/textures/mat.webp), a public path (/mods/…), or an http(s)/data:/blob: URL — all resolved the same way every other asset reference is. An unresolved path leaves the board its base colour; it does not fall back to a grid pattern, so a mod's art is never silently replaced by a checkerboard.
  • Heaviest default mass (4) and highest default friction (0.92) of any kind, consistent with something static and not meant to slide.
  • Collision: collisionHalfHeightForObject gives board its own case — half-height is the scale-derived value plus a configured top clearance, rather than the generic scaleY / 2 every other kind gets, so pieces resting on it do not clip through.

Reachable actions: lift, rotate, lock/unlock, delete. flick is the one action explicitly withheld from board and no other kind (isObjectActionAllowedForTarget's flick case is target.kind !== "board").

Bag#

A random-draw or ordered container that is not a deck of cards — a dice bag, a chip bank, a Go bowl.

  • containerMode"random", "stack", or "queue", selecting draw order. The host resolves it from this field first, then the legacy metadata.containerMode key, then the per-kind default (random for a bag, stack otherwise) — resolveContainerConfig, packages/shared/src/tableContainers.ts. Write the field.
  • capacityLimit — 1–1000, resolved by the same precedence over the legacy metadata.containerCapacity. It refuses a combine that would overflow the target pile, and that is the only route it gates. The standard presets: 60 (Bowl), 100 (Bag), 200 (each Go Bowl), 1000 (Infinite Bag).
  • container — the replicated container configuration: form ("holder" or "bag"), infinite, and the piece contents of a finite bag. Every bag is given one on load, defaulting to { form: "bag" }. A legacy metadata.infinite — the "Infinite Bag" preset's flag, which no code once read — is moved onto container.infinite and deleted from metadata by the same load. form and infinite already decide which container actions a target will accept (isObjectActionAllowedForTarget); the drop-to-absorb and infinite-draw behaviours they describe are still being built, so treat the rest of the field as configuration the platform records and validates rather than something that yet changes play.

Reachable actions: lift, rotate, lock/unlock, draw, deal, flick, delete. Not flip, split, combine, roll, any reveal-*, or shuffle — a bag draws at random, so it has no order for a shuffle to change.

Custom#

The escape hatch for anything that is not one of the other eight shapes — chess pieces, dominoes, mahjong tiles, blocks, notecards, a digital clock, 3D text. Most of a mod's custom-modeled content is custom.

  • Collision defaults to "auto" in the suggestion function (basePhysicsForKind) — meaning "keep the runtime's current per-kind auto behavior" rather than a fixed primitive; author an explicit physics.collisionShape for anything with an unusual silhouette.
  • metadata is where all of the kind-specific behavior lives, because there is no dedicated schema surface for it: tokenShape, text (the "3D Text" preset), and every other preset-specific key in Standard presets.
  • The "Card Holder" preset (id card-holder) is kind: "custom", not kind: "card-holder" — see the next section for why that is not a contradiction.

Reachable actions: lift, rotate, lock/unlock, flick, delete. Same narrow set as token.

Card holder#

A seat- or hand-associated holder entity. Watch the naming: the preset id card-holder (labelled "Card Holder") is actually kind: "custom", so its name is not a guide to its kind. The one preset in STANDARD_OBJECT_PRESETS that really is kind: "card-holder" is the KayKit card-stand ("Card Stand"). So a card-holder entity is usually authored directly (a spawn or template with kind: "card-holder"), and there is exactly one catalog preset that spawns one.

It also has no dedicated case in any of the runtime's per-kind physics functions or in collisionHalfHeightForObject — every one of those falls through to its generic default case for card-holder, which is why the physics table above shows five "(default)" values for this kind, more than any other.

Holder behavior is driven by metadata, not by the kind. A holder captures a card or deck dropped within a scale-derived planar radius and stamps metadata.holderId on it; what makes an entity act as a holder at all is metadata.cardHolder === true, so any kind can be one and card-holder is simply the stock shape for it.

A holder no longer confers a seat's ownership by itself. It used to: a holder that had drifted inside a seat's zone stamped every card it captured with that seat, competing with — and quietly overriding — the seat's own hand zone. That implicit fallback was removed. What remains is an explicit opt-in: set metadata.holderOwnerSeat and the holder is treated as that seat's rack, cards included, exactly as before. With neither, seat membership comes only from the seat's hand zone. A holder published before that opt-in existed is promoted on load — its own ownerSeat is copied into metadata.holderOwnerSeat once, so an existing mod's rack keeps gating who may take cards out of it. The promotion never overwrites a value you wrote, including an explicit null. See Player Zones and seat templates for when to reach for which.

Reachable actions: lift, rotate, lock/unlock, flick, delete — identical to custom and token.

Button#

A pressable control: a coloured cap sitting in a frame, with optional text or an image on its top face. Pressing it dips the cap, plays a sound and raises the press action — which a table or mod script observes as onPressed.

Two presets ship it: Button (rectangular) and Round Button. They are the SAME kind — shape is configuration, not a separate type — so everything below applies to both.

Configuration#

Everything about a button lives in metadata.button, validated by buttonConfigSchema (packages/shared/src/tableButtons.ts). It is metadata rather than object-state fields on purpose: metadata already travels through the schema, the snapshot delta, migrations and persistence with no threading, and a button needs a dozen knobs, none of which the server ever queries.

Field Default What it does
shape "rect" "rect" or "round". Round uses width as the diameter and ignores depth.
width / depth / height 0.34 / 0.24 / 0.075 The cap's footprint and how tall it stands, in table units (1 unit = 1 ft).
travel 0.032 How far the cap sinks on a press. Clamped to 80% of height at render time.
pressSeconds 0.11 Duration of the dip-and-return.
colors see below cap, capPressed, frame, base, text.
frame on, 0.028 / 0.026 The bezel: enabled, thickness, height.
face { kind: "none" } none, { kind: "text", text, size, bold }, or { kind: "image", texturePath, inset }.
eventName "button-press" The name a press reports to scripts.

capPressed is optional: leave it out and the runtime darkens cap by 22% for the held state, which is usually what you want and always readable.

An image face is a repo-relative path resolved through the same AssetResolver as every other asset, so it works local-first in the editor and from GitHub for remote peers. A text face is drawn to a canvas locally and never crosses the wire — the same rule prop surfaces follow.

Geometry#

The button is built from primitives at runtime, not from a model.glb. That is deliberate: it is fully described by the numbers you are already editing, so it is correct the instant it spawns, resizes live as you drag those numbers, and has no asset that can 404 (unlike most prop presets, which render as placeholder boxes when their model file is absent). The stack, bottom to top, is plinth → frame → cap → face, with the cap the only part that moves.

The object's scale IS buttonOuterFootprint(config) and every child is a fraction of it, so there is exactly one place that decides how big a button is.

Pressing#

Click it. The press claims the pointer before the grab path — the same way a prop-surface button does — so pushing a button never also starts dragging it.

A press is an ordinary press object action, which means it is host-authoritative and replicated like any other mutation: a peer's click sends an intent, the host applies it, and every client animates its own cap. The cap dip is local cosmetics and starts immediately on click, because feedback that waits for a round trip feels broken.

A locked button is still pressable. Both presets ship locked because locking is how a control stays put — a stray drag sliding the End Turn button across the table is never what anyone meant — so isObjectActionAllowedForTarget exempts press from the locked gate. Unlock it in the inspector to reposition it.

Reachable actions: press, lift, rotate, lock/unlock, flick, delete.

Sound#

The press sound rides the ordinary sound path: press maps to the settle runtime event, which OBJECT_SOUND_EVENT_MAP.button binds to a place action against the button's sound material (plastic by default). To give one button its own click, set a per-object override in soundSetOverrides — the same mechanism every other kind uses, so there is no button-specific sound API to learn.

See also#

  • Object actions — the full 9-kind × 23-action gating matrix.
  • Object state — every TableObjectState field, kind-agnostic.
  • Standard presets — the 60 ready-made definitions built on these 9 kinds.
  • Action vocabularies — the three action vocabularies (engine/table-script/mod), a different axis from this page's per-kind gating.
  • ObjectKind — the nine kinds the table-script declaration names.
  • Runtime sound events — the sound events these kinds emit.
  • SoundMaterial — the materials behind each kind's default sound.
  • Mod scripting APITableObjectState.kind, reading a kind from a mod.
  • ObjectHandleObjectData.kind, reading it from a table script.
  • TypesSpawnObjectOptions.kind, choosing a kind at spawn.