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) })
    })
  })
})()
examples/asteroids/ — booting the demo

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() } }
  }
})()
src/games/asteroids/render.js