JavaScript Games

Asteroids is a multiplayer arena for up to 50 ships at once. Everyone flies in the same wrap-around field of rocks, shooting the rocks and each other, with a live leaderboard of the top ten. It runs on Gamehoster, my realtime game server. The game itself is a folder of small JavaScript and JSON files, and Gamehoster runs them, keeps the players in step, and streams the game to every browser.

This page goes through the game, then the server half of it, then the front end and the bots, and then the binary messages that go between the server and the browser.

The game

The arena is 3,600 by 2,400 pixels and wraps at the edges, so flying off one side brings you in at the other. It's bigger than the screen: the camera follows your ship, with a minimap of the whole arena at the top right and the leaderboard under it.

There are only two controls. The ship points at the mouse pointer, and holding the button thrusts it that way. Let go and it coasts, slowing a little all the time. There's no fire button: every ship fires three times a second, all the time, so playing is all about where you point and where you fly.

Rocks come in three sizes. A hit splits a big rock into two medium ones and a medium rock into two small ones, and destroys a small one. Touching a rock of any size destroys your ship. Bullets from other ships do 20 damage each, against 100 health that recovers at a point a second, so it takes six hits close together to destroy a ship. Every rock you hit scores 1 and every ship you destroy scores 5. When you're destroyed, your score goes back to zero and you come back two seconds later, somewhere clear of the rocks.

The arena is kept stocked with rocks. Each size has a weight, 4 for a big rock, 2 for a medium one and 1 for a small one, and whenever the total falls below 196, new big rocks are added away from the ships to bring it back up to 200.

It's never empty, either. When there are only a few people playing, the server adds bots, up to 20 of them, and takes them away one at a time as people join, until there are none once 20 people are playing. A bot is a player like any other: it reads the game and flies its ship by sending the same two commands that a person's mouse sends. There are three kinds, and they differ only in how often they break off from shooting rocks to chase another ship.

Built on Gamehoster

A Gamehoster game is a folder, and the name of each file in it says what it is: a schema for part of the state, or a body of JavaScript that runs at a particular moment. Asteroids is 40 files:

asteroids/
├── gamehoster-game.json                    # The rates, the capacity and the bots
├── gamehoster-instance/
│   ├── gamehoster-instance-schema.json     # The arena: its size, the physics and the leaderboard
│   ├── gamehoster-instance-create.js       # Builds the spatial grids when an arena opens
│   └── gamehoster-instance-update.js       # The arena's tick: collisions, respawns, firing, rocks
├── gamehoster-player/
│   ├── gamehoster-player-schema.json       # A ship
│   ├── gamehoster-player-join-schema.json  # What a player sends to join: a name
│   ├── gamehoster-player-join.js           # Seats a player in an arena and sets up their ship
│   ├── gamehoster-player-leave.js          # Removes a leaving player's bullets
│   ├── gamehoster-player-update.js         # A ship's tick: thrust, friction and movement
│   ├── gamehoster-player-view.js           # Which objects each player is sent
│   ├── gamehoster-player-predict.js        # In the browser: your own ship, ahead of the server
│   └── gamehoster-player-smooth.js         # In the browser: other ships between updates
├── gamehoster-commands/
│   ├── aim/                                # Point the ship
│   └── thrust/                             # Thrust on or off
├── gamehoster-entities/
│   ├── rock/
│   ├── bullet/
│   └── hit/, dust/, explosion/             # The effects
├── gamehoster-frontend/                    # The setup, the three inputs and the drawing
└── gamehoster-bots/
    └── ace/, sniper/, drifter/             # The three kinds of bot
The game's folder (trimmed)

The bodies that run on the server are the authority: they're the only place the game is simulated. The ones in gamehoster-frontend/, and the predict and smooth bodies, run only in the browser. Gamehoster builds a client from the folder, gamehoster.client.js, with the schemas, the browser bodies and the binary codec built in, and that's all the page has to load. Gamehoster's files reference lists every file a game can have.

