Instance

An instance is the shared arena everyone in it flies. Its state is the arena's size, the view radius, and every tuning constant the ship, bullet and rock code reads. It carries one per-tick script, an authoritative backend update that owns everything a client cannot predict: hit-testing, splitting, scoring, respawns, firing and restocking.

gamehoster-instance-schema.json

The instance's shared state. phase is always "playing"; arenaW and arenaH are the 3600 × 2400 world; viewRadius drives relevance. The rest are the physics and pacing knobs: ship handling (thrust, maxSpeed, friction, shipR), bullets (bulletSpeed, bulletLife, fireEvery), the rock field (targetWeight, refillFloor), and spacing (safeSpawn, respawnDelay). All are sent to every client so both sides run from the same numbers.

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

gamehoster-instance-update-backend.js

The authoritative per-tick pass, run after the shared bodies have already advanced every ship, bullet and rock this tick. Nothing here is predictable, so it all lives on the server and reaches players as sent state, spawns and destroys. In order it culls expired bullets, resolves bullet-versus-rock hits (retiring the bullet, splitting or destroying the rock, crediting the shooter), kills ships that touch a rock and schedules their respawn, brings dead ships back when the timer is up, fires one bullet per ship on its own firePhase, and tops the field back up to its target weight of rock. Firing is authoritative: a bullet exists here only if its owner was alive on the fire tick, so a client that predicted a shot for an already-destroyed ship simply never receives that bullet.

// The instance's backend-only pass (step 3): the private, authoritative heart of the
// game, run AFTER the shared bodies have already advanced every ship, bullet and rock
// this tick. A client cannot predict any of what happens here, so it all lives on the
// server and reaches players as `sent` state, spawns and destroys:
//
//   1  cull:   retire bullets past their lifetime
//   2  hits:   bullet vs rock: destroy the bullet, split or destroy the rock, score
//   3  deaths: ship vs rock: destroy the ship, schedule a respawn
//   4  spawns: respawn ships whose timer is up
//   5  fire:   every ship emits a bullet on its own phase, on exactly this tick, so a
//              bullet's (owner, tick, origin, angle) are known and reproducible
//   6  stock:  keep the arena topped up to its target weight of rocks
//
// Firing is authoritative: a bullet is created here only if its owner is alive on the
// fire tick. A client predicting the same shot for a ship that the server had already
// destroyed simply never receives that bullet: the extra one is reconciled away.

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 safe     = Gamehoster.Instance.State.Get(here, "safeSpawn")
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
  return Gamehoster.Entity.Spawn("rock", {
    x: x, y: y, vx: Math.cos(dir) * sp, vy: Math.sin(dir) * sp,
    rot: Gamehoster.Entropy() * Math.PI * 2, spin: (Gamehoster.Entropy() - 0.5) * 0.08,
    size: size, r: radiusOf(size), seed: Math.floor(Gamehoster.Entropy() * 1e6), spawnTick: tick
  })
}

// ── 1 · cull expired bullets ──
for (const id of Gamehoster.Entity.List("bullet")) {
  if (tick - Gamehoster.Entity.State.Get(id, "spawnTick") >= bLife) Gamehoster.Entity.Destroy(id)
}

// ── 2 · bullet vs rock ──
const gone = {}
for (const rid of Gamehoster.Entity.List("rock")) {
  if (gone[rid]) continue
  const rx = Gamehoster.Entity.State.Get(rid, "x")
  const ry = Gamehoster.Entity.State.Get(rid, "y")
  const rr = Gamehoster.Entity.State.Get(rid, "r")
  const size = Gamehoster.Entity.State.Get(rid, "size")
  for (const bid of Gamehoster.Entity.List("bullet")) {
    if (gone[bid]) continue
    const dx = wrapDelta(Gamehoster.Entity.State.Get(bid, "x"), rx, W)
    const dy = wrapDelta(Gamehoster.Entity.State.Get(bid, "y"), ry, H)
    if (dx * dx + dy * dy > rr * rr) continue
    // hit: retire the bullet, split or destroy the rock, credit the shooter
    const owner = Gamehoster.Entity.State.Get(bid, "owner")
    gone[bid] = 1; gone[rid] = 1
    Gamehoster.Entity.Destroy(bid)
    Gamehoster.Entity.Destroy(rid)
    if (size > 1) { spawnRock(size - 1, rx, ry); spawnRock(size - 1, rx, ry) }
    for (const pid of Gamehoster.Player.List()) {
      if (pid === owner) Gamehoster.Player.State.Set(pid, "score", Gamehoster.Player.State.Get(pid, "score") + 1)
    }
    break
  }
}

