The typed game API: a whole game in one declarative file
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.
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.
npm create netforge@latest my-game -- --template survivor --yesA complete game module
This is a whole, playable survivor-like. The docs build plays it with two bots for a minute on every check.
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.
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:
model: reference assets by library id (e.g. "style.lowpoly.bat"), never by URL or pathCross-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.runplays the declared game with bots and reports what happened. See Testing with playtest bots. - 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.