The game's settings are in gamehoster-game.json:

{
  "gamehoster-game-title": "Asteroids",
  "gamehoster-game-browse": "none",
  "gamehoster-game-create": "none",
  "gamehoster-game-tickRate": 30,
  "gamehoster-game-sendRate": 15,
  "gamehoster-game-capacity": 50,
  "gamehoster-game-botRate": 15,
  "gamehoster-game-warmInstances": 1,
  "gamehoster-game-botsMax": 20,
  "gamehoster-game-botsZeroAt": 20
}
30 ticks a second, 15 updates a second to each player, 50 players to an arena, and up to 20 bots

Players don't choose an arena. The join body puts each new player into the first arena with room and opens a new one when they're all full, and one arena is kept running at all times, so there's always a game to drop into.

The server

Each arena is an instance, and the server moves every instance on by one tick 30 times a second. A tick has three steps: the commands that have arrived since the last tick, then each ship's own update, and then the instance update, which does everything that involves more than one object.

The state

Everything that's sent to players is declared in a schema, field by field, with a type and, for a number, its range and how many decimal places matter. The range and the decimals are what the protocol packs the value into, as the protocol section below shows. Here's a ship's x:

{
  "gamehoster-player-schema-name": "x",
  "gamehoster-player-schema-type": "number",
  "gamehoster-player-schema-default": 0,
  "gamehoster-player-schema-visibility": "public",
  "gamehoster-player-schema-wrap": "arenaW",
  "gamehoster-player-schema-min": 0,
  "gamehoster-player-schema-max": 3600,
  "gamehoster-player-schema-decimals": 1
}
A ship's x: 0 to 3,600 to one decimal place, wrapping at the arena's width

This is the state, and what's kept on the server and never sent:

ObjectSent to playersKept on the server
InstanceThe arena's size, the physics, the view radius, and the leaderboard as ten name and score pairsThe spatial grids, and the leaderboard as it was last sent
ShipName, colour, x, y, angle, thrusting, alive, spawn, score and healthIts velocity, whether thrust is held, when it respawns, and when it fires
Rockx0, y0, vx, vy, spawnTick, size and seedIts radius
Bulletx0, y0, angle and spawnTickWhich ship fired it
Hit, dust, explosionWhere and when it started, and a velocity for dust and explosions
What the game's state holds

Keeping state off the wire is the first saving. A ship's velocity never needs to be sent: other ships are drawn from their positions, and your own ship's velocity is worked out in your browser. A ship's colour is one of ten, packed into a 15-bit number with five bits each for red, green and blue.

Rocks and bullets

A rock moves in a straight line at a constant speed, so where it is at any tick is where it started plus its velocity times its age, wrapped round the arena. Its state is set when it's spawned and never changes:

const id = Gamehoster.Entity.Spawn("rock", {
  x0: x, y0: y, vx: vx, vy: vy,
  size: size, r: r, seed: Math.floor(Gamehoster.Entropy() * 1e6), spawnTick: tick
})
Spawning a rock: a start, a velocity, a size, a seed and the tick it started on

So a rock is sent to a player once, when it first comes into view, and after that the browser works out where it is for itself. Its radius, r, isn't in the rock's schema, so it stays on the server, where the collisions use it; the browser gets the radius from the size. A rock doesn't send its shape or how it spins either, because the browser makes both from the seed.

A bullet doesn't even send a velocity. Every bullet flies at the same speed, 9 pixels a tick, so its angle is enough, and the server and the browser both work its velocity out from that. Which ship fired it is kept on the server too, so that a ship's own bullets can't hit it and the kill is scored to the right ship:

const bid = Gamehoster.Entity.Spawn("bullet", { x0: ox, y0: oy, angle: a, owner: pid, spawnTick: tick })
Firing a bullet from the nose of the ship

The instance update