// ── 3 · ship vs rock ──
for (const pid of Gamehoster.Player.List()) {
  if (!Gamehoster.Player.State.Get(pid, "alive")) continue
  const px = Gamehoster.Player.State.Get(pid, "x")
  const py = Gamehoster.Player.State.Get(pid, "y")
  for (const rid of Gamehoster.Entity.List("rock")) {
    if (gone[rid]) continue
    const dx = wrapDelta(px, Gamehoster.Entity.State.Get(rid, "x"), W)
    const dy = wrapDelta(py, Gamehoster.Entity.State.Get(rid, "y"), H)
    const rr = Gamehoster.Entity.State.Get(rid, "r") + shipR
    if (dx * dx + dy * dy < rr * rr) {
      Gamehoster.Player.State.Set(pid, "alive", false)
      Gamehoster.Player.State.Set(pid, "thrusting", false)
      Gamehoster.Player.State.Set(pid, "vx", 0)
      Gamehoster.Player.State.Set(pid, "vy", 0)
      Gamehoster.Player.State.Set(pid, "respawnAt", tick + respawnDelay)
      break
    }
  }
}

// ── 4 · respawn ships whose timer is up, clear of the other ships ──
for (const pid of Gamehoster.Player.List()) {
  if (Gamehoster.Player.State.Get(pid, "alive")) continue
  if (tick < Gamehoster.Player.State.Get(pid, "respawnAt")) continue
  let sx = W / 2, sy = H / 2
  for (let t = 0; t < 24; t++) {
    const cx = Gamehoster.Entropy() * W, cy = Gamehoster.Entropy() * H
    let clear = true
    for (const oid of Gamehoster.Player.List()) {
      if (oid === pid || !Gamehoster.Player.State.Get(oid, "alive")) continue
      const dx = wrapDelta(cx, Gamehoster.Player.State.Get(oid, "x"), W)
      const dy = wrapDelta(cy, Gamehoster.Player.State.Get(oid, "y"), H)
      if (dx * dx + dy * dy < safe * safe) { clear = false; break }
    }
    if (clear) { sx = cx; sy = cy; break }
  }
  Gamehoster.Player.State.Set(pid, "x", sx)
  Gamehoster.Player.State.Set(pid, "y", sy)
  Gamehoster.Player.State.Set(pid, "alive", true)
}

// ── 5 · fire: each ship emits a bullet on its own phase, on exactly this tick ──
for (const pid of Gamehoster.Player.List()) {
  if (!Gamehoster.Player.State.Get(pid, "alive")) continue
  const phase = Gamehoster.Player.State.Get(pid, "firePhase")
  if (((tick - phase) % fireEvery + fireEvery) % fireEvery !== 0) continue
  const a = Gamehoster.Player.State.Get(pid, "angle")
  const ox = Gamehoster.Player.State.Get(pid, "x") + Math.cos(a) * shipR
  const oy = Gamehoster.Player.State.Get(pid, "y") + Math.sin(a) * shipR
  Gamehoster.Entity.Spawn("bullet", {
    x: ox, y: oy, x0: ox, y0: oy, angle: a, owner: pid, spawnTick: tick,
    vx: Gamehoster.Player.State.Get(pid, "vx") + Math.cos(a) * bSpeed,
    vy: Gamehoster.Player.State.Get(pid, "vy") + Math.sin(a) * bSpeed
  })
}

// ── 6 · keep the arena stocked. Large rocks weigh 4, medium 2, small 1; we begin at
//    50 large = 200. If the total falls below the floor (196) we add large rocks until
//    the next one would exceed the target (the largest total we can reach without
//    going over), dropped in clear of every ship. ──
let weight = 0
for (const rid of Gamehoster.Entity.List("rock")) weight += weightOf(Gamehoster.Entity.State.Get(rid, "size"))
if (weight < floor) {
  while (weight + 4 <= target) {
    let px = Gamehoster.Entropy() * W, py = Gamehoster.Entropy() * H
    for (let t = 0; t < 16; t++) {
      let clear = true
      for (const pid of Gamehoster.Player.List()) {
        const dx = wrapDelta(px, Gamehoster.Player.State.Get(pid, "x"), W)
        const dy = wrapDelta(py, Gamehoster.Player.State.Get(pid, "y"), H)
        if (dx * dx + dy * dy < safe * safe) { clear = false; break }
      }
      if (clear) break
      px = Gamehoster.Entropy() * W; py = Gamehoster.Entropy() * H
    }
    spawnRock(3, px, py)
    weight += 4
  }
}
asteroids/gamehoster-instance/gamehoster-instance-update-backend.js