Instance

An instance is a single Snake arena: the wrapped grid many players share and the state that drives it. It simulates every tick, so it carries state, a create hook, and one authoritative update. The update runs on the server four times a second and is the whole of the game.

<gamehoster-config-contentRoot>/
  game.gamehoster.org/
    gamehoster-games/
      snake/
        gamehoster-instance/
          gamehoster-instance-schema.json
          gamehoster-instance-create.js
          gamehoster-instance-update.js
The gamehoster-instance/ folder: the world schema, the create hook, and the authoritative update.

Contents

The instance folder holds its state schema, a create hook, and one update script.

NameTypeDescription
gamehoster-instance-schema.jsonfileThe arena size and tuning constants.
gamehoster-instance-create.jsfileSets up the server-only scratch when an instance is created.
gamehoster-instance-update.jsfileThe authoritative per-tick game logic.

gamehoster-instance-schema.json

The instance's state, all public. Four fields let the client size and draw the world: gridW, gridH, cell and viewRadius. The other three are tuning constants the update reads: foodTarget, startLength and respawnDelay. (Genuinely server-only scratch, like the tail-stamp sequence, is not in the schema; it lives in the instance's Persistent bag instead.)

[
  {
    "gamehoster-instance-schema-name": "gridW",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 120,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "gridH",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 80,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "cell",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 16,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "viewRadius",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 34,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "foodTarget",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 70,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "startLength",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 4,
    "gamehoster-instance-schema-visibility": "public"
  },
  {
    "gamehoster-instance-schema-name": "respawnDelay",
    "gamehoster-instance-schema-type": "int",
    "gamehoster-instance-schema-default": 8,
    "gamehoster-instance-schema-visibility": "public"
  }
]
snake/gamehoster-instance/gamehoster-instance-schema.json

gamehoster-instance-create.js

Runs once when an instance is created. It sets up the server-only persistent scratch the tick uses: a single incrementing seq that stamps each tail cell as it is laid, so the update always knows which cell of a snake is the oldest and should expire first. The persistent state is never sent to any client.

// Runs once when an instance is created, with that instance in context. We set up the server-only
// persistent scratch the tick uses: a single incrementing `seq` that stamps each tail segment as it
// is laid, so the update always knows which segment of a snake is the oldest and should expire
// first. `persistent` is never serialised or sent to any client.

Gamehoster.Instance.Persistent.Set({ seq: 1 })
snake/gamehoster-instance/gamehoster-instance-create.js

gamehoster-instance-update.js

The instance's single authoritative update, run every tick on the server, four times a second. It is the whole of the game. It builds the occupancy for the tick, moves every snake whose move is due, lays a tail cell where the head was and trims the tail, eats food and grows, or crashes into a body and dies. Killing a snake scatters part of it as food. It then tops the food up to its target and spawns a fresh snake for any dead player whose timer is up.

// The instance's single authoritative update, run each tick on the server (4 ticks per second). It
// is the whole of the game:
//
//   1  occupancy: build the grid the instance holds this tick, what is in every cell
//   2  move:      advance each snake whose move is due, lay a tail segment, trim the tail, eat food,
//                 or crash into a body and die
//   3  food:      top the field up to its target (it only drops below by being eaten)
//   4  respawn:   place a fresh snake for any dead player whose timer is up, and for new joiners
//
// A snake moves every OTHER tick, or every tick while it holds the direction it is already going.
// Its body is a run of separate `tail` entities that never move: each is laid where the head was and
// expires from the far end as the head advances, kept to length minus one. Eating a food raises the
// length, so for one move nothing expires and the snake grows. Killing a snake scatters part of it
// as food (each of its cells has a one in four chance), which can push the field above its target,
// so no new food appears until eating brings it back below. Directions: 0 right, 1 down, 2 left, 3 up.

const here = Gamehoster.Context.instanceId
const gridW = Gamehoster.Instance.State.Get(here, "gridW")
const gridH = Gamehoster.Instance.State.Get(here, "gridH")
const foodTarget = Gamehoster.Instance.State.Get(here, "foodTarget")
const startLen = Gamehoster.Instance.State.Get(here, "startLength")
const respawnDelay = Gamehoster.Instance.State.Get(here, "respawnDelay")
const tick = Gamehoster.Tick()

let P = Gamehoster.Instance.Persistent.Get()
if (!P || typeof P.seq !== "number") { P = { seq: 1 }; Gamehoster.Instance.Persistent.Set(P) }

const DX = [1, 0, -1, 0], DY = [0, 1, 0, -1]
const key = (x, y) => x + "," + y
const wrapX = (x) => ((x % gridW) + gridW) % gridW
const wrapY = (y) => ((y % gridH) + gridH) % gridH

