Instance
An instance is a self-contained running world. This page works through what it is made of and how it moves forward one tick at a time, including how a late command rewinds it. It goes top down, from the timeline that holds its recent history to the snapshot and inputs inside each tick.
Timeline
The timeline is the instance's recent history. It is an object with three parts: a fixed array of tick objects used as a circular buffer, and two indices into it, the start and the end. The start is the oldest tick still held. The end is the present tick. As the present moves forward the end advances and wraps around the array, and once the buffer is full the start follows it, so the timeline always holds the last stretch of ticks and no more.
{
start: 3, // index of the oldest tick still held
end: 10, // index of the present tick
ticks: [ ... ] // fixed-size array of tick objects, used as a ring
}
The size of the ring, how many ticks of history it keeps, is set by
gamehoster-game-timelineTicks in gamehoster-game.json. It fixes how far back a late
input can reach: an input older than the timeline can no longer be placed on its exact tick, so the depth is
chosen to cover the worst round trip we expect to serve.
Tick
A tick object represents what happened at one particular tick of the server. It holds the tick number, a snapshot of the instance at the start of the tick, and an array of the inputs that happened on that tick, the commands received from players.
{
number: 128,
snapshot: { ... },
inputs: [ ... ]
}
Its two parts follow. The snapshot is the instance frozen at one instant, and the inputs are the player commands for the tick.
Snapshot
A snapshot is the whole instance at a single tick. It holds the instance state, the players, and the entities. The instance state is a flat set of named values. The players and the entities are each a plain object keyed by id, so reading or writing one is a direct lookup with no search. A player's value is its state. An entity's value is its state with its type alongside.
{
state: {
arenaW: 3600,
arenaH: 2400,
fireEvery: 8
},
players: {
"p1": {
x: 1000,
y: 1000,
vx: 3.1,
angle: 0,
score: 4
}
},
entities: {
"e1": {
type: "rock",
x: 500,
y: 500,
vx: -0.5,
size: 3
}
}
}
Input
An input is one command from one player. It carries the player id and the command itself. It does not need a tick number, because it is stored inside the tick it belongs to, so which tick it happened on is already known.
{
player: "p1",
command: {
type: "thrust",
params: {
on: true
}
}
}
A command arrives from a player tagged with the tick that player made it on. The server uses that tag to file the command straight into the inputs of that tick, so rather than holding one running queue it drops each one into the tick it belongs to. Once filed, the input no longer needs the number. That is why a tick keeps its inputs together with the snapshot they act on.
The tick function
The tick function is the heart of the instance. It takes one tick object and returns the next snapshot. It
points the Gamehoster object at this tick, hands it to the user's instance tick, and returns the
next snapshot the writes leave behind.
function tick({ number, snapshot, inputs }) {
Gamehoster._snapshot = snapshot // reads come from here
Gamehoster._inputs = inputs // this tick's inputs
Gamehoster._next = { ...snapshot } // the next snapshot, starting by sharing every part of this one
userTick(Gamehoster)
return Gamehoster._next
}
function userTick(Gamehoster) {
// user-supplied instance tick function
//
// reads each player's inputs and the start-of-tick state through Gamehoster, works out
// what happens this tick, and writes it back with Set, Spawn and Destroy.
}
There is one Gamehoster object, defined once and reused every tick rather than built fresh each
time. The tick function just sets its three private fields for this tick. _snapshot is the
start-of-tick state that every read comes from. _inputs is the tick's inputs. _next
is the snapshot being built, and it starts as a shallow copy of the snapshot, so at first it shares the very
same state, players and entities objects. Ticks run one at a time, so sharing the one object is safe.
The user's writes build _next by copy on write, cloning only the parts they touch. The next
section shows how. Everything left untouched, every unchanged player and entity, stays shared by reference with
the previous snapshot. Because the state is immutable, sharing it is safe, so a tick that moves three ships in
a field of two hundred rocks copies three ships and nothing else.
The snapshot the user reads never changes under them, so the tick function stays pure. Same tick object in, same next snapshot out. That is what lets it re-run over any slot in the timeline to reproduce what came after, which is what makes an exact rewind possible.
Putting the whole tick in one user function is deliberate. Rather than a separate handler for every command type, the inputs, the per-actor updates and the instance body, the three steps from Updates, all run in this one place.
The same tick runs on both sides, but not the same amount of it. The user's code is split by trust: the shared
bodies (the -update-frontend.js files, and the shared half of each command handler) run on the
server and on every front end, and the backend-only bodies (-update-backend.js) run after them on
the server alone. So userTick here is the shared bodies followed, on the server, by the backend
bodies, while a front end runs only the shared half. Which fields each half may touch, and how that keeps
prediction honest, is Updates.
The Gamehoster object
This is the one object the tick function hands to the user's code. Reads come straight from
_snapshot. Writes go through _own and _ownItem, which copy on write into
_next: a part is cloned the first time it is written, then writes land in it, and anything left
unwritten stays shared with the previous snapshot.
const Gamehoster = {
_snapshot: null, // start-of-tick snapshot; every read comes from here
_next: null, // the snapshot being built; writes copy into here
_inputs: null, // this tick's inputs
// clone a part of _next the first time it is written, then return it to write on
_own(key) {
if (this._next[key] === this._snapshot[key]) this._next[key] = { ...this._snapshot[key] }
return this._next[key]
},
// clone the bag, then the one player or entity in it, the first time each is written
_ownItem(bag, id) {
const box = this._own(bag)
if (box[id] === this._snapshot[bag][id]) box[id] = { ...box[id] }
return box[id]
},
Instance: {
State: {
// the tick runs for one instance; its id is Context.instanceId
Get: (instanceId, name) => Gamehoster._snapshot.state[name],
Set: (instanceId, name, value) => { Gamehoster._own("state")[name] = value }
}
},
Player: {
State: {
Get: (playerId, name) => Gamehoster._snapshot.players[playerId][name],
Set: (playerId, name, value) => { Gamehoster._ownItem("players", playerId)[name] = value }
},
Inputs: (playerId) => commandsFor(Gamehoster._inputs, playerId),
Remove: (playerId) => { delete Gamehoster._own("players")[playerId] }
},
Entity: {
State: {
Get: (entityId, name) => Gamehoster._snapshot.entities[entityId][name],
Set: (entityId, name, value) => { Gamehoster._ownItem("entities", entityId)[name] = value }
},
Spawn: (type, state) => { const id = newId(); Gamehoster._own("entities")[id] = { type, ...state }; return id },
Destroy: (entityId) => { delete Gamehoster._own("entities")[entityId] }
}
}
A Set to a player calls _ownItem("players", id): it clones the players object if it
is still the snapshot's, then clones that one player if it is still the snapshot's, then returns it to set the
value on. A second Set to the same player finds both already cloned and just sets.
Spawn, Destroy and Remove clone the entities or players object once, then add or delete a key.
The underscore names are private plumbing. The user code only ever touches Instance,
Player and Entity.
The player view
Each player is sent only their view: the subset of the instance they are allowed to know about. Two things decide it, and the game controls both.
The schemas decide which values leave the server. Every field in the instance, player and entity schemas is marked with who may see it: public fields go to everyone who can see the object, owner fields go only to that player, and server fields never leave the server at all. So a player's own private state reaches them and no one else, and purely server-side values reach nobody.
Visibility is one axis. How each visible field reaches a player is another, set by the field's
sync: a shared field is reconstructed on the
front end from the relayed commands, so it need not travel as a value, while a sent field is pushed
as an authoritative value. Which fields ride the commands and which are sent is what the
update function turns into each player's stream.
The view decides which objects are sent. For each player the game says which other players and entities are in their view, for example everything within a certain range of them. Anything not in a player's view is not sent to them. With no view given, every player sees every object.
The update function runs this for each player every time it sends: it works out that player's current view, keeps each visible object to the fields that player may see, and builds their stream from it. The front end's receiving end is the other side of it.
The update function
The update function moves the instance forward and keeps the timeline true. It runs on the tick clock, and again whenever a late input lands on a tick that has already run.
function update(timeline) {
let n = earliestChangedTick(timeline) // usually the present; earlier after a late input
while (n < present) {
timeline[n + 1].snapshot = tick(timeline[n])
n = n + 1
}
advancePresent(timeline) // add a new tick, overwrite the oldest slot
for (const player of players) send(player, streamFor(present, player))
}
It walks the timeline from the earliest tick that changed, feeding each slot through the tick function to rebuild the snapshot of the slot after it, and carries on to the present. When it reaches the present it adds a new tick, writing over the oldest slot as the buffer wraps. If no late input arrived, the earliest changed tick is the present, so the common case is a single step forward. If a command came in for an earlier tick, the walk starts there instead, and every snapshot from that tick to the present is rebuilt with the command now in place. The result is the latest snapshot, which may differ from the one computed before the command arrived.
When the walk is done, the update function builds each player their own stream from the new present. It is not
a full snapshot. It carries the relayed commands of the players relevant to them, so the front
end can reconstruct those players' shared fields itself; the authoritative values
of the sent fields they can see; and enter and leave as objects
cross into and out of their view, an enter carrying the full state of the thing that arrived.
To hold the front end's reconstruction true the server also sends the odd keyframe: the
authoritative value of a shared field, cycled object by object in a round robin so no one tick
carries a big block. And there is one case it keyframes at once. Whenever a
backend-only body wrote a shared field this tick, that
value goes out, because the server knows the front end could not have reconstructed it. So the client is
corrected exactly where it could not have known, and nowhere else. What goes on the wire, and how each front end
applies it, is the protocol.
The front end runs the same machine
A front end runs this same model over its own view. The same snapshot shape, the same timeline of tick objects, the same pure tick function. Its own commands enter the moment the player makes them, and a server update rewinds it and replays forward. Prediction and reconciliation are then the same operation, on the Frontend page.