# The typed game API: a whole game in one declarative file

> A Net Forge game is one TypeScript file, src/game.ts, that declares what exists and the rules with game(), prefab(), weapon(), director(), system(), earn and store from @netforge/engine. Declarations are Zod-validated, and a mistake fails with one line that says what to fix.

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/concepts/game-api  
Last updated: 2026-10-23. Checked against @netforge/engine 0.2.0.

A Net Forge game is one TypeScript file, `src/game.ts`, that declares what exists and how it behaves: the game and its players, prefabs for enemies and pickups, weapons, the spawn director, systems for timed events, earn rules and store items. It imports only `@netforge/engine`, never the renderer, netcode or physics, so the same file runs on the server, in the browser and under the playtest bots.

```sh
npm create netforge@latest my-game -- --template survivor --yes
```

## A complete game module

This is a whole, playable survivor-like. The docs build plays it with two bots for a minute on every check.

```ts title=src/game.ts run
import { director, earn, game, prefab, store, weapon } from "@netforge/engine";

export default game({
  template: "survivor",
  style: "lowpoly",
  places: { field: { chunks: "places/field", spawn: [0, 1, 0] } },
  field: { halfSize: 40, pillars: [[10, 10, 2], [-10, -10, 2]] },
  players: { prefab: "style.lowpoly.hooded_adventurer", max: 4, weapon: "spark", magnet: 4 },
});

prefab("shade", { model: "style.lowpoly.ghost", health: 20, speed: 3.6, radius: 0.35, damage: 6, ai: "chase", drops: ["gem"] });
prefab("gem", { model: "style.lowpoly.gem_small", health: 1, speed: 0, kind: "pickup", value: 1 });

weapon("spark", {
  kind: "bolt", damage: 10, cooldown: 0.5, range: 9, speed: 16, count: 1,
  levels: [{ damage: "+25%" }, { count: 1 }, { cooldown: "-15%" }, { damage: "+25%" }, { pierce: 1 }],
});

director({
  start: 4,
  alive: [[0, 10], [60, 40], [180, 160]],
  rate: [[0, 1], [60, 3], [180, 8]],
  pool: [{ prefab: "shade", weight: 1 }],
});

earn.rule("hundred_down", { when: "enemy.killed", n: 100, pay: 5, oncePer: "run" });
earn.stake({ min: 20, max: 200, multiple: 2 });
store.sku("revive", { coins: 50 });
```

Played headless by two bots for 90 seconds on an Apple M2 Pro, this module scored 488 kills, fired `hundred_down` once per bot and cost 0.13 ms per tick.

## The declarations

| Function | Declares | Notes |
|---|---|---|
| `game({ template, style, players, ... })` | The game: which template runs it, the art style, the players' prefab, starting weapon and limits, the field, the look. | Exactly one per file, as the default export. |
| `prefab(id, { model, health, speed, ai, drops, ... })` | Anything that exists in the world: enemies, bosses, pickups, props. | `model` is an asset library id such as `style.lowpoly.ghost`. |
| `weapon(id, { kind, damage, cooldown, levels, evolve })` | A weapon: survivor kinds (`nova`, `bolt`, `orbit`, `cone`, `chain`, `meteor`, `boomerang`, `aura`) or arena kinds (`blaster`, `beam`). | `levels` lists what each level changes. |
| `upgrade(id, { stat, step, maxLevel })` | A passive offered at level-up. | Stats such as `speed`, `armor`, `area`, `amount`. |
| `director({ alive, rate, health, pool, ... })` | Who spawns, how many and how tough over the run's clock. | Curves are numbers or `[seconds, value]` keyframes. |
| `system(id, setup)` | Timed and event logic: `every`, `after`, `on(event)`, spawns, announcements. | Runs on the server's tick, never on a clock. |
| `earn.rule(id, { when, n, pay, oncePer })` | When a run pays coins. | Required: at least one per game. |
| `store.sku(id, { coins, grants })` | Something players can buy with coins: a revive, a cosmetic, a character. | Play money in local play. |
| `achievement`, `mission`, `title`, `keeper` | Reasons to come back: unlocks, daily goals, end-screen titles, playable characters. | None of them pay coins. |

Board genres add their own typed declarations to the same module, for example `towerBoard`, `tower` and `waves` for tower defense, and `card` and `cardTable` for the card battler. The full list is in the [@netforge/engine reference](https://forge.netnet.capital/docs/reference/engine.md).

## Errors written for the person or agent fixing them

Every declaration is validated with Zod when the module loads. A bad one throws `GameDefinitionError` with one line per problem that names the field and the fix:

```text
model: reference assets by library id (e.g. "style.lowpoly.bat"), never by URL or path
```

Cross-references are checked when the manifest is compiled: a director pool entry that names a missing prefab, a weapon evolution whose passive does not exist, or an arena weapon in a survivor game each fail with a line the model can act on. The aim is to fail at definition time rather than at tick 3,000.

## Why a declarative file

- **It is safe to hand to an agent.** The file cannot reach the renderer, netcode or physics, so an edit can make the game harder or add an enemy but cannot break multiplayer.
- **The server can trust it.** The same declarations run on the server, which decides every outcome. The client never computes a result the server accepts on faith.
- **It can be tested without a browser.** `engine.test.run` plays the declared game with bots and reports what happened. See [Testing with playtest bots](https://forge.netnet.capital/docs/concepts/playtest-bots.md).
- **It is small.** The survivor template's full game, with 17 enemy types, 15 weapons and evolutions, 8 passives and a boss, is 354 lines.

## Units

Seconds, metres, metres per second and hit points. The simulation ticks at 30 Hz. In the survivor template players run at 7 m/s, so chasers sit at 3 to 4 m/s and fast enemies stay under 6.

## FAQ

### Can src/game.ts import other npm packages?

No. The play build rejects any import other than `@netforge/engine`, with the message `src/game.ts may import only "@netforge/engine"`. This keeps a game portable between local play and the hosted service and keeps it replayable on the server.

### Is the game API typed?

Yes. Every declaration has TypeScript types generated from its Zod schema (`PrefabInput`, `WeaponInput`, `DirectorInput` and so on), so editors and agents get completion and type errors before anything runs.

### How do I add logic that is not a number, like a boss at ten minutes?

Use a `system`: `after(600, () => spawnEdge("boss_id", { count: 1 }))` inside its setup spawns a boss just off screen at ten minutes. Systems run on the server's fixed tick.

### What are earn rules?

Declarations of when a run pays coins, such as "100 enemies killed, pay 5 once a run". In local play coins are play money. On the hosted service the same rules can pay a game's own token, decided by the server.

### Where do models come from?

From the CC0 asset library, by id (`style.lowpoly.ghost`). See [Assets and art styles](https://forge.netnet.capital/docs/concepts/assets-art-styles.md).
