Frontend

The front end is the browser half of Gamehoster, and it does very little. The server is the only simulator. The front end keeps no timeline of its own and runs no game logic beyond one optional body: the game's predict body, which the client runs each tick for your own object. It receives a per-viewer stream of state deltas, decodes them against this game's baked-in schema, folds them into a snapshot store, and each display frame reads back an interpolated view of the public state it can see, drawn a fixed buffer behind the server's leading edge so motion stays smooth under jitter. Two files are the whole of it: snapshot.js, the store and the render clock, and gamehoster-client.js, the socket and the conn API. This page walks both, then how your own object is predicted on top.

snapshot.js

The store holds no game logic of its own and simulates no world. The schema tables and the binary codec are baked into the generated client, so it decodes each update against this game's own schema; the game bodies it runs are all client-only rendering helpers — the predict body for your own object, and the optional per-object -enter.js (once, on appear), -smooth.js (each frame, to override interpolation) and -exit.js (once, on disappear) bodies. It keeps one record per object it can see, each carrying the object's kind (instance, player or entity), its type, its newest authoritative state (latest), a short history of recent samples (each a public state stamped with the server tick it arrived on), and a front-end persistent data bag that lives for the object's whole visible life.

Every record carries a stable id, fixed from the moment the object appears until it disappears. The wire numbers objects with a compact netId that is reused as objects come and go, so the store maps each netId to its stable id. The renderer, and any per-object state the bodies keep, address an object by an id that stays constant under it, and read its persistent bag straight off the frame as object.data.

Folding an update in is three plain steps. Newly visible objects appear with their full public state and seed a first sample. Still-visible objects that changed fold their changed fields into latest and push a new sample. Objects that left have their records dropped.

onUpdate(u) {
  for (const e of u.appear)
    this.objects.set(e.id, {
      id: e.id,
      kind: e.kind,
      type: e.type,
      latest: { ...e.state },
      samples: [{ tick: u.tick, state: { ...e.state } }]
    })
  for (const c of u.changed) {
    const o = this.objects.get(c.id)
    o.latest = { ...o.latest, ...c.fields }
    o.samples.push({ tick: u.tick, state: { ...o.latest } })
  }
  for (const id of u.gone)
    this.objects.delete(id)
}
Folding a delta into the store: appear seeds a record, changed folds in fields, gone drops it.

appear, changed and gone

Every send tick the server builds one delta for each viewer, stamped with the instance tick, and sends only what that viewer can see. It carries state, not commands: three lists and an ack.

{
  t: 'update',
  tick: 4021,                                     // the instance tick this delta is stamped with
  ack: 17,                                         // highest command seq applied for this viewer
  appear:  [ { id, kind, type, state: { /* all public fields */ } } ],
  changed: [ { id, fields: { /* only what changed */ } } ],
  gone:    [ id ]
}
One send tick of the per-viewer delta, stamped with the server tick.

A ballistic entity, a rock or a bullet, carries constant flight parameters as its public state: a start position, a velocity, a spawn tick. Because those never change, the object appears once and never turns up in changed again. The front end computes its live position from the parameters and the clock, so a rock crosses the wire a single time. Which fields reach a viewer is set by each field's visibility: a public field reaches every viewer, a private (owner-only) field reaches only the object's owner. State that reaches no client isn't a field at all — it stays in the server-only persistent bag, never streamed.

Note. appear here is the protocol term for an object entering this viewer's delta. It is distinct from the per-object client-only -enter.js body, which the store runs when an object appears to seed its front-end persistent data.

The render clock

The clock is a fractional server tick, the front end's estimate of the server's leading edge. It is advanced by the local timer at the game's tick rate, so its pace is constant however jittery the network is, and only gently slewed toward the tick each fresh packet implies: bounded to about a ten percent speed-up or slow-down, and hard-snapped only if it has fallen hopelessly behind. Rendering happens a fixed buffer behind the leading edge, about one and a half send intervals, so there is normally a later sample already in hand to interpolate toward.

The render clock A timeline of public-state samples. The leading edge is the newest server tick estimate; the render point sits a fixed buffer behind it, between two samples, and the frame is interpolated between them. BUFFER public-state samples render point a fixed buffer behind leading edge newest server tick
The render point sits a fixed buffer behind the leading edge, between two samples the frame interpolates.
advance(nowMs) {
  const dt = (nowMs - this._lastMs) / 1000
  this._lastMs = nowMs
  // where the server should be now: newest tick received, projected at the tick rate
  const target = this._srvTick + ((nowMs - this._srvAtMs) / 1000) * this.tickRate
  const diff = target - this.clock
  if (Math.abs(diff) > this.buffer + 30) {         // hopelessly behind: snap
    this.clock = target
    return
  }
  const speed = Math.max(0.9, Math.min(1.1, 1 + 0.1 * diff))
  this.clock += dt * this.tickRate * speed
}
The clock free-runs at the tick rate and is slewed within a tight band toward the server's projected edge.

Interpolation

