Async and snapshots
Your script runs in a sandboxed frame. The table lives outside it, in the host's runtime. The only thing that crosses between them is a message. Every awkwardness on this page comes from that one boundary.
Two rules cover almost every case:
- A promise resolves when the host answers the message, not when the table settles.
- A method that returns nothing changed nothing you can see yet. It posted an intent and returned; your handle still holds the values it already had.
Handles hold a copy, not a live view#
An ObjectHandle is a small cached record of an entity — id, kind, name, position, rotation,
locked, faceUp, tags, metadata — with methods bolted on. Reading handle.position reads that
cache. It does not reach across to the runtime.
The cache is filled from three places, and nowhere else:
| Filled by | When |
|---|---|
world.getObjectById / world.getAllObjects |
Each call fetches a snapshot from the host and refills every handle it returns. |
handle.refresh() |
Refills that one handle and resolves with the fresh data, or null when the entity no longer exists. |
| Any lifecycle event about that entity | The sandbox refills the entity's handle before it fans the event out. |
The third one is easy to miss and useful: handles are cached per entity id, so a handle you
stored in a variable at startup is refilled whenever an event about that entity arrives. A
handler that reads refObject.position is reading values the event itself refreshed a moment
earlier.
Outside of an event, assume the cache is as old as your last read.
The four shapes of async#
Every promise-returning member is one of four shapes. The reference entry for each one says which, in its Gotchas section, in these words:
Host round-trip#
world.getObjectById, world.getAllObjects, handle.refresh, world.getSavedData,
handle.getSavedData.
Resolves after a round-trip to the host, so the value is the host's state at the moment it answered — not necessarily still true when your handler continues.
Each of these fetches the whole snapshot and filters it in the frame. Calling
world.getAllObjects four times in a row is four snapshot round-trips; fetch once and reuse the
array.
Optimistic#
world.spawnObject.
Resolves before the host has validated or applied the request. A non-
nullresult means the request was well-formed, not that the entity exists.
Acknowledged write#
world.setSavedData, handle.setSavedData.
Resolves once the host has accepted the write. The value reaches other peers with the next snapshot, not when this resolves.
Timer#
world.wait.
Resolves after the delay elapses on the host. Nothing about the table is guaranteed to have changed.
Prefer world.wait(seconds) over setTimeout for pacing. It reads as what it is, it takes
seconds rather than milliseconds, and a negative or non-finite argument is clamped to zero
rather than throwing.
Sync mutators, and the one sentence that matters#
setPosition, setRotation, flip, rotate, lock, unlock, roll, shuffle, draw,
deal and destroy all return nothing. They post an intent and return on the same line. Every
one of their reference entries carries this sentence:
This returns immediately. The change is not visible in
ObjectData— including this handle's own properties — until the next snapshot arrives. Callawait handle.refresh()if you need to read the result.
They are not failures if nothing appears to happen. Three separate things can swallow one:
- The host refused the intent. An action outside the script allowlist, or a spawn whose definition fails schema validation, is dropped with a diagnostic in the script console and no other signal. Read the console before assuming the method is broken.
- The entity is locked. A locked entity refuses every action except
unlock. - The action does not apply to that kind.
drawon a die does nothing. See Action vocabularies.
Ordering across the boundary#
Messages from your script to the host arrive in the order you sent them, and the host processes
each one before the next. So an intent posted before a read is applied before that read is
answered: entity.lock() followed by await entity.refresh() returns the locked state. That is
the ordering the example below relies on.
What is not ordered is the rest of the table. Physics keeps running, other players keep acting, and a snapshot you fetched is a photograph. Between your read and your next line, a die can settle somewhere else and a card can leave the table.
When other players find out#
They find out on the host's next state broadcast — a full snapshot, or a delta against the last one they acknowledged. Nothing your script does reaches another peer any other way. A promise resolving tells you the host answered you; it says nothing about what any other player has drawn on screen yet. Never build a countdown, a simultaneous reveal or a race on the assumption that a resolved promise means everyone is looking at the same table.
Saved data rides the same broadcast#
world.getSavedData / setSavedData store a string against the table; handle.getSavedData /
setSavedData store one against a single entity. Both are namespaced per scene, so two scenes
cannot read each other's values, and both survive save and load.
Both also ride every snapshot broadcast. There is no size limit enforced at the API, and a
large blob is re-sent to every peer on every keyframe. Store a compact JSON.stringify of the
state your game needs to resume, not a log.
In Edit Mode saved data is kept in memory for one run and cleared on every ▶ Play, so a script under test always starts from empty. At a real table it persists.
The two timing values#
The timing badge is read off the declared return type, and nothing else.
async#
The declared return type mentions Promise. The entry's Gotchas section then names which of
the four shapes above it is — the badge alone does not tell you whether you are waiting on the
host, on a timer, or on nothing at all.
sync#
Anything else, including void. A void mutator is sync even though its effect appears
later; the badge describes the call, not the consequence.
Example#
// content/scripting-api/examples/concepts.async-and-snapshots.ts
// Scene script: a sync mutator returns immediately and does NOT update the
// handle you called it on. Only refresh() reads the host's answer back.
async function lockSomething(): Promise<void> {
const entities = await world.getAllObjects();
const entity = entities[0];
if (!entity) {
world.log("Nothing on the table yet - add an entity and restart the scripts.");
return;
}
world.log(`before lock(): ${entity.id} locked = ${entity.locked}`);
entity.lock();
world.log(`straight after lock(): ${entity.id} locked = ${entity.locked}`);
// The host applies the intent, then answers this read. Until it does, the
// handle still holds the values it was built with.
const applied = await entity.refresh();
if (!applied) {
world.log("That entity is gone - the host removed it.");
return;
}
world.log(`after refresh(): ${applied.id} locked = ${applied.locked}`);
}
void lockSomething();
On a table with at least one entity the script console prints three lines, for example
before lock(): obj-1 locked = false, straight after lock(): obj-1 locked = false, then
after refresh(): obj-1 locked = true. The middle line is the whole point: the mutator has
already been sent and the handle still says false.
See also#
- Host authority — who applies the change your promise is waiting on.
- Events and delegates — the other way fresh data reaches a handle.
- Execution order — what has already happened by the time your first
awaitruns. world.spawnObject— the optimistic shape, worked end to end.ObjectHandle— every method on this page, with its own badges.- The mod
apiobject — the same round-trip on Surface B, where eight host-side reads return a promise the host interface answers synchronously. - Mod hooks and capabilities —
ModTableEventPayload.snapshot, a snapshot handed to you that goes stale by exactly these rules.
