Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Bots in a room

Bots can play a room, so one person can test what four players do.

spawnite play room plays the game’s own room with no browser. It starts the room on a free port, with the rest of the environment its spawnite.room sets, and runs one bot, or --bots <n>, for --seconds of play, 60 by default. Each bot is a player in Node: it joins over the room’s WebSocket as a page does, sends the input for the ticks to the room’s next send at each of its sends, 20 a second of the room’s time, so she keeps up with a room a tool runs fast and stands while it is held, and a ping each second, and holds the world as her page’s stream holds it. It turns to the nearest living entity with health, as spawnite play profile --drive bot does on a page, and fires at it, or with a scene’s skill at the target she chose, as below. From past 12 m it walks there round walls: it sends the walk a page’s click sends, which the room paths along its navmesh, and walks again as the target moves 2 m. Within 12 m it strafes beside the target, and with none in sight it walks a circle. Where a walk leaves it standing, as in a room with no navmesh, it walks straight at the target, as the page’s bot always does: for 3 s, twice as long after each stall in a row, up to a minute.

These bots are pages with no browser, the room’s load and wire test. A seat a game fills with a bot inside the room, such as a match topped up to eight, is a bot player, addBotPlayer(step, { name }), and AI that drives a kart or lands a dragon through the input path is a room controller, addController(step, { name }); the player page covers both.

What only the game knows, its scene says in a bot, which the scene’s file exports beside its component, as it re-exports plugins. A shortened form of Holdfast’s fires whichever gun of the fire’s rack its warden holds, sends the words a player’s page sends from its menus, and walks to the fire to buy between waves:

import { WalletTrait, type SceneBot } from "@spawnite/engine/core";
import { gunList, guns } from "./guns";
import { ReadinessMachine } from "./life";
import { PhaseTrait, readPhase } from "./phase";
import { siegePlugin } from "./siege.plugin";
import { WardenTrait } from "./traits";
export const bot: SceneBot = {
weapons: gunList.map((gun) => ({
name: gun,
settings: guns[gun].settings,
})),
destination({ world, character }) {
const phase = readPhase(world.queryFirst(PhaseTrait));
const coins = character.get(WalletTrait)?.coins ?? 0;
// Beside the fire, to feed it, between waves.
if (phase === "breather" && coins >= 10) return [0, 0, 2.4];
return undefined;
},
play({ world, character, send }) {
const phase = readPhase(world.queryFirst(PhaseTrait));
const warden = character?.get(WardenTrait);
if (!warden) return;
const waiting = phase === "waiting" || phase === "over";
const { messages } = siegePlugin;
if (waiting && !character?.has(ReadinessMachine.is.ready))
send(messages.ready.name);
if (
phase === "breather" &&
warden.taken === "" &&
warden.offer.length > 0
)
send(messages.pick.name, { slot: 0 });
},
};
  • weapons lists the weapons it may fire, each by the name and settings the scene registers with registerWeapon. The bot fires the first of them her character holds, so a bot that buys a gun fires it, at the nearest target in range, at the settings’ rate as her stats raise it, from the middle of her body, at a metre over the target’s feet, or at the middle of a body the stream says is shorter than two metres. Where the page judges the weapon, as trusting the players sets, she claims the hits her copy of the world shows, as a page does.
  • destination returns a place the bot walks to over the nearest target, such as a shop it means to buy at, or nothing. It is read on each of her moves, before play. She walks there along the room’s navmesh as she walks to a target, stands once she is there, and still fires at what is in range on the way.
  • play runs on each of the bot’s moves, whatever her player controls, so a bot plays a card game as well as a shooter. Its turn holds the room’s world as her page holds it, the game’s own traits among them; player, her player entity; controlled, every entity her player controls, as the room last told her; character, the one of them with a character’s pose, or null where she controls none; input.set(handle, value), which holds one of the game’s inputs for the ticks to her next move, over the walk below; and send, which sends one of the game’s commands by its plugin.key name, with its payload where it declares one. A command declared entity: true names the entity it acts through, one of controlled: send("wave.wave", {}, { entity: character }). A room’s bot knows no declaration, so it sends no entity it is not given, and the room refuses such a command malformed; a page bot fills in her character where entity is left out. The room takes a name’s message a few times a second at most, so send one while the world says it is owed, as Holdfast says “ready” while the siege waits and she has not taken her place. The walk, the strafe, the shots, destination and dangers are the characters bot’s part, and run only where her player controls a character in a game that lists characters().
  • dangers returns the places the bot leaves, each a middle and a radius, such as the ring of a slam winding up. Once she has stood in one for her skill’s reaction, she runs straight out from its middle, still facing and firing at her target. A danger outranks her destination and her target.
  • skill says how well she aims and moves, as a person’s eyes and hands do. Without it she fires at the nearest target the moment it is in range, true to its middle, which measures the game’s own numbers but not a player’s night.

