← All posts
February 20, 2026·5 min read

Building a Browser Roguelike, Part 1: Procedural Maps and Getting the Game Loop Right

Series · Building Derelict Command

Part 1 of 2

I spend most of my professional life in terminals, admin consoles, and incident bridges. The most creative code I write at work is a clever Streamlit app or a Slack bot that automates something tedious. Good work. Not exactly artistically fulfilling.

So last February I decided to build a game.

Derelict Command is a tactical extraction roguelike set on a procedurally generated space station. 3D rendered in the browser via WebGL. Keyboard and touch controls. Enemies with pathfinding AI. Fog of war. Weapons that degrade. High scores. No plugins, no downloads — it runs in a tab.

This is Part 1 of how I built it: the game loop architecture and procedural map generation. Part 2 covers enemy AI, fog of war raycasting, and how I synthesized all the audio with zero dependencies.

Why 3D in the Browser?

Honest answer: I wanted to learn Three.js and this was a good excuse.

The practical answer: React Three Fiber (a React wrapper for Three.js) has gotten genuinely good. Declarative 3D scene composition with React's component model is surprisingly natural. The performance ceiling for WebGL in modern browsers is high enough that a tactical grid game doesn't approach it.

The trade-off I didn't fully appreciate at the start: 3D adds rendering complexity without adding gameplay depth for this genre. A roguelike plays the same whether it's 2D ASCII or 3D WebGL. The 3D elements — tile heights, enemy models, dynamic lighting — each added a week of implementation work for visual effect. If I were starting over, I'd ship the 2D version first and add the 3D rendering as a second pass once the game loop was solid.

State Management: The Decision That Affects Everything

A game is a state machine. Every frame, you read input, update state, render the new state. How you structure that state is the most consequential early decision.

I tried a few approaches before settling on one.

First attempt: React component state. Player position, enemy positions, map data, all as component state. This worked for the first two hours of prototyping. It fell apart when I needed game logic that touched multiple entities simultaneously — the enemy update loop needed to read player state, which created prop-drilling chains that made the code unreadable.

Second attempt: Redux. The standard answer for complex React state. Technically correct, but the boilerplate for a game is brutal. Every enemy movement required an action, a reducer case, a selector. I ended up with 400 lines of Redux machinery for behavior that was conceptually simple.

Third attempt: Zustand with a game store. One central store object. Direct mutation via set(). No reducers, no actions. The game loop reads from the store, updates it, and React re-renders what changed.

const useGameStore = create<GameState>((set, get) => ({
  player: { x: 0, y: 0, hp: 100, ... },
  enemies: [],
  map: [],
  turn: 0,

  movePlayer: (dx: number, dy: number) => set(state => {
    // all game logic lives here
    const next = { x: state.player.x + dx, y: state.player.y + dy }
    if (!isWalkable(state.map, next)) return state
    // process enemy turns, check win/loss conditions...
    return { ...state, player: { ...state.player, ...next }, turn: state.turn + 1 }
  }),
}))

Zustand was the right call. The store is the game; React is the renderer. Clear separation.

Procedural Map Generation with BSP

Every time you start a run, the map is different. I generate maps using Binary Space Partitioning (BSP).

The algorithm:

  1. Start with the full map area as a single rectangle
  2. Randomly split it into two sub-rectangles (either horizontal or vertical split)
  3. Recursively split each sub-rectangle until they're below a minimum size
  4. Convert the leaf nodes (smallest sub-rectangles) into rooms
  5. Connect adjacent rooms with corridors

The result is a map that's guaranteed to be fully connected — every room is reachable from every other room. This is the property that makes BSP preferable to naive random room placement, which requires a flood-fill connectivity check after generation.

function bspSplit(rect: Rect, depth: number): Room[] {
  if (depth === 0 || isTooSmall(rect)) {
    return [rectToRoom(rect)]
  }
  const [a, b] = splitRect(rect)
  return [...bspSplit(a, depth - 1), ...bspSplit(b, depth - 1)]
}

I added two things to basic BSP:

Minimum room size constraint. Rooms smaller than 4×4 tiles are rejected during the room creation pass. Small rooms aren't fun to play in — they don't give enemies room to move and they make the map feel cramped.

Special room placement. The starting room (where the player spawns) and the extraction point (the win condition) are placed in rooms that BSP selected but I override. The starting room is always far from the extraction point — I measure by pathfinding distance, not Euclidean distance, to ensure the player actually has to traverse the map to win.

Rendering the Map

The 3D rendering is straightforward Three.js. Each tile is a box mesh with height and material determined by its type (floor, wall, doorway). The map is rebuilt as a Three.js scene graph whenever it's generated — during a run, the map itself is static, so there's no per-frame map rendering cost.

The main performance consideration: instanced meshes for repeated tile types. A 50×50 map has up to 2,500 floor tiles. Rendering each as its own Three.js mesh creates 2,500 draw calls per frame, which collapses performance on modest hardware. Using InstancedMesh, all floor tiles are a single draw call with per-instance transform data. Frame rate jumped from ~40fps to ~60fps locked on the same hardware after this change.

What's Next

That covers the bones of the game. In Part 2: how the enemies find their way to you (A* pathfinding), how the fog of war works (Bresenham line raycasting), and how I made all the game audio using only the Web Audio API — no sound files, no audio libraries.

Series · Building Derelict Command

Part 1 of 2