How to make a co-op dungeon crawler with procedural rooms in TypeScript
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
| Part | Where | Notes |
|---|---|---|
| Procedural floor plan | dungeon({...}) | 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 pathing | engine-sim | Walls stop players, enemies and bolts. Enemies behind a wall path round through the doors on a flow field, big bodies included. |
| The crawl | system("crawl", ...) in src/game.ts | Rooms wake as the party nears them, encounters by depth, a quiet timer, reliquaries and respawns on clear. |
| Permadeath | players.downed | No free get-up; if the whole party is down at once the descent is over. |
| Fog of war and x-ray walls | template client | Rooms nobody entered stay dark; a wall never hides your character. |
| Server cost | measured | Four 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 22. Grow a different crypt
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 };seedis any whole number; a different seed is a different floor plan.roomsis 3 to 20, the start and boss rooms included. About 10 makes a 6 to 10 minute descent, about 40 seconds a room.cellis the room spacing in metres (16 to 32),sizethe smallest and largest room side,corridorthe corridor width (6 lets a boss through), andloopsthe 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%.
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:
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 testThe 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.