Update
Every instance advances one tick at a time, and a tick is a pure function: it takes the previous state of everything and produces the next state, with no hidden inputs. It runs in exactly three steps: handle the commands that arrived, let every player and entity update itself, then run the one global instance update. Because it is pure and its order is fixed, the server and the front end compute the very same tick.
one tick = previous state → next state (a pure function)
1 HANDLE COMMANDS each command's handler runs — updates the sender's player
state and/or instance state (legality = the handler ignoring)
2 UPDATES every player and entity runs its update, in parallel:
reads the PREVIOUS state, writes ONLY its own state
3 INSTANCE UPDATE the one global pass — spawn/destroy, cross-cutting logic,
react to flags set in step 1, run the phase machine
Each actor's update is split not by side but by trust. The shared, predictable part
lives in -update-frontend.js and runs on both sides: on the front end (as
prediction) and on the backend (as the first part of its work). The private, authoritative part lives in
-update-backend.js and runs only on the backend, after the shared part. So
the backend runs the superset (the shared file, then its own) while the
front end runs just the shared file. The shared logic exists once and cannot drift. The
three steps below are that whole backend tick; the clocks, the three
kinds of state, per-player relevance and random then
describe how it reaches each player, and the checks that keep the split honest.
Step 1: handle commands
Every command a player sent this tick runs its shared
gamehoster-command-handler-frontend.js, then, on the server only, any
gamehoster-command-handler-backend.js. A handler reads its inputs with
Gamehoster.Command.Get(param), takes the sender as Gamehoster.Context.playerId,
and writes the sender's player state and/or instance state. This is the only step driven by player input.
There is no separate legality check. Legality is the handler ignoring what the player's state
disallows: pong's moveY returns early unless the instance is
playing, so an out-of-turn or tampered command simply does nothing:
// gamehoster-commands/moveY/gamehoster-command-handler-frontend.js
const me = Gamehoster.Context.playerId
const here = Gamehoster.Context.instanceId
if (Gamehoster.Instance.State.Get(here, "phase") !== "playing") return // legality = ignore
const y = Gamehoster.Player.State.Get(me, "y") + Gamehoster.Command.Get("axis")
* Gamehoster.Instance.State.Get(here, "paddleSpeed")
Gamehoster.Player.State.Set(me, "y", y) // writes the sender's own state
Alongside its handler, each command declares the fields it writes: pong's
moveY ships a gamehoster-command.json
of { "writes": ["player.y"] }. That declaration feeds the
deploy check: together with each field's sync it
lets the engine prove, before the game runs, that every shared field is one a client
can actually reproduce, and reject as shared anything it can't.
Step 2: updates, in parallel
Every player and every entity runs its own update once. First the shared
-update-frontend.js (this runs on both sides) then, on the backend only, any
-update-backend.js for that actor. They all read the same previous state
(the snapshot the tick began with) and each writes only its own state. Nothing sees
another actor's half-finished changes, so they are effectively updated at the same time.
Because no update depends on the order the actors happen to be visited in, and none can touch another's state, the result of the step is identical however it is scheduled. That order-independence, plus the shared file being one function run on both sides, is what lets the server and the front end compute the same tick: the foundation prediction and reconciliation are built on.
// gamehoster-entities/ball/gamehoster-entity-update-frontend.js (shared: both sides)
const me = Gamehoster.Context.entityId
const x = Gamehoster.Entity.State.Get(me, "x") // read the PREVIOUS state
const y = Gamehoster.Entity.State.Get(me, "y")
Gamehoster.Entity.State.Set(me, "x", x + Gamehoster.Entity.State.Get(me, "vx")) // write OWN state only
Gamehoster.Entity.State.Set(me, "y", y + Gamehoster.Entity.State.Get(me, "vy"))
-update-backend.jsStep 3: the instance update
After every actor has stepped itself, the instance takes its one global pass. It,
too, is split by trust. The shared
gamehoster-instance-update-frontend.js runs on both
sides for the cross-cutting logic a client can reproduce: pong's wall-and-paddle bounce. Then,
on the backend only, gamehoster-instance-update-backend.js
does what a client cannot: spawn and destroy entities, keep score, react to flags step 1
set (a ready that should start the match), roll an authoritative serve with
Entropy(), and run the phase machine from lobby to
playing to over.
// gamehoster-instance/gamehoster-instance-update-backend.js — the backend-only extra,
// run AFTER the shared bounce. It owns what the client can't predict.
const here = Gamehoster.Context.instanceId
const players = Gamehoster.Player.List()
// react to a flag set by the ready command in step 1
if (Gamehoster.Instance.State.Get(here, "phase") === "waiting"
&& players.length === 2
&& players.every(id => Gamehoster.Player.State.Get(id, "ready"))) {
Gamehoster.Instance.State.Set(here, "phase", "playing")
Gamehoster.Entity.Spawn("ball", {}) // spawn — a global action, backend only
}
// cross-cutting outcome: a goal no single actor owns (the ball was already bounced
// by the shared pass this tick)
const ball = Gamehoster.Entity.List("ball")[0]
if (ball !== undefined && Gamehoster.Entity.State.Get(ball, "x") < 0) {
const r = players.find(id => Gamehoster.Player.State.Get(id, "side") === "right")
Gamehoster.Player.State.Set(r, "score", Gamehoster.Player.State.Get(r, "score") + 1)
}
When it returns, the tick is done. The state it leaves behind becomes the previous state the next tick reads. Because the whole cycle is a pure function of that previous state, replaying the same commands always reproduces the same tick: on the server, and on every front end for the shared part it runs.
Three clocks: the tick rate, the send rate and rendering
The three steps above run at the tick rate
(gamehoster-game-tickRate, say 60 a second)
and they run at that same rate on the server and on every client, because the tick is pure. The
server does not send a packet every tick. It sends at the lower send rate,
gamehoster-game-sendRate (say 20 a second),
and between packets each client keeps ticking on its own, predicting forward from
what it last heard. Rendering is a third, independent clock: the
front end draws as fast as it likes from an interpolated view,
with no fixed rate at all.
tickRate (e.g. 60/s) the pure simulation step — server AND every client
sendRate (e.g. 20/s) how often the server emits a packet, per player
render (free-running) the client's own draw loop, from an interpolated view
Sending less often than we simulate is the whole game: the wire carries as little as possible, and the client fills the gaps by running the same tick locally. What must cross the wire, and how, is decided field by field, by the kind of state each field is.
Three kinds of state
Every field in a schema declares how the client
gets it (its sync) and there are exactly three answers. This is the whole of the
netcode; there is nothing else to tune.
sync | Computed | On the wire | Example |
|---|---|---|---|
server | backend only | never sent | a hidden dice roll, matchmaking scratch, an AI's target |
sent | backend, from data only it has | pushed to clients that can see it; the client takes it as truth (snaps / reconciles) | the score, a spawn, a fog reveal |
shared | both sides, by running the same commands | not sent as a value: reproduced from commands, corrected by an occasional keyframe | a paddle's y, the ball's position |
A field also declares its audience: owner (only the player it
belongs to) or all (everyone it is relevant to), and an optional
smooth for render interpolation. The
full table of sync/audience/smooth,
with the exact combinations, is on the Players page. The three kinds are also what a
front-end body may touch: it can read sent and shared fields of
the things relevant to it, plus its own owner fields, but never a
server field, because that value simply isn't on the client to read.
The front end runs the shared half
Between the sparse send-rate packets, the client keeps ticking on the
-update-frontend.js files (the same files the backend runs, not a copy) so
the shared (and own owner) fields it can compute from the commands it holds stay
live at the full tick rate. It never runs a -update-backend.js: it has no
server state to feed one. When the next packet arrives the engine reconciles:
sent fields snap to the server's word, shared fields are corrected toward the
keyframe and (where smooth is set) eased rather than jumped. Because the shared file is one
function that ran on both sides, those corrections come only from the backend's private pass, and are
normally tiny.
What the player sees is drawn a little behind this, so it eases toward known state instead of guessing past it.
Your own player and deterministic entities are drawn at the present, other players and sent state a
little behind, set by gamehoster-game-displayDelay. The full
display model is on the Frontend internals page.
Each player sees only their slice
A packet is per player, not a broadcast, and a player may not be able to see the whole
instance. Which players, entities and streamed geometry a given viewer can see is
relevance, decided by an optional
gamehoster-player-view.js: it runs on the backend for each viewer and marks what that
viewer may receive (including what they will need soon, so it arrives before it is wanted). With
no view script, everything is relevant to everyone, which is why pong and the other small
examples never write one.
So each player's packet is a small, tailored stream: the relayed commands of the players
relevant to them (which reproduce those players' shared fields locally), plus
pushes of the sent fields, enter/leave as things
cross into and out of relevance, and the odd keyframe.
Partial visibility raises one sharp question: what if something a player can't see affects
something they can: a door they see, opened by a plate hidden in the fog? The answer is a
rule about what may be predicted, not a runtime patch. A field may be shared
only when every client that sees it is guaranteed to hold all its inputs: when it is
owner-driven and reads only its own object, always-present instance globals, and its owner's commands.
Anything whose inputs could be hidden must be sent. So the door is a sent field:
it opens on the server's authoritative word, identically for everyone who can see it, with the cause left
hidden. Prediction is reserved for what a client can always reproduce; everything else is authority.
This is why a shared field cannot drift from a missing input: by
construction it never has one. A client is only ever "wrong" about objects it was deliberately not sent,
which is the whole point of the fog. There is no per-client shadow simulation and no override machinery:
the boundary is a checkable property, not a mechanism. And results of
server-only logic still reach clients just one way: the backend writes them into a
sent field or a spawn; the private working state stays behind.
Predictable and unpredictable random
Because shared logic must land on the same answer on both sides, randomness comes in two
flavours:
| Function | What it is | Where |
|---|---|---|
Gamehoster.Random() | Predictable: seeded by the tick and the current actor, so it returns the same value on server and client. Safe inside shared logic; it is a pure function of the tick. | backend and front end |
Gamehoster.Entropy() | Unpredictable: true randomness. Drives authoritative decisions whose outcome must therefore live in a sent field or a spawn. | backend only |
So a coin-flip that decides who serves is Entropy() on the backend, and its result (the
ball's launch velocity) travels as state; a scatter that must look identical on every client is
Random(), computed in step with the server. Cosmetic-only randomness (particles, screen
shake) has no place in a predict body at all: it belongs in the
render loop, where nothing it touches is synced.
Verified before it runs
The split only holds if a shared body never reaches for something a client doesn't have. Gamehoster checks that for you, and will not synchronise a game to the server if the checks fail: a broken definition is a deploy error, not a live desync. It works in two layers, because the meaning of arbitrary code can't be read from the outside:
| Layer | What it does |
|---|---|
| Static, from the schema and declarations: a deploy check | Every field a body names exists and is correctly typed; a -update-frontend.js or a shared command handler (-frontend.js) is not declared to reach server fields; a -backend.js body may; the sync/audience combinations are legal and each command's writes names real fields; the predictability rule holds: a shared field is provably reproducible by every viewer, or it must be sent; and Entropy() never appears in a shared body. Anything it can't prove safe, it forces to sent. A failure blocks the deploy: nothing broken ever reaches a live game. |
Runtime, in the Gamehoster.* abstraction: never a crash | The abstraction distinguishes two things. A genuine fixed-schema violation the deploy check couldn't see (a field name built at runtime) is a contained throw: the engine catches it, drops that body's effects for the tick, and surfaces it: it never takes down the server or a client. A view-boundary condition is not an error at all: reading an object out of the client's view simply finds nothing (it isn't in its lists), and a write to a field outside a body's class simply has no effect: the authoritative value stands. |
So the declarative layer forces anything it can't prove safe to sent and blocks a broken
deploy outright, and the abstraction keeps a running game graceful: throwing only on a real schema
violation, and quietly ignoring the ordinary boundaries of what a client can see. Between them, "predict
on the front end, keep the private truth on the backend" is a boundary you can't cross by accident and
can't crash by trying.