The render tick is the clock minus the buffer, and everything is drawn as of that one tick, so streamed objects and formula-driven objects share a single timeline. For a streamed object the store reads its public state at the render tick by interpolating between the two samples that straddle it: numeric public fields are linearly blended, and any other field takes the later sample. Constant fields, like a rock's parameters, are identical across samples and so pass straight through.

// object o's public state at render tick rt, between the two straddling samples a and b
const t = (rt - a.tick) / (b.tick - a.tick)
const out = { ...a.state }
for (const k in b.state) {
  const va = a.state[k], vb = b.state[k]
  out[k] = (typeof va === 'number' && typeof vb === 'number')
    ? lerp(va, vb, t)
    : vb
}
return out
Numeric public fields lerp between the two samples that straddle the render tick; others take the later value.

The store blends each field on its own and never couples two together. Deterministic objects need no samples at all: given the smooth clock, the renderer computes their position from a formula. Each frame the front end reads frame(), an interpolated view stamped with the render tick, { self, tick, instance, players, entities }, and that is what it draws.

Per-object smoothing overrides

The linear blend above is the default, and it is right for ordinary motion. A game that needs to shape it per field ships a per-object client-only -smooth.js body, which the store runs each frame for that object and lets override the rendered value of any field. A body that does nothing leaves full default smoothing in place; it changes only the fields it chooses to. Inside it, Gamehoster.Smooth(name) is the default interpolated value, Gamehoster.Latest(name) the newest authoritative one, and Prev/Next/Frac with Lerp/LerpWrap/Wrap build a custom blend; Gamehoster.Set(name, value) writes the value to draw, and Persist.Get/Set is a per-object front-end persistent bag seeded once by the object's -enter.js body. The canonical use is a respawn or teleport: interpolating a position across a respawn would slide the object across the map, so the body watches a public counter (a deaths or spawns field) and snaps to Latest while the smoothed counter still lags — the coupled or short-way-round easing that is otherwise the renderer's own business. Both bodies are additive: an object that ships neither is drawn exactly as before.

Per-object lifecycle and persistent data

The persistent bag is the front-end analogue of an object's server-side Persistent state, and it gives a game three small per-object bodies that mirror the backend. -enter.js runs once when the object appears, to decide anything that should be fixed for its whole life and stow it with Persist.Set. -smooth.js runs each frame. -exit.js runs once when the object disappears, before its record is dropped, for a leave effect. The renderer reads the bag straight off each object in the frame as object.data, so effects that need a value chosen once — an explosion's random puffs, a colour, a seed — read it there rather than keeping a parallel table keyed by id. Because the store carries the bag across the object's whole life and drops it on disappear, the renderer keeps no per-object bookkeeping of its own.

Your own object

Interpolation draws every object a buffer behind the present, which is right for the rest of the world but would add a round-trip of lag to your own actions. Closing that gap for your own object is an add-on the renderer chooses, not machinery the engine imposes. Two forms are built, both entirely on top of the interpolated view.

Continuous prediction

For a continuously moving object, your own ship, the client runs the game's predict body (gamehoster-player-predict.js) once per tick for your own object, fed by the live input you push with conn.input. In one body it simulates that object forward from live input and reconciles the result toward the authoritative state — extrapolated to the present with Gamehoster.Lead(), so a matched prediction needs almost no correction — snapping outright on a big jump such as a respawn. Only your own object is predicted; everything else is interpolated. This is an official path now: the body is baked into the generated client and run by the snapshot store, not inline renderer code.

// in gamehoster-player-predict.js: ease our predicted object toward the
// authoritative position, extrapolated to the present with our own velocity
const lead = Gamehoster.Lead()
const ax = Gamehoster.Authoritative.Get(me, "x")
const ay = Gamehoster.Authoritative.Get(me, "y")
const tx = wrap(ax + vx * lead, W)
const ty = wrap(ay + vy * lead, H)
const ex = wrapDelta(tx, x, W)
const ey = wrapDelta(ty, y, H)
if (ex * ex + ey * ey > 240 * 240) {              // big jump (respawn): snap
  x = ax; y = ay; vx = 0; vy = 0
} else {                                          // otherwise nudge a fraction of the way
  x = wrap(x + ex * 0.2, W)
  y = wrap(y + ey * 0.2, H)
}
Gamehoster.Player.State.Set(me, "x", x)
Gamehoster.Player.State.Set(me, "y", y)
The predict body eases its predicted object toward the authoritative position extrapolated to the present, snapping on a big jump.

Optimistic overlay

For a discrete change, firing a shot or opening a door, conn.send takes a third argument, a predict patch. The client lays that patch over every frame until the server acks the command's sequence number, at which point the authoritative state stands, whether the server accepted the command or not. The patch is a shallow set of overrides on the view, or a function that mutates it.

// fire a shot and show the bullet at once; the overlay drops when the server acks this seq
conn.send('fire', { angle }, {
  entities: {
    [localId]: { type: 'bullet', x0, y0, vx, vy, spawnTick }
  }
})
An optimistic patch laid over every frame until the server acks that command's sequence.

