Frontend

Everything so far has been the backend, the schemas and scripts the server runs. The front end is the browser half, and Asteroids 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 interpolated 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). Asteroids ships a handful of these snippets: setup caches the 2D context once, render draws every ship, rock and bullet each frame, and three per-event input bodies aim the ship on pointer move and thrust on the left button, feeding the same values into the prediction; a small settings file throttles the aim command. The per-object predict and smooth bodies on the player run as before.

<gamehoster-config-contentRoot>/
  game.gamehoster.org/
    gamehoster-games/
      asteroids/
        gamehoster-frontend/
          gamehoster-frontend-setup.js
          gamehoster-frontend-render.js
          gamehoster-frontend-settings.json
          gamehoster-frontend-input-mouse-move.js
          gamehoster-frontend-input-mouse-button-left-press.js
          gamehoster-frontend-input-mouse-button-left-release.js
The gamehoster-frontend/ folder: setup, render, the input settings, and the per-event input bodies.

Contents

The folder holds a one-time setup, the per-frame render, a settings file, and three per-event input bodies. Bullets fire from the instance update, so there is no fire body.

NameTypeDescription
gamehoster-frontend-setup.jsfileCache the 2D context once, on connect.
gamehoster-frontend-render.jsfileDraw the whole scene each frame.
gamehoster-frontend-settings.jsonfileThrottle the aim command.
gamehoster-frontend-input-mouse-move.jsfileAim at the cursor on pointer move.
gamehoster-frontend-input-mouse-button-left-press.jsfileThrust while the left button is down.
gamehoster-frontend-input-mouse-button-left-release.jsfileCoast when the left button is released.

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 with a random pilot name. 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.

(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'
  function load(src, next) { var s = document.createElement('script'); s.src = src; s.onload = next; document.body.appendChild(s) }
  load(httpBase + '/asteroids/gamehoster.client.js', function () {
    Gamehoster.start('game', { name: 'Pilot' + Math.floor(Math.random() * 1000) })
  })
})()
How the Play page boots the demo.

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 seeds the small amount of state the renderer carries between frames too: the eased own-ship flame and heading, and the seed-to-outline cache for the rocks.

// Runs once, on connect. The page hands us the canvas as the root element; stash its 2D context and
// the state the renderer carries between frames: the eased own-ship flame and heading, and the
// seed→outline cache for the rocks.
var canvas = Gamehoster.Root()
Gamehoster.Persistent.Set('ctx', canvas.getContext('2d'))
Gamehoster.Persistent.Set('ownFlame', 0)
Gamehoster.Persistent.Set('ownAngle', null)
Gamehoster.Persistent.Set('shapeCache', {})   // seed -> rock outline
asteroids/gamehoster-frontend/gamehoster-frontend-setup.js

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. A camera locks to your own ship (Gamehoster.Players.self) at the centre of the wrap-around arena. Ships stream their position and are interpolated, so a view's top-level x/y are already smoothed. Rocks and bullets are ballistic: the view carries their start, velocity and spawn tick, so their live position is a pure function of those and the render tick Gamehoster.Frame.tick. Each other ship's flame eases from its public lastThrustTick, kept in that ship's own .data bag; your own ship's heading eases toward the live Gamehoster.Mouse.angle and its flame from the live button. A minimap and score round out the HUD.

// The whole Asteroids example draw, run automatically each frame by the abstraction. A camera locked
// to our own ship at the centre of a wrap-around arena. Ships stream their position and are
// interpolated; rocks and bullets are BALLISTIC (the frame carries their start, velocity and spawn
// tick), so their live position is a pure function of those and the render tick. Own-ship heading and
// thrust come from the live pointer; everything else is read off the frame.
var ctx = Gamehoster.Persistent.Get('ctx')
var canvas = Gamehoster.Root()
var W = canvas.width, H = canvas.height
var TAU = Math.PI * 2
ctx.fillStyle = '#0b0c0f'; ctx.fillRect(0, 0, W, H)

var inst = Gamehoster.Instance
if (!inst || inst.arenaW == null) {
  ctx.fillStyle = '#8a8f98'; ctx.font = '20px sans-serif'; ctx.textAlign = 'center'
  ctx.fillText(Gamehoster.connected ? 'connecting…' : 'connection lost — click to reconnect', W / 2, H / 2)
  if (!Gamehoster.connected && Gamehoster.Mouse.pressed('left')) Gamehoster.Reconnect()
  return
}

