Testing a game with playtest bots
Net Forge, the open-source multiplayer browser game engine built for AI coding agents, is published on npm under @netforge with source at github.com/netnetdev/net-forge.
engine.test.run plays your declared game with bots, headless, at full speed, and reports what happened: kills, deaths, waves, levels reached, which earn rules fired, whether the game softlocked, the server cost per tick and design metrics such as how long until the first threat. In a project made with npm create netforge, npm test runs it on your src/game.ts, and npm run check prints the report as JSON.
npm test
npm run checkNo browser, server or network is involved, so it takes seconds and is the check a coding agent should run after every edit.
What the report contains
| Field | Meaning |
|---|---|
kills, deaths, wave, maxLevel | What the bots achieved over the run. |
runs.won, runs.lost | Runs that ended in a win or a loss. |
earned | How many times each earn rule paid (play money). |
earnEmitted | Earn events the game emitted. |
softlock | true if 30 seconds of game time passed with no kill and no hit. |
msPerTick, p99MsPerTick | Server cost of one simulation tick, mean and 99th percentile. The budget at 30 Hz is 33 ms. |
peakEnemies | The most enemies alive at once. |
fun | Design metrics: seconds to the first threat and the first reward, level at 1, 3, 6 and 9 minutes, deaths in the first minute, share of spawns that appeared on screen, share of deaths that were telegraphed. |
rules.checks | For board genres: each rules check with a pass or fail line. |
failed | The names of assertions that failed. Empty means pass. |
Write your own assertions
import { engine } from "@netforge/engine";
const report = await engine.test.run({
ticks: 30 * 180, // three minutes at 30 Hz
bots: 4,
seed: 3,
bot: "human", // reacts 200 ms late, kites, reads cards for a second
assert: [
{ name: "nobody down in the first minute", check: (r) => (r.fun?.deathsBeforeMinute ?? 0) === 0 },
{ name: "levels come fast", check: (r) => r.maxLevel >= 5 },
{ name: "an earn rule fired", check: (r) => r.earnEmitted > 0 },
{ name: "no softlock", check: (r) => !r.softlock },
],
});
if (report.failed.length) throw new Error(`playtest failed: ${report.failed.join(", ")}`);Import your game module before the call (for example import "../src/game.ts";) so its declarations are registered. seed makes a run repeatable: the same seed and the same game play out the same way (only the timing fields depend on the machine).
Stress the server
Pass enemies to hold a crowd at a fixed size and measure what one tick costs:
import { engine } from "@netforge/engine";
const r = await engine.test.run({ ticks: 30 * 30, bots: 4, seed: 4, enemies: 2000 });
console.log(`peak ${r.peakEnemies} enemies, ${r.msPerTick.toFixed(2)} ms/tick mean, p99 ${r.p99MsPerTick.toFixed(2)} ms`);With the survivor template on an Apple M2 Pro under Node 22 this printed peak 2000 enemies, 2.22 ms/tick mean, p99 5.21 ms.
The design rules the bots measure
The engine ships a design playbook (GAME-DESIGN.md in @netforge/engine) built from measured play. Among its rules: the first reward within about 10 seconds, real danger by 0:30, nobody dies in the first minute, enemies never spawn on screen, and big hits are telegraphed for 0.5 to 1 second. The fun metrics in the report check these, so a change that makes the opening empty or unfair shows up as numbers, not as a feeling after a play session.
Bots in multiplayer
The same bots fill empty seats in rooms that ask for them (the arena shooter's players.bots). Seat bots never earn: killing one scores and appears in the kill feed but pays no earn rule, and a "top of the round" rule pays only when another person was in the round. See How to make a .io-style arena shooter.
FAQ
How do I test a multiplayer browser game without opening browsers?
Run npm test. The playtest plays your game headless with several bots at full speed and reports kills, progress, earn rules fired, softlocks and the cost per tick. Use npm run play -- --players 4 when you want to see it in real tabs.
What does softlock mean in the report?
That the run went 30 seconds of game time with nothing happening: no kill and no hit. It usually means enemies stopped spawning or cannot reach the players.
Are playtest results repeatable?
Yes. With the same seed, game and engine version, a run plays out the same way. The msPerTick timings depend on the machine.
Can a coding agent read the results?
Yes. npm run check prints the result as JSON: whether it passed, one line per problem, and the full report.