Frontend
Everything so far has been the backend, the schemas and scripts the server
runs. The front end is the browser half, and Quake 0 is built on the
front-end abstraction: the page hands over a
<canvas> by id, and the game ships its drawing and input 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 dispatch 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).
Quake 0 ships a small handful of snippets: setup caches the 2D context once, and
render draws the whole level, the platforms, the ballistic bullets, the hits and the fighters
each frame. All control is by keyboard, so instead of a per-tick loop the game ships one per-event
input body per key edge: the arrows drive a held run direction, a held jump and a drop-through
hold, each sending a command only when the derived state changes.
There is no prediction here — our own fighter is drawn from the same interpolated timeline as
the bullets, so hits land where they are drawn — and no tick or settings file. The
one deviation from plain interpolation lives on the player: the
smooth body snaps a fighter across a respawn instead of
sliding it up the map.
<gamehoster-config-contentRoot>/
game.gamehoster.org/
gamehoster-games/
quake0/
gamehoster-frontend/
gamehoster-frontend-setup.js
gamehoster-frontend-render.js
gamehoster-frontend-input-mouse-button-left-press.js
gamehoster-frontend-input-keyboard-left-press.js
gamehoster-frontend-input-keyboard-left-release.js
gamehoster-frontend-input-keyboard-right-press.js
gamehoster-frontend-input-keyboard-right-release.js
gamehoster-frontend-input-keyboard-up-press.js
gamehoster-frontend-input-keyboard-up-release.js
gamehoster-frontend-input-keyboard-space-press.js
gamehoster-frontend-input-keyboard-space-release.js
gamehoster-frontend-input-keyboard-down-press.js
gamehoster-frontend-input-keyboard-down-release.js
Contents
The folder holds a one-time setup, the per-frame render, and the per-event input bodies — one per key edge, plus a mouse click for reconnecting. There is no tick loop and no settings file: commands are sent straight from the key edges, only when the derived state changes.
| Name | Type | Description |
|---|---|---|
gamehoster-frontend-setup.js | file | Cache the 2D context and seed the held-key and colour bags, on connect. |
gamehoster-frontend-render.js | file | Draw the whole level, platforms, bullets, hits, fighters, scoreboard each frame. |
gamehoster-frontend-input-mouse-button-left-press.js | file | Reconnect on a click after the socket drops. |
gamehoster-frontend-input-keyboard-left-press.js | file | Update the held run direction and send move on a change. |
gamehoster-frontend-input-keyboard-left-release.js | file | Update the held run direction and send move on a change. |
gamehoster-frontend-input-keyboard-right-press.js | file | Update the held run direction and send move on a change. |
gamehoster-frontend-input-keyboard-right-release.js | file | Update the held run direction and send move on a change. |
gamehoster-frontend-input-keyboard-up-press.js | file | Begin or end the held jump. |
gamehoster-frontend-input-keyboard-up-release.js | file | Begin or end the held jump. |
gamehoster-frontend-input-keyboard-space-press.js | file | Begin or end the held jump. |
gamehoster-frontend-input-keyboard-space-release.js | file | Begin or end the held jump. |
gamehoster-frontend-input-keyboard-down-press.js | file | Begin or end the drop-through hold. |
gamehoster-frontend-input-keyboard-down-release.js | file | Begin or end the drop-through hold. |
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
display 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 + '/quake0/gamehoster.client.js', function () {
Gamehoster.start('game', { name: 'Fighter' + 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, ready for every render frame. It also seeds two front-end-only
bags: the held-key state the input bodies drive the commands from, and the per-session colour each rival
fighter is pinned to.
// Runs once, on connect. The page hands us the canvas as the root element; stash its 2D context in
// the game's front-end persistent bag so every render frame can reach it without touching the DOM.
// We also seed the two front-end-only bags the rest of the front end keeps between frames.
var canvas = Gamehoster.Root()
Gamehoster.Persistent.Set('ctx', canvas.getContext('2d'))
// Held-input state the keyboard input bodies drive the commands from: which run keys are down, the
// last run direction we told the server, and whether jump and drop are currently held. Each input
// body updates this bag and only sends a command when the DERIVED value actually changes — exactly
// as the old renderer did on each keydown/keyup, so a key repeat or the other key being held never
// floods the socket.
Gamehoster.Persistent.Set('keys', { left: false, right: false, dir: 0, jump: false, drop: false })
// Colour is a FRONT-END choice, not a server field: your own fighter is always green and every rival
// is pinned to a random blue -> purple for this session, so the same player can look different on
// each screen. This bag remembers each rival's pinned colour across frames.
Gamehoster.Persistent.Set('colorOf', {})
gamehoster-frontend/gamehoster-frontend-render.js
The whole draw, run automatically each frame by the abstraction. There is no camera and no scrolling: the
fixed 30×12 level is scaled each frame to fill the canvas with a five-tile margin, so a fighter knocked off
the edge is seen falling away. It reads the context back from the persistent bag and the world from
Gamehoster.Instance, Gamehoster.Players and Gamehoster.Entities.
Bullets are ballistic, so the renderer computes each one's live position from its start, velocity and the
render tick (Gamehoster.Frame.tick). Each fighter's position is already interpolated and
snapped across a respawn by the smooth body, so it is
drawn straight; scores are read from each view's .current so they never flicker fractional.
Colour is a front-end choice — your own fighter green, the rest a pinned blue → purple.
// The whole Quake 0 draw, run automatically each frame by the abstraction. A 2-D vector platform
// shooter: a fixed 30x12 grid level floats in empty space and the WHOLE of it is always on screen,
// scaled each frame to fill the canvas with a BOUNDARY-tile margin of empty space around it (so a
// fighter knocked off the edge is seen falling away). There is no camera and no wrap, and every cell
// is drawn taller than it is wide.
//
// Every field we read off the world is already interpolated a fixed buffer behind the server, so
// motion stays smooth; the per-object gamehoster-player-smooth.js body snaps a fighter across a
// respawn so it never slides up the map. Nothing here is predicted — our own fighter is drawn from
// the same interpolated timeline as the bullets and hits, so a shot lands on our body exactly where
// it is drawn (see the Frontend doc). Bullets are ballistic: the frame carries only x0/y0, velocity
// and spawnTick, so this renderer computes each bullet's live position itself.
var ctx = Gamehoster.Persistent.Get('ctx')
var canvas = Gamehoster.Root()
var W = canvas.width, H = canvas.height
var BOUNDARY = 5 // tiles of empty space kept around the level, always on screen
ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, W, H)
// still connecting (no level streamed yet), or the socket dropped — a click reconnects (handled by
// the mouse-button-left-press input body)
var inst = Gamehoster.Instance
if (!inst || !inst.level) {
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)
return
}
var T = Gamehoster.Frame.tick // the render tick — the clock the interpolated frame is drawn at
var cols = inst.cols, rows = inst.rows
var pw = inst.playerW, ph = inst.playerH
var rowsArr = inst.level.split('\n')
// scale so the whole level plus a BOUNDARY-tile margin fills the canvas; cells come out taller than
// they are wide (the canvas is wider in tiles than it is tall, but not twice as much)
var cw = W / (cols + BOUNDARY * 2)
var chh = H / (rows + BOUNDARY * 2)
var ox = BOUNDARY * cw, oy = BOUNDARY * chh
function sx(x) { return ox + x * cw }
function sy(y) { return oy + y * chh }
// the level rectangle: a faint frame so the floating island reads as a bounded place
ctx.strokeStyle = '#eceef1'; ctx.lineWidth = 1
ctx.strokeRect(sx(0), sy(0), cols * cw, rows * chh)
// ── platforms: a slightly-thick light-grey bar at the bottom of each solid cell, thin darker
// outline. Adjacent solid cells merge into a continuous floor. The bar hangs just BELOW the
// cell's floor line — where the fighters' feet rest — so they clearly stand on top of it. ──
var barH = Math.max(3, chh * 0.24)
ctx.lineWidth = 1
for (var r = 0; r < rows; r++) {
var line = rowsArr[r]; if (!line) continue
var c = 0
while (c < cols) {
if (line.charCodeAt(c) !== 95) { c++; continue } // 95 = '_', a floor cell
var c2 = c
while (c2 < cols && line.charCodeAt(c2) === 95) c2++ // merge the run c..c2-1
var x0 = sx(c), x1 = sx(c2), yb = sy(r + 1)
ctx.fillStyle = '#d9dbde'
ctx.fillRect(x0, yb, x1 - x0, barH)
ctx.strokeStyle = '#aeb2b8'
ctx.strokeRect(x0 + 0.5, yb + 0.5, x1 - x0 - 1, barH - 1)
c = c2
}
}
// ── bullets: a short orange line with a thin yellow border, computed from flight parameters. The
// tracer's tail is clamped to the muzzle (x0/y0 — the shooter's centre), so a fresh shot emanates
// FROM the player and grows to full length as it flies, instead of appearing behind them. ──
var bullets = Gamehoster.Entities.ofType('bullet')
for (var bi = 0; bi < bullets.length; bi++) {
var e = bullets[bi].current // flight params are immutable — read authoritative
var age = T - e.spawnTick
var bx = e.x0 + e.vx * age, by = e.y0 + e.vy * age
var sp = Math.hypot(e.vx, e.vy) || 1
var ux = e.vx / sp, uy = e.vy / sp
var traveled = sp * Math.max(0, age) // world distance flown from the muzzle
var len = Math.min(0.55, traveled) // never extend behind the shooter's centre
var hx = sx(bx), hy = sy(by)
var tx = sx(bx - ux * len), ty = sy(by - uy * len)
ctx.lineCap = 'round'
ctx.strokeStyle = '#ffe14d'; ctx.lineWidth = 4.4
ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(tx, ty); ctx.stroke()
ctx.strokeStyle = '#ff8c1a'; ctx.lineWidth = 2.2
ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(tx, ty); ctx.stroke()
}
// ── hits: a small expanding dot where a bullet stopped (on a floor or a body) ──
var hitLife = inst.hitLife || 16
var hits = Gamehoster.Entities.ofType('hit')
for (var hi = 0; hi < hits.length; hi++) {
var h = hits[hi].current
var hage = T - h.spawnTick; if (hage < 0 || hage > hitLife) continue
var kk = hage / hitLife
var rad = (0.12 + kk * 0.5) * Math.min(cw, chh)
ctx.globalAlpha = 1 - kk
ctx.fillStyle = '#ffcf4d'
ctx.beginPath(); ctx.arc(sx(h.x), sy(h.y), rad, 0, Math.PI * 2); ctx.fill()
ctx.strokeStyle = '#ff8c1a'; ctx.lineWidth = 2
ctx.beginPath(); ctx.arc(sx(h.x), sy(h.y), rad, 0, Math.PI * 2); ctx.stroke()
ctx.globalAlpha = 1
}
// colour helpers: own fighter always green, every other pinned to a random blue -> purple kept a
// touch dark so nothing reads as light/green next to your own green. A resting fighter gets a cheap
// deterministic idle bob keyed off its id + the tick, identical on every screen and purely cosmetic.
var OWN_COLOR = '#39c15e'
var OTHER_PALETTE = ['#2f74e6', '#2f63d8', '#3a5bd0', '#4356d6', '#5551d6', '#6a5acd', '#7b5cff', '#8e5cff', '#9b59b6', '#8e44ad']
var colorOf = Gamehoster.Persistent.Get('colorOf')
function playerColor(id, isMe) {
if (isMe) return OWN_COLOR
if (!colorOf[id]) colorOf[id] = OTHER_PALETTE[Math.floor(Math.random() * OTHER_PALETTE.length)]
return colorOf[id]
}
function bob(id, t) { var s = 0; for (var i = 0; i < id.length; i++) s = (s + id.charCodeAt(i)) & 255; return Math.sin((t + s) * 0.12) }
// ── players: rectangles a little smaller than a tile. Own fighter green, the rest blue -> purple.
// Position is already interpolated and snapped across a respawn by the smooth body, so we draw it
// straight — no per-frame death handling is needed here any more. ──
var rectW = pw * cw, rectH = ph * chh
var players = []
for (var pit of Gamehoster.Players.values()) players.push(pit)
for (var pi = 0; pi < players.length; pi++) {
var pl = players[pi]
var isMe = pl.self
var gx = sx(pl.x), gy = sy(pl.y)
var wob = pl.onGround === false ? 0 : bob(pl.id, T) * (rectH * 0.04)
ctx.fillStyle = playerColor(pl.id, isMe)
ctx.fillRect(gx, gy + wob, rectW, rectH - wob)
ctx.strokeStyle = isMe ? '#0c5c26' : 'rgba(0,0,0,.35)'
ctx.lineWidth = isMe ? 2 : 1
ctx.strokeRect(gx + 0.5, gy + wob + 0.5, rectW - 1, rectH - wob - 1)
// above the fighter: its score (in its colour, read authoritative so it never flickers fractional),
// and its name below it
var score = pl.current ? (pl.current.score | 0) : 0
var cxp = gx + rectW / 2
ctx.textAlign = 'center'
ctx.fillStyle = playerColor(pl.id, isMe); ctx.font = 'bold 12px sans-serif'
ctx.fillText(String(score), cxp, gy - 19)
if (pl.name) { ctx.fillStyle = '#6b7078'; ctx.font = '11px sans-serif'; ctx.fillText(pl.name + (isMe ? ' (You)' : ''), cxp, gy - 7) }
}
// ── HUD: a scoreboard (each entry in that player's colour, yours tagged), and a control hint ──
function scoreOf(pl) { return (pl.current ? pl.current.score : 0) | 0 }
var board = players.slice().sort(function (a, b) { return scoreOf(b) - scoreOf(a) })
ctx.textAlign = 'left'; ctx.font = '13px sans-serif'
var yy = 20
for (var si = 0; si < board.length; si++) {
var bp = board[si]
var col = playerColor(bp.id, bp.self)
ctx.fillStyle = col; ctx.fillRect(12, yy - 10, 10, 10)
ctx.fillStyle = col
ctx.fillText((bp.name || 'anon') + (bp.self ? ' (You)' : '') + ' ' + scoreOf(bp), 28, yy)
yy += 18
}
ctx.fillStyle = '#aeb2b8'; ctx.textAlign = 'right'
ctx.fillText('← → run ↑ jump ↓ drop through · you fire automatically', W - 12, H - 12)
gamehoster-frontend/gamehoster-frontend-input-keyboard-left-press.js
Arrow-left goes down: start running left. Left and Right are a held run direction, changeable in mid-air, and Right wins if both are held. We recompute the effective direction from the held-key bag and send the move command only when it actually changes, so a key repeat never floods the socket.
// Arrow-left goes down: start running left. Left/Right are a HELD run direction, changeable in
// mid-air, and Right wins if both are held (matching the old renderer). We recompute the effective
// direction and only send `move` when it actually changes.
var k = Gamehoster.Persistent.Get('keys')
k.left = true
var dir = k.right ? 1 : k.left ? -1 : 0
if (dir !== k.dir) { k.dir = dir; Gamehoster.Command('move', { dir: dir }) }
gamehoster-frontend/gamehoster-frontend-input-keyboard-left-release.js
Arrow-left comes up: stop running left, falling back to running right if that key is still held. Again move is sent only when the effective direction changes.
// Arrow-left comes up: stop running left. If Right is still held we fall back to running right,
// otherwise we stop. We only send `move` when the effective direction actually changes.
var k = Gamehoster.Persistent.Get('keys')
k.left = false
var dir = k.right ? 1 : k.left ? -1 : 0
if (dir !== k.dir) { k.dir = dir; Gamehoster.Command('move', { dir: dir }) }
gamehoster-frontend/gamehoster-frontend-input-keyboard-right-press.js
Arrow-right goes down: start running right (Right wins if both run keys are held). Sends move only on a real change of direction.
// Arrow-right goes down: start running right (Right wins if both run keys are held). We recompute the
// effective direction and only send `move` when it actually changes.
var k = Gamehoster.Persistent.Get('keys')
k.right = true
var dir = k.right ? 1 : k.left ? -1 : 0
if (dir !== k.dir) { k.dir = dir; Gamehoster.Command('move', { dir: dir }) }
gamehoster-frontend/gamehoster-frontend-input-keyboard-right-release.js
Arrow-right comes up: stop running right, falling back to left if it is still held.
// Arrow-right comes up: stop running right. If Left is still held we fall back to running left,
// otherwise we stop. We only send `move` when the effective direction actually changes.
var k = Gamehoster.Persistent.Get('keys')
k.right = false
var dir = k.right ? 1 : k.left ? -1 : 0
if (dir !== k.dir) { k.dir = dir; Gamehoster.Command('move', { dir: dir }) }
gamehoster-frontend/gamehoster-frontend-input-keyboard-up-press.js
Arrow-up is a held jump: while it is held you jump again off every landing, so holding it bounces you. Up and Space share one held flag, so this sends the jump command {on:true} once when the hold begins.
// Arrow-up (or Space) is a HELD jump: while it is held you jump again off every landing, so holding
// it bounces you. Up and Space share one held flag, so we send jump{on:true} once when the hold
// begins and ignore repeats.
var k = Gamehoster.Persistent.Get('keys')
if (!k.jump) { k.jump = true; Gamehoster.Command('jump', { on: true }) }
gamehoster-frontend/gamehoster-frontend-input-keyboard-up-release.js
Arrow-up comes up: end the held jump. Up and Space share one held flag, so releasing either ends the jump; sends jump {on:false} once.
// Arrow-up comes up: end the held jump. Up and Space share one held flag, so releasing EITHER ends
// the jump (matching the old renderer). We send jump{on:false} once, only if it was held.
var k = Gamehoster.Persistent.Get('keys')
if (k.jump) { k.jump = false; Gamehoster.Command('jump', { on: false }) }
gamehoster-frontend/gamehoster-frontend-input-keyboard-space-press.js
Space is an alias for the held jump: begin the hold and send jump {on:true} once. It shares the one held flag with the arrow-up body.
// Space is an alias for the held jump (see the arrow-up body): begin the hold and send jump{on:true}
// once. Up and Space share one held flag.
var k = Gamehoster.Persistent.Get('keys')
if (!k.jump) { k.jump = true; Gamehoster.Command('jump', { on: true }) }
gamehoster-frontend/gamehoster-frontend-input-keyboard-space-release.js
Space comes up: end the held jump (Space and Up share one held flag).
// Space comes up: end the held jump (Up and Space share one held flag, so releasing either ends it).
var k = Gamehoster.Persistent.Get('keys')
if (k.jump) { k.jump = false; Gamehoster.Command('jump', { on: false }) }
gamehoster-frontend/gamehoster-frontend-input-keyboard-down-press.js
Arrow-down goes down: drop through the one-way platform you are standing on. It is a hold — press sends the drop command {on:true}, release sends {on:false} — and the server drops you through one platform per press.
// Arrow-down goes down: drop through the one-way platform you are standing on. It is a hold — press
// sends drop{on:true}, release sends drop{on:false} — and the server drops you through one platform
// per press. (The old renderer also bumped a local sequence number here for its predict step, but
// this game predicts nothing on the client, so there is nothing to bump.)
var k = Gamehoster.Persistent.Get('keys')
if (!k.drop) { k.drop = true; Gamehoster.Command('drop', { on: true }) }
gamehoster-frontend/gamehoster-frontend-input-keyboard-down-release.js
Arrow-down comes up: end the drop hold, sending drop {on:false} once.
// Arrow-down comes up: end the drop hold, sending drop{on:false} once.
var k = Gamehoster.Persistent.Get('keys')
if (k.drop) { k.drop = false; Gamehoster.Command('drop', { on: false }) }