Frontend
Everything so far has been the backend: the schemas and scripts the server
runs. The front end is the browser half. It builds on the game's baked client
(gamehoster.client.js, generated from the definition and served with the game), which gives you
Gamehoster.connect, command sending, client-side
prediction and reconciliation, and an interpolated view each
frame. On top of that sits one hand-written file, render.js, that turns pointer input into
aim and thrust commands (bullets are fired for you by the
instance update), scrolls a wrap-around camera locked to your
ship, and draws every ship, rock and bullet from the interpolated server frame. The baked client's
command-replay prediction covers command-driven state, so your aim and thrust
register immediately, and the client also runs the shared ship body locally, so each frame already places your
own ship exactly. The renderer just draws it and locks the camera to it.
Loading the client
The Play page loads the game's baked client from the game server (so it
carries this game's config, its predicted fields, and the command handlers the client replays), then the
renderer, then starts it on a <canvas> with a random pilot name.
(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 () {
load('/src/games/asteroids/render.js', function () {
GamehosterGames.asteroids(document.getElementById('game'), { name: 'Pilot' + Math.floor(Math.random() * 1000) })
})
})
})()
render.js
The whole renderer. It connects, turns mouse motion into an aim angle and the
mouse button into a thrust hold, and every animation frame reads conn.frame().
The client runs the shared ship body locally, so the frame already places your own ship exactly: the renderer
draws it, keeps it centred, and locks the camera to it while the world scrolls and wraps around it. Every
other ship, rock and bullet is drawn at its nearest wrapped image from the interpolated frame, so the 15/s
send rate still looks smooth. Each ship's thrust flame is eased in and out, so a briefly-mispredicted rival's
flame dims rather than pops. A minimap and score round out the HUD.
// Asteroids renderer: a scrolling camera locked to your own ship in the centre of a
// wrap-around arena far larger than the canvas. It turns pointer motion into `aim` and
// mouse-button hold into `thrust`, and draws every ship, rock and bullet the reconstructed
// frame provides. The client runs the shared ship body locally, so the frame already places
// your own ship exactly (we have all our own commands); we just draw it and lock the camera to
// it. The thrust flame is faded so a mispredicted rival's thrust does not pop on and off.
// Registered as GamehosterGames.asteroids.
(function () {
'use strict'
window.GamehosterGames = window.GamehosterGames || {}
// deterministic rock outline from its seed, so a rock looks the same everywhere
const shapeCache = {}
function rockShape(seed, r) {
if (shapeCache[seed]) return shapeCache[seed]
let s = seed >>> 0
const rand = () => { s = (Math.imul(s ^ (s >>> 15), 1 | s) + 0x6d2b79f5) >>> 0; return (s >>> 8) / 16777216 }
const n = 9 + Math.floor(rand() * 4), pts = []
for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2, rad = r * (0.72 + rand() * 0.42); pts.push([Math.cos(a) * rad, Math.sin(a) * rad]) }
return (shapeCache[seed] = pts)
}
window.GamehosterGames.asteroids = function (canvas, opts) {
opts = opts || {}
const conn = Gamehoster.connect('asteroids', { name: opts.name || 'Pilot' })
const ctx = canvas.getContext('2d')
// pointer + button state → aim angle + thrust hold
let aim = 0, thrusting = false, lastAimSent = 0
function pointer(e) {
const r = canvas.getBoundingClientRect()
const t = e.touches ? e.touches[0] : e
const mx = (t.clientX - r.left) * (canvas.width / r.width)
const my = (t.clientY - r.top) * (canvas.height / r.height)
aim = Math.atan2(my - canvas.height / 2, mx - canvas.width / 2)
}
function setThrust(on) { if (on !== thrusting) { thrusting = on; conn.send('thrust', { on }) } }
canvas.addEventListener('mousemove', pointer)
canvas.addEventListener('mousedown', (e) => { pointer(e); setThrust(true); e.preventDefault() })
window.addEventListener('mouseup', () => setThrust(false))
canvas.addEventListener('touchstart', (e) => { pointer(e); setThrust(true); e.preventDefault() }, { passive: false })
canvas.addEventListener('touchmove', (e) => { pointer(e); e.preventDefault() }, { passive: false })
window.addEventListener('touchend', () => setThrust(false))
// per-ship thrust-flame intensity, eased toward 0/1 so it fades rather than blinks (a
// rival whose thrust we briefly mispredict shows a dimming flame, not a hard pop)
const flame = {}
function flameOf(id, on) { const cur = flame[id] || 0; const v = cur + ((on ? 1 : 0) - cur) * 0.18; flame[id] = v; return v }
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'
const 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()
}
function draw() {
const v = conn.frame()
const W = canvas.width, H = canvas.height
ctx.fillStyle = '#0b0c0f'; ctx.fillRect(0, 0, W, H)
if (!v) { ctx.fillStyle = '#8a8f98'; ctx.font = '20px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('connecting…', W / 2, H / 2); return requestAnimationFrame(draw) }
const inst = v.instance, AW = inst.arenaW || 3600, AH = inst.arenaH || 2400
const me = v.players.find(p => p.id === conn.self) // our own ship, already predicted by the client
const alive = !me || me.alive !== false
const cam = me ? { x: me.x, y: me.y } : { x: AW / 2, y: AH / 2 }
// nearest-image offset of a world point from the camera, for a wrap-around arena
const relX = (x) => { let d = x - cam.x; d = ((d + AW / 2) % AW + AW) % AW - AW / 2; return W / 2 + d }
const relY = (y) => { let d = y - cam.y; d = ((d + AH / 2) % AH + AH) % AH - AH / 2; return H / 2 + d }
const onScreen = (sx, sy, m) => sx > -m && sx < W + m && sy > -m && sy < H + m
// faint arena border, drawn wherever the edges fall on screen
ctx.strokeStyle = '#20242c'; ctx.lineWidth = 2
ctx.strokeRect(relX(0), relY(0), AW, AH)
// rocks
for (const e of v.entities) {
if (e.type !== 'rock') continue
const sx = relX(e.x), sy = relY(e.y); if (!onScreen(sx, sy, (e.r || 46) + 8)) continue
const pts = rockShape(e.seed || 1, e.r || 46)
ctx.save(); ctx.translate(sx, sy); ctx.rotate(e.rot || 0)
ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); ctx.closePath()
ctx.strokeStyle = '#c9ccd2'; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = 'rgba(160,166,176,.10)'; ctx.fill()
ctx.restore()
}
// bullets
for (const e of v.entities) {
if (e.type !== 'bullet') continue
const sx = relX(e.x), sy = relY(e.y); if (!onScreen(sx, sy, 6)) continue
ctx.fillStyle = e.owner === conn.self ? '#7cc5ff' : '#ffd479'
ctx.beginPath(); ctx.arc(sx, sy, 2.6, 0, 7); ctx.fill()
}
// other ships
for (const p of v.players) {
if (p.id === conn.self) continue
if (p.alive === false) continue
const sx = relX(p.x), sy = relY(p.y); if (!onScreen(sx, sy, 24)) continue
drawShip(sx, sy, p.angle || 0, p.color, false, flameOf(p.id, p.thrusting))
if (p.name) { ctx.fillStyle = '#8a8f98'; ctx.font = '11px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(p.name, sx, sy - 20) }
}
// our own ship, centred (drawn with our live aim + thrust for the crispest local feel)
if (alive) drawShip(W / 2, H / 2, aim, '#7cc5ff', true, flameOf(conn.self, thrusting))
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
ctx.fillStyle = '#8a8f98'; ctx.font = '13px sans-serif'; ctx.textAlign = 'left'
ctx.fillText('score ' + (me ? me.score : 0) + ' · ' + v.players.length + ' in view · rocks ' + v.entities.filter(e => e.type === 'rock').length, 12, 20)
const mw = 120, mh = mw * (AH / AW), mx = W - mw - 12, my = 12
ctx.strokeStyle = '#2a2d34'; ctx.strokeRect(mx, my, mw, mh)
for (const e of v.entities) { if (e.type !== 'rock') continue; ctx.fillStyle = '#565b64'; ctx.fillRect(mx + (e.x / AW) * mw - 1, my + (e.y / AH) * mh - 1, 2, 2) }
for (const p of v.players) { if (p.alive === false) continue; ctx.fillStyle = p.id === conn.self ? '#7cc5ff' : '#e06666'; ctx.fillRect(mx + (p.x / AW) * mw - 1.5, my + (p.y / AH) * mh - 1.5, 3, 3) }
requestAnimationFrame(draw)
}
requestAnimationFrame(draw)
// send our aim a few times a second (the ship also turns instantly on the client)
const aimTimer = setInterval(() => { const now = performance.now(); if (now - lastAimSent > 45) { conn.send('aim', { angle: aim }); lastAimSent = now } }, 50)
return { close() { clearInterval(aimTimer); conn.close() } }
}
})()