Bots

Asteroids fills a thin arena with company. This example instance holds up to five bots when a lone player is flying, and thins out as people join, reaching zero at twenty players. The bots fly, dodge rocks, lead their shots and — some of them — pick fights, all by sending the same aim and thrust commands a person sends. To the rest of the game they are simply players; this is the general bots facility, dropped into Asteroids with a folder and two settings.

asteroids/
  gamehoster-bots/
    drifter/   likelihood 3 · peaceful, clears rocks
      gamehoster-bot.json
      gamehoster-bot-join.js
      gamehoster-bot-update.js
    ace/       likelihood 2 · aggressive, hunts ships
      gamehoster-bot.json
      gamehoster-bot-join.js
      gamehoster-bot-update.js
    sniper/    likelihood 1 · takes the odd shot
      gamehoster-bot.json
      gamehoster-bot-join.js
      gamehoster-bot-update.js
asteroids · gamehoster-bots/: three personalities, each a likelihood, a join body and the shared brain.

Contents

One folder per personality. Each holds a likelihood, a join body that sets up its private memory, and the brain — and the brain is the same gamehoster-bot-update.js in all three.

NameTypeDescription
drifter/directoryLikelihood 3 · peaceful, clears rocks.
ace/directoryLikelihood 2 · aggressive, hunts ships.
sniper/directoryLikelihood 1 · takes the odd shot.

Three personalities, by likelihood

Each personality's gamehoster-bot.json carries a single weight. They total 6, so a new bot is a drifter three times in six, an ace twice, a sniper once.

{ "gamehoster-bot-likelihood": 3 }
asteroids/gamehoster-bots/drifter/gamehoster-bot.json
{ "gamehoster-bot-likelihood": 2 }
asteroids/gamehoster-bots/ace/gamehoster-bot.json
{ "gamehoster-bot-likelihood": 1 }
asteroids/gamehoster-bots/sniper/gamehoster-bot.json

gamehoster-bot-join.js

By the time a bot's join runs, the engine has already run the game's own player-join for it, so its ship exists, sits clear of the rocks and has a heading. The bot-join body only sets up the bot's private memory — its personality's attackChance and a little scratch its brain keeps between ticks. The three personalities differ only in this one number: the drifter's 0, the ace's 0.4, the sniper's 0.15.

// The drifter: a peaceful bot that roams the field and clears rocks, never picking a fight with
// another ship (attackChance 0). Runs once when this bot is padded into an instance, with the bot
// already a fully set-up player (the game's own player-join built its ship). Here we set up only
// this bot's PRIVATE memory — the scratch its per-update brain reads and writes. It never touches
// game state; that happens through commands on update.

Gamehoster.Bot.Persistent.Set({ attackChance: 0, wasAlive: true, facing: null, target: null })
asteroids/gamehoster-bots/drifter/gamehoster-bot-join.js
// The ace: an aggressive bot that will often break off to hunt and fire on a nearby ship
// (attackChance 0.4). Runs once when this bot is padded into an instance, with the bot already a
// fully set-up player (the game's own player-join built its ship). Here we set up only this bot's
// PRIVATE memory — the scratch its per-update brain reads and writes. It never touches game state;
// that happens through commands on update.

Gamehoster.Bot.Persistent.Set({ attackChance: 0.4, wasAlive: true, facing: null, target: null })
asteroids/gamehoster-bots/ace/gamehoster-bot-join.js
// The sniper: a mostly-peaceful bot that now and then takes a shot at a ship that wanders close
// (attackChance 0.15). Runs once when this bot is padded into an instance, with the bot already a
// fully set-up player (the game's own player-join built its ship). Here we set up only this bot's
// PRIVATE memory — the scratch its per-update brain reads and writes. It never touches game state;
// that happens through commands on update.

Gamehoster.Bot.Persistent.Set({ attackChance: 0.15, wasAlive: true, facing: null, target: null })
asteroids/gamehoster-bots/sniper/gamehoster-bot-join.js

gamehoster-bot-update.js

The brain, run on the bot timer (gamehoster-game-botRate). It is the same file in all three folders — a personality lives entirely in the attackChance its join stored. It reads the world exactly as the tick does — its own ship from Player.All(), the rocks from Entity.All("rock") (whose live positions it recomputes ballistically, just like the front end) — decides what to do, and acts only by sending the game's own aim and thrust commands through Gamehoster.Bot.Send. There is no fire command in Asteroids: a living ship auto-fires, so to shoot a rock the bot simply points where the bullet will meet it. Survival comes first — an incoming rock is dodged (PANIC) before the bot will hunt a ship, engage a rock ahead, or travel to a roaming target.

