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 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
}
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
}
This is the state, and what's kept on the server and never sent:
| Object | Sent to players | Kept on the server |
|---|---|---|
| Instance | The arena's size, the physics, the view radius, and the leaderboard as ten name and score pairs | The spatial grids, and the leaderboard as it was last sent |
| Ship | Name, colour, x, y, angle, thrusting, alive, spawn, score and health | Its velocity, whether thrust is held, when it respawns, and when it fires |
| Rock | x0, y0, vx, vy, spawnTick, size and seed | Its radius |
| Bullet | x0, y0, angle and spawnTick | Which ship fired it |
| Hit, dust, explosion | Where and when it started, and a velocity for dust and explosions |
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
})
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 })
The instance update
The instance update is where most of the work is done. It runs in seven steps each tick:
- Remove the bullets that have flown for their 46 ticks, and the effects whose animation has finished.
- Bring the spatial grids up to date, working out where every rock, bullet, ship and effect is now.
- Collide the rocks with bullets and ships, then the bullets with ships, and add up the scores.
- Bring back the destroyed ships whose two seconds are up, at a spot clear of the rocks.
- Fire a bullet from each ship whose turn it is.
- Top up the rocks.
- 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(),
...
})
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)
}
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 }
}
}
}
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 }
}
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)
}
}
}
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) })
...
})
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 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) }
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) }
}
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"))
}
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)
}
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))
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:
| Kind | Weight | Chance of hunting |
|---|---|---|
| Drifter | 3 | 0, never |
| Ace | 2 | 0.4 |
| Sniper | 1 | 0.15 |
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 })
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 })
}
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 }
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
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
}
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)
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)
}
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:
| Message | Direction | What it carries |
|---|---|---|
| hello | Client to server | The first message: the protocol version, the schema hash and the join fields |
| welcome | Server to client | The first reply: the server's schema hash and the instance's id |
| cmd | Client to server | Every message after the hello: one command |
| update | Server to client | Every message after the welcome: what has changed for this player |
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:
| Field | Range | Steps | Bits |
|---|---|---|---|
| x, x0 | 0 to 3,600, to 1 decimal place | 36,001 | 16 |
| y, y0 | 0 to 2,400, to 1 decimal place | 24,001 | 15 |
| angle | −3.15 to 3.15, to 3 decimal places | 6,301 | 13 |
| health | 0 to 100, to 1 decimal place | 1,001 | 10 |
| vx, vy | −3 to 3, to 2 decimal places | 601 | 10 |
| color | 0 to 32,767 | 32,768 | 15 |
| seed | 0 to 1,000,000 | 1,000,001 | 20 |
| size | 1 to 3 | 3 | 2 |
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:
| Command | Fields | Bits | Bytes |
|---|---|---|---|
| aim | 1 bit for the command, a 32-bit float for the angle | 33 | 5 |
| thrust | 1 bit for the command, 1 bit for on or off | 2 | 1 |
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:
| Field | Bits |
|---|---|
| Ship or entity | 1 |
| Entity type | 3 |
| x0 | 16 |
| y0 | 15 |
| vx | 10 |
| vy | 10 |
| spawnTick | 16, or 24 far from when you joined |
| size | 2 |
| seed | 20 |
| Total | 93, just under 12 bytes |
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:
| Field | Bits |
|---|---|
| Object number | 6 |
| Which fields are included | 10 |
| x | 16 |
| y | 15 |
| angle | 13 |
| thrusting | 1 |
| health | 10 |
| Total | 71, just under 9 bytes |
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