Dicey Table

Prop surfaces

A prop surface is a rectangular region on a table object's face that displays a texture and receives pointer input. It is what makes the countdown timer's buttons pushable, and it is the primitive intended to carry a PDF-reading tablet and a wall-mounted screen.

This page is the honest version: what it does, why it is built the way it is, and — in the last section — what it will never be able to do, so you do not design toward it.

The shape of one#

A surface is authored in the object's local space, so it survives the object being moved, rotated or rescaled with no per-frame bookkeeping.

Field Meaning
position / rotation Centre and orientation of the rect, object-local. Identity faces +Y
size Extent along the surface's local X and Y, in object-local units
resolution Texture pixels. 512×512 by default — these are props, not monitors
source Where the pixels come from (see below)
widgets What is drawn on it, and what can be pressed
interactive false renders the surface but swallows no input

Widget rects are [x, y, w, h] in 0..1 of the surface, origin top-left — the same frame as texture UV, so nothing in the chain flips.

Surfaces are derived, not replicated#

A surface layout never crosses the wire. It is looked up from the object's preset (propSurfaceRegistry.ts), which every client can already do because every client knows the preset id.

Only the state the surface displays is replicated. For the timer that is metadata.timer, and nothing else.

This is the same discipline seat zones follow — derive locally, replicate the cause — and it is why adding a surface does not touch the snapshot-delta threading checklist at all.

How a press works#

There is no sub-mesh picking in the engine: a raycast hits an object's rigidbody, and a button cap has no collider of its own. Surfaces sidestep that entirely.

  1. The pointer raycast returns a world hit point.
  2. The point is transformed into object-local space by the inverse of the object's world matrix — which removes translation, rotation and scale in one step, so a surface authored against the preset's unit box works at any object size.
  3. The point is transformed again into the surface's plane, and tested against the rect, then the widget rects.
  4. A surface-press intent is sent carrying the surface UV — not a widget id.

Step 4 matters. The host re-resolves the UV against its own copy of the layout, so a peer cannot name a widget that is not there or one it cannot reach. It is the same reasoning that makes a drag send a position rather than a finished transform.

A surface claims the press before the grab path, so pushing a button never also drags the prop.

The pressed-cap animation is local only. The state change replicates; the ~130 ms of visual depression does not, because a purely cosmetic flourish is not worth a press/release pair on the wire.

Presses are deliberately not undoable. A device control should not bury real table moves on the undo stack.

Repainting#

The texture is redrawn only when what it shows actually changes, compared through a content signature. A running countdown is evaluated every frame but uploaded about once a second.

That is only possible because timer state is stored as cause, not as a tick:

{ presetMs, remainingMs, running, startedAtMs }

While running, each client derives the display from startedAtMs and its own clock. Replicating a decrementing counter would put a snapshot delta on the wire ~60×/second for a prop whose meaning changes once a second.

The countdown timer#

Preset timer-standard. Five caps and an MM:SS readout, clamped at 99:59.

Button Stopped Running
MIN +1 minute to the set duration extends the remaining time by a minute
SEC +1 second to the set duration extends the remaining time by a second
START / STOP starts; a finished timer restarts from the preset in one press stops, banking the elapsed time
RESET back to the set duration stops and returns to the set duration
CLEAR zeroes the duration and the remaining time same

Adding time while running re-bases the run, so the added minute is not swallowed by the elapsed offset. The accent cap is always labelled with what the press will do, never with the current state — a toggle labelled with its own state is ambiguous.

Turn order applies. A surface press is an ordinary participant intent, so on a table with turn order enabled a player who is not the active player cannot press the buttons. That is consistent with every other mutation; it is called out because a shared clock is the kind of thing people expect to be exempt.

Sources: what can actually be shown#

source is a union, and the differences between the variants are not cosmetic.

Source Backing Available
widgets canvas, drawn by the platform now
canvas a canvas you draw into — e.g. pdf.js now
video a <video>: file, HLS, or a MediaStream planned
overlay a real DOM iframe, CSS-3D transformed planned

What cannot be done, ever#

A browser cannot read pixels from a cross-origin iframe. That is the same-origin policy — it exists to stop clickjacking and data theft, foreignObject → canvas taints on cross-origin, and there is no DOM-to-texture API. No library bypasses this and none ever will.

So rendering an arbitrary website onto a 3D surface, client-side, is impossible. Not difficult; impossible.

What can be done#

The restriction is about the viewer's browser. Move the rendering elsewhere and it dissolves, because a <video> element is a legal texture source:

  • PDF rulebooks — fully in world, no server. pdf.js renders to a canvas, and a canvas is a texture. Use the canvas source.
  • Self-hosted video — fully in world, no server. Use the video source.
  • Arbitrary websites — needs a server-rendered browser. A headless browser streamed over WebRTC arrives as a MediaStream, which textures correctly with full occlusion and lighting.
  • YouTube — cannot be a texture by any legitimate route. Their terms require their player, which is cross-origin. Either an overlay (the official iframe, CSS-3D transformed onto the face) or route it through a server-rendered browser.

The overlay caveat#

An overlay source is a real DOM element composited on top of the WebGL canvas. It therefore cannot be occluded by 3D geometry — nothing can be drawn in front of it, and it will not respect depth when the camera moves behind something. This is a well-known, still-unsolved problem, only ever approximated with masking.

The schema forces you to acknowledge it: an overlay source is rejected unless it carries acceptsNoOcclusion: true. That flag exists purely so nobody adopts an overlay without meeting this paragraph first.

Known gaps#

  • Only widgets is implemented today. canvas, video and overlay are in the schema and parse, but no runtime reads them yet.
  • Surfaces cannot be authored by a mod. The registry is keyed by platform preset. Mod-authored surfaces are the point at which replication would need revisiting.
  • One surface per press: the first surface whose rect contains the hit point wins, and surfaces on one object are not depth-sorted against each other.