Frontend

Everything so far has been the backend: the schemas and scripts the server runs. The front end is the browser half, and for a game it is small. 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 wires input to commands and draws the frame.

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 display 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 + '/pong/gamehoster.client.js', function () {
    load('/src/games/pong/render.js', function () {
      GamehosterGames.pong(document.getElementById('game'), { name: 'P' + Math.floor(Math.random() * 1000) })
    })
  })
})()
examples/pong/ — booting the demo

render.js

The whole renderer. It connects, turns pointer and keyboard input into moveY and ready commands, and every animation frame it reads conn.frame() and draws the court. Your own paddle is the reconciled prediction (it answers input instantly, because y is shared and moveY is incremental, so the client replays your pending moves), while the ball and the opponent's paddle are interpolated between the server snapshots. The input loop steers toward the target using the raw conn.latest() snapshot, never the predicted view, so the steering does not feed back on itself and oscillate.

// Pong renderer — draws the court from the interpolated frame and turns pointer /
// keyboard input into moveY + ready commands. The own paddle is PREDICTED by the client:
// `y` is a shared field and moveY is incremental, so replaying your pending moveY commands
// reproduces the paddle instantly. Registered as GamehosterGames.pong.
(function () {
  'use strict'
  window.GamehosterGames = window.GamehosterGames || {}
  window.GamehosterGames.pong = function (canvas, opts) {
    opts = opts || {}
    const conn = Gamehoster.connect('pong', { name: opts.name || 'Player' })
    const ctx = canvas.getContext('2d')
    let pointerY = null, up = false, down = false

    canvas.addEventListener('mousemove', (e) => {
      const r = canvas.getBoundingClientRect()
      pointerY = (e.clientY - r.top) * (canvas.height / r.height)
    })
    canvas.addEventListener('mouseleave', () => { pointerY = null })
    const key = (e, v) => { if (e.key === 'ArrowUp') up = v; else if (e.key === 'ArrowDown') down = v; else return; e.preventDefault() }
    window.addEventListener('keydown', (e) => { if (e.key === ' ') { conn.send('ready'); e.preventDefault() } key(e, true) })
    window.addEventListener('keyup', (e) => key(e, false))
    canvas.addEventListener('click', () => { const v = conn.frame(); if (v && v.instance.phase !== 'playing') conn.send('ready') })

    // where the player wants the paddle: the top on ↑, the bottom on ↓, else the pointer
    function targetY(half, court) {
      if (up) return half
      if (down) return court - half
      if (pointerY != null) return Math.max(half, Math.min(court - half, pointerY))
      return null
    }

    // send loop: steer the paddle toward the target using the AUTHORITATIVE position (the raw
    // snapshot, not the predicted view — steering off the prediction would feed back on itself
    // and oscillate). The client predicts the paddle by replaying these moveY commands.
    const inputTimer = setInterval(() => {
      const n = conn.latest(); if (!n || n.instance.phase !== 'playing') return
      const s = n.instance, speed = s.paddleSpeed || 8, half = (s.paddleH || 80) / 2, court = s.courtH || 480
      const tgt = targetY(half, court); if (tgt == null) return
      const me = n.players.find(p => p.id === conn.self); if (!me) return
      const axis = Math.max(-1, Math.min(1, (tgt - me.y) / speed))
      if (Math.abs(axis) > 0.03) conn.send('moveY', { axis })
    }, 33)

    function draw() {
      const v = conn.frame()          // own paddle is the reconciled prediction; ball + rival interpolated
      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 s = v.instance
      // net
      ctx.strokeStyle = '#2a2d34'; ctx.setLineDash([8, 12]); ctx.beginPath(); ctx.moveTo(W / 2, 0); ctx.lineTo(W / 2, H); ctx.stroke(); ctx.setLineDash([])
      // paddles
      const pxOff = s.paddleX || 24, ph = s.paddleH || 80
      for (const p of v.players) {
        const px = p.side === 'left' ? pxOff : W - pxOff
        ctx.fillStyle = p.id === conn.self ? '#7cc5ff' : '#e6e6e6'
        ctx.fillRect(px - 5, p.y - ph / 2, 10, ph)
      }
      // ball
      const ball = v.entities.find(e => e.type === 'ball')
      if (ball) { ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(ball.x, ball.y, 7, 0, 7); ctx.fill() }
      // scores
      ctx.fillStyle = '#8a8f98'; ctx.font = 'bold 40px monospace'; ctx.textAlign = 'center'
      const L = v.players.find(p => p.side === 'left'), R = v.players.find(p => p.side === 'right')
      ctx.fillText(L ? L.score : 0, W / 2 - 60, 52); ctx.fillText(R ? R.score : 0, W / 2 + 60, 52)
      // status overlay
      if (s.phase !== 'playing') {
        ctx.fillStyle = 'rgba(0,0,0,.55)'; ctx.fillRect(0, H / 2 - 44, W, 88)
        ctx.fillStyle = '#fff'; ctx.font = '22px sans-serif'
        const me = v.players.find(p => p.id === conn.self)
        let msg = 'Waiting for an opponent…'
        if (v.players.length >= 2) msg = s.phase === 'over' ? `${s.winner === (me && me.side) ? 'You win!' : (s.winner + ' wins')} — SPACE to play again` : (me && me.ready ? 'Ready — waiting for opponent' : 'Press SPACE / click to ready up')
        ctx.fillText(msg, W / 2, H / 2 + 8)
      }
      requestAnimationFrame(draw)
    }
    requestAnimationFrame(draw)
    return { close() { clearInterval(inputTimer); conn.close() } }
  }
})()
src/games/pong/render.js