// An Asteroids bot's brain, run on the bot timer (gamehoster-game-botRate, 5/s here). A bot IS a
// player: it reads the world exactly as the tick does — Player.All(), Entity.All("rock"),
// Instance.State — and it acts ONLY by sending the same aim/thrust commands a human's mouse would,
// through Gamehoster.Bot.Send. There is no fire command: a living ship auto-fires, so "shoot that
// rock" just means "point at where the bullet will meet it". Its personality (how likely it is to
// hunt other players) is read from its private memory, set up in gamehoster-bot-join.js.

const me   = Gamehoster.Bot.id
const here = Gamehoster.Context.instanceId
const tick = Gamehoster.Tick()
const mem  = Gamehoster.Bot.Persistent.Get() || {}

const W = Gamehoster.Instance.State.Get(here, "arenaW")
const H = Gamehoster.Instance.State.Get(here, "arenaH")
if (W == null) return                                   // instance state not ready yet
const shipR  = Gamehoster.Instance.State.Get(here, "shipR")
const bSpeed = Gamehoster.Instance.State.Get(here, "bulletSpeed")

const players = Gamehoster.Player.All()
const s = players.get(me)
if (!s) return                                          // our ship isn't in the roster yet
// our own ship's velocity is server-only now (Player.Persistent), no longer a streamed field on `s`
const smem = Gamehoster.Player.Persistent.Get(me) || { vx: 0, vy: 0 }

const TAU = Math.PI * 2
const wrapDelta = (d, span) => { d %= span; if (d > span / 2) d -= span; else if (d < -span / 2) d += span; return d }
const angDiff   = (a, b) => { let d = (a - b) % TAU; if (d > Math.PI) d -= TAU; else if (d < -Math.PI) d += TAU; return d }

// Commit this update's intent and persist memory. Aim slews toward the target so the nose turns
// like a real ship rather than snapping; both commands are sent only on a real change, so the
// command stream stays about as light as a human's.
function act(aim, thrust) {
  if (Number.isFinite(aim)) {
    if (mem.facing == null) mem.facing = aim
    else {
      // Slew the nose toward the aim, wrap-aware (angDiff gives the shortest signed turn), so the
      // ship rotates like a craft instead of snapping — and never spins or flips. At most 20° per
      // update; and once we are within 30° of the target close only HALF the remaining gap each
      // tick, so the nose eases in and settles rather than jittering across it.
      const MAX = 20 * Math.PI / 180, NEAR = 30 * Math.PI / 180
      const d = angDiff(aim, mem.facing)
      const step = Math.abs(d) <= NEAR ? d * 0.5 : Math.max(-MAX, Math.min(MAX, d))
      const f = mem.facing + step
      mem.facing = Math.atan2(Math.sin(f), Math.cos(f))
    }
    if (mem.lastAim == null || Math.abs(angDiff(mem.facing, mem.lastAim)) > 0.02) {
      Gamehoster.Bot.Send("aim", { angle: mem.facing }); mem.lastAim = mem.facing
    }
  }
  if (thrust != null && thrust !== mem.lastThrust) { Gamehoster.Bot.Send("thrust", { on: thrust }); mem.lastThrust = thrust }
  Gamehoster.Bot.Persistent.Set(mem)
}

// dead: coast, and clear the trip so we set off afresh when we respawn somewhere new
if (!s.alive) { mem.wasAlive = false; act(NaN, false); return }
if (mem.wasAlive === false) { mem.wasAlive = true; mem.target = null; mem.attackId = null; mem.facing = s.angle }

// rocks near us, live positions (ballistic: start + velocity × age) relative to our ship
const rocks = []
for (const [, e] of Gamehoster.Entity.All("rock")) {
  const st = e.state, dt = tick - st.spawnTick
  const wx = (((st.x0 + st.vx * dt) % W) + W) % W, wy = (((st.y0 + st.vy * dt) % H) + H) % H
  const rx = wrapDelta(wx - s.x, W), ry = wrapDelta(wy - s.y, H)
  const dist = Math.hypot(rx, ry)
  if (dist < 900) rocks.push({ rx, ry, dist, R: (st.r || 20) + shipR, vx: st.vx, vy: st.vy })
}

// PANIC — a rock about to reach us: dodge away, overriding everything else (survival first)
let panic = null, panicScore = Infinity
for (const r of rocks) {
  const gap = r.dist - r.R
  if (gap < 55) {
    const closing = -((r.vx - smem.vx) * r.rx + (r.vy - smem.vy) * r.ry) / (r.dist || 1)
    const score = gap - closing * 6
    if (score < panicScore) { panicScore = score; panic = r }
  }
}
if (panic) { act(Math.atan2(-panic.ry, -panic.rx), true); return }