// ── 1 · occupancy for this tick ──
const tailAt = new Map()      // "x,y" -> tail entity id
const headAt = new Map()      // "x,y" -> player id (living snakes)
const foodAt = new Map()      // "x,y" -> food entity id
const ownerTails = new Map()  // player id -> [{ id }], oldest segment first
for (const tid of Gamehoster.Entity.List("tail")) {
  const x = Gamehoster.Entity.State.Get(tid, "x"), y = Gamehoster.Entity.State.Get(tid, "y")
  tailAt.set(key(x, y), tid)
  const tmem = Gamehoster.Entity.Persistent.Get(tid)   // owner + seq are SERVER-ONLY (Entity.Persistent), never streamed
  const owner = tmem ? tmem.owner : ""
  let arr = ownerTails.get(owner); if (!arr) { arr = []; ownerTails.set(owner, arr) }
  arr.push({ id: tid, seq: tmem ? tmem.seq : 0 })
}
for (const arr of ownerTails.values()) arr.sort((a, b) => a.seq - b.seq)   // oldest (lowest seq) first
for (const fid of Gamehoster.Entity.List("food")) foodAt.set(key(Gamehoster.Entity.State.Get(fid, "x"), Gamehoster.Entity.State.Get(fid, "y")), fid)
for (const pid of Gamehoster.Player.List()) {
  if (Gamehoster.Player.State.Get(pid, "alive")) headAt.set(key(Gamehoster.Player.State.Get(pid, "x"), Gamehoster.Player.State.Get(pid, "y")), pid)
}

function addFood(x, y) { foodAt.set(key(x, y), Gamehoster.Entity.Spawn("food", { x: x, y: y })) }

// kill a snake: scatter part of it as food, destroy its tail, schedule a respawn
function kill(pid) {
  const hx = Gamehoster.Player.State.Get(pid, "x"), hy = Gamehoster.Player.State.Get(pid, "y")
  headAt.delete(key(hx, hy))
  if (Gamehoster.Entropy() < 0.25 && !foodAt.has(key(hx, hy))) addFood(hx, hy)
  for (const t of (ownerTails.get(pid) || [])) {
    const tx = Gamehoster.Entity.State.Get(t.id, "x"), ty = Gamehoster.Entity.State.Get(t.id, "y")
    tailAt.delete(key(tx, ty))
    if (Gamehoster.Entropy() < 0.25 && !foodAt.has(key(tx, ty))) addFood(tx, ty)
    Gamehoster.Entity.Destroy(t.id)
  }
  ownerTails.set(pid, [])
  Gamehoster.Player.State.Set(pid, "alive", false)
  const mem = Gamehoster.Player.Persistent.Get(pid)   // respawn timer is SERVER-ONLY (Player.Persistent)
  if (mem) mem.respawnAt = tick + respawnDelay
}

// ── 2 · move every snake whose move is due ──
for (const pid of Gamehoster.Player.List()) {
  if (!Gamehoster.Player.State.Get(pid, "alive")) continue
  const mem = Gamehoster.Player.Persistent.Get(pid); if (!mem) continue   // length/held/pending/moveCounter are SERVER-ONLY
  const dir0 = Gamehoster.Player.State.Get(pid, "dir")
  const held = mem.held
  const moveEvery = (held !== -1 && held === dir0) ? 1 : 2            // boost while holding the current direction
  const mc = mem.moveCounter + 1
  if (mc < moveEvery) { mem.moveCounter = mc; continue }
  mem.moveCounter = 0

  // apply a queued turn, re-checked against the current heading so it is never a reversal
  let dir = dir0
  const pending = mem.pending
  mem.pending = -1
  if (pending !== -1 && pending !== dir && pending !== (dir + 2) % 4) dir = pending

  const x = Gamehoster.Player.State.Get(pid, "x"), y = Gamehoster.Player.State.Get(pid, "y")
  const nx = wrapX(x + DX[dir]), ny = wrapY(y + DY[dir]), nk = key(nx, ny)

  // the head may enter the cell its own tail-tip is about to vacate this move
  const myTails = ownerTails.get(pid) || []
  const keep = mem.length - 1
  let freeTip = null
  if (myTails.length >= keep && myTails.length > 0) {
    const t0 = myTails[0]; freeTip = key(Gamehoster.Entity.State.Get(t0.id, "x"), Gamehoster.Entity.State.Get(t0.id, "y"))
  }

  // crash into any body segment or another living head
  if ((tailAt.has(nk) && nk !== freeTip) || (headAt.has(nk) && headAt.get(nk) !== pid)) { kill(pid); continue }

  // eat a food on the new cell: grow, score, remove the food
  if (foodAt.has(nk)) {
    Gamehoster.Entity.Destroy(foodAt.get(nk)); foodAt.delete(nk)
    mem.length = mem.length + 1
    Gamehoster.Player.State.Set(pid, "score", Gamehoster.Player.State.Get(pid, "score") + 1)
  }

  // lay a tail segment where the head was, then advance the head. `owner`/`seq` are server-only
  // (Entity.Persistent) — the client only needs the segment's x/y/color.
  const tid = Gamehoster.Entity.Spawn("tail", { x: x, y: y, color: Gamehoster.Player.State.Get(pid, "color") })
  Gamehoster.Entity.Persistent.Set(tid, { owner: pid, seq: P.seq++ })
  tailAt.set(key(x, y), tid)
  headAt.delete(key(x, y)); headAt.set(nk, pid)
  Gamehoster.Player.State.Set(pid, "x", nx)
  Gamehoster.Player.State.Set(pid, "y", ny)
  Gamehoster.Player.State.Set(pid, "dir", dir)

  // trim the tail back to length minus one (a snake that just ate keeps the extra segment this move)
  myTails.push({ id: tid })
  const keepNow = mem.length - 1
  while (myTails.length > keepNow) {
    const old = myTails.shift()
    tailAt.delete(key(Gamehoster.Entity.State.Get(old.id, "x"), Gamehoster.Entity.State.Get(old.id, "y")))
    Gamehoster.Entity.Destroy(old.id)
  }
}

