Instance

The files that describe an Asteroids instance: its state schema, the arena and every tuning constant, and the authoritative per-tick update that runs the whole game.

<gamehoster-config-contentRoot>/
  game.gamehoster.org/
    gamehoster-games/
      asteroids/
        gamehoster-instance/
          gamehoster-instance-schema.json
          gamehoster-instance-create.js
          gamehoster-instance-update.js
The gamehoster-instance/ folder: the state schema and the authoritative update.

Contents

The instance folder holds its state schema and one update script.

NameTypeDescription
gamehoster-instance-schema.jsonfileThe arena and tuning constants.
gamehoster-instance-create.jsfileOne-time per-instance setup.
gamehoster-instance-update.jsfileThe authoritative per-tick game logic.

gamehoster-instance-schema.json

The instance state: the arena size and view radius, and the physics and gameplay constants (thrust, top speed, friction, bullet life and speed, fire rate, the target weight of rocks, spawn clearances and respawn delay) — every field public (visibility public), streamed to every viewer so the front end and bots run the same physics. The bot-padding fields are no longer here: botsMax and botsZeroAt moved to the game config as gamehoster-game-botsMax and gamehoster-game-botsZeroAt.

[
  {
    "gamehoster-instance-schema-name": "phase",
    "gamehoster-instance-schema-type": "string",
    "gamehoster-instance-schema-default": "playing",
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "arenaW",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 3600,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "arenaH",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 2400,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "viewRadius",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 820,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "thrust",
    "gamehoster-instance-schema-type": "number",
    "gamehoster-instance-schema-default": 0.35,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "maxSpeed",
    "gamehoster-instance-schema-type": "number",
    "gamehoster-instance-schema-default": 6.5,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "friction",
    "gamehoster-instance-schema-type": "number",
    "gamehoster-instance-schema-default": 0.988,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "shipR",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 14,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "bulletSpeed",
    "gamehoster-instance-schema-type": "number",
    "gamehoster-instance-schema-default": 9,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "bulletLife",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 46,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "fireEvery",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 10,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "targetWeight",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 200,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "refillFloor",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 196,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "safeSpawn",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 460,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "respawnDelay",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 60,
    "gamehoster-instance-schema-visibility": "public"
  }
]
asteroids/gamehoster-instance/gamehoster-instance-schema.json

gamehoster-instance-create.js

Optional. Runs once when an instance is created, with that instance in context, to set up its server-only persistent data. Asteroids builds three server-only spatial grids here — one each for rocks, bullets and ships — so the per-tick collision pass, and every viewer’s relevance, stay cheap.

// Runs once when an instance is created, with that instance already in context. We build the
// SERVER-ONLY persistent state the per-tick update maintains and the per-viewer relevance reads:
// THREE spatial grids (rocks, bullets, ships), each a fixed array of per-cell bucket arrays plus
// a `known` map (id -> a cached record carrying the object's CURRENT position). The update pass
// keeps these in sync incrementally — it computes every object's live position exactly ONCE per
// tick and caches it here, so collision and all 45 viewers' relevance reuse it instead of each
// re-deriving positions through the per-property accessor. `persistent` is never sent to clients.

const here = Gamehoster.Context.instanceId
const W = Gamehoster.Instance.State.Get(here, "arenaW")
const H = Gamehoster.Instance.State.Get(here, "arenaH")

const CELL = 100                       // ≈ a big rock's collision query box, so a rock touches ~2×2 cells
const gw = Math.ceil(W / CELL), gh = Math.ceil(H / CELL)
function emptyGrid() {
  const cells = new Array(gw * gh)
  for (let i = 0; i < cells.length; i++) cells[i] = []
  return { cells: cells, known: new Map() }
}

Gamehoster.Instance.Persistent.Set({
  CELL: CELL, gw: gw, gh: gh,
  rock: emptyGrid(), bullet: emptyGrid(), ship: emptyGrid()
})
asteroids/gamehoster-instance/gamehoster-instance-create.js

gamehoster-instance-update.js