// HUNT — some personalities occasionally chase and fire on a nearby player. Once a target is
// picked we hold it for a beat so the auto-fire has time to land, then let it lapse.
const attackChance = mem.attackChance || 0
if (attackChance > 0) {
  let near = null, nd = Infinity
  for (const [pid, p] of players) {
    if (pid === me || !p.alive) continue
    const rx = wrapDelta(p.x - s.x, W), ry = wrapDelta(p.y - s.y, H), d = Math.hypot(rx, ry)
    if (d < 520 && d < nd) { nd = d; near = { pid, rx, ry } }
  }
  if (near) {
    if (mem.attackId !== near.pid && Math.random() < attackChance * 0.08) { mem.attackId = near.pid; mem.attackUntil = tick + 40 }
    if (mem.attackId === near.pid && tick < (mem.attackUntil || 0)) { act(Math.atan2(near.ry, near.rx), false); return }
  } else mem.attackId = null
}

// ENGAGE — a rock close and roughly ahead of our travel: stop thrusting, lead it, let auto-fire spray
const travelDir = (smem.vx * smem.vx + smem.vy * smem.vy > 0.25) ? Math.atan2(smem.vy, smem.vx) : (mem.facing != null ? mem.facing : s.angle)
let engage = null
for (const r of rocks) {
  if (r.dist > r.R + 170) continue
  const bearing = Math.atan2(r.ry, r.rx)
  if (Math.abs(angDiff(bearing, travelDir)) < 0.7 && (!engage || r.dist < engage.dist)) engage = r
}
if (engage) {
  let t = engage.dist / bSpeed
  for (let k = 0; k < 2; k++) { const ax = engage.rx + engage.vx * t, ay = engage.ry + engage.vy * t; t = Math.hypot(ax, ay) / bSpeed }
  act(Math.atan2(engage.ry + engage.vy * t, engage.rx + engage.vx * t), false); return
}

// TRAVEL — keep a roaming destination; on arrival pick a new far one. Steer toward it, easing
// around any rock sitting in the corridor, and thrust.
if (!mem.target || Math.hypot(wrapDelta(mem.target.x - s.x, W), wrapDelta(mem.target.y - s.y, H)) < 160) {
  mem.target = null
  for (let i = 0; i < 8; i++) {
    const tx = Math.random() * W, ty = Math.random() * H
    if (Math.hypot(wrapDelta(tx - s.x, W), wrapDelta(ty - s.y, H)) > 600) { mem.target = { x: tx, y: ty }; break }
  }
  if (!mem.target) mem.target = { x: Math.random() * W, y: Math.random() * H }
}
const head = Math.atan2(wrapDelta(mem.target.y - s.y, H), wrapDelta(mem.target.x - s.x, W))
let sx = Math.cos(head), sy = Math.sin(head)
for (const r of rocks) {
  const range = r.R + 220; if (r.dist > range) continue
  const bearing = Math.atan2(r.ry, r.rx); if (Math.abs(angDiff(bearing, head)) > 1.3) continue
  const w = (range - r.dist) / range, inv = 1 / (r.dist || 1)
  let tx = -r.ry * inv, ty = r.rx * inv
  if (tx * Math.cos(head) + ty * Math.sin(head) < 0) { tx = -tx; ty = -ty }
  sx += (tx * 1.3 - r.rx * inv * 0.7) * w * 1.6
  sy += (ty * 1.3 - r.ry * inv * 0.7) * w * 1.6
}
act(Math.atan2(sy, sx), true)
asteroids/gamehoster-bots/<name>/gamehoster-bot-update.js (identical in all three folders)

The two bot-padding settings

Padding is turned on by two fields in the game configgamehoster-game-botsMax and gamehoster-game-botsZeroAt — alongside the bot rate. (They used to sit in the instance schema; in schema v2 the padding fields moved to the game config, where the bot facility itself is configured.) Nothing else in the game changed.

  "gamehoster-game-botsMax": 5,
  "gamehoster-game-botsZeroAt": 20
asteroids/gamehoster-game.json (the two bot-padding fields)
  "gamehoster-game-botRate": 5,
  "gamehoster-game-warmInstances": 1
asteroids/gamehoster-game.json (the bot rate, and one always-on warm arena)

With botsMax 5 and botsZeroAt 20 the arena holds up to five bots and sheds one for every four people who join, reaching zero at twenty. When nobody is present it does the opposite of draining — it stays full at five, and gamehoster-game-warmInstances = 1 keeps one such arena alive at all times, so Asteroids is always running: a monitorable, warmed-up game that the first arriving player drops straight into. The full rules — the target formula, the one-bot-per-pass easing, weighted selection and how a joining human bumps a bot from a full arena — are on the Bots usage page.