The instance update is where most of the work is done. It runs in seven steps each tick:

  1. Remove the bullets that have flown for their 46 ticks, and the effects whose animation has finished.
  2. Bring the spatial grids up to date, working out where every rock, bullet, ship and effect is now.
  3. Collide the rocks with bullets and ships, then the bullets with ships, and add up the scores.
  4. Bring back the destroyed ships whose two seconds are up, at a spot clear of the rocks.
  5. Fire a bullet from each ship whose turn it is.
  6. Top up the rocks.
  7. Update the leaderboard.

Each ship fires every 10 ticks, but each has its own turn, picked at random when it joins, so the bullets of 50 ships are spread over the ticks instead of all leaving at once.

Spatial grids

The simple way to find collisions is to test every bullet against every rock. With 50 ships each firing three times a second, and bullets that last about a second and a half, there are around 230 bullets in flight, and there are from 50 to 200 rocks depending on their sizes. That would be from about 10,000 to 45,000 tests every tick.

Instead the arena is divided into cells 100 pixels square, 36 across and 24 down, and there's a grid of them for each of the rocks, bullets, ships and effects. Each cell holds a list of what's in it. The grids are made when the arena opens and kept in the instance's persistent state, which stays on the server from one tick to the next and is never sent:

const CELL = 100
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(),
  effect: emptyGrid(),
  ...
})
Making the four grids when an arena opens (trimmed)

Each object in a grid has a record, which holds where the object is this tick. The second step of the update works out each position once, and moves a record to another cell only when the object has crossed into it:

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) } }
...
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)
}
Finding an object's cell, and moving each rock or bullet on to where it is this tick (trimmed)

Then each rock only looks in the cells around it. For bullets, that's the cells within the rock's radius plus the distance a bullet travels in a tick. The test takes the bullet's whole path over the coming tick and finds the point on it closest to the rock's centre, so a bullet can't pass through a rock between one tick and the next:

const hitR = rr + 5, hit2 = hitR * hitR
const extB = hitR + bSpeed + 1
...
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 }
    }
  }
}
Testing a rock against the bullets in the cells around it (trimmed)

The search around a big rock covers four to nine cells, and with 230 bullets spread over 864 cells, the rock is tested against a few bullets rather than all of them. Rocks against ships, and bullets against ships, work the same way: each looks only in the cells near it, and a ship skips its own bullets.

New objects go into their grid as soon as they're made. When a rock splits, its two halves are added straight away, though they aren't tested until the next tick, and a new bullet is added at once so that it's in the very next update.

Reading and writing once

The game's state is read with Get and written with Set, a field at a time. Writes are held until the body finishes and then applied together, and that's how the server knows which fields have changed. Calling Get for every field of every ship adds up, so the update reads each kind of object in one go, with Player.All and Entity.All, and takes all its values from that. Scores are added up in a map while the collisions run, so each ship's score is written once, however many hits it made.

The leaderboard is worked out every 6 to 24 ticks, a random gap each time, rather than every tick. The board as it was last sent is kept in the persistent state, and only the names and scores that have moved are written, so the players are sent the slot that changed rather than the whole board:

for (let i = 0; i < 10; i++) {
  const name = i < rank.length ? rank[i].name : ""
  const score = i < rank.length ? rank[i].score : 0
  if (name !== prev[i].name) { Gamehoster.Instance.State.Set(here, "lb" + i + "name", name); prev[i].name = name }
  if (score !== prev[i].score) { Gamehoster.Instance.State.Set(here, "lb" + i + "score", score); prev[i].score = score }
}
Writing only the leaderboard slots that have changed

What each player is sent

Each player can only see part of the arena, so each is sent only what's near them. The view body runs for every player before each update and marks the objects within 940 pixels of their ship, which is the view radius of 820 and a margin, so things are already there when they come on screen. That's about a third of the arena.

