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)
}
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.
- appear: the full public state of objects newly created or newly in view.
- changed: for objects still in view, only the public fields that changed since this viewer was last sent them.
- gone: the ids that left view or were destroyed.
- ack: the highest command sequence the server has applied for this viewer.
{
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 ]
}
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.
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
}
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
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)
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 }
}
})
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>
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:
Gamehoster.Frame—number,time,dt(since last frame),tick(the render tick),lead.Gamehoster.Root()— the element the page gave;Gamehoster.Persistent.Get/Set— the game's own front-end bag (the canvas, the context, image caches).Gamehoster.Instance,Gamehoster.Players(a Map keyed by netId,.selfis your own),Gamehoster.Entities(a Map keyed by netId,.ofType(type)). Each object is a view: the interpolated fields at the top level, plus.current(newest authoritative),.prev(the previous sample, equal tocurrenton first appear),.data(its persistent bag),.id(the stable id) and.type.Gamehoster.Mouse(the pointer in several spaces at once —x/y,fx/fy,cx/cy,nx/ny,angle,dx/dy— plusinside,captured,down/pressed/released(button)anddrag) andGamehoster.Keys(down/pressed/released(key)) — the live input state, read each frame.Gamehoster.Command(name, params)— the one generated sender: it validatesparamsagainst that command's schema, then encodes and sends it, throttled by theratein settings.Gamehoster.Input.Set(name, value)feeds the predict body.
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)
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.
| Space | Is |
|---|---|
x, y | Pixels from the top-left, with width, height the root size. |
fx, fy | Fraction 0 to 1 from the top-left. |
cx, cy | Pixels from the centre. |
nx, ny | Square proportion from the centre: ±1 is half the shorter axis, so the longer axis runs past ±1. |
angle | Radians from the centre to the pointer. |
dx, dy | The 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
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.