NET FORGE DOCS

How to make a co-op dungeon crawler with procedural rooms in TypeScript

Last updated Checked against @netforge/engine 0.2.0View as Markdown

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.

Run npm create netforge@latest my-crypt -- --template dungeon --yes and you have a co-op dungeon crawler: a crypt of ten rooms grown from a seed, up to four players going down room by room, enemies that walk round walls through the doors, an upgrade at every cleared room and a boss in the deepest one. The floor plan is one line of TypeScript, dungeon({ seed, rooms, ... }), and the room logic is a system you can read and change.

npm create netforge@latest my-crypt -- --template dungeon --yes
cd my-crypt
npm run play

--template crawler is accepted as the same template.

What you get

PartWhereNotes
Procedural floor plandungeon({...})Rooms on a grid joined by corridors, from a seed. The same seed gives the same crypt on the server, every client and the playtest; nothing is downloaded.
Walls and pathingengine-simWalls stop players, enemies and bolts. Enemies behind a wall path round through the doors on a flow field, big bodies included.
The crawlsystem("crawl", ...) in src/game.tsRooms wake as the party nears them, encounters by depth, a quiet timer, reliquaries and respawns on clear.
Permadeathplayers.downedNo free get-up; if the whole party is down at once the descent is over.
Fog of war and x-ray wallstemplate clientRooms nobody entered stay dark; a wall never hides your character.
Server costmeasuredFour bots for four minutes clear 219 enemies across 8 rooms at 0.40 ms per tick on average, p99 0.77 ms (Apple M2 Pro, Node 22).

1. Start it

npm create netforge@latest my-crypt -- --template dungeon --yes
cd my-crypt
npm run play -- --players 2

2. Grow a different crypt

src/game.ts
import { dungeon } from "@netforge/engine";

const CRYPT = dungeon({ seed: 7, rooms: 14, cell: 28, size: [16, 22], corridor: 6, loops: 1 });

const deepest = CRYPT.boss;
const firstRoom = CRYPT.roomAt(0, 0);
export { CRYPT, deepest, firstRoom };
  • seed is any whole number; a different seed is a different floor plan.
  • rooms is 3 to 20, the start and boss rooms included. About 10 makes a 6 to 10 minute descent, about 40 seconds a room.
  • cell is the room spacing in metres (16 to 32), size the smallest and largest room side, corridor the corridor width (6 lets a boss through), and loops the extra doors that make loops (0 is a pure tree).

Pass field: CRYPT.field to game({...}). CRYPT.boss is the deepest room, CRYPT.rooms gives each room's centre, half sizes, depth and links, and CRYPT.roomAt(x, z) the room holding a point (or -1 in a corridor), so your systems can reason about rooms.

3. Change what each depth holds

The crawl system reads an ENCOUNTERS table: what a woken room sends, by its depth, for one player. Each extra player adds 35%.

src/game.ts
const ENCOUNTERS: readonly (readonly (readonly [string, number])[])[] = [
  [["bone_rattler", 7], ["crypt_bat", 5]],
  [["bone_rattler", 9], ["bone_archer", 3], ["grave_shambler", 2]],
  [["grave_shambler", 3], ["crypt_bat", 9], ["wax_wisp", 4], ["bone_archer", 3]],
];
export { ENCOUNTERS };

Add a new enemy with prefab(...), put it in a row of ENCOUNTERS and in the DEAD list, so a room that still has one standing is not cleared:

src/game.ts
import { prefab } from "@netforge/engine";

prefab("ghoul", { model: "style.lowpoly.zombie", health: 30, speed: 3.2, radius: 0.4, damage: 8, ai: "chase", drops: ["wick_small"] });
prefab("bone_archer", { model: "style.lowpoly.skeleton", health: 24, speed: 2.8, ai: "ranged", attack: { damage: 9, range: 10, speed: 7 }, drops: ["wick_small"] });

A ranged enemy shows an aim line before it fires and needs a clear line, so walls are cover.

4. Test it

npm test

The playtest sends four bots down the crypt and checks that rooms clear, players level and earn rules fire, with no softlock. A crypt whose boss room cannot be reached, or a room that never clears, shows up here.

How it works

The crawler runs on the same machinery as the survivor-like: auto-firing weapons, level-up cards, co-op downs and revives, bosses with telegraphed attacks. The server steps it at 30 Hz and is authoritative; your character is predicted in the browser. Walls are boxes in the Rapier world for players, and enemies, bolts and spawns are resolved against them, with a flow field (a wider grid for big bodies) guiding enemies round to the doors. A woken room's enemies come in inside that room but off every player's screen. See Simulation and ECS and the dungeon crawler template page.

Alternatives

  • Phaser is a free, open-source 2D framework with WebGL and Canvas rendering. Pick it for a 2D tile-based crawler where you write the generation and the loop yourself.
  • Godot has a scene editor and tile tools and exports to the web as WebAssembly with WebGL 2. Pick it when you want to hand-design rooms in an editor.

Pick Net Forge when you want the co-op 3D crawl with procedural rooms, pathing and netcode already working.

FAQ

Is the dungeon generated on the server or the client?

Both, from the same spec. dungeon({ seed, ... }) is deterministic, so the server, every client and the playtest grow the same crypt without sending it over the network.

How do enemies find players behind walls?

They follow a flow field computed over the floor plan, which leads them round to the doors. Big enemies use a wider grid so they do not get stuck in corridors.

Can I turn permadeath off?

Yes. Set players.downed to allow a free get-up (selfRevives: 1) or a respawn time in seconds.

How long is a run?

About 40 seconds a room. Ten rooms make a 6 to 10 minute descent.

Can I hand-design the rooms instead of generating them?

The template grows the crypt from dungeon(...). A game can also declare its own field with walls and floors as boxes, which is how the party template builds its single playroom.