Player
The files that describe an Asteroids player: the one field they supply on join, the per-instance state the server keeps for their ship, the scripts run on join and leave, the one authoritative update that flies the ship on the server, the per-viewer relevance pass that decides what each player is sent, the client-only predict body that runs your own ship ahead of the server from your live input, and the client-only smooth body that snaps a ship across a respawn.
<gamehoster-config-contentRoot>/
game.gamehoster.org/
gamehoster-games/
asteroids/
gamehoster-player/
gamehoster-player-join-schema.json
gamehoster-player-schema.json
gamehoster-player-join.js
gamehoster-player-leave.js
gamehoster-player-update.js
gamehoster-player-view.js
gamehoster-player-predict.js
gamehoster-player-smooth.js
Contents
The player folder holds two schemas, the lifecycle scripts, the authoritative update, the view, and client-only predict and smooth bodies.
| Name | Type | Description |
|---|---|---|
gamehoster-player-join-schema.json | file | The data a player supplies on connect. |
gamehoster-player-schema.json | file | The per-ship state. |
gamehoster-player-join.js | file | Route the player in and place their ship. |
gamehoster-player-leave.js | file | Destroy a leaving player’s bullets. |
gamehoster-player-update.js | file | The ship’s authoritative per-tick update. |
gamehoster-player-view.js | file | What each viewer is sent. |
gamehoster-player-predict.js | file | Client-only prediction of your own ship. |
gamehoster-player-smooth.js | file | Client-only: snap a ship across a respawn. |
gamehoster-player-join-schema.json
The data a client sends to join. Asteroids asks for one field, the player’s display name, shown above their ship.
[
{
"gamehoster-player-join-schema-name": "name",
"gamehoster-player-join-schema-type": "string"
}
]
gamehoster-player-schema.json
The per-ship state. Every field here is public, streamed to every viewer who can see the ship. The name and color are set once. Position x/y and heading angle stream each tick; x and y wrap at the arena bounds arenaW/arenaH and, like angle, carry min/max/decimals range hints. lastThrustTick is the tick thrust was last held, a public timestamp each viewer uses to fade the flame on its own screen. alive flags a live ship. spawns is a counter the server bumps on each respawn, which the front end watches to snap position across the respawn rather than slide it there. score is the running total. The ship's server-only physics (velocity, the thrust flag, the respawn timer and this ship's fire phase) are not in the schema; they live in a Player.Persistent bag that is never streamed.
[
{
"gamehoster-player-schema-name": "name",
"gamehoster-player-schema-type": "string",
"gamehoster-player-schema-visibility": "public"
},
{
"gamehoster-player-schema-name": "color",
"gamehoster-player-schema-type": "string",
"gamehoster-player-schema-visibility": "public"
},
{
"gamehoster-player-schema-name": "x",
"gamehoster-player-schema-type": "number",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-visibility": "public",
"gamehoster-player-schema-wrap": "arenaW",
"gamehoster-player-schema-min": 0,
"gamehoster-player-schema-max": 3600,
"gamehoster-player-schema-decimals": 1
},
{
"gamehoster-player-schema-name": "y",
"gamehoster-player-schema-type": "number",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-visibility": "public",
"gamehoster-player-schema-wrap": "arenaH",
"gamehoster-player-schema-min": 0,
"gamehoster-player-schema-max": 2400,
"gamehoster-player-schema-decimals": 1
},
{
"gamehoster-player-schema-name": "angle",
"gamehoster-player-schema-type": "number",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-visibility": "public",
"gamehoster-player-schema-min": -3.15,
"gamehoster-player-schema-max": 3.15,
"gamehoster-player-schema-decimals": 3
},
{
"gamehoster-player-schema-name": "lastThrustTick",
"gamehoster-player-schema-type": "int",
"gamehoster-player-schema-default": -1000,
"gamehoster-player-schema-visibility": "public"
},
{
"gamehoster-player-schema-name": "alive",
"gamehoster-player-schema-type": "bool",
"gamehoster-player-schema-default": true,
"gamehoster-player-schema-visibility": "public"
},
{
"gamehoster-player-schema-name": "spawns",
"gamehoster-player-schema-type": "int",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-visibility": "public"
},
{
"gamehoster-player-schema-name": "score",
"gamehoster-player-schema-type": "int",
"gamehoster-player-schema-default": 0,
"gamehoster-player-schema-visibility": "public"
}
]
gamehoster-player-join.js
Runs on the server as a player connects. It routes them into an instance with room (or a fresh one), then places their ship. The public, streamed fields (name, a palette color, spawn point, a random heading, alive, the spawns counter and the score) are written as ship state; the server-only physics (velocity, the thrust flag, the respawn timer, and a firePhase so this ship’s bullet stream is offset from the rest) go into the Player.Persistent bag, which is never sent to clients.
// 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 rocks, 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 shipR = Gamehoster.Instance.State.Get(here, "shipR")
const palette = ["#e6194b", "#3cb44b", "#4363d8", "#f58231", "#911eb4", "#42d4f4", "#f032e6", "#ffe119", "#7cc5ff", "#bfef45"]
// Find the most open spawn point, measured ONLY against rocks (ships do not collide with each other,
// so there is no need to space them apart). Score several random spots by their gap to the nearest
// rock and keep the best; never fall back to the arena centre.
const grid = Gamehoster.Instance.Persistent.Get()
const wrapDelta = (a, b, s) => { let d = a - b; if (d > s / 2) d -= s; else if (d < -s / 2) d += s; return d }
const ROCK_CLEAR = 2.5 // keep 2.5x a rock's kill radius (rr+shipR) clear, so a fresh ship is safe
let sx = 0, sy = 0, best = -Infinity
for (let tries = 0; tries < 40; tries++) {
const cx = Gamehoster.Entropy() * W, cy = Gamehoster.Entropy() * H
let gap = Infinity
if (grid) for (const rrec of grid.rock.known.values()) {
const dx = wrapDelta(cx, rrec.x, W), dy = wrapDelta(cy, rrec.y, H)
const g = Math.hypot(dx, dy) - ROCK_CLEAR * (rrec.r + shipR); if (g < gap) gap = g
}
if (gap > best) { best = gap; sx = cx; sy = cy }
if (gap >= 0) break
}
// PUBLIC (streamed) ship state. `angle` is normalised to [-π, π] to match the aim command
// (atan2), and `spawns` is a counter the front end watches to snap across a respawn.
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, "angle", (Gamehoster.Entropy() * 2 - 1) * Math.PI)
Gamehoster.Player.State.Set(me, "alive", true)
Gamehoster.Player.State.Set(me, "spawns", 0)
Gamehoster.Player.State.Set(me, "score", 0)
// SERVER-ONLY per-player physics + timers — velocity, whether thrust is held, the respawn timer
// and this ship's fire phase — live in Player.Persistent, never streamed. (The client makes up its
// own vx/vy for prediction; it never needs the server's.)
Gamehoster.Player.Persistent.Set(me, {
vx: 0, vy: 0, thrusting: false, respawnAt: 0,
firePhase: Math.floor(Gamehoster.Entropy() * Gamehoster.Instance.State.Get(here, "fireEvery"))
})
gamehoster-player-leave.js
Runs when a player disconnects. The engine reclaims the ship state; because a bullet belongs to the player who fired it, this destroys the ones still in flight.
// 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. 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.js
The ship’s single authoritative update, run each tick on the server. It reads the owner’s angle and the thrust flag (the flag and the velocity live in the ship’s Player.Persistent bag, never streamed), turns them into motion, accelerating to a capped speed and coasting with friction, wraps at the arena edges, and streams only the position x/y to every viewer. While thrust is held it stamps the public lastThrustTick with the current tick, so each viewer fades the flame from how long ago it was last set.
// The ship's per-tick update, run on the authoritative server. It integrates the ship the
// player is flying from its owner-driven inputs (`angle` and `thrusting`, set by the aim /
// thrust commands) plus its current velocity, and streams the resulting position to every
// viewer, who interpolate it. `vx`/`vy` and `thrusting` are server-only (Player.Persistent, never
// streamed — the front end makes up its own for prediction); `x`/`y`/`angle` are public and
// streamed. Position wraps at the arena edges. A
// destroyed ship (alive === false) is frozen until respawn.
//
// Thrust is shown, not sent as a flag: while thrusting we stamp `lastThrustTick` with the
// current tick. That is a public timestamp, so each viewer can fade the flame out on its own
// screen from how long ago it was last set — no need to deliver the on/off edges reliably.
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")
// velocity + thrust flag are SERVER-ONLY (Player.Persistent); only x/y/angle stream to viewers
const mem = Gamehoster.Player.Persistent.Get(me)
const angle = Gamehoster.Player.State.Get(me, "angle")
let vx = mem.vx
let vy = mem.vy
if (mem.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 }
Gamehoster.Player.State.Set(me, "lastThrustTick", Gamehoster.Tick())
} 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
mem.vx = vx
mem.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 and marks only the ships, rocks and bullets near that viewer, so each player is sent just the slice of the arena on or just off their screen. The instance update has already derived every object’s live position once this tick and cached it on the persistent spatial grids, so this pass just reads those cached positions and distance-tests them.
// Per-viewer relevance (fog of war). Runs on the server per viewer at the send rate. It marks the
// ships, rocks and bullets near this viewer as relevant, so each player is sent only the slice of
// the arena on (or just off) their screen. The viewer and the instance are always relevant (the
// engine includes them); we add the nearby objects.
//
// The heavy lifting — deriving every object's live position — was already done ONCE this tick by
// the instance update, which cached each object's current x/y on its record in the persistent
// spatial grids. So here we just read those cached positions (no ballistic recompute, no
// per-property accessor) and distance-test them. The arena wraps, so distances are the short way.
//
// (The grid's cells prune well for the tight collision query, but a view radius covers a large
// fraction of the arena, so we simply sweep the cached records — the win is reusing the once-per-
// tick positions across all viewers, not spatial pruning. Trimming this to only the objects that
// crossed the view border since last send needs per-player persistent state — a later step.)
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: pre-fetch just off-screen
const r2 = reach * reach
const grid = Gamehoster.Instance.Persistent.Get()
if (grid) {
const meState = Gamehoster.Player.All().get(me)
const cx = meState ? meState.x : 0, cy = meState ? meState.y : 0
const grids = [grid.ship, grid.rock, grid.bullet]
for (let gi = 0; gi < grids.length; gi++) {
for (const rec of grids[gi].known.values()) {
let dx = rec.x - cx; if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W
let dy = rec.y - cy; if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H
if (dx * dx + dy * dy <= r2) Gamehoster.View.Include(rec.id)
}
}
}
gamehoster-player-predict.js
Client only: predicts your OWN ship. The client runtime runs it each tick from your live input, simulating the same motion the server runs and easing the result toward the authoritative position, so your ship responds the instant you act. Only your own ship is predicted; everything else is interpolated.
// Client-only: predict our OWN ship and reconcile it toward the server. The client runtime runs
// this once per tick for the local player, fed our live input, so the ship moves the instant we
// act instead of a round trip later. It runs the same motion the server's update runs, from live
// input, then eases the result toward the authoritative position (extrapolated to the present with
// our own velocity, so a matched prediction needs almost no correction), snapping on a big jump
// such as a respawn. Everything else on screen is interpolated; only our own ship is predicted.
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")
if (W == null) return // no instance state yet, nothing to predict
const wrap = (n, s) => ((n % s) + s) % s
const wrapDelta = (a, b, s) => { let d = a - b; if (d > s / 2) d -= s; else if (d < -s / 2) d += s; return d }
// a destroyed ship is frozen at the server's position until it respawns
if (Gamehoster.Authoritative.Get(me, "alive") === false) {
const dx = Gamehoster.Authoritative.Get(me, "x"), dy = Gamehoster.Authoritative.Get(me, "y")
if (dx != null) { Gamehoster.Player.State.Set(me, "x", dx); Gamehoster.Player.State.Set(me, "y", dy) }
Gamehoster.Player.State.Set(me, "vx", 0); Gamehoster.Player.State.Set(me, "vy", 0)
return
}
// integrate from LIVE input, the same physics the server runs
let vx = Gamehoster.Player.State.Get(me, "vx") || 0
let vy = Gamehoster.Player.State.Get(me, "vy") || 0
if (Gamehoster.Input.Get("thrusting")) {
const a = Gamehoster.Input.Get("angle") || 0
const thrust = Gamehoster.Instance.State.Get(here, "thrust")
const maxSpeed = Gamehoster.Instance.State.Get(here, "maxSpeed")
vx += Math.cos(a) * thrust; vy += Math.sin(a) * thrust
const sp = Math.hypot(vx, vy); if (sp > maxSpeed) { vx = vx / sp * maxSpeed; vy = vy / sp * maxSpeed }
} else {
const f = Gamehoster.Instance.State.Get(here, "friction")
vx *= f; vy *= f
}
let x = wrap((Gamehoster.Player.State.Get(me, "x") || 0) + vx, W)
let y = wrap((Gamehoster.Player.State.Get(me, "y") || 0) + vy, H)
// reconcile toward the authoritative position, extrapolated to the present with our velocity
const ax = Gamehoster.Authoritative.Get(me, "x"), ay = Gamehoster.Authoritative.Get(me, "y")
if (ax != null) {
const lead = Gamehoster.Lead()
const tx = wrap(ax + vx * lead, W), ty = wrap(ay + vy * lead, H)
const ex = wrapDelta(tx, x, W), ey = wrapDelta(ty, y, H)
if (ex * ex + ey * ey > 240 * 240) { x = ax; y = ay; vx = 0; vy = 0 } // big jump (respawn): snap
else { x = wrap(x + ex * 0.2, W); y = wrap(y + ey * 0.2, 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-smooth.js
Client only: how a ship’s streamed fields are shown each frame. By default the runtime interpolates every numeric field between the two samples straddling the render tick, which is right while a ship flies. A ship respawns at a fresh point across the arena, and interpolating position across that jump would slide the ship in a straight line from where it died to where it reappears. This body watches the public spawns counter the instance update bumps on each respawn: while the smoothed counter still lags the latest one, it snaps x and y to their latest authoritative values instead of easing across the gap. Every other field, and normal flight, keeps the default smoothing; your own ship is predicted on top of this, so this mainly governs how other players’ ships handle a respawn.
// Client-only: how a ship's streamed fields are shown each render frame. By default the runtime
// interpolates every numeric field between the two samples straddling the render tick, which is
// exactly right while a ship flies around. But a ship RESPAWNS at a fresh point across the arena,
// and interpolating position across that jump would slide the ship smoothly from where it died to
// where it reappears — a line straight across the map.
//
// The ship carries a public `spawns` counter that the server bumps on every respawn. While the
// interpolated (smoothed) counter still lags the authoritative one — i.e. the render tick has not
// yet reached the respawn in the sample timeline — we SNAP position to the latest authoritative
// value instead of interpolating across the gap. Every other field (and normal flight) keeps the
// default smoothing untouched. Our own ship is predicted on top of this, so this mainly governs
// how OTHER players' ships handle a respawn.
if (Gamehoster.Smooth("spawns") !== Gamehoster.Latest("spawns")) {
Gamehoster.Set("x", Gamehoster.Latest("x"))
Gamehoster.Set("y", Gamehoster.Latest("y"))
}