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) })
})
})
})()
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() } }
}
})()