A skill takes four numbers, after the reaction time and aim focus of Counter-Strike’s bot profiles:

  • reactionSeconds: the wait from a target becoming hers to her first shot at it, and from her first step into a danger to her first step out. She keeps her target until it falls, leaves her weapon’s range, or another stands within 60% of its distance.
  • trackingSeconds: how far her aim trails a moving target. She aims where it stood that long ago, so a fast one crossing her pulls her shots behind it.
  • spreadRadians: how far each shot strays, to each side and up or down, as the deviation of a normal draw. A target twice as far is missed about twice as wide.
  • backAwayMetres: within this distance of the nearest target she backs away as she strafes, deciding again once a reaction has passed. Left out, she holds her ground.

Every stray comes from the bot’s own random stream, which the run’s --seed starts, mixed with which bot she is, so a bot on one seed strays alike in every run. Holdfast’s decentWarden in its bot is a worked example: a person’s wait and lag, and a stray calibrated so that one bot alone falls around the wave where a measured player did.

A scene with no bot plays with the walk and the aim alone, and fires nothing. The command prints what each bot did, with the bytes and frames the room sent her a second and the share of its deltas each trait took, largest first, and a table of the run: a row for each part of it, such as each wave, with how long the part lasted, what the scene’s timeline recorded in it, the room’s step and the bytes a page received a second. --bots 1-4 --seed 1-3 --speed 8 plays each bot count over each seed, faster than real time, and adds a table over the runs of each count. Measuring runs says how a scene declares its timeline and how to read the tables:

$ pnpm spawnite play room --seconds 30 --speed 4 --seed 1
Played the room of ./src/scenes/Holdfast.tsx once with no browser, asked to run at 4× real time.
1 bot, seed 1: stopped after its 0:30 of play, at wave 2, phase fight (4.0× real time).
Bot 1, character 15: 603 moves, 38 shots, 34 hits for 304 damage, walked 153 m; sent siege.pick ×2, siege.ready ×4. The room sent her 1,790 B/s in 21 frames; its deltas spent facing 23%, transform 21%, velocity 17%, input 13%, events 11%, phase 4%, steer 3%.
wave phase starts lasts kills downs coins monsters peak step ms longest B/s a page peak B/s
0 waiting 0:00 0:05 0 0 0 0 0.48 9.63 1,634 3,124
0 breather 0:05 0:03 0 0 0 0 0.40 2.40 1,370 1,639
1 fight 0:08 0:11 5 0 2 1 0.36 9.99 1,812 2,885
1 breather 0:19 0:03 0 0 4 0 0.32 7.17 1,812 2,885
2 fight 0:22 0:08 4 0 0 1 0.29 7.73 1,942 2,175
run 0:00 0:30 10 0 6 1 0.36 9.99 1,770 3,124
Kept in …/spawnite/room-runs/Holdfast-2026-09-28T10-50-29-319Z.json: spawnite play room report reads it again.
The room recorded run 20260928-045018-z7k1: spawnite replay state --run 20260928-045018-z7k1 --at <m:ss> prints its world at a moment of play, and spawnite replay check --run 20260928-045018-z7k1 checks that a continue of it comes back exactly.

--invulnerable spares every character, so the bots play on through a long run, and --room-env NAME=VALUE sets the room’s environment over its target’s, such as ROOM_REPLAY=0. --latency, --jitter and --stall play each bot over a slow link, in the game’s time. BOT_AIM_ZONE in the command’s own environment names a hit zone each bot aims at on a target that declares one, such as BOT_AIM_ZONE=head pnpm spawnite play room, or in PowerShell $env:BOT_AIM_ZONE = "head"; pnpm spawnite play room; Remove-Item Env:BOT_AIM_ZONE, so a pass measures what weak spot hits do to the game beside a pass with none; a target without that zone is aimed at as before. BOT_SKILL sets numbers over the scene bot’s skill as JSON, such as BOT_SKILL='{"spreadRadians":0.06}', or none for the perfect aim, so a pass calibrates the skill without an edit to the scene. --json carries each run’s bots, parts and costs. A dev room records while a bot is in it, as it does for a page, so spawnite replay check --run checks a run no browser played. A run played with --invulnerable or --room-env checks too: the run’s record keeps the settings that change what the room does with an action, such as its players and its send rate, and the check continues it on those rather than on the game’s own.

The room runs under the heap limit of the platform’s rooms, 259 MB, so a game that keeps more memory every wave fails here the way it would in production, with Node’s JavaScript heap out of memory in the room’s output. The run’s output names the limit in force. --room-heap <megabytes> raises the room’s old space past its 256 MB, and --room-heap none lifts the limit.

Unity’s Netcode for Entities runs thin clients, headless clients that send the input a system of the game’s writes and simulate nothing, and Unreal’s Gauntlet starts a server and clients with no renderer, each played by a controller the game writes. Godot’s --headless runs the whole game, and a bot is the game’s own script. Roblox runs local clients in Studio, each a window. Here the bot is the platform’s, so every room game has one with no code, and the scene adds only the weapons, the words and the places the platform cannot know.