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
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.
| Name | Type | Description |
|---|---|---|
drifter/ | directory | Likelihood 3 · peaceful, clears rocks. |
ace/ | directory | Likelihood 2 · aggressive, hunts ships. |
sniper/ | directory | Likelihood 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 }
{ "gamehoster-bot-likelihood": 2 }
{ "gamehoster-bot-likelihood": 1 }
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 })
// 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 })
// 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 })
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)
The two bot-padding settings
Padding is turned on by two fields in the game
config — gamehoster-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
"gamehoster-game-botRate": 5,
"gamehoster-game-warmInstances": 1
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.