The view doesn't work out any positions. They were all worked out once this tick by the instance update, and they're on the grid records, so all the view has to do is measure distances, the short way round the arena, for each of the 50 players:

const reach = Gamehoster.Instance.State.Get(here, "viewRadius") + 120
const r2 = reach * reach

const grid = Gamehoster.Instance.Persistent.Get()
if (grid) {
  const meState = Gamehoster.Player.All().get(me)
  const cx = meState ? meState.x : 0, cy = meState ? meState.y : 0
  const grids = grid.effect ? [grid.ship, grid.rock, grid.bullet, grid.effect] : [grid.ship, grid.rock, grid.bullet]
  for (let gi = 0; gi < grids.length; gi++) {
    for (const rec of grids[gi].known.values()) {
      let dx = rec.x - cx; if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W
      let dy = rec.y - cy; if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H
      if (dx * dx + dy * dy <= r2) Gamehoster.View.Include(rec.id)
    }
  }
}
The view: everything within reach of this player's ship, from the positions already worked out this tick

Gamehoster then builds each player's update from three lists: what has come into view, with all of its state; what has gone; and the fields that have changed of what's still in view. It knows which fields have changed without comparing anything, because every Set marks its field as changed. A rock or a bullet is never written after it's spawned, so it's never sent again, while a ship's position is written every tick and goes in every update. Gamehoster's instance reference goes through the tick, the change tracking and the bots in full.

The front end

The page

asteroids.net is a single page with a canvas that fills the window. It loads the game's client from the game server and starts it, with the canvas and a name:

