# How to test a multiplayer game locally with bots and play money

> Open one room in several browser tabs with npm run play, let phones on your network join, and run headless playtest bots with npm test and npm run check. Everything runs on your computer with play money.

Net Forge, the open-source multiplayer browser game engine built for AI coding agents, is published on npm under `@netforge` with source at https://github.com/netnetdev/net-forge.

Source: https://forge.netnet.capital/docs/guides/local-multiplayer-testing  
Last updated: 2026-10-23. Checked against @netforge/engine 0.2.0.

To test a Net Forge multiplayer game on your own computer, run `npm run play -- --players 2` to open two players in one room, and `npm test` to have bots play it headless and report what broke. Both run entirely on your machine: no wallet, no chain, no Net Forge servers, and coins are play money.

```sh
npm create netforge@latest my-game -- --template arena --yes
cd my-game
npm run play -- --players 2
npm test
```

## The tools

| Command | What it does |
|---|---|
| `npm run play` | Builds the client with your `src/game.ts`, starts the local room host and opens `http://127.0.0.1:4663` |
| `npm run play -- --players 3` | Opens three tabs, each a separate player in the same room |
| `npm run play -- --host 0.0.0.0` | Lets phones and laptops on your network join |
| `npm run play -- --port 5000 --no-open` | Another port, and print the link instead of opening a browser |
| `npm test` | Compiles `src/game.ts` as the play build does and plays it headless with bots |
| `npm test -- --minutes 5 --bots 4 --seed 2` | A longer run, more bots, another seed |
| `npm run check` | The same playtest, printed as JSON for scripts and coding agents |

## 1. Play several players at once

Each tab or device that opens the link is its own player with its own input, prediction and view. Every tab joins the open room until it is full, then a new room opens (the local host holds up to 8). Seat bots join the arena shooter while at least one person is in the room, so one tab already has opponents.

To try a phone, run `npm run play -- --host 0.0.0.0`, then open the printed link on the phone with `localhost` replaced by your computer's network address, in the page address and in its `server` parameter.

## 2. Run the playtest bots

```sh
npm test
```

The test compiles `src/game.ts` exactly as the play build does, so a disallowed import or an invalid declaration fails here first, with its file, line and reason. Then bots play the game with no browser and no server, and it prints a short report:

```text
Compiling src/game.ts (arena shooter)...
Playtest: 2 bots for 3 minutes of game time...
  kills 41, downs 41, wave 1, top level 0
  earn rules fired 2 times (first_blood 2)
  0.10 ms per tick on this computer
PASS: the game compiles and the playtest checks pass.
```

It exits 1 when the game does not compile, a playtest check fails (for example nobody scores, no earn rule fires, or one of the genre's own checks fails), or the game softlocks: nothing changes for 30 seconds of game time.

## 3. Read the JSON in scripts and agents

```sh
npm run check
```

prints the whole result: `ok`, `template`, `problems` (one line each) and `report` with `ticks`, `bots`, `kills`, `deaths`, `wave`, `maxLevel`, `earned` per rule, `softlock`, `msPerTick` and `p99MsPerTick`, plus genre measures such as the seconds to the first threat and the first reward. A coding agent should run it after every change and fix each line in `problems`.

## 4. Write your own playtest

The same harness is the engine's `engine.test.run`. Template packages use it in their own tests with named assertions:

```ts title=playtest.test.ts
import { engine } from "@netforge/engine";

export async function playtest(): Promise<void> {
  const r = await engine.test.run({
    ticks: 30 * 180,
    bots: 4,
    seed: 3,
    assert: [
      { name: "kills", check: (x) => x.kills > 500 },
      { name: "levels", check: (x) => x.maxLevel >= 5 },
      { name: "earn emitted", check: (x) => x.earnEmitted > 0 },
      { name: "no softlock", check: (x) => !x.softlock },
    ],
  });
  if (r.failed.length > 0) throw new Error(`playtest failed: ${r.failed.join(", ")}`);
  console.info(`${r.msPerTick.toFixed(2)} ms per tick, peak ${r.peakEnemies} enemies`);
}
```

Call it after your game module has been loaded (the template's manifest loads `src/game.ts`). Measured with this harness on an Apple M2 Pro and Node 22: the survivor template holds 2,000 enemies with 4 bots at 2.22 ms per tick on average (p99 5.21 ms), and the arena shooter runs 4 bots for 4 minutes at 60 Hz at 0.10 ms per tick.

## How it works

`kit-play` builds the template's browser client with your `src/game.ts` in place of the template's own, and bundles a local room host from the engine's open simulation and netcode: one world, a fixed tick, one input queue per player and per-player delta snapshots at 20 Hz. Sign-in, the referee and every money route answer 403, so earn rules fire and show but pay nothing. Bots play through the same simulation as people, and in arena games they never earn. See [Testing with playtest bots](https://forge.netnet.capital/docs/concepts/playtest-bots.md).

## Alternatives

- **[Colyseus Playground](https://docs.colyseus.io/tools/playground)** lets you connect test clients to Colyseus rooms from the browser and inspect their state.
- **[Playwright](https://playwright.dev/)** drives real browsers from tests, which suits checking menus and page flows around a game.
- **[Vitest](https://vitest.dev/)** runs fast TypeScript tests in Node. Net Forge's own template tests run `engine.test.run` inside Vitest.

Pick `npm test` when you want a bot playtest of the actual game rules, with no browser, in seconds.

## FAQ

### How do I test multiplayer on one computer?

Open the game in two or more tabs or windows. `npm run play -- --players 2` opens them for you, and each one is a separate player in the same room.

### Do the bots need a browser or a GPU?

No. `npm test` runs the simulation headless in Node with no renderer, so it runs in CI and in a coding agent's terminal.

### Is any real money involved in local testing?

No. Coins are play money, there is no wallet and no chain, and the local host makes no calls to Net Forge servers.

### Why did my test fail with a softlock?

Nothing changed for 30 seconds of game time: no kills, no damage and no movement. It usually means enemies cannot reach players, a wave never ends, or a rule stopped the run. The report's numbers show where it stalled.

### Can I make the test reproducible?

Yes. Runs are deterministic for a seed, so `npm test -- --seed 2` gives the same result every time. Change the seed to explore other runs.

### Where does progress go in local play?

Into memory. Achievements and looks bought with play money last until `kit-play` stops.
