Dicey Table

Execution order

Table scripts come in two kinds, and the difference is only how they are attached:

  • A scene script belongs to the whole scene. It is listed in the scene's sceneScriptIds, and it gets world and globalEvents.
  • An object script belongs to one entity. The entity points at it through metadata.scriptId, and the script gets world, globalEvents and refObject — a live handle to the entity it is attached to.

One script file can be both. Attach the same script to the scene and to three entities and it runs four times, as four independent instances with their own handler registrations.

The boot sequence#

Starting the script host is deterministic, and the order does not vary between runs. It is exercised end to end in apps/web/src/scripting/__tests__/tableScriptHost.test.ts"runs scene scripts in order, then object scripts, then posts started".

1. The host collects the run list
     scene scripts, in sceneScriptIds order
     then object scripts, in objectAttachments order
     a repeated objectId + scriptId pair is skipped
     a script with no compiled body is dropped, with a load diagnostic
2. The sandbox seeds handles
     one ObjectHandle per attached entity, filled from the live snapshot
3. Scene scripts run, in order, one after another
4. Object scripts run, in order, one after another
5. The frame reports "started"

Three things follow from step 2 sitting before step 3:

  • refObject is already populated when an object script's body runs. Reading refObject.name on the first line gives you the entity's slug, not a placeholder.
  • The table already exists. Every entity the scene loaded is on the table before any script body evaluates. Your script never observes an empty table that is about to fill up.
  • onCreated does not fire for an entity that was already on the table. Everything present at start was created before your script existed, so put the work you would have done in onCreated at the top level of the script body — that is the "this object is ready" moment. An entity created while scripts are running is the exception: its object script is attached before the event is dispatched, so its own onCreated does fire.

What a script body should do#

Register handlers and return. The body runs once, synchronously, and everything after that is event-driven.

  • Registration is global to the frame, not to the script. globalEvents is one frozen singleton shared by every running script. Two scripts adding a handler to globalEvents.onObjectDropped both get called.
  • A handler registered later still works — there is no "registration is closed" moment. It only misses events that already fired.
  • Handler ownership is remembered. The sandbox records which script registered each handler, so a handler that throws is reported against the script that registered it, not the script whose event happened to be in flight.
  • A body that throws does not stop the boot. The failure is reported as a load-phase diagnostic naming the script, the remaining scripts still run, and the frame still reports started — so the host never hangs waiting for a script that crashed.

Attachment happens at start, and again on creation#

The run list is built when the script host starts, from the live snapshot's object metadata (falling back to the scene definition on a first load). After that it grows one way: every objectCreated event is inspected for a metadata.scriptId, and an entity that names one gets that object script attached (apps/web/src/scripting/objectScriptAttachment.ts, used by both embedders — apps/web/src/ui/App.tsx and apps/web/src/ui/TableEditModeShell.tsx). The attachment happens before the event is dispatched, so the newly attached script's own onCreated fires for the entity it belongs to. Attaching the same script to the same entity twice is a no-op.

That covers a world.spawnObject, a mod's api.createObject, a player's spawn, and every gameplay path that produces an entity — a draw, a deal, either half of a split, a combine. It does not cover a table rebuilt from a snapshot, which raises no creation events at all; see objectCreated does not fire for a snapshot rebuild. Restart the scripts after a rebuild if object scripts have to be live on entities that arrived that way.

Restarting, and what a restart clears#

In Edit Mode the viewport toolbar has ▶ Play, ⏹ Stop and ↻ Restart. A restart disposes the sandbox iframe, drops every registered handler, rebuilds the run list from the current snapshot, and clears the run's saved data — Edit Mode keeps script saved data in memory for the duration of one run, so world.getSavedData() starts empty on every ▶ Play. At a real table, saved data rides the snapshot and persists.

Starting scripts in Edit Mode also switches the simulation from Frozen to Live, because a script that spawns or rolls needs physics running to observe anything. Re-freeze afterwards if you want to inspect the result.

A script that has never been saved has no compiled body and does not run. The editor transpiles on save; the sandbox executes only the transpiled body. You get a diagnostic naming the script — "has no compiled output and will not run" — rather than silence.

The four availability values#

The availability badge answers: which kind of script can reach this member?

both#

The member is reachable from a scene script and from an object script. world and globalEvents are injected into both kinds identically, so nearly every table-scripting member carries this value.

object-script#

The member is reachable only through refObject, which exists only in an object script.

Known gap. refObject is declared as an always-present ObjectHandle, and it is undefined in a scene script. The declaration's own comment says so — "Defined ONLY in object scripts … undefined in scene scripts" (packages/shared/src/scripting.ts) — but the type does not, so the editor offers you refObject.flip() in a scene script and the script throws at run time. Everything about refObject works correctly in an object script, which is where it is meant to be used. In a scene script, address entities through world.getObjectById or world.getAllObjects instead. See Known limitations.

mod#

The member belongs to mod scripting. Mod scripting has one script per mod and no scene/object distinction, so every mod entry carries this value.

scene-script#

Reserved for a member reachable only from a scene script. Nothing carries it — world and globalEvents are identical in both script kinds, and the only asymmetry runs the other way, through refObject.

See also#