var local = /^(localhost$|127\.|\[?::1|$)/.test(location.hostname)
if (!local) window.GAMEHOSTER_SERVER = 'wss://game.asteroids.net'
var httpBase = local ? '' : 'https://game.asteroids.net'
...
load(httpBase + '/asteroids/gamehoster.client.js', function () {
  Gamehoster.start('game', { name: 'Pilot' + Math.floor(Math.random() * 1000) })
  ...
})
Starting the game: the client comes from the game server, and so does the WebSocket (trimmed)

The client opens a WebSocket to the server, and from then on the game's front-end bodies do the rest. The setup body runs once and gets the canvas ready, the input bodies run on mouse events, and the render body draws every frame. The join body on the server makes sure the name is unique in the arena, and changes the number on the end until it is.

What it sends

A browser sends the server just two things, both from the mouse:

// gamehoster-frontend-input-mouse-move.js
var a = Gamehoster.Mouse.angle
Gamehoster.Command('aim', { angle: a })
Gamehoster.Input.Set('angle', a)

// gamehoster-frontend-input-mouse-button-left-press.js
Gamehoster.Command('thrust', { on: true })
Gamehoster.Input.Set('thrusting', true)

// gamehoster-frontend-input-mouse-button-left-release.js
Gamehoster.Command('thrust', { on: false })
Gamehoster.Input.Set('thrusting', false)
The three input bodies: moving the pointer aims, and the button holds thrust on and off (trimmed)

The aim command is held to 20 a second by the front end's settings, so moving the mouse doesn't flood the socket, and thrust is only sent when the button goes down or up. Each input is also handed to the prediction, so that your own ship responds straight away.

On the server, each command has a handler that checks the ship is alive and stores the value. A command can only write the fields it lists, so aim can change the ship's angle and nothing else, and thrust only sets a flag in the ship's server-side state.

What it receives

Updates arrive 15 times a second, and the client keeps a store of every object it can see. It draws a little behind the newest update, about one and a half updates behind, so that there's normally a later update to move towards, and it eases each ship's numbers between the updates either side. The schema says x and y wrap, so a ship crossing the edge of the arena is eased the short way, across the edge, rather than all the way back across the arena.

Rocks, bullets, dust and explosions are drawn from their start, velocity and spawn tick, against the same clock, which is the same sum the server does:

var ballX = function (e) { return wrap(e.x0 + e.vx * (T - e.spawnTick), AW) }
var ballY = function (e) { return wrap(e.y0 + e.vy * (T - e.spawnTick), AH) }
Where a rock is on this frame, from what it was sent when it came into view

Gamehoster's front-end reference has the store, the clock and the prediction in full.

Your own ship

Waiting for the server would make your own ship slow to respond, so it's predicted. Every tick, the predict body runs the same thrust and friction as the server's ship update, using your live mouse input, so the ship moves the moment you do. Then it pulls the result a fifth of the way towards the server's position, moved on by how far ahead the prediction runs:

const ax = Gamehoster.Authoritative.Get(me, "x"), ay = Gamehoster.Authoritative.Get(me, "y")
if (ax != null) {
  const lead = Gamehoster.Lead()
  const tx = wrap(ax + vx * lead, W), ty = wrap(ay + vy * lead, H)
  const ex = wrapDelta(tx, x, W), ey = wrapDelta(ty, y, H)
  if (ex * ex + ey * ey > 240 * 240) { x = ax; y = ay; vx = 0; vy = 0 }
  else { x = wrap(x + ex * 0.2, W); y = wrap(y + ey * 0.2, H) }
}
Pulling the predicted ship towards the server's position, or jumping to it after a respawn

When the two agree, there's almost nothing to correct. When they're far apart, as they are after a respawn on the other side of the arena, the ship jumps straight there.

Other ships

Other ships are eased between updates, but easing a ship that respawns would slide it right across the arena, from where it was destroyed to where it comes back. So a ship has a spawn bit, which the server flips every time it respawns, and the smooth body snaps the ship's position until the eased bit has caught up with the latest one:

if (Gamehoster.Smooth("spawn") !== Gamehoster.Latest("spawn")) {
  Gamehoster.Set("x", Gamehoster.Latest("x"))
  Gamehoster.Set("y", Gamehoster.Latest("y"))
}
Snapping a ship that has just respawned, instead of sliding it across the arena

The same body eases each ship's thrust flame, quickly on and slowly off, so that it doesn't flicker.

Drawing

The render body draws the arena with the camera on your ship, zoomed so that at most four and a half of the 200-pixel grid squares fit across the shorter side of the window. A rock's outline comes from its seed and a small random number generator, 9 to 12 points at varying distances from the centre. How fast it spins comes from the seed too, so every browser draws the same rock the same way, from a 20-bit number:

function rockShape (seed, r) {
  if (shapeCache[seed]) return shapeCache[seed]
  var s = seed >>> 0
  var rand = function () { s = (Math.imul(s ^ (s >>> 15), 1 | s) + 0x6d2b79f5) >>> 0; return (s >>> 8) / 16777216 }
  var n = 9 + Math.floor(rand() * 4), pts = []
  for (var i = 0; i < n; i++) { var a = (i / n) * TAU, rad = r * (0.72 + rand() * 0.42); pts.push([Math.cos(a) * rad, Math.sin(a) * rad]) }
  return (shapeCache[seed] = pts)
}
A rock's outline from its seed, made once and kept

An explosion's puffs are random, chosen once when the explosion appears and then played through against the clock. Your score, your best score and your last one are kept in the browser's local storage.

Bots

An arena is never empty, because the players are padded out with bots. A bot is a player without a socket. It has a ship like anyone else's, it's sent to the other players like anyone else, and it flies by sending the same aim and thrust commands, which go through the same handlers. Nothing in the game's own code knows which ships are bots.

How many bots

The number of bots depends on how many people are in the arena. With one person playing there are 20 bots, and there's one fewer for each person who joins, down to none once 20 people are playing. An arena with nobody in it is kept full of bots too, so there's always a game going on when someone arrives:

let t = h === 0 ? M : (Z > 0 ? Math.max(0, M - Math.floor(h * M / Z)) : M)
const cap = this.game.config['gamehoster-game-capacity'] || 0
if (cap) t = Math.min(t, Math.max(0, cap - h))
Gamehoster's target for the number of bots, where M is the most bots, 20, Z is the number of people at which there are none, also 20, and h is the number of people in the arena (trimmed)

Bots never take a place a person could have, and when someone joins and every arena is full, a bot is removed to make room. The bots run on their own timer, 15 times a second, and each time the count moves one bot towards the target, so bots come and go one at a time rather than all at once.

A new bot is one of three kinds, picked at random by a weight in each kind's folder. All three run the same code, and they differ only in how likely they are to go after other ships:

KindWeightChance of hunting
Drifter30, never
Ace20.4
Sniper10.15
The three kinds of bot: half are drifters, a third are aces and a sixth are snipers

When a bot joins, its kind's join body sets up its memory, which only the bot itself can see:

Gamehoster.Bot.Persistent.Set({ attackChance: 0.4, wasAlive: true, facing: null, target: null })
The ace's memory, with its chance of hunting

How a bot flies

Each time it runs, a bot reads the arena the same way the instance update does, with Player.All and Entity.All, and decides what to do. It can't change the game: all it can do is send commands. It goes through four things in order and stops at the first one that applies: get out of the way of a rock, hunt a ship, shoot a rock, or travel.

First it finds the rocks within 900 pixels, and where each one is now relative to its own 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 })
}
The rocks near the bot, relative to its ship

