Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Live state

A game declares a few live states with defineLiveState, each a JSON document that every room and page of the game reads. A room reads each state before its first step and gets each new revision within seconds. A room writes one against the revision it read, and the creator writes one with spawnite live-state set. A page shows one with useLiveState.

Roblox carries the same need with MessagingService topics, which deliver messages and keep no state, and MemoryStoreService keys, which a server must poll. Unity Remote Config and PlayFab title data hold settings a client fetches. A live state is state the platform keeps and pushes: a room that starts late reads the current one, and no room polls.

Declare each state at module scope in src/liveState.ts, with its name, its schema and its initial state:

  • The name is kebab-case, such as events or double-coins. A game declares 8 at most.
  • The state is 1 KiB at most as JSON.
  • Every room reads the initial state at revision 0, before the first write.

Split the states by who writes them. A creator’s weekend schedule and a room’s boss each get their own, so neither write refuses the other’s.

A game with rooms exports each live state from the room’s scene file too, beside game: export { events } from "../liveState". spawnite build fails where the scene leaves one out.

readLiveState(world, handle) answers the state the room applied, frozen, since every reader shares it, its revision, and wasChanged, which is true on the tick a revision applied and on no other. A room applies each new revision at the start of a tick and streams it to its pages, so a predicted system reads on the page what the room applied.

setLiveState(step, handle, state, { expectedRevision }) writes a state against the revision the room read. The write lands only where no other write landed since, and LiveStateSetEvent answers it on the tick its answer arrives, the tick a write that landed applies on. A refusal names its reason, such as stale-revision: read the state again and write from it.

packages/room/test/outside/bossEvent.ts
import type { World } from "@spawnite/engine/core";
import {
defineLiveState,
readLiveState,
setLiveState,
type AuthoritativeStep,
} from "@spawnite/engine/core";
import { z } from "@spawnite/schema";
// A weekend event every room of the game shares, built from the engine's
// public entry alone: the creator turns double coins on from the cli, a
// room raises the boss, and the room whose players defeat it clears it
// in every room.
/** The game's event state, in src/liveState.ts; the room's scene file
* exports it too. */
export const events = defineLiveState("events", {
schema: z.object({
doubleCoins: z.boolean(),
boss: z.nullable(z.string()),
}),
initial: { doubleCoins: false, boss: null },
});
/** The coins a pickup worth `coins` gives now. */
export function readCoinReward(world: World, coins: number) {
return readLiveState(world, events).state.doubleCoins ? coins * 2 : coins;
}
/** Raises `boss` in every room, unless one is up: answers whether the
* write went out. */
export function raiseBoss(world: World, step: AuthoritativeStep, boss: string) {
const { state, revision } = readLiveState(world, events);
if (state.boss !== null) return false;
return (
setLiveState(
step,
events,
{ ...state, boss },
{ expectedRevision: revision },
) === null
);
}
/** Clears the boss in every room, as one room's players defeat it. */
export function defeatBoss(world: World, step: AuthoritativeStep) {
const { state, revision } = readLiveState(world, events);
return setLiveState(
step,
events,
{ ...state, boss: null },
{ expectedRevision: revision },
);
}

A state that turns over on a schedule, such as the day’s prices, is written by the first room whose clock reaches the new day. Each room compares the state’s day with the UTC day, and writes the new day’s state against the revision it read. A room refused stale-revision reads the state again and finds the day already written by another room.

A live state carries no transaction. A room can write “boss defeated” and stop before its checkpoint commits the defeat, so the reward for the defeat comes from the room’s saves, never from the live state. A counter every room adds to is a keyed record that each room sends a deposit, never a live state.

useLiveState(handle) reads a state through the app, at each new revision, in a room game and a game played alone alike. It answers the initial state while it loads.

packages/engine/test/outside/EventBanner.tsx
import { Text, useLiveState } from "@spawnite/engine";
import { events } from "./liveState";
export function EventBanner() {
const { state } = useLiveState(events);
if (state.boss !== null) return <Text>{`${state.boss} is up!`}</Text>;
return state.doubleCoins ? <Text>Double coins this weekend</Text> : null;
}

spawnite live-state show prints each state the served version reads, with its revision. spawnite live-state set events '{"doubleCoins":true,"boss":null}' writes one, whole. The state must fit the served version’s schema, with no field it does not name. --expected-revision makes the write land only where the state is still at that revision. The MCP’s read_live_state and set_live_state tools do the same.

A publish compares each state’s schema with the one served before:

  • A change that only adds optional fields keeps the state. A room on an older version still reads it, and its writes keep the fields it does not know.
  • Any other change starts the state again at the new version’s initial state, and spawnite publish says so. Rooms of older versions keep their own state until they drain.
  • Publishing an older version again restarts its state from the served one where its schema reads it, and from its initial state otherwise.

The game’s dev server keeps a stand-in in .spawnite/live-state.json. The development room reads it every second and writes it, and a page outside the app reads it. To write a state in development, edit the file or post to the dev server’s /__live-state, as the dev server page says.