Instance
An instance is a self-contained running world. This page works through what it holds and how it moves forward one tick at a time. It goes top down, from the state and the waiting commands, through the one tick that advances it, to the per-viewer send that gives each player just what they can see. The server is the only simulator. There is no history kept, no rewind, and no client copy of these bodies.
What an instance holds
An instance is a plain object. It holds its state, its players, its entities, the commands received since the
last tick, and its tick counter, alongside the indices and scratch the engine keeps for it. The state is a flat
set of named values. The players are a map keyed by player id, so reading or writing one is a direct lookup. The
entities are a map of maps — keyed first by entity type, then by id — so listing one type's
entities is O(entities of that type); a flat byId index alongside maps every id, of any type,
straight to its entry, so the id-keyed reads and writes (Get, Set, Destroy) stay O(1). A player's value is its
state. An entity's entry is its state with its id and type alongside.
{
id: "i1",
state: {
arenaW: 3600,
arenaH: 2400,
fireEvery: 8
},
players: Map { // pid -> player state
"p1" => { x: 1000, y: 1000, vx: 3.1, angle: 0, score: 4 }
},
entities: Map { // type -> Map(id -> entry)
"rock" => Map {
"e1" => { id: "e1", type: "rock", state: { x0: 500, y0: 500, vx: -0.5, spawnTick: 40 } }
}
},
byId: Map { // id -> the same entry, flat across every type
"e1" => { id: "e1", type: "rock", state: { ... } }
},
persistent: undefined, // server-only scratch, never sent, kept between ticks
dirty: Map { // id -> Set of schema field names changed since the last send
"p1" => Set { "x", "y", "vx" }
},
pending: [ ... ], // commands received since the last tick, applied on the next one
queue: [ ... ], // the commands being applied this tick
tick: 128, // this instance's tick counter, its game clock
_ec: 12 // entity id counter
}
There is no snapshot history and no record of past ticks. The instance is only ever its present. When a tick
runs it writes the state in place and marks each changed schema field on the instance's dirty map,
so the send knows exactly what moved without ever comparing
snapshots. The only per-viewer memory kept is a set of the object ids that viewer currently holds. Beside the
sent state the instance also carries persistent, a server-only scratch value it keeps between ticks
and never sends — set through Instance.Persistent, undefined until a
gamehoster-instance-create.js body builds it up, for a thing like a spatial grid the simulation
needs but no client sees. Nothing else about the past is stored.
Commands waiting for the next tick
A command is one input from one player. When it arrives the server checks the player and the command type,
then pushes it onto the instance's pending list. It is applied on the next tick of that instance.
There is no tick tag on it and no rewind: authority applies it when it arrives.
{
sender: "p1", // the player who sent it
type: "thrust", // the command type, one of the game's commands
params: {
on: true
},
seq: 47 // the client's own sequence number, echoed back as an ack once applied
}
The seq is the client's own counter. When the command is applied the server records it as that
player's ack, the highest sequence number applied for them, and rides it back on every update so the client
knows the server has caught up. The Frontend uses that ack to
retire an action it drew ahead of time.
advanceInstance
One tick advances every instance once. The step loop bumps each instance's tick, moves its
pending commands into queue so new arrivals start collecting for the next tick, and
calls advanceInstance. That runs the three steps of the model in order: the commands, then the
per-actor updates, then the instance update.
step(now) {
this._now = now
for (const inst of this.instances.values()) {
inst.tick++
inst.queue = inst.pending // the commands received since the last tick
inst.pending = [] // new arrivals collect for the next tick
this.advanceInstance(inst)
}
}
advanceInstance(inst) {
// step 1: commands, in the order they arrived, each gated by its writes manifest
for (const c of inst.queue) {
this.run(commands[c.type].handler, { self: c.sender, instance: inst, command: c.params, commandType: c.type }, "command")
ack(c.sender, c.seq) // applying a command acks it for its sender
}
// step 2: per-actor updates — ONE authoritative body per object (the single -update.js),
// falling back to the legacy -update-frontend + -update-backend pair, front then back
for (const pid of inst.players.keys()) {
if (playerUpdate) {
this.run(playerUpdate, { self: pid, instance: inst }, "playerUpdate")
} else {
this.run(playerUpdateFrontend, { self: pid, instance: inst }, "playerUpdate")
this.run(playerUpdateBackend, { self: pid, instance: inst }, "playerUpdate")
}
}
for (const m of inst.entities.values()) { // each entity type's own map
for (const eid of m.keys()) {
const u = entities[m.get(eid).type]
if (u.update) {
this.run(u.update, { entity: eid, instance: inst }, "entityUpdate")
} else {
this.run(u.updateFrontend, { entity: eid, instance: inst }, "entityUpdate")
this.run(u.updateBackend, { entity: eid, instance: inst }, "entityUpdate")
}
}
}
// step 3: the instance update (single body, else the legacy pair)
if (instanceUpdate) {
this.run(instanceUpdate, { instance: inst }, "instanceUpdate")
} else {
this.run(instanceUpdateFrontend, { instance: inst }, "instanceUpdate")
this.run(instanceUpdateBackend, { instance: inst }, "instanceUpdate")
}
}
Each command runs its handler with a context naming the sender, the instance and the command params. A command only touches the fields in its writes manifest, held by the write firewall below. Applying it records the ack for its sender.
The per-actor updates then run for every player and every entity. Each object runs one authoritative
body, its single -update.js. A game not yet converted may still ship the legacy
-update-frontend.js + -update-backend.js pair; the engine prefers the single body and
falls back to running the pair front then back. Either way these are the authoritative bodies, run here on the
server. The server is the only simulator, so there is no client copy of them running anywhere. The instance
update runs the same way, once, to finish the tick. The forms are laid out on
the Player schema.
Every body runs through run, which builds a Gamehoster object for the call, lets the
body write into a scratch buffer, then flushes those writes through the firewall. The next two sections are
those two halves.
botPass
A bot is a player the server plays: it sits in inst.players
with ordinary player state and no socket. Nothing in the tick above knows about bots — its commands arrive
in pending like anyone's and run through the game's own handlers, and the
send streams it to other viewers as a player. The only
code that treats a bot specially is a per-instance bot pass, run on its own timer at
gamehoster-game-botRate (default 10/s),
independent of the tick. Each pass runs every live bot's gamehoster-bot-update.js brain —
read-only for game state, acting only by queueing a command through Bot.Send — and then moves
the bot population one step toward a target computed from the live human count.
botPass(inst) {
// run each bot's brain: it reads the world and may queue a command as itself (Bot.Send).
for (const [bid, bot] of inst.bots) // inst.bots: the bot ids among inst.players
this.run(botUpdate, { self: bid, instance: inst, bot }, "botUpdate") // firewalled: no game-state writes
// move the population by ONE toward the target, so bots ease in and out rather than pop.
// botsMax / botsZeroAt are game config, read from gamehoster-game.json (not the instance schema).
const humans = inst.players.size - inst.bots.size
const target = humans === 0 ? 0
: Math.max(0, cfg.botsMax - Math.floor(humans * cfg.botsMax / cfg.botsZeroAt))
const capped = Math.min(target, capacity - humans) // never fill a slot a human could take
if (inst.bots.size < capped) this.addBot(inst) // short: add one, type picked by likelihood
else if (inst.bots.size > capped) this.removeBot(inst) // over: remove a random one
}
botsMax and botsZeroAt are two
game-config values — gamehoster-game-botsMax and
gamehoster-game-botsZeroAt in gamehoster-game.json, moved there out of the instance
schema: the most bots the instance holds, and the human count at which the target reaches zero. Adding a bot
picks a personality by weighted
random over each type's gamehoster-bot-likelihood
(a likelihood of 0 never appears), joins it through the game's own
join with its instance pinned, and runs its optional
gamehoster-bot-join.js to set up its private memory;
removing one runs the game's own leave, so its ship and any
in-flight entities are cleaned up exactly as a leaving human's would be. When no humans are present the
target is botsMax, not zero: an unattended instance is kept warm at a full
complement of bots rather than draining, so the game keeps running and is monitorable even with nobody
connected, and the gamehoster-game-warmInstances game
setting keeps that many instances alive at all times so one always exists to be warmed. And because
humans take priority, when a person joins and every instance is full the engine evicts a
random bot to free a slot, so a real player never opens an empty arena while bots sit in a full one.
The one place the tick's own machinery accounts for a bot is the outbound socket: a bot has no connection,
so the send loop has no viewer to build for it and
skips it. Everything else — command handling, the per-actor updates, the view, the delta — treats it as the
player it is, which is why a game gets bots purely by adding a gamehoster-bots/ folder and the
two instance settings, with no change to its own logic.
The Gamehoster object
This is the object each body is handed. Reads come from the live state, but read through the body's own
buffered writes first, so a body sees what it has already set this call. Writes do not touch the state
directly. They are recorded into a scratch buffer keyed by kind:id:name, to be committed together
once the body returns, through the firewall in the next section.
lib(ctx, scratch) {
// record a buffered write; reads in the same body see it (read-through). `inst` is the
// instance that owns the object, so a committed write can be marked dirty for the send.
const record = (kind, id, container, type, name, value, inst) =>
scratch.set(`${kind}:${id}:${name}`, { kind, id, container, type, name, value, inst, exists: fieldExists(kind, name, type) })
const buffered = (kind, id, name) => scratch.get(`${kind}:${id}:${name}`)
return {
Context: { playerId, instanceId, entityId }, // who this body is running for
Tick: () => ctx.instance.tick, // the instance clock, stamped on every update
Time: () => world._now, // wall-clock milliseconds
Random: () => rng(), // seeded by (actor, tick)
Entropy: () => Math.random(), // server-only true randomness, unseen by clients
Command: { Get: (p) => ctx.command[p] }, // this command's params
View: { Include: (id) => ctx.includes.add(id) }, // mark an object visible, during the view pass
Player: {
State: {
Get: (id, name) => {
const w = buffered("player", id, name) // this body's own write, if any
if (w) return w.value
return inst.players.get(id)[name] // otherwise the live state
},
Set: (id, name, value) => record("player", id, inst.players.get(id), null, name, value, inst)
},
Persistent: (id) => ({ Get, Set }) // this player's server-only bag, by id — never sent
},
Entity: {
List: (type) => [...inst.entities.get(type).keys()], // O(entities of this type)
State: { Get, Set }, // same read-through-buffer shape
Persistent: (id) => ({ Get, Set }), // this entity's server-only bag, by id — never sent
Spawn: (type, fields) => { /* add an entity to its type map + byId, return its id */ },
Destroy: (id) => { /* remove an entity from its type map, byId and dirty */ }
},
Instance: {
State: { Get, Set },
Persistent: { Get, Set }, // server-only scratch, kept between ticks, never sent
Create: () => world.createInstance(),
Join: (id) => { /* put this player into instance id */ }
}
}
}
Tick() is the instance's tick counter, its game clock, stamped on every update so the front end
can place events and drive its own render clock. Random() is seeded from the actor and the tick,
so it is the same value for any reader of the same actor on the same tick. Entropy() is real
randomness the server keeps to itself, for a rock spawn or a respawn the client could never reproduce.
Time() is the wall clock. Instance.Persistent is the current instance's server-only
scratch — a value kept between ticks and never sent, for something like a spatial grid the simulation needs but
no client sees; Get returns the live object and Set replaces it.
Player.Persistent(id) and Entity.Persistent(id) are the same, one bag per object, by
id — the home of velocities, cooldowns, timers and AI memory, the free-form state that was once a server-only
schema field. The full surface,
every field of the object, is the Functions reference.
The write firewall
A body never writes the state as it runs. It fills the scratch buffer, and when it returns those writes are
flushed against the set of writes it was allowed. Update, join, leave and startup bodies are the server's own
code, so they write freely. A command is bounded: it may only write the fields named in its
writes manifest, resolved to concrete kind:id:name targets.
allowedWrites(kind, ctx) {
if (kind !== "command") return "all" // updates, join, leave, startup: write freely
const set = new Set()
for (const w of commands[ctx.commandType].writes || []) {
const [obj, field] = split(w) // e.g. "player.angle" -> "player", "angle"
if (obj === "player" && ctx.self) set.add(`player:${ctx.self}:${field}`)
if (obj === "instance" && ctx.instance) set.add(`instance:${ctx.instance.id}:${field}`)
}
return set
}
// commit one buffered write, and if it lands on a real SCHEMA field mark that field dirty on
// the owning instance — this is the whole of change tracking, done at write time, not by diffing.
commit(e) {
e.container[e.name] = e.value
if (e.exists) { // a real schema field is streamed, so track it
let s = e.inst.dirty.get(e.id)
if (!s) { s = new Set(); e.inst.dirty.set(e.id, s) }
s.add(e.name) // public → every viewer; private → its owner
}
}
flush(scratch, allowed) {
if (allowed === "all") { // updates, join, leave, startup: commit them all
for (const e of scratch.values()) this.commit(e)
return
}
for (const key of allowed) {
const e = scratch.get(key)
if (e) this.commit(e) // commit the writes this command was allowed
}
for (const [key, e] of scratch) { // anything left over was not permitted
if (allowed.has(key)) continue
if (!e.exists) console.error(`write to unknown field "${e.name}" — ignored`)
}
}
Flush walks the allowed targets and commits whichever the body set. Whatever is left in the scratch buffer was not permitted. A write to a real field the command was not granted is a silent no-op. A write to a field that does not exist in the schema is a contained error: it is logged and dropped, and the tick carries on. A bad body can hurt only its own writes, never the instance.
Committing is also where change tracking happens. When a write lands on a real schema field,
commit adds that field's name to the instance's dirty map for that object, so the next
send knows exactly which fields moved without ever diffing a snapshot. Both public and private fields are
tracked — they are streamed alike, just to different viewers — and the send
below narrows a private field to its owner. A write to something that is not a schema field marks nothing:
server-only scratch lives in a persistent bag outside this path.
The lookup is O(1): the engine precomputes a per-kind name → field map, so deciding
whether a written name is a real field is a single map read, not a schema scan. The send
below consumes that dirty map.
The view
Before the server sends, it works out which objects each player can see. If the game gives no view body, every
player sees every object. If it gives one, the body runs for that player and marks the objects in view with
View.Include, for example everything within a range of them. A player always sees themself and the
instance.
visibleTo(inst, pid) {
const vis = new Set([inst.id]) // the instance itself is always visible
if (!scripts.view) { // no view given: everyone sees everything
for (const id of inst.players.keys()) vis.add(id)
for (const id of inst.entities.keys()) vis.add(id)
return vis
}
vis.add(pid) // you always see yourself
const includes = new Set()
this.run(scripts.view, { self: pid, instance: inst, includes }, "view") // View.Include fills it
for (const id of includes)
if (inst.players.has(id) || inst.byId.has(id)) vis.add(id) // byId: the flat id index over every type
return vis
}
The set it returns is the ids this player may be sent this tick. What of each of those objects reaches them is the state model: a field is private or public.
Public and private state
Every schema field declares a visibility, public or private. A
public field is sent to each viewer who can see the object, whenever it changes. A
private field is owner-only: on a player it reaches only that player, and no
other viewer — the one place a field is filtered per viewer. (Private is only meaningful on players; an
instance or entity has no owner, so its fields are all effectively public.) Both kinds are streamed — the
difference is only to whom. State that reaches no client is not a schema field at all: it
lives in the object's server-only persistent bag, which is never
serialised.
const isOwnerOnly = (f) => f.visibility === "private" // only meaningful on players
// the fields of an object a viewer may see: every public field, plus this object's
// private (owner-only) fields when the viewer is its owner.
visibleState(schema, state, isOwn) {
const out = {}
for (const f of fields(schema)) {
if (isOwnerOnly(f) && !isOwn) continue // owner-only: skip for everyone but the owner
out[f.name] = state[f.name]
}
return out
}
This is the whole of the state model. A field is streamed to everyone or to its owner alone; anything that
should never leave the server is a persistent bag, not a field. The send carries just the fields that were
marked changed, which is next. How visibility is declared is on
the Player schema.
buildUpdate
The send runs once per viewer, at the send rate. It works out what this viewer can see, then for each visible
object emits either its first sight in full, or the fields the instance marked changed since the last send.
There is no snapshot to diff against: the changed fields come straight from the instance's dirty
map, and all this viewer remembers is the set of object ids it currently holds. The update it returns
has three lists: appear, changed and gone. It carries the instance tick
and this viewer's ack.
buildUpdate(inst, p) {
const vis = this.visibleTo(inst, p.id) // what this viewer can see now
const sent = p.sent // a Set of the object ids this viewer holds
const appear = [], changed = [], gone = []
for (const id of vis) {
if (!sent.has(id)) { // newly visible: send its full public state
const entry = this.objEntry(inst, id, p.id)
if (entry) { appear.push(entry); sent.add(id) }
continue
}
const dirty = inst.dirty.get(id) // the fields written on this object since the last send
if (!dirty || !dirty.size) continue // nothing moved — say nothing
const fields = this.changedFor(inst, id, p.id, dirty) // filter to what this viewer may see, with values
if (fields && Object.keys(fields).length) changed.push({ id, fields })
}
for (const id of sent) // previously sent, no longer visible
if (!vis.has(id)) { gone.push(id); sent.delete(id) }
return { t: "update", tick: inst.tick, ack: p.ackSeq, appear, changed, gone }
}
The sent Set on the player is the one piece of per-viewer memory kept, and it holds only ids:
which objects this viewer has been sent an appear for and not yet a gone. What
changed does not come from it — it comes from the instance's dirty map, the schema fields
written since the last send, read through changedFor, which filters that field set per viewer by
visibility, so another player's private (owner-only) field is dropped. appear carries the full public
state of an object this viewer had not been sent, whether it was just created or just came into view.
changed carries only the fields the instance marked changed since the last send.
gone is the ids this viewer had been sent that are no longer visible, because the object was
destroyed or left their view. The update is stamped with the instance tick and the viewer's
ack, the highest command sequence applied for them.
The dirty map is shared by all of an instance's viewers and consumed once per send pass. The
server's send loop builds every open-socket viewer's
update from it, and only when all of them have been served does it clear the map (inst.dirty.clear()),
so the next window starts clean. Dirty therefore accumulates across the ticks between two sends and is read by
every viewer before it is cleared. This is safe because every active viewer is rebuilt every pass — there is no
per-viewer snapshot left to fall out of step with, only the shared record of what changed.
This is why a cheap constant-state object is nearly free. Give a ballistic rock public state that never
changes, its start position, its velocity and its spawn tick, and let the front end compute its live position
from those and the clock. Its public fields are never written, so it is never marked dirty, so after the one
appear it is never sent again, however far it flies. The other side of this, how the front end
holds these deltas and draws between them, is the Frontend,
and the exact wire shape is the protocol.