// ── 3 · top the food up to the target (it only falls below it by being eaten) ──
let foodCount = Gamehoster.Entity.List("food").length
let guard = foodTarget + 4
while (foodCount < foodTarget && guard-- > 0) {
  let placed = false
  for (let t = 0; t < 10; t++) {
    const rx = Math.floor(Gamehoster.Entropy() * gridW), ry = Math.floor(Gamehoster.Entropy() * gridH), k = key(rx, ry)
    if (tailAt.has(k) || headAt.has(k) || foodAt.has(k)) continue
    addFood(rx, ry); foodCount++; placed = true; break
  }
  if (!placed) break   // ten tries found no clear cell this pass; try again next tick
}

// ── 4 · spawn a fresh snake for each dead player whose timer is up (a new joiner starts dead too) ──
for (const pid of Gamehoster.Player.List()) {
  if (Gamehoster.Player.State.Get(pid, "alive")) continue
  const mem = Gamehoster.Player.Persistent.Get(pid); if (!mem) continue   // respawn timer + snake scratch are SERVER-ONLY
  if (tick < mem.respawnAt) continue

  let hx = -1, hy = -1
  for (let t = 0; t < 24; t++) {
    const rx = Math.floor(Gamehoster.Entropy() * gridW), ry = Math.floor(Gamehoster.Entropy() * gridH)
    if (!tailAt.has(key(rx, ry)) && !headAt.has(key(rx, ry)) && !foodAt.has(key(rx, ry))) { hx = rx; hy = ry; break }
  }
  if (hx < 0) continue   // arena momentarily full; try again next tick
  const dir = Math.floor(Gamehoster.Entropy() * 4)
  const color = Gamehoster.Player.State.Get(pid, "color")
  // lay the starting body behind the head, the tip (furthest back) with the lowest seq so it expires first
  const arr = []
  for (let k = startLen - 1; k >= 1; k--) {
    const bx = wrapX(hx - DX[dir] * k), by = wrapY(hy - DY[dir] * k)
    if (tailAt.has(key(bx, by)) || headAt.has(key(bx, by))) continue
    const tid = Gamehoster.Entity.Spawn("tail", { x: bx, y: by, color: color })   // owner/seq → Entity.Persistent
    Gamehoster.Entity.Persistent.Set(tid, { owner: pid, seq: P.seq++ })
    tailAt.set(key(bx, by), tid); arr.push({ id: tid })
  }
  ownerTails.set(pid, arr)
  headAt.set(key(hx, hy), pid)
  Gamehoster.Player.State.Set(pid, "x", hx)
  Gamehoster.Player.State.Set(pid, "y", hy)
  Gamehoster.Player.State.Set(pid, "dir", dir)
  mem.length = startLen
  mem.held = -1
  mem.pending = -1
  mem.moveCounter = 0
  Gamehoster.Player.State.Set(pid, "score", 0)
  Gamehoster.Player.State.Set(pid, "alive", true)
}
snake/gamehoster-instance/gamehoster-instance-update.js