Staying alive comes first. If a rock is within 55 pixels of touching the ship, the bot turns straight away from it and thrusts. When there's more than one, it picks the most urgent: the closest, counting how fast each one is closing in:

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 }
Getting out of the way of the rock that will reach the ship soonest

Next, a bot that hunts looks for the nearest living ship within 520 pixels. Each time it runs, it has a small chance of picking that ship as its target, and once it has one it keeps it for 40 ticks, a little over a second. While it has a target, it points at it and stops thrusting, and the automatic fire does the rest:

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
Picking a ship to hunt now and then, and keeping after it for a second or so (trimmed)

Then the rocks. If one is within 170 pixels of the ship and roughly ahead of it, within about 40 degrees of the way it's moving, the bot stops thrusting and aims where its bullets will meet the rock. It works out how long a bullet would take to reach the rock, moves the rock on by that long, and does that twice more to get closer to the right answer:

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
}
Aiming ahead of a rock, at where the bullets will reach it

Otherwise it travels. It keeps a destination, somewhere at least 600 pixels away, heads for it and thrusts, and picks a new one when it gets within 160 pixels. Any rock close by and roughly ahead bends its course: it's pushed sideways round the rock and a little away from it, more strongly the closer the rock is:

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)
Heading for the destination, and steering round the rocks in the way

Whatever it decides goes through one function, act. The nose turns at most 20 degrees each time, and once it's within 30 degrees of where it's going it closes half the remaining gap, so it turns like a ship rather than snapping round. Aim is only sent when the heading has changed by more than about a degree, and thrust only when it changes, so a bot sends about as many commands as a person does:

function act(aim, thrust) {
  if (Number.isFinite(aim)) {
    if (mem.facing == null) mem.facing = aim
    else {
      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)
}
Turning the nose smoothly, and sending only the commands that change anything

While its ship is destroyed, a bot just coasts, and when it comes back it forgets its destination and sets off afresh from wherever it has reappeared.

The protocol

The server and the client talk over one WebSocket, in binary. Every message is a single stream of bits, and every value takes exactly as many bits as its range needs, so a field that holds 1 to 3 takes 2 bits. Both sides build their tables from the same schemas, and when the client connects they check that they agree, with a hash of the schemas. There are four messages:

MessageDirectionWhat it carries
helloClient to serverThe first message: the protocol version, the schema hash and the join fields
welcomeServer to clientThe first reply: the server's schema hash and the instance's id
cmdClient to serverEvery message after the hello: one command
updateServer to clientEvery message after the welcome: what has changed for this player
The four messages

Gamehoster's protocol reference has the layout of each of them in full. Here's what they carry in Asteroids.

Packing values

A number with a range and a number of decimal places is stored as a whole number of steps up from its minimum, in just enough bits for the number of steps there are. A ship's x is 0 to 3,600 to one decimal place, which is 36,001 steps, so it takes 16 bits:

FieldRangeStepsBits
x, x00 to 3,600, to 1 decimal place36,00116
y, y00 to 2,400, to 1 decimal place24,00115
angle−3.15 to 3.15, to 3 decimal places6,30113
health0 to 100, to 1 decimal place1,00110
vx, vy−3 to 3, to 2 decimal places60110
color0 to 32,76732,76815
seed0 to 1,000,0001,000,00120
size1 to 332
The ship and rock fields, and the bits each one packs into

A true or false is one bit. A spawn tick is sent counting from the tick the player joined on, in a byte if it's small and a byte more for each seven bits after that, so two bytes cover four and a half minutes either side of joining. A number with no range is a 32-bit float. Gamehoster's types reference has all the types and the ranges they take.

Joining

The hello is the protocol version in 8 bits, the schema hash in 32, and then the join fields, which in Asteroids is just the name, as its length and its bytes. A pilot called Pilot123 sends a 14-byte hello. The welcome that comes back is 8 bytes: the server's schema hash, for the client to check against its own, and the id of the arena it has been put in.

Commands

A command is its number, in just enough bits to pick one of the game's commands, and then its values. Asteroids has two commands, so one bit says which:

CommandFieldsBitsBytes
aim1 bit for the command, a 32-bit float for the angle335
thrust1 bit for the command, 1 bit for on or off21
The two commands on the wire

The aim command's angle has no range in its schema, so it's sent as a float. Commands aren't numbered on the wire: both sides count them, and each update says how many of yours the server has applied, which is how the client knows when the server has caught up with what you did.

Updates

An update starts with 8 bits for how many ticks have passed since the last one and 8 bits for how many of your commands have been applied. Then come three lists, each starting with its count: the objects that have gone, the objects that have come into view, and the objects that have changed.

Objects aren't sent with their ids. Each player's objects are numbered from 0, with no gaps: 0 is always the arena, 1 is always your own ship, and the rest follow in the order they came into view. When an object goes, the last one moves into its number, and both sides do the same, so they always agree. A number then takes only as many bits as there are objects in view: 6 bits for up to 64.

An object coming into view is 1 bit for whether it's a ship or an entity, then for an entity 3 bits for which of the five kinds it is, and then all of its state. A rock:

FieldBits
Ship or entity1
Entity type3
x016
y015
vx10
vy10
spawnTick16, or 24 far from when you joined
size2
seed20
Total93, just under 12 bytes
A rock coming into view, which is the only time it's sent

A bullet coming into view is 64 bits, 8 bytes: the same 4 bits to say what it is, then its start, its spawn tick and its angle. A ship coming into view is 1 bit and then all ten of its fields.

A changed object is its number, then a bit for each of its fields to say whether that field is included, then the fields that are. A ship has 10 fields. A flying ship's position, health and thrust bit are written every tick, and its angle whenever it turns:

FieldBits
Object number6
Which fields are included10
x16
y15
angle13
thrusting1
health10
Total71, just under 9 bytes
A ship that is flying and turning, in one update

So each ship in view costs about 9 bytes an update. An update with nothing in it isn't sent at all, apart from one every so often to keep the tick count from passing 255. The arena's own fields, the leaderboard among them, are changes to object 0: it has 41 fields, so that's a 41-bit mask and then just the slots that moved.

See also

  • Asteroids - The game, playable in the browser at asteroids.net
  • Gamehoster - How the game engine and server behind it work
  • Gamehoster - Realtime multiplayer rooms, hosted on your own server