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.
- The pointer raycast returns a world hit point.
- 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.
- The point is transformed again into the surface's plane, and tested against the rect, then the widget rects.
- A
surface-pressintent 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
canvassource. - Self-hosted video — fully in world, no server. Use the
videosource. - 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
widgetsis implemented today.canvas,videoandoverlayare 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.