The continuous-prediction body is now built — it is the predict body the client runs for your own object — and the optimistic overlay is the other engine-side hook. Marked clearly as not yet built, so not to be taken as real: per-command-id acks any finer than the single highest sequence, a taxonomy of smooth versus discrete field masking, and a general animation tick hook.

The abstraction

Everything above is the store and the loop a renderer drives by hand. A game may instead be built entirely from snippets, the way the back end is, and let the client own the loop, the input and the per-object lifecycle. The page then provides only a root element:

<canvas id="game"></canvas>
<script src=".../gamehoster.client.js"></script>
<script>Gamehoster.start('game', { name: 'Ada' })</script>
A game on the abstraction: the page hands over a canvas by id and a join, and ships no render code.

The snippets live in gamehoster-frontend/ beside the game's other definitions: -setup.js runs once (grab the canvas and context into the game's persistent bag), -render.js runs each frame, -update.js runs once per server update after every per-object handler, -tick.js runs each client tick, one -input-<event>.js body runs on each pointer or key event it is named for, and -settings.json holds the input settings. The per-object -enter/-smooth/-exit/-predict bodies are the same ones a manual game uses. All are optional; a game that ships none stays fully manual.

The library

Each snippet runs against Gamehoster, the front-end twin of the back end's. It reads the world all at once and never touches a netId or a socket:

Input

Input is a folder of per-event bodies, each named for the event it runs on and run on that event. The library attaches a DOM listener only for a family (mouse or keyboard) that has at least one body, so a pointer-only game takes no key listener and a game with no input body leaves the page's keyboard and pointer untouched. The mouse bodies are gamehoster-frontend-input-mouse-move.js, -mouse-enter.js, -mouse-leave.js and -mouse-button-<left|middle|right>-<press|release>.js; the keyboard bodies are -keyboard-<down|up|left|right|space|enter|letter|number>-<press|release>.js for the arrows, space, enter, any letter and any digit, plus -keyboard-any-<press|release>.js, which fires alongside the specific one. The character is in Gamehoster.Event.key. Asteroids aims on mouse-move and thrusts on the left button:

// gamehoster-frontend-input-mouse-move.js
var a = Gamehoster.Mouse.angle
Gamehoster.Command('aim', { angle: a })
Gamehoster.Input.Set('angle', a)
One per-event body: the pointer angle becomes a throttled aim and feeds the local prediction.

Inside a body Gamehoster.Command(name, params) sends a game command: it validates params against that command's schema, encodes it, and sends it throttled by the rate in settings. Gamehoster.Input.Set(name, value) feeds the same value to the local prediction so your own object moves the instant you act. Gamehoster.Event carries the event's key and button.

Mouse coordinate spaces

Gamehoster.Mouse gives the pointer in several spaces at once, so a body reads the one that suits it.

SpaceIs
x, yPixels from the top-left, with width, height the root size.
fx, fyFraction 0 to 1 from the top-left.
cx, cyPixels from the centre.
nx, nySquare proportion from the centre: ±1 is half the shorter axis, so the longer axis runs past ±1.
angleRadians from the centre to the pointer.
dx, dyThe movement this event.

Alongside these it carries inside, captured, down/pressed/released(button) for the left, middle and right buttons, and drag: null, or a snapshot of the point a button went down in every space plus its button. Gamehoster.Keys gives the keyboard the same way with down/pressed/released(key).

Mouse capture and settings

gamehoster-frontend-settings.json holds the input settings: rate throttles a command to that many sends a second, coalescing to the latest, and mouseCapture turns on pointer lock. Under capture the library locks the pointer on a button press, mouse-move then reports the movement as Gamehoster.Mouse.dx/dy, the cursor is hidden and recentred, and Escape releases it, firing mouse-leave. Gamehoster.Mouse.capture(), .release() and .captured control and report the lock.

gamehoster-client.js

Gamehoster.connect opens the websocket, exchanges a hello and a welcome, and returns a conn. The wire is the binary protocol, decoded against this game's schema. Everything the renderer needs hangs off conn.

const conn = Gamehoster.connect('asteroids', { name: 'Ada' })
conn.onReady(() => { /* welcome received, updates flowing */ })
conn.send(type, params, predict)   // issue a command; returns its seq
conn.input(name, value)            // feed live local input to the predict body
conn.frame()                       // interpolated view + render tick, to draw
conn.latest(id)                    // newest authoritative state of one object
conn.latest()                      // the whole newest authoritative view
conn.now()                         // leading-edge (present) tick estimate
conn.lead()                        // ticks the present leads the newest data
conn.tickRate                      // the game's server tick rate
The browser client API on conn: connect, send commands up, read an interpolated view down.

A command you send takes effect on the server's next tick and the change comes back in a later delta. The server holds the only authoritative simulation; the one bit of local responsiveness the client runs for you is your own object's prediction, from the game's predict body and the live input you push with conn.input.