Frontend
Everything so far has been the backend, the schemas and scripts the server
runs. The front end is the browser half, and Snake is built on the
front-end abstraction: the page hands over a
<canvas> by id, and the game ships its drawing as snippets in a
gamehoster-frontend/ folder beside its backend files. The game's baked client
(gamehoster.client.js, generated and served with the game) owns the loop, the input and the
per-object lifecycle. It hands each snippet a Gamehoster library that reads the whole world at
once: Gamehoster.Instance, Gamehoster.Players and Gamehoster.Entities,
each object a view with its interpolated fields at the top level plus .current
(its newest authoritative sample), .prev and .data (its front-end persistent bag).
Snake ships a handful of these snippets: setup caches the 2D context and the steering state,
render draws the visible slice of the arena each frame, and three per-event input
bodies forward steering and offer click-to-reconnect. There is no prediction and no interpolation
here: snakes step one whole cell four times a second, so render reads each object's .current
directly — easing grid cells would smear a snake across the arena every time it wrapped round an edge. So there
is no per-object predict, smooth or tick body.
<gamehoster-config-contentRoot>/
game.gamehoster.org/
gamehoster-games/
snake/
gamehoster-frontend/
gamehoster-frontend-setup.js
gamehoster-frontend-render.js
gamehoster-frontend-input-keyboard-any-press.js
gamehoster-frontend-input-keyboard-any-release.js
gamehoster-frontend-input-mouse-button-left-press.js
Contents
The folder holds a one-time setup, the per-frame render, and three per-event input bodies. Snakes move from their own server updates a cell at a time, so there is no per-tick control loop and no settings file — steering is sent straight from the key events.
| Name | Type | Description |
|---|---|---|
gamehoster-frontend-setup.js | file | Cache the 2D context and set up the steering state, on connect. |
gamehoster-frontend-render.js | file | Draw the visible slice of the arena, the heads, food and minimap each frame. |
gamehoster-frontend-input-keyboard-any-press.js | file | A direction key going down: push a heading and send steer. |
gamehoster-frontend-input-keyboard-any-release.js | file | A direction key coming up: drop that heading and send steer. |
gamehoster-frontend-input-mouse-button-left-press.js | file | Wires the pointer so render can offer click-to-reconnect. |
Loading the client
The Play page provides only a <canvas>, loads the game's
baked client from the game server, and calls Gamehoster.start on the canvas. The baked client
is generic: it carries this game's config and the snapshot runtime, so the same client
serves every game. All the game-specific drawing and input lives in the gamehoster-frontend/
snippets, which the client loads and runs for you. Snake asks for a name first; it is sent with the join and
shown above your snake's head.
(function () {
var local = /^(localhost$|127\.|\[?::1)/.test(location.hostname)
if (!local) window.GAMEHOSTER_SERVER = 'wss://game.gamehoster.org'
var httpBase = local ? '' : 'https://game.gamehoster.org'
var name = (window.prompt('Choose a name', 'Snake' + Math.floor(Math.random() * 1000)) || '').trim().slice(0, 16) || 'Snake'
function load(src, next) { var s = document.createElement('script'); s.src = src; s.onload = next; s.onerror = function () { console.error('failed to load ' + src) }; document.body.appendChild(s) }
load(httpBase + '/snake/gamehoster.client.js', function () {
try { Gamehoster.start('game', { name: name }) } catch (e) { console.error(e) }
})
})()
gamehoster-frontend/gamehoster-frontend-setup.js
Runs once, on connect. The page handed the client a canvas as the root element, so setup grabs it with
Gamehoster.Root() and stashes its 2D context in the game's front-end persistent bag with
Gamehoster.Persistent.Set. It also seeds the steering state — the stack of held directions and
the last value sent — and stores one shared steering helper the input bodies call, which sends a
steer command only when the active heading changes.
// Runs once, on connect. The page hands us the canvas as the root element; stash its 2D context in
// the game's front-end persistent bag so every render frame can reach it without touching the DOM.
// We also set up steering state here: a stack of the directions currently held (newest last) and the
// last steer value we sent, so a command only goes out when the active heading actually changes —
// exactly the old renderer's behaviour.
var canvas = Gamehoster.Root()
Gamehoster.Persistent.Set('ctx', canvas.getContext('2d'))
Gamehoster.Persistent.Set('held', []) // directions pressed, in press order; the newest is active
Gamehoster.Persistent.Set('sent', -2) // last steer value sent (-2 means nothing sent yet)
// One shared steering update, called by the two keyboard input bodies (press with on=true, release
// with on=false). It keeps the held stack deduped by direction — like the old code, two keys mapping
// to the same heading count once — and sends a steer command only when the newest-held direction, or
// -1 when nothing is held, differs from what we last sent. Holding your current heading re-sends
// nothing; the server already reads a repeat of your heading as a sprint. A reversal the server
// ignores. This closure captures the one Gamehoster library instance, so it is safe to call later
// from the input bodies. An unmapped key arrives here as dir == null and is dropped.
Gamehoster.Persistent.Set('steer', function (dir, on) {
if (dir == null) return
var held = Gamehoster.Persistent.Get('held')
if (on) { if (held.indexOf(dir) < 0) held.push(dir) }
else { var i = held.indexOf(dir); if (i >= 0) held.splice(i, 1) }
var active = held.length ? held[held.length - 1] : -1
if (active !== Gamehoster.Persistent.Get('sent')) {
Gamehoster.Persistent.Set('sent', active)
Gamehoster.Command('steer', { dir: active })
}
})
gamehoster-frontend/gamehoster-frontend-render.js
The whole draw, run automatically each frame by the abstraction. It reads the context back from the
persistent bag and the world from Gamehoster.Instance, Gamehoster.Players and
Gamehoster.Entities. Because snakes jump a whole cell at a time it reads each object's
.current — the newest authoritative sample — rather than the eased top-level fields, so cells
never smear across the wrap seam. The camera is locked to your own head
(Gamehoster.Players.self), every cell is placed by its shortest wrapped distance from that head,
and a minimap plots everything the per-viewer view currently sends you.
A dropped socket draws the reconnect overlay and reconnects on a left click.
// The whole Snake draw, run automatically each frame by the abstraction. The whole game lives on the
// authoritative server; this only draws what the server sends. There is NO front-end prediction:
// snakes move in discrete whole-cell steps four times a second, so we read each object's `.current`
// (its newest authoritative sample) and draw it directly rather than the eased top-level fields —
// interpolating grid cells would smear a snake across the arena each time it wrapped round an edge.
// The camera is locked to your own head, and every cell is placed by its shortest wrapped distance
// from that head, so the world scrolls seamlessly across the seam.
//
// It draws a faint cell grid fixed to the world, the arena boundary (a dashed edge that comes into
// view as you near the wrap), a ring on every head (solid white on yours, half-strength white on
// rivals so yours reads brightest), and a minimap in the top corner plotting everything the server
// currently sends you — which, with the per-viewer fog of war, is exactly what is within your view.
var ctx = Gamehoster.Persistent.Get('ctx')
var canvas = Gamehoster.Root()
var W = canvas.width, H = canvas.height
ctx.fillStyle = '#0b0c0f'; ctx.fillRect(0, 0, W, H)
// dropped socket: the old renderer's reconnect overlay. A left click starts a fresh connection.
if (!Gamehoster.connected) {
ctx.fillStyle = 'rgba(12,14,19,0.82)'; ctx.fillRect(0, 0, W, H)
ctx.fillStyle = '#e6e6e6'; ctx.textAlign = 'center'
ctx.font = '20px sans-serif'; ctx.fillText('Connection lost', W / 2, H / 2 - 14)
ctx.fillStyle = '#7cc5ff'; ctx.font = '15px sans-serif'
ctx.fillText('Click to reconnect', W / 2, H / 2 + 16)
if (Gamehoster.Mouse.pressed('left')) Gamehoster.Reconnect()
return
}
// our own snake is the camera. Until the first authoritative sample of it (and the instance) arrives,
// show connecting… — mirrors the old renderer waiting on conn.latest() and its own player view.
var inst = Gamehoster.Instance
var me = Gamehoster.Players.self
if (!inst || !inst.current || !me || !me.current) {
ctx.fillStyle = '#8a8f98'; ctx.font = '18px sans-serif'; ctx.textAlign = 'center'
ctx.fillText('connecting…', W / 2, H / 2)
return
}
var gw = inst.current.gridW || 120, gh = inst.current.gridH || 80
var SPAN = 34 // cells shown across the shorter axis of the canvas
var cell = Math.floor(Math.min(W, H) / SPAN)
var hx = me.current.x, hy = me.current.y // head position: authoritative, un-interpolated
function wrapDelta(a, b, size) { var d = a - b; if (d > size / 2) d -= size; if (d < -size / 2) d += size; return d }
function placeX(x) { return W / 2 + wrapDelta(x, hx, gw) * cell }
function placeY(y) { return H / 2 + wrapDelta(y, hy, gh) * cell }
function onScreen(px, py) { return px > -cell && px < W + cell && py > -cell && py < H + cell }
function cellRect(px, py, s, col) {
var r = Math.max(1, s * 0.14)
ctx.fillStyle = col
ctx.beginPath()
ctx.roundRect(px - s / 2, py - s / 2, s - 1, s - 1, r)
ctx.fill()
}
// faint cell grid, fixed to the world. The camera snaps to whole cells, so cell centres sit at
// W/2 + integer·cell; the grid lines sit on the edges between them and stay put while the field steps
// past — graph paper that the snakes move across a cell at a time.
ctx.strokeStyle = 'rgba(255,255,255,.05)'; ctx.lineWidth = 1; ctx.beginPath()
var px0 = (((W / 2 + cell / 2) % cell) + cell) % cell
for (var gx = px0; gx <= W; gx += cell) { ctx.moveTo(gx, 0); ctx.lineTo(gx, H) }
var py0 = (((H / 2 + cell / 2) % cell) + cell) % cell
for (var gy = py0; gy <= H; gy += cell) { ctx.moveTo(0, gy); ctx.lineTo(W, gy) }
ctx.stroke()
// arena boundary: the wrap seam. The rectangle is one whole arena across, so only the near edge falls
// on screen — it slides into view as you approach the edge of the world.
ctx.setLineDash([6, 6]); ctx.strokeStyle = 'rgba(255,150,90,.55)'; ctx.lineWidth = 2
ctx.strokeRect(placeX(-0.5), placeY(-0.5), gw * cell, gh * cell)
ctx.setLineDash([])
// food
var foods = Gamehoster.Entities.ofType('food')
for (var fi = 0; fi < foods.length; fi++) {
var f = foods[fi].current; if (!f) continue
var fpx = placeX(f.x), fpy = placeY(f.y); if (!onScreen(fpx, fpy)) continue
ctx.fillStyle = '#ffd166'
ctx.beginPath(); ctx.arc(fpx, fpy, cell * 0.3, 0, 7); ctx.fill()
}
// tail segments (each carries its snake's colour)
var tails = Gamehoster.Entities.ofType('tail')
for (var ti = 0; ti < tails.length; ti++) {
var t = tails[ti].current; if (!t) continue
var tpx = placeX(t.x), tpy = placeY(t.y); if (!onScreen(tpx, tpy)) continue
cellRect(tpx, tpy, cell, t.color || '#7cc5ff')
}
// heads: every head gets a ring — solid white on yours, half-strength white on rivals so yours stays
// the brightest thing on screen
for (var p of Gamehoster.Players.values()) {
var pc = p.current; if (!pc || !pc.alive) continue
var own = p.self
var ppx = placeX(pc.x), ppy = placeY(pc.y); if (!own && !onScreen(ppx, ppy)) continue
cellRect(ppx, ppy, cell, pc.color || '#fff')
ctx.strokeStyle = own ? '#fff' : 'rgba(255,255,255,.5)'; ctx.lineWidth = 2
ctx.strokeRect(ppx - cell / 2, ppy - cell / 2, cell - 1, cell - 1)
if (pc.name) { // each player's name in small text just above the head
ctx.fillStyle = own ? '#fff' : 'rgba(230,232,235,.85)'
ctx.font = '11px sans-serif'; ctx.textAlign = 'center'
ctx.fillText(pc.name, ppx, ppy - cell * 0.7)
}
}
// hud — read the score authoritative (from .current) so it never flickers fractional between samples
ctx.fillStyle = '#e6e8eb'; ctx.font = '16px sans-serif'; ctx.textAlign = 'left'
ctx.fillText('score ' + (me.current.score || 0), 12, 24)
if (!me.current.alive) {
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fillRect(0, H / 2 - 34, W, 68)
ctx.fillStyle = '#fff'; ctx.font = '22px sans-serif'; ctx.textAlign = 'center'
ctx.fillText('you died — respawning…', W / 2, H / 2 + 8)
}
ctx.fillStyle = '#8a8f98'; ctx.font = '13px sans-serif'; ctx.textAlign = 'left'
ctx.fillText('arrows / WASD to steer · hold your heading to sprint · ' + Gamehoster.Players.size + ' here', 12, H - 12)
// minimap: the whole arena in the top corner. With per-viewer fog of war the server only sends what
// is near you, so plotting everything we hold shows exactly what is within view. The dashed box marks
// the slice currently on screen.
var mw = 132, mh = Math.round(mw * (gh / gw)), mmx = W - mw - 12, mmy = 12
function sx(x) { return mmx + (x / gw) * mw }
function sy(y) { return mmy + (y / gh) * mh }
ctx.fillStyle = 'rgba(0,0,0,.4)'; ctx.fillRect(mmx, mmy, mw, mh)
ctx.save(); ctx.beginPath(); ctx.rect(mmx, mmy, mw, mh); ctx.clip()
for (var mfi = 0; mfi < foods.length; mfi++) {
var mf = foods[mfi].current; if (!mf) continue
ctx.fillStyle = 'rgba(255,209,102,.8)'; ctx.fillRect(sx(mf.x) - 0.5, sy(mf.y) - 0.5, 1.5, 1.5)
}
for (var mti = 0; mti < tails.length; mti++) {
var mt = tails[mti].current; if (!mt) continue
ctx.fillStyle = mt.color || '#7cc5ff'; ctx.fillRect(sx(mt.x) - 0.5, sy(mt.y) - 0.5, 1.5, 1.5)
}
for (var mq of Gamehoster.Players.values()) {
var mqc = mq.current; if (!mqc || !mqc.alive) continue
ctx.fillStyle = mq.self ? '#fff' : (mqc.color || '#e06666')
ctx.fillRect(sx(mqc.x) - 1.5, sy(mqc.y) - 1.5, 3, 3)
}
// the on-screen viewport, centred on your head
var vwm = (W / cell / gw) * mw, vhm = (H / cell / gh) * mh
ctx.strokeStyle = 'rgba(255,255,255,.55)'; ctx.lineWidth = 1
ctx.strokeRect(sx(hx) - vwm / 2, sy(hy) - vhm / 2, vwm, vhm)
ctx.restore()
ctx.strokeStyle = 'rgba(255,150,90,.55)'; ctx.lineWidth = 1; ctx.strokeRect(mmx + .5, mmy + .5, mw - 1, mh - 1)
gamehoster-frontend/gamehoster-frontend-input-keyboard-any-press.js
Runs when any key goes down. The arrows and W A S D map to the
four headings (0 right, 1 down, 2 left, 3 up); anything else is dropped. It pushes the heading onto the held
stack through the shared helper, which sends a steer
command when the newest-held heading changes. Holding the way you are already going re-sends nothing — the
server reads a repeat of your heading as a sprint — and a reversal the server ignores.
// A key going down. The arrows and WASD map to the four headings (0 right, 1 down, 2 left, 3 up);
// anything else maps to undefined and the shared steering helper drops it. This mirrors the old
// keydown listener and its KEYS table — one handler for every key, dispatched by name. Pushing the
// heading onto the held stack and, if it changed the active heading, sending a steer command all
// happen inside the helper set up by gamehoster-frontend-setup.js. Auto-repeat is filtered by the
// abstraction, so a held key fires this once.
var DIR = { ArrowRight: 0, ArrowDown: 1, ArrowLeft: 2, ArrowUp: 3, d: 0, s: 1, a: 2, w: 3, D: 0, S: 1, A: 2, W: 3 }
Gamehoster.Persistent.Get('steer')(DIR[Gamehoster.Event.key], true)
gamehoster-frontend/gamehoster-frontend-input-keyboard-any-release.js
The twin of the press body. When a direction key comes up it removes that heading from the held stack; the
active heading falls back to whatever else is still held, or -1 when nothing is, and the shared
helper sends the change.
// A key coming up — the twin of the press body. It removes that heading from the held stack; if the
// heading you were on is now released, the active heading falls back to whatever else is still held
// (or -1 when nothing is), and the shared helper sends the change. Unmapped keys are dropped.
var DIR = { ArrowRight: 0, ArrowDown: 1, ArrowLeft: 2, ArrowUp: 3, d: 0, s: 1, a: 2, w: 3, D: 0, S: 1, A: 2, W: 3 }
Gamehoster.Persistent.Get('steer')(DIR[Gamehoster.Event.key], false)