The instance’s single authoritative update, run each tick on the server: cull spent bullets, test bullets against rocks and split or destroy them, kill ships that hit a rock, respawn on a timer, fire each ship’s bullet on its phase, and keep the arena stocked. Rocks and bullets are ballistic, so a reconcile pass computes each one’s position from its parameters once and caches it on the grids the collision and relevance passes reuse.

// The instance's authoritative per-tick pass. The expensive parts — computing where every rock,
// bullet and ship is, and testing collisions — used to be redone from the accessor over and over
// (per rock, per ship, and again per viewer in relevance). Now we do it ONCE per tick against
// three persistent spatial grids (set up in gamehoster-instance-create.js):
//
//   • reconcile: read each type's whole map in one go (Entity.All / Player.All — direct field
//     access, no per-property accessor), add newcomers, drop the gone, and recompute every
//     object's CURRENT position, caching it on its grid record. Objects only change cell when
//     they actually cross a boundary.
//   • collide: iterate rocks once; for each, scan the few cells its radius overlaps in the BULLET
//     grid (analytic line–circle hit) and the SHIP grid (ship death). No brute-force N×M.
//   • the cached positions are then reused, unchanged, by the per-viewer relevance body.
//
// Rocks and bullets stay BALLISTIC on the wire (constant params + spawnTick); a bullet sends only
// its angle (velocity = angle × constant bulletSpeed), which server and front end both derive — so
// the bullet stores no vx/vy at all. Ship velocity, thrust and timers are server-only and live in
// Player.Persistent, not the streamed schema.

const tick     = Gamehoster.Tick()
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 bLife    = Gamehoster.Instance.State.Get(here, "bulletLife")
const bSpeed   = Gamehoster.Instance.State.Get(here, "bulletSpeed")
const fireEvery = Gamehoster.Instance.State.Get(here, "fireEvery")
const target   = Gamehoster.Instance.State.Get(here, "targetWeight")
const floor    = Gamehoster.Instance.State.Get(here, "refillFloor")
const respawnDelay = Gamehoster.Instance.State.Get(here, "respawnDelay")

function weightOf(s) { return s === 3 ? 4 : s === 2 ? 2 : 1 }
function radiusOf(s) { return s === 3 ? 46 : s === 2 ? 26 : 14 }
function speedOf(s)  { return s === 3 ? 0.6 : s === 2 ? 1.1 : 1.8 }
function wrapDelta(a, b, span) { let d = a - b; if (d > span / 2) d -= span; else if (d < -span / 2) d += span; return d }

function spawnRock(size, x, y) {
  const sp = speedOf(size) * (0.6 + Gamehoster.Entropy() * 0.8)
  const dir = Gamehoster.Entropy() * Math.PI * 2
  const vx = Math.cos(dir) * sp, vy = Math.sin(dir) * sp, r = radiusOf(size)
  const id = Gamehoster.Entity.Spawn("rock", {
    x0: x, y0: y, vx: vx, vy: vy,
    rot0: Gamehoster.Entropy() * Math.PI * 2, spin: (Gamehoster.Entropy() - 0.5) * 0.08,
    size: size, r: r, seed: Math.floor(Gamehoster.Entropy() * 1e6), spawnTick: tick
  })
  // add to the rock grid now (its position at spawn is its start point), so it is relevant this
  // send — reconcile will skip it (already known) and just move it from next tick on
  const rec = { id: id, x0: x, y0: y, vx: vx, vy: vy, spawnTick: tick, r: r, size: size, cell: -1, x: x, y: y }
  place(grid.rock, rec); grid.rock.known.set(id, rec)
  return id
}

