Player
The files that describe an Asteroids player: the one field they supply when they join, the per-instance state the engine keeps for their ship, the two scripts the server runs on join and leave, and two per-tick scripts, the shared body that flies the ship (run on both sides) and the viewer-centric pass that decides what each player is sent.
gamehoster-player-join-schema.json
The shape of the data a client must send to join. Asteroids asks for a single field: the player's
display name, shown floating above their ship.
[
{
"gamehoster-player-join-schema-name": "name",
"gamehoster-player-join-schema-type": "string"
}
]
gamehoster-player-schema.json
The per-ship state. The name and color are sent
once. The kinematics, position x/y, velocity vx/vy,
heading angle and the thrusting flag, are all
shared, so the same body integrates them on both sides (with
x, y and angle smoothed between keyframes). The alive,
firePhase and score fields are sent to everyone, and respawnAt is
server-private, the tick a dead ship comes back.
[
{
"gamehoster-player-schema-name": "name",
"gamehoster-player-schema-type": "string",
"gamehoster-player-schema-sync": "sent",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "color",
"gamehoster-player-schema-type": "string",
"gamehoster-player-schema-sync": "sent",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "x",
"gamehoster-player-schema-type": "number",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-sync": "shared",
"gamehoster-player-schema-audience": "all",
"gamehoster-player-schema-smooth": true
},
{
"gamehoster-player-schema-name": "y",
"gamehoster-player-schema-type": "number",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-sync": "shared",
"gamehoster-player-schema-audience": "all",
"gamehoster-player-schema-smooth": true
},
{
"gamehoster-player-schema-name": "vx",
"gamehoster-player-schema-type": "number",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-sync": "shared",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "vy",
"gamehoster-player-schema-type": "number",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-sync": "shared",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "angle",
"gamehoster-player-schema-type": "number",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-sync": "shared",
"gamehoster-player-schema-audience": "all",
"gamehoster-player-schema-smooth": true
},
{
"gamehoster-player-schema-name": "thrusting",
"gamehoster-player-schema-type": "bool",
"gamehoster-player-schema-default": false,
"gamehoster-player-schema-sync": "shared",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "alive",
"gamehoster-player-schema-type": "bool",
"gamehoster-player-schema-default": true,
"gamehoster-player-schema-sync": "sent",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "respawnAt",
"gamehoster-player-schema-type": "int",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-sync": "server"
},
{
"gamehoster-player-schema-name": "firePhase",
"gamehoster-player-schema-type": "int",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-sync": "sent",
"gamehoster-player-schema-audience": "all"
},
{
"gamehoster-player-schema-name": "score",
"gamehoster-player-schema-type": "int",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-sync": "sent",
"gamehoster-player-schema-audience": "all"
}
]
gamehoster-player-join.js
Runs on the server as a player connects: it routes them into an
instance that still has room (or a fresh one), then places their
ship. It searches for a spawn point clear of every other ship, gives it a colour, a random heading, and a
firePhase so this ship's steady bullet stream is offset from everyone else's rather than all
leaving on the same tick. Rocks are not seeded here: the
instance update keeps the arena stocked on every tick, the
first included.
// Runs on the server the moment a player connects. It routes them into an instance
// that still has room (or opens a fresh one), then sets up their ship: a colour, a
// spawn point clear of the other ships, a random heading, and a fire phase so their
// constant stream of bullets is offset from everyone else's rather than all leaving
// on the same tick. Rocks are not seeded here: the instance update keeps the arena
// stocked to its target weight on every tick, including the first.
const open = Gamehoster.Instance.List().find(id =>
Gamehoster.Instance.Info(id).players < Gamehoster.Instance.Info(id).capacity)
Gamehoster.Instance.Join(open !== undefined ? open : Gamehoster.Instance.Create())
const me = Gamehoster.Context.playerId
const here = Gamehoster.Context.instanceId
const W = Gamehoster.Instance.State.Get(here, "arenaW")
const H = Gamehoster.Instance.State.Get(here, "arenaH")
const safe = Gamehoster.Instance.State.Get(here, "safeSpawn")
const palette = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#42d4f4", "#f032e6", "#ffe119", "#7cc5ff", "#bfef45"]
// find a spawn point that is not on top of another ship
let sx = W / 2, sy = H / 2
for (let tries = 0; tries < 24; tries++) {
const cx = Gamehoster.Entropy() * W
const cy = Gamehoster.Entropy() * H
let clear = true
for (const id of Gamehoster.Player.List()) {
if (id === me) continue
const dx = cx - Gamehoster.Player.State.Get(id, "x")
const dy = cy - Gamehoster.Player.State.Get(id, "y")
if (dx * dx + dy * dy < safe * safe) { clear = false; break }
}
if (clear) { sx = cx; sy = cy; break }
}
Gamehoster.Player.State.Set(me, "name", Gamehoster.Join.Get("name"))
Gamehoster.Player.State.Set(me, "color", palette[Math.floor(Gamehoster.Entropy() * palette.length)])
Gamehoster.Player.State.Set(me, "x", sx)
Gamehoster.Player.State.Set(me, "y", sy)
Gamehoster.Player.State.Set(me, "vx", 0)
Gamehoster.Player.State.Set(me, "vy", 0)
Gamehoster.Player.State.Set(me, "angle", Gamehoster.Entropy() * Math.PI * 2)
Gamehoster.Player.State.Set(me, "thrusting", false)
Gamehoster.Player.State.Set(me, "alive", true)
Gamehoster.Player.State.Set(me, "respawnAt", 0)
Gamehoster.Player.State.Set(me, "firePhase", Math.floor(Gamehoster.Entropy() * Gamehoster.Instance.State.Get(here, "fireEvery")))
Gamehoster.Player.State.Set(me, "score", 0)
gamehoster-player-leave.js
Runs when a player disconnects. The engine reclaims the ship state, and because a bullet belongs to the player who fired it, this script destroys the ones still in flight. Rocks and other ships are left alone.
// A player has left. The engine reclaims their ship state; their in-flight bullets
// are owned by them, so we destroy the ones still travelling. (This is also where the
// backend removes a bullet the client predicted but that should never have existed:
// see the instance update, a bullet is only truly created if its owner was alive on
// its fire tick.) Rocks and other players are untouched.
const me = Gamehoster.Context.playerId
for (const id of Gamehoster.Entity.List("bullet")) {
if (Gamehoster.Entity.State.Get(id, "owner") === me) Gamehoster.Entity.Destroy(id)
}
gamehoster-player-update-frontend.js
The ship's own per-tick body, a shared script the engine runs on both sides. It turns the
owner-driven inputs, angle and thrusting (set by the
aim and thrust commands), plus the current velocity into motion:
it accelerates toward the heading up to a cap, applies friction when the player is coasting, and wraps the
position at the arena edges. A destroyed ship is frozen until it respawns.
// The ship's own per-tick update: the shared body, run on both sides. It integrates
// the ship the player is flying from its own owner-driven inputs (`angle` and
// `thrusting`, set by the aim / thrust commands) plus its current velocity, so it is
// fully reproducible on the owner's front end: the same code the server runs, letting
// the ship move the instant the player acts rather than a round-trip later. Position
// wraps at the arena edges. A destroyed ship (alive === false) is frozen until respawn.
const me = Gamehoster.Context.playerId
if (!Gamehoster.Player.State.Get(me, "alive")) return
const here = Gamehoster.Context.instanceId
const thrust = Gamehoster.Instance.State.Get(here, "thrust")
const maxSpeed = Gamehoster.Instance.State.Get(here, "maxSpeed")
const friction = Gamehoster.Instance.State.Get(here, "friction")
const W = Gamehoster.Instance.State.Get(here, "arenaW")
const H = Gamehoster.Instance.State.Get(here, "arenaH")
const angle = Gamehoster.Player.State.Get(me, "angle")
let vx = Gamehoster.Player.State.Get(me, "vx")
let vy = Gamehoster.Player.State.Get(me, "vy")
if (Gamehoster.Player.State.Get(me, "thrusting")) {
vx += Math.cos(angle) * thrust
vy += Math.sin(angle) * thrust
const sp = Math.hypot(vx, vy)
if (sp > maxSpeed) { vx = vx / sp * maxSpeed; vy = vy / sp * maxSpeed }
} else {
vx *= friction
vy *= friction
}
let x = (((Gamehoster.Player.State.Get(me, "x") + vx) % W) + W) % W
let y = (((Gamehoster.Player.State.Get(me, "y") + vy) % H) + H) % H
Gamehoster.Player.State.Set(me, "vx", vx)
Gamehoster.Player.State.Set(me, "vy", vy)
Gamehoster.Player.State.Set(me, "x", x)
Gamehoster.Player.State.Set(me, "y", y)
gamehoster-player-view.js
Per-viewer relevance. It runs once per viewer at the send rate
(Gamehoster.Context.playerId is the viewer) and marks only the ships, rocks and bullets near
that viewer with View.Include, so each player is streamed just the slice of the arena on (or
just off) their screen rather than all 50 ships and the hundreds of rocks and bullets in flight. Distances
are measured the short way round the wrap, and a margin keeps things included slightly before they are
needed.
// Per-viewer relevance (fog of war). It runs on the backend at the send rate,
// viewer-centric (Gamehoster.Context.playerId is the viewer) and marks only the ships, rocks
// and bullets near that viewer as relevant, so the server streams each player just the
// slice of the arena on (or just off) their screen rather than all 50 ships and the
// hundreds of rocks and bullets in flight. Things crossing the view radius arrive as
// `enter` and drop out as `leave`; the margin keeps them included slightly before they
// are needed. The arena wraps, so distances are measured the short way round.
//
// NOTE: the current MVP engine (src/server/engine.js) treats View.Include as a no-op
// and sends every object to every player, so this file is written to the intended
// contract but does not yet reduce traffic. It is the exact hook the relevance feature
// will switch on; see the notes on the example page.
const me = Gamehoster.Context.playerId
const here = Gamehoster.Context.instanceId
const W = Gamehoster.Instance.State.Get(here, "arenaW")
const H = Gamehoster.Instance.State.Get(here, "arenaH")
const reach = Gamehoster.Instance.State.Get(here, "viewRadius") + 120 // + margin to pre-fetch
const r2 = reach * reach
Gamehoster.View.Include(me)
const cx = Gamehoster.Player.State.Get(me, "x")
const cy = Gamehoster.Player.State.Get(me, "y")
function near(x, y) {
let dx = Math.abs(x - cx); if (dx > W / 2) dx = W - dx
let dy = Math.abs(y - cy); if (dy > H / 2) dy = H - dy
return dx * dx + dy * dy <= r2
}
for (const pid of Gamehoster.Player.List()) {
if (near(Gamehoster.Player.State.Get(pid, "x"), Gamehoster.Player.State.Get(pid, "y"))) Gamehoster.View.Include(pid)
}
for (const rid of Gamehoster.Entity.List("rock")) {
if (near(Gamehoster.Entity.State.Get(rid, "x"), Gamehoster.Entity.State.Get(rid, "y"))) Gamehoster.View.Include(rid)
}
for (const bid of Gamehoster.Entity.List("bullet")) {
if (near(Gamehoster.Entity.State.Get(bid, "x"), Gamehoster.Entity.State.Get(bid, "y"))) Gamehoster.View.Include(bid)
}