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
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.
| Name | Type | Description |
|---|---|---|
gamehoster-frontend-setup.js | file | Cache the 2D context once, on connect. |
gamehoster-frontend-render.js | file | Draw the whole scene each frame. |
gamehoster-frontend-settings.json | file | Throttle the aim command. |
gamehoster-frontend-input-mouse-move.js | file | Aim at the cursor on pointer move. |
gamehoster-frontend-input-mouse-button-left-press.js | file | Thrust while the left button is down. |
gamehoster-frontend-input-mouse-button-left-release.js | file | Coast 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) })
})
})()
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
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) }
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 }
}
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)