var AW = inst.arenaW, AH = inst.arenaH, T = Gamehoster.Frame.tick
var shapeCache = Gamehoster.Persistent.Get('shapeCache')
var wrap = function (n, s) { return ((n % s) + s) % s }
function rockShape (seed, r) {
  if (shapeCache[seed]) return shapeCache[seed]
  var s = seed >>> 0
  var rand = function () { s = (Math.imul(s ^ (s >>> 15), 1 | s) + 0x6d2b79f5) >>> 0; return (s >>> 8) / 16777216 }
  var n = 9 + Math.floor(rand() * 4), pts = []
  for (var i = 0; i < n; i++) { var a = (i / n) * TAU, rad = r * (0.72 + rand() * 0.42); pts.push([Math.cos(a) * rad, Math.sin(a) * rad]) }
  return (shapeCache[seed] = pts)
}
function drawShip (sx, sy, angle, color, isMe, thrust) {
  ctx.save(); ctx.translate(sx, sy); ctx.rotate(angle)
  if (thrust > 0.04) {
    ctx.save(); ctx.globalAlpha = Math.min(1, thrust); ctx.fillStyle = '#ff8a3d'
    var tip = -10 - (10 + Math.random() * 6) * thrust
    ctx.beginPath(); ctx.moveTo(-10, -5); ctx.lineTo(tip, 0); ctx.lineTo(-10, 5); ctx.closePath(); ctx.fill()
    ctx.restore()
  }
  ctx.beginPath(); ctx.moveTo(15, 0); ctx.lineTo(-11, -9); ctx.lineTo(-6, 0); ctx.lineTo(-11, 9); ctx.closePath()
  ctx.fillStyle = isMe ? '#7cc5ff' : color || '#e6e6e6'; ctx.fill()
  if (isMe) { ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.stroke() }
  ctx.restore()
}

var me = Gamehoster.Players.self
var alive = !me || me.alive !== false
var cam = me ? { x: me.x, y: me.y } : { x: AW / 2, y: AH / 2 }
var ballX = function (e) { return wrap(e.x0 + e.vx * (T - e.spawnTick), AW) }
var ballY = function (e) { return wrap(e.y0 + e.vy * (T - e.spawnTick), AH) }
var relX = function (x) { var d = x - cam.x; d = ((d + AW / 2) % AW + AW) % AW - AW / 2; return W / 2 + d }
var relY = function (y) { var d = y - cam.y; d = ((d + AH / 2) % AH + AH) % AH - AH / 2; return H / 2 + d }
var onScreen = function (sx, sy, m) { return sx > -m && sx < W + m && sy > -m && sy < H + m }

// faint grid + arena border
var GRID = 200
ctx.strokeStyle = 'rgba(255,255,255,.10)'; ctx.lineWidth = 1; ctx.beginPath()
for (var gx = Math.ceil((cam.x - W / 2) / GRID) * GRID; gx - cam.x < W / 2; gx += GRID) { var xg = gx - cam.x + W / 2; ctx.moveTo(xg, 0); ctx.lineTo(xg, H) }
for (var gy = Math.ceil((cam.y - H / 2) / GRID) * GRID; gy - cam.y < H / 2; gy += GRID) { var yg = gy - cam.y + H / 2; ctx.moveTo(0, yg); ctx.lineTo(W, yg) }
ctx.stroke()
ctx.strokeStyle = '#20242c'; ctx.lineWidth = 2; ctx.strokeRect(relX(0), relY(0), AW, AH)

// rocks (position + rotation from their streamed parameters)
for (var e of Gamehoster.Entities.ofType('rock')) {
  var rsx = relX(ballX(e)), rsy = relY(ballY(e)); if (!onScreen(rsx, rsy, (e.r || 46) + 8)) continue
  var rot = (e.rot0 || 0) + (e.spin || 0) * (T - e.spawnTick), pts = rockShape(e.seed || 1, e.r || 46)
  ctx.save(); ctx.translate(rsx, rsy); ctx.rotate(rot)
  ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (var pi = 1; pi < pts.length; pi++) ctx.lineTo(pts[pi][0], pts[pi][1]); ctx.closePath()
  ctx.strokeStyle = '#c9ccd2'; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = 'rgba(160,166,176,.10)'; ctx.fill()
  ctx.restore()
}
// bullets: a short fading tracer along the firing angle, its length growing from the shot so it
// never pokes out behind the muzzle
var fe = inst.fireEvery || 8, bs = inst.bulletSpeed || 12, L = Math.min(64, bs * fe * 0.8)
for (var b of Gamehoster.Entities.ofType('bullet')) {
  var bdx = Math.cos(b.angle || 0), bdy = Math.sin(b.angle || 0), bage = T - b.spawnTick
  var bsx = relX(wrap(b.x0 + bdx * bs * bage, AW)), bsy = relY(wrap(b.y0 + bdy * bs * bage, AH)); if (!onScreen(bsx, bsy, 80)) continue
  var trailLen = Math.min(L, Math.max(0, bs * bage))
  var tx = bsx - bdx * trailLen, ty = bsy - bdy * trailLen
  var grad = ctx.createLinearGradient(bsx, bsy, tx, ty)
  grad.addColorStop(0, 'rgba(255,212,121,1)'); grad.addColorStop(1, 'rgba(255,212,121,0)')
  ctx.strokeStyle = grad; ctx.lineWidth = 2.4; ctx.lineCap = 'round'
  ctx.beginPath(); ctx.moveTo(bsx, bsy); ctx.lineTo(tx, ty); ctx.stroke()
}
// other ships — flame eased from how recently they last thrust (their public lastThrustTick vs the
// render tick); the eased value lives in the ship's persistent bag, so it survives a netId renumber
for (var p of Gamehoster.Players.values()) {
  if (p.self || p.alive === false) continue
  var psx = relX(p.x), psy = relY(p.y); if (!onScreen(psx, psy, 24)) continue
  var plit = (T - (p.current.lastThrustTick != null ? p.current.lastThrustTick : -1000)) < 3 ? 1 : 0
  var pf = p.data.flame || 0; pf += (plit - pf) * (plit > pf ? 0.4 : 0.08); p.data.flame = pf
  drawShip(psx, psy, p.angle || 0, p.color, false, pf)
  if (p.name) { ctx.fillStyle = '#8a8f98'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(p.name, psx, psy - 20) }
}
// our own ship, centred: heading eased toward the live pointer, flame from the live thrust button
if (alive) {
  var ownAngle = Gamehoster.Persistent.Get('ownAngle'), target = Gamehoster.Mouse.angle
  if (ownAngle == null) ownAngle = target
  var da = target - ownAngle; da = ((da + Math.PI) % TAU + TAU) % TAU - Math.PI; ownAngle += da * 0.5
  Gamehoster.Persistent.Set('ownAngle', ownAngle)
  var lit = Gamehoster.Mouse.down('left') ? 1 : 0
  var ownFlame = Gamehoster.Persistent.Get('ownFlame'); ownFlame += (lit - ownFlame) * (lit > ownFlame ? 0.4 : 0.08)
  Gamehoster.Persistent.Set('ownFlame', ownFlame)
  drawShip(W / 2, H / 2, ownAngle, '#7cc5ff', true, ownFlame)
} else {
  ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(0, H / 2 - 30, W, 60)
  ctx.fillStyle = '#fff'; ctx.font = '20px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('destroyed, respawning…', W / 2, H / 2 + 7)
}

