State, saving, and cleanup
First decide who needs a value and how long it must live. That choice determines which SDK API to use.
Choose where data lives
| Need | Use | Who can read it | Survives restart? |
|---|---|---|---|
| Local animation or open panel | React state or a ref | This client | No |
| Owner-configured world settings | Manifest configSchema | Mod client and server | Saved by the host as world configuration |
| A placed object's settings | editorItems properties | World object renderer; permitted object reads | Yes, when persisted by the editor/object API |
| Temporary moving objects | ctx.entities | Mod clients | No; recreate on start |
| Shared score or activity phase | ctx.state | Every client of this mod | No; restore from storage if needed |
| Private saved progress | ctx.storage | This mod's server in this world | Yes, after a successful write |
| Player inventory or rewards | ctx.inventory / ctx.rewards | Host-managed account surfaces | Host-managed persistence |
| A request or one-time notification | ctx.messages / client message hooks | Its recipients | No replay for late joiners |
Replicated means sent to connected players. Persistent means saved for a later session. A replicated value is not automatically persistent.
Start, run, stop
onWorldStart(ctx)loads saved data and initializes current state.- Player, message, interaction, and tick hooks run during the session.
onDispose(ctx)handles normal shutdown work.- Host-owned timers and runtime entities are cleaned up. Registered resource disposers run after
onDispose.
Save important changes when they occur. A process crash may prevent onDispose from running. Keep state per context with a WeakMap; the same mod module can serve more than one world.
Check save results
ctx.storage.set() returns Promise<boolean>. Await it and inspect the result:
async function saveProgress(ctx, progress) {
const saved = await ctx.storage.set('progress', progress);
if (!saved) {
ctx.log('Progress was not saved');
return false;
}
ctx.state.set({ completed: progress.completed });
return true;
}This helper illustrates write-result handling; call it from a server hook after validating the action. Storage and replicated state are separate operations. Reward and inventory writes are separate too: several successful calls do not form one database transaction. Do not promise an exactly-once reward simply because a “claimed” flag was saved.
IDs and time
Use the full entity ID returned by ctx.entities.spawn() for updates and removal. It may return null. If the client sends a full ID back, validate that it belongs to an entity your mod owns; avoid splitting on : and assuming all IDs have only two parts.
Use ctx.now() for server timestamps and ctx.schedule for timers. The test harness controls this clock, so tests can advance time without waiting. sessionId identifies a connection; do not assume it stays the same on reconnect.
Repeated requests
Declare mutating messages in messageSchemas, using delivery: "at-most-once" when appropriate. Reuse the returned operation ID only for a retry of the same intent. The replay window is bounded and does not survive every restart.
Check gameplay state before each change. If correctness depends on a durable business operation, design its saved operation record and recovery behavior; transport deduplication alone is insufficient.
Release resources
Use ctx.schedule.interval() and ctx.schedule.timeout() for server timers; both return a cancellation function. Use ctx.resources.defer() for custom server subscriptions and useModResourceScope() for custom client resources. React effects should return cleanup functions for listeners and subscriptions.
Built-in asset hooks manage their caches. For large repeated objects, use batch renderers and avoid React state updates on every animation frame.
Verify the boundary you depend on
Use the test harness for validation and clock-driven behavior. Use a real hosted preview with two clients for replication. Verify persistence across a real runtime restart; a component remount alone does not prove it.