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
}
An instance: its state, its players keyed by id and its entities as a map of maps by type (with a flat byId index), the server-only persistent scratch, the dirty map of changed fields, the pending and in-flight command lists, and its tick 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
}
A queued command: its sender, its type and params, and the client sequence number acked when it applies

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")
  }
}
One tick: drain the queued commands, run every actor's update, then run the instance update

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.

One tick and the per-viewer send A tick drains the commands received since the last tick, then runs three steps in order (commands, per-actor updates, the instance update), writing the new state in place, marking each changed public field on the instance dirty map, and bumping the tick counter. Separately, per viewer, the send takes what that viewer can see now and the fields marked changed since the last send, producing appear, changed and gone. One tick advanceInstance, on the server pending since last tick drain 1 commands 2 actor updates 3 instance update state tick++ The send buildUpdate, once per viewer, at the send rate the view ids visible to this viewer now dirty + sent changed fields · ids held build per viewer appear changed gone full public state changed fields only ids that left view
Above: one tick drains the commands, runs the three steps, writes the new state and marks the changed public fields dirty. Below: per viewer, the send takes what that viewer can see and the fields marked changed since the last send, into appear, changed and gone.

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
}
The bot pass: run each bot's update, then add or remove at most one bot to ease the population toward the human-driven target

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 */ }
    }
  }
}
The Gamehoster object built for one call: read-through-buffer Get and Set, the clocks, Random and Entropy, the per-type Entity.List, the server-only persistent bags (Instance.Persistent, and Player/Entity.Persistent by id), and the Player, Entity and Instance surface

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`)
  }
}
allowedWrites resolves a command's writes manifest to concrete targets; commit applies a write and marks a schema field dirty for the send; flush commits the allowed writes and reports a write to an unknown field

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 view: the instance and yourself always, plus the objects the game's view body includes for you

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
}
Public fields reach every viewer; private (owner-only) fields reach only the owning player. Server-only state isn't a field — it's the persistent bag.

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 per-viewer send: appear (full public state) for newly visible objects, changed for the fields the instance marked dirty since the last send, gone for ids that left view

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.