// HUD + minimap
var rockCount = Gamehoster.Entities.ofType('rock').length
ctx.fillStyle = '#8a8f98'; ctx.font = '13px sans-serif'; ctx.textAlign = 'left'
ctx.fillText('score ' + (me ? me.score : 0) + ' · ' + Gamehoster.Players.size + ' in view · rocks ' + rockCount, 12, 20)
var mw = 120, mh = mw * (AH / AW), mx = W - mw - 12, my = 12
ctx.strokeStyle = '#2a2d34'; ctx.strokeRect(mx, my, mw, mh)
for (var mr of Gamehoster.Entities.ofType('rock')) { ctx.fillStyle = '#565b64'; ctx.fillRect(mx + (ballX(mr) / AW) * mw - 1, my + (ballY(mr) / AH) * mh - 1, 2, 2) }
for (var mp of Gamehoster.Players.values()) { if (mp.alive === false) continue; ctx.fillStyle = mp.self ? '#7cc5ff' : '#e06666'; ctx.fillRect(mx + (mp.x / AW) * mw - 1.5, my + (mp.y / AH) * mh - 1.5, 3, 3) }
asteroids/gamehoster-frontend/gamehoster-frontend-render.js

gamehoster-frontend/gamehoster-frontend-settings.json

The front-end input settings. Asteroids sets one rate: the aim command is throttled to twenty sends a second, coalescing to the latest angle, so a fast pointer never floods the socket. It leaves mouseCapture off, so the cursor stays free.

{
  "rate": { "aim": 20 }
}
asteroids/gamehoster-frontend/gamehoster-frontend-settings.json

gamehoster-frontend/gamehoster-frontend-input-mouse-move.js

Runs on every pointer move. It reads the pointer angle from the centre with Gamehoster.Mouse.angle, sends it as the aim command (the settings file throttles it to twenty a second), and feeds the same angle to the local prediction so your own ship turns the instant the pointer moves.

// Point the ship at the cursor. Send the aim command (settings throttle it to 20/s), and feed the
// same angle to the local prediction so our own ship turns the instant the pointer moves.
var a = Gamehoster.Mouse.angle
Gamehoster.Command('aim', { angle: a })
Gamehoster.Input.Set('angle', a)
asteroids/gamehoster-frontend/gamehoster-frontend-input-mouse-move.js

gamehoster-frontend/gamehoster-frontend-input-mouse-button-left-press.js

Runs when the left button goes down. It sends thrust on and feeds the prediction, so acceleration starts at once.

// Hold thrust while the left button is down. Feed the prediction so acceleration starts at once.
Gamehoster.Command('thrust', { on: true })
Gamehoster.Input.Set('thrusting', true)
asteroids/gamehoster-frontend/gamehoster-frontend-input-mouse-button-left-press.js

gamehoster-frontend/gamehoster-frontend-input-mouse-button-left-release.js

Runs when the left button comes up. It sends thrust off and feeds the prediction, so the ship coasts.

// Coast when the left button is released.
Gamehoster.Command('thrust', { on: false })
Gamehoster.Input.Set('thrusting', false)
asteroids/gamehoster-frontend/gamehoster-frontend-input-mouse-button-left-release.js