// ── persistent grids ──
let grid = Gamehoster.Instance.Persistent.Get()
if (!grid) {   // defensive: instance-create normally builds this before any tick
  const gw0 = Math.ceil(W / 100), gh0 = Math.ceil(H / 100)
  const mk = () => { const c = new Array(gw0 * gh0); for (let i = 0; i < c.length; i++) c[i] = []; return { cells: c, known: new Map() } }
  grid = { CELL: 100, gw: gw0, gh: gh0, rock: mk(), bullet: mk(), ship: mk() }
  Gamehoster.Instance.Persistent.Set(grid)
}
const CELL = grid.CELL, gw = grid.gw, gh = grid.gh
function cellIndex(x, y) {
  const wc = ((Math.floor(x / CELL) % gw) + gw) % gw
  const wr = ((Math.floor(y / CELL) % gh) + gh) % gh
  return wr * gw + wc
}
function place(g, rec) { rec.cell = cellIndex(rec.x, rec.y); g.cells[rec.cell].push(rec) }
function drop(g, rec) { const a = g.cells[rec.cell]; const i = a.indexOf(rec); if (i >= 0) a.splice(i, 1) }
function reslot(g, rec) { const ci = cellIndex(rec.x, rec.y); if (ci !== rec.cell) { drop(g, rec); rec.cell = ci; g.cells[ci].push(rec) } }

// Spawn-clearance helpers. A rock's hit area against a ship is radiusOf(size) + shipR (the distance
// at which they collide); a new spawn keeps 1.5x clear of that so nothing appears on top of anyone.
// Both read the cached grid positions, which already reflect any moves and respawns applied earlier
// this tick (the raw player state has those writes still buffered until the body ends).
function noPlayerNear(x, y, size) {
  const c2 = (1.5 * (radiusOf(size) + shipR)) ** 2
  for (const srec of grid.ship.known.values()) {
    if (!srec.alive) continue
    const dx = wrapDelta(x, srec.x, W), dy = wrapDelta(y, srec.y, H)
    if (dx * dx + dy * dy < c2) return false
  }
  return true
}
function noRockNear(x, y) {
  for (const rrec of grid.rock.known.values()) {
    const c2 = (1.5 * (rrec.r + shipR)) ** 2
    const dx = wrapDelta(x, rrec.x, W), dy = wrapDelta(y, rrec.y, H)
    if (dx * dx + dy * dy < c2) return false
  }
  return true
}
// a spawn point for a rock of `size`: keep the preferred spot if it is clear of players, else try
// random positions until one is clear (or give up after a few tries and use the preferred spot)
function rockSpot(size, px, py) {
  if (noPlayerNear(px, py, size)) return { x: px, y: py }
  for (let t = 0; t < 16; t++) {
    const cx = Gamehoster.Entropy() * W, cy = Gamehoster.Entropy() * H
    if (noPlayerNear(cx, cy, size)) return { x: cx, y: cy }
  }
  return { x: px, y: py }
}

// ── 1 · cull expired bullets ──
const bulletsMap = Gamehoster.Entity.All("bullet")
const expired = []
for (const [id, e] of bulletsMap) if (tick - e.state.spawnTick >= bLife) expired.push(id)
for (const id of expired) Gamehoster.Entity.Destroy(id)

// ── 2 · reconcile grids (compute every position once, cache it) ──
// ballistic types (rock, bullet): position = start + velocity × age. A rock streams its velocity
// (public vx/vy); a bullet does NOT — its velocity is angle × constant bulletSpeed, so we derive it
// here from the public `angle` rather than carry redundant server state. `vel(state)` yields {vx,vy}.
function reconcileBallistic(g, type, copy, vel) {
  const all = Gamehoster.Entity.All(type)
  for (const [id, e] of all) {
    if (g.known.has(id)) continue
    const s = e.state
    const v = vel(s)
    const rec = { id: id, x0: s.x0, y0: s.y0, vx: v.vx, vy: v.vy, spawnTick: s.spawnTick, cell: -1, x: 0, y: 0 }
    for (const f of copy) rec[f] = s[f]
    const dt = tick - s.spawnTick
    rec.x = (((s.x0 + s.vx * dt) % W) + W) % W
    rec.y = (((s.y0 + s.vy * dt) % H) + H) % H
    place(g, rec); g.known.set(id, rec)
  }
  for (const [id, rec] of g.known) {
    if (!all.has(id)) { drop(g, rec); g.known.delete(id); continue }
    const dt = tick - rec.spawnTick
    rec.x = (((rec.x0 + rec.vx * dt) % W) + W) % W
    rec.y = (((rec.y0 + rec.vy * dt) % H) + H) % H
    reslot(g, rec)
  }
}
reconcileBallistic(grid.rock, "rock", ["r", "size"], (s) => ({ vx: s.vx, vy: s.vy }))
reconcileBallistic(grid.bullet, "bullet", ["owner"], (s) => ({ vx: Math.cos(s.angle) * bSpeed, vy: Math.sin(s.angle) * bSpeed }))
// ships: live position from player state
{
  const g = grid.ship, all = Gamehoster.Player.All()
  for (const [pid, st] of all) {
    if (g.known.has(pid)) continue
    const rec = { id: pid, x: st.x, y: st.y, cell: -1, alive: st.alive }
    place(g, rec); g.known.set(pid, rec)
  }
  for (const [pid, rec] of g.known) {
    const st = all.get(pid)
    if (!st) { drop(g, rec); g.known.delete(pid); continue }
    rec.x = st.x; rec.y = st.y; rec.alive = st.alive; reslot(g, rec)
  }
}

// ── 3 · collide: iterate rocks once, query the bullet grid (hits) then the ship grid (deaths) ──
const players = Gamehoster.Player.All()
function scoreHit(owner) {
  if (players.has(owner)) Gamehoster.Player.State.Set(owner, "score", Gamehoster.Player.State.Get(owner, "score") + 1)
}
// snapshot: split rocks spawned during this loop join the grid but must NOT be collided this tick
for (const rrec of [...grid.rock.known.values()]) {
  const rid = rrec.id, rx = rrec.x, ry = rrec.y, rr = rrec.r, size = rrec.size
  const hitR = rr + 5, hit2 = hitR * hitR
  // bullet hit — scan cells within one tick of bullet travel of the rock
  const extB = hitR + bSpeed + 1
  let hitBullet = null
  {
    const c0 = Math.floor((rx - extB) / CELL), c1 = Math.floor((rx + extB) / CELL)
    const r0 = Math.floor((ry - extB) / CELL), r1 = Math.floor((ry + extB) / CELL)
    for (let cc = c0; cc <= c1 && !hitBullet; cc++) {
      const wc = ((cc % gw) + gw) % gw
      for (let rc = r0; rc <= r1 && !hitBullet; rc++) {
        const arr = grid.bullet.cells[(((rc % gh) + gh) % gh) * gw + wc]
        for (const b of arr) {
          const bx = wrapDelta(b.x, rx, W), by = wrapDelta(b.y, ry, H)
          const dx = b.vx, dy = b.vy, len2 = dx * dx + dy * dy
          let t = len2 > 1e-9 ? -(bx * dx + by * dy) / len2 : 0
          if (t < 0) t = 0; else if (t > 1) t = 1
          const px = bx + t * dx, py = by + t * dy
          if (px * px + py * py <= hit2) { hitBullet = b; break }
        }
      }
    }
  }
  if (hitBullet) {
    Gamehoster.Entity.Destroy(hitBullet.id); drop(grid.bullet, hitBullet); grid.bullet.known.delete(hitBullet.id)
    Gamehoster.Entity.Destroy(rid); drop(grid.rock, rrec); grid.rock.known.delete(rid)
    if (size > 1) { const a = rockSpot(size - 1, rx, ry), b = rockSpot(size - 1, rx, ry); spawnRock(size - 1, a.x, a.y); spawnRock(size - 1, b.x, b.y) }
    scoreHit(hitBullet.owner)
    continue
  }
  // ship death — scan cells within (rock + ship radius) of the rock
  const extS = rr + shipR
  const c0 = Math.floor((rx - extS) / CELL), c1 = Math.floor((rx + extS) / CELL)
  const r0 = Math.floor((ry - extS) / CELL), r1 = Math.floor((ry + extS) / CELL)
  const hitDist2 = extS * extS
  for (let cc = c0; cc <= c1; cc++) {
    const wc = ((cc % gw) + gw) % gw
    for (let rc = r0; rc <= r1; rc++) {
      const arr = grid.ship.cells[(((rc % gh) + gh) % gh) * gw + wc]
      for (const s of arr) {
        if (s.alive === false) continue
        const dx = wrapDelta(s.x, rx, W), dy = wrapDelta(s.y, ry, H)
        if (dx * dx + dy * dy < hitDist2) {
          Gamehoster.Player.State.Set(s.id, "alive", false)        // public: viewers stop drawing it
          const mem = Gamehoster.Player.Persistent.Get(s.id)       // server-only: freeze + arm respawn
          if (mem) { mem.thrusting = false; mem.vx = 0; mem.vy = 0; mem.respawnAt = tick + respawnDelay }
          s.alive = false   // so a later rock this tick skips this ship
        }
      }
    }
  }
}

// ── 4 · respawn ships whose timer is up, clear of the other ships AND of every rock ──
for (const [pid, st] of players) {
  if (st.alive) continue
  const mem = Gamehoster.Player.Persistent.Get(pid); if (!mem) continue   // respawn timer is server-only
  if (tick < mem.respawnAt) continue
  // Place the ship at the safest of many random spots, measured ONLY against rocks. Ships do not
  // collide with each other, so there is no reason to space respawns apart — and avoiding ships is
  // exactly what let a big fleet blanket the arena and force a centre fallback. The gap to a rock is
  // distance − ROCK_CLEAR·(rr+shipR), where rr+shipR is the exact radius at which a rock destroys a
  // ship (see the collision test above). Take the first spot clear of every rock, else the one with
  // the largest gap, so a fresh ship is never dropped inside a rock. There is no fixed fallback.
  const ROCK_CLEAR = 2.5
  let sx = 0, sy = 0, best = -Infinity
  for (let t = 0; t < 40; t++) {
    const cx = Gamehoster.Entropy() * W, cy = Gamehoster.Entropy() * H
    let gap = Infinity
    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
  }
  Gamehoster.Player.State.Set(pid, "x", sx)
  Gamehoster.Player.State.Set(pid, "y", sy)
  Gamehoster.Player.State.Set(pid, "alive", true)
  // bump the public respawn counter so each viewer's front end SNAPS this ship to its new spot
  // instead of sliding it across the arena from where it died (see gamehoster-player-smooth.js)
  Gamehoster.Player.State.Set(pid, "spawns", (st.spawns || 0) + 1)
  const rec = grid.ship.known.get(pid); if (rec) { rec.x = sx; rec.y = sy; rec.alive = true; reslot(grid.ship, rec) }
}

// ── 5 · fire: each ship emits a bullet on its own phase, on exactly this tick ──
for (const [pid, st] of players) {
  if (!st.alive) continue
  const mem = Gamehoster.Player.Persistent.Get(pid); if (!mem) continue   // fire phase is server-only
  if (((tick - mem.firePhase) % fireEvery + fireEvery) % fireEvery !== 0) continue
  const a = st.angle
  const ox = st.x + Math.cos(a) * shipR, oy = st.y + Math.sin(a) * shipR
  const bvx = Math.cos(a) * bSpeed, bvy = Math.sin(a) * bSpeed
  // bullet carries only its angle (public); its velocity is angle × constant bulletSpeed, which
  // the server (reconcile/collision) and the front end both derive — no vx/vy stored on the bullet
  const bid = Gamehoster.Entity.Spawn("bullet", { x0: ox, y0: oy, angle: a, owner: pid, spawnTick: tick })
  // add to the bullet grid now so it is relevant this send (it is NOT collided until next tick)
  const rec = { id: bid, x0: ox, y0: oy, vx: bvx, vy: bvy, spawnTick: tick, owner: pid, cell: -1, x: ox, y: oy }
  place(grid.bullet, rec); grid.bullet.known.set(bid, rec)
}

// ── 6 · keep the arena stocked to its target weight of rocks ──
let weight = 0
for (const [, e] of Gamehoster.Entity.All("rock")) weight += weightOf(e.state.size)
if (weight < floor) {
  while (weight + 4 <= target) {
    const pos = rockSpot(3, Gamehoster.Entropy() * W, Gamehoster.Entropy() * H)   // random, clear of players
    spawnRock(3, pos.x, pos.y)
    weight += 4
  }
}
asteroids/gamehoster-instance/gamehoster-instance-update.js