# Recursive Descent: Vibe Coded Rogue-like in Common Lisp

> Source: <https://funcall.blogspot.com/2026/09/recursive-descent-vibe-coded-rogue-like.html>
> Published: 2026-09-03 07:00:00+00:00

I was talking with Amit Patel
of [Red Blob Games](http://www.redblobgames.com/) the
other day and he mentioned that he had been participating in a
programming endeavor where people were creating variations of
rogue-like games based on
a [tutorial](https://rogueliketutorials.com/). He had
just begun experimenting with vibe coding and figuring out how to do
it and what works for him. This sounded like an interesting idea,
so I decided to try it out myself. I began with the basic tutorial,
but since I'm a Lisp programmer, I decided to vibe code the game in
Common Lisp. I had a few goals in mind:

I mostly vibe coded this, but I did step in and make adjustments here and there. For example, I specifically requested that the LLM use a functional programming style, and I asked it to refactor large files into smaller ones.

Getting started was tricky. I basically wanted a simple terminal
emulator in the browser that would would display a fixed-width grid
of characters that the back end could update. I wanted to be able
to send keypresses to the back end and have it update the display.
I didn't have a clear idea about how to do this, so I experimented a
bit and came up with something relatively easy. The front end is a
simple HTML page with a `<div>`

is expected to contain the
grid of characters. The front-end runs some JavaScript opens a
WebSocket connection to the back end and sits in a loop waiting for
messages. The back end sends messages to the front end that contain
a block of html that the front end just inserts into
the `<div>`

. The front end also listens for keypresses and
sends them to the back end. I didn't expect that this would be a
very efficient way to do it, but I figured that a modern browser and
reasonably good internet connection would be able to handle a modest
refresh rate.

As coding progressed, the LLM extended the front end to include
multiple `<div>`

s, including pop-up modals. The
LLM also augmented the front end to reconnect to the back end if the
connection was lost, and direct focus to the playing area with the
page was displayed. Othewise, the front end is a relatively thin
client that mostly displays exactly what the back end sends it.

I started out with the standard rogue-like game loop, which is a synchronous, turn-based loop. The back end would wait for a keypress, then update the game state and send the new display to the front end. This works, but I remembered how the developers of Diablo said that when they decided to make it real-time it completely changed the game. I decided to make the back end real-time, but with a slow enough tick rate that I wasn't overwhelming the connection or the browser. Eventually I decided on a tick rate of 20Hz. Most of the effects in the game are timed around a 0.1 second interval (the rate at which the keyboard repeats when you hold down a key) and 20Hz is the Nyquist frequency to avoid aliasing (which would make the game stutter weirdly if you tried to run by holding down an arrow key). This makes the game feel responsive enough without it needing to refresh at CRT rates. Since the game is based on a grid of ascii characters rather than a bitmap, I guessed that the bandwidth requirements would be modest enough that this would work.

The first few hours vibe coding were spent getting a player character to run around a procedurally generated dungeon. Once I had that working, I asked the LLM to refactor the back end into a functional core with a stateful wrapper. The functional core is a pure function that takes the current game state and a message from the front end (typically a keypress) and returns the new game state. The stateful wrapper manages the WebSocket connection and the game loop. Once the back-end had been refactored into a functional core, the LLM generally continued to keep side effects out of the code, although it did introduce some reasonable side effects to manage a LRU cache of game state in order to save on recalculations.

Every action in the game is modeled as a pure
reducer
function. ``MOVE-PLAYER``

, ``DRINK-POTION``

, ``PROCESS-ENEMY-TURNS``

,
etc. all follow the same signature: they take the
current ``GAME-STATE``

plus some inputs, and return a
freshly allocated ``GAME-STATE``

representing the world
one tick later.

To achieve this without writing thousands of lines of boilerplate copy
constructors, the engine heavily leverages Common Lisp's Meta-Object Protocol
(MOP). The ``copy-instance``

and ``update-entity``

helpers dynamically iterate over a
class's slots at runtime. When an Orc takes 5 damage, the engine
doesn't mutate the Orc; it uses the MOP to spin up a brand new Orc
with identical properties, except for a modified HP slot, and
substitutes it into the new ``GAME-STATE``

's entity list.

To avoid locking as much as possible, the engine decouples the I/O
state from the game state. When the Hunchensocket WebSocket read-thread receives a JSON packet from a
client, it does exactly two things: it parses the JSON into an immutable
`RDESCENT-COMMAND`

CLOS object
(like `move-command`

or `drink-command`

), and it
dumps that command into a thread-safe `SB-CONCURRENCY:QUEUE`

. It never touches
the game state.

Meanwhile, a single, dedicated game-loop thread acts as the heartbeat. Once
every 50ms, the `TICK-ALL-CLIENTS`

function wakes up, drains the input queues
for every connected client, and folds those commands over that client's
`GAME-STATE`

using the pure `ADVANCE-GAME-STATE`

reducer. This means a player
mashing the keyboard at 100 APM can never cause a race condition or force the
engine to lock the state tree. The I/O is asynchronous, but the game
logic is predictably synchronous.

Because the game runs in real-time, it needs a way to blend the fast-paced player inputs with slower, methodical monster AI. This is handled via an Energy accrual system.

Every tick, every entity (players and monsters alike) accrues `ENERGY` equal to their `SPEED` stat. Actions have flat energy costs. The game loop refuses to process an action for an entity until its energy balance can afford it. This allows the engine to support speed-altering buffs and debuffs simply by tweaking the energy thresholds or accrual rates, without needing a bespoke cooldown-timer subsystem.

Dungeon generation in `Recursive Descent` is
deterministic, seeded by a hash of the dungeon level. This means
`GENERATE-DUNGEON`

will always carve the exact same rooms and corridors for
Level 5, every time. The engine doesn't need to store the entire
dungeon in memory for every player; it can simply regenerate the same
layout on demand.

Because the generation is deterministic, and the `GAME-MAP`

geometry (the
`TILE`

array) is strictly immutable, the architecture introduces a
`*DUNGEON-CACHE*`

. When a player drops down to Level 5, the engine checks the
cache. If the geometry is already there, it just hands a pointer to the existing
immutable map to the player's `GAME-STATE`

. Multiple players on the same tier
and level share the same physical memory space for the dungeon walls
and floors, reducing the memory footprint of the server.

Instead of maintaining a massive, clustered database to store player
progression, the server is entirely stateless across sessions. When a player
hits the `Save`

button, the Lisp server serializes their entire immutable
`GAME-STATE`

(including all visited levels, dropped items, and explored
fog-of-war bit-vectors) into an association list. It then zlib-compresses it,
signs it with an HMAC-SHA256 hash using a server-side secret key, base64 encodes
it, and sends it back to the client over the WebSocket.

The client's browser stores the save blob in localStorage. When the player reconnects, they hand the blob back. The server verifies the signature, decompresses it, and resurrects the CLOS objects. Thus we offloaded the database hosting to the user's hard drive.

Tracking connected users in a multithreaded web server usually involves wrapping a global list in a heavy mutex, which creates a bottleneck every time the game loop iterates over it.

To solve this, `server.lisp`

isolates the `*RDESCENT-CLIENTS*`

list inside a
dedicated background actor thread (`RDESCENT-CLIENTS-REGISTRY-LOOP`

). No other
thread is allowed to touch it. When Hunchensocket receives a new connection or a
disconnect, it drops a simple `(:CONNECT client)`

or `(:DISCONNECT client)`

message into the actor's mailbox. When the game loop needs the list of players
for the next tick, it sends a `(:SNAPSHOT)`

message and waits for the actor to
reply with the current list. This guarantees that the client roster is never
mutated out from under an active iteration, cleanly sidestepping deadlocks.

Modern game development typically uses
Entity-Component-System (ECS) architectures to avoid massive inheritance trees.
`Recursive Descent` ignores this trend. The base `ENTITY`

class is
deliberately "fat." It holds everything: spatial coordinates (`X`

, `Y`

),
rendering data (`CHAR`

, `RENDER-ORDER`

), combat stats (`HP`

, `POWER`

,
`DEFENSE`

), inventory, equipment, and all seven RPG Stats
(Strength, Dexterity, Charisma, etc.).

In a mutable OOP design, a fat base class is a maintenance nightmare. In a
purely functional CLOS architecture, it is an advantage. Because state
mutation is handled entirely by a Meta-Object Protocol (MOP) helper
(`COPY-INSTANCE`

/ `UPDATE-ENTITY`

) that dynamically walks the class slots to
clone the object, having a wide, flat property list is functionally cheap. You
don't need complex component-querying logic; you just ask the entity for its
`DOMAIN-KNOWLEDGE`

and move on.

Interestingly, there is no `PLAYER`

class. The player is simply a baseline
`ENTITY`

instance that happens to be bound to the `PLAYER`

slot of the
`GAME-STATE`

. It uses the exact same combat resolution, inventory handling, and
stat scaling as any monster.

Instead of overriding
methods to change behavior, the `ENTITY`

subclasses primarily exist to provide
specific `:DEFAULT-INITARGS`

and to act as dispatch targets for generic
functions.

`ENEMY`

— Adds no new slots. It simply provides an `:AFTER`

initialization method to guarantee an enemy defaults to a `:HOSTILE`

disposition and derives its XP value from its HP. `AUTO-PICKUP-ITEM`

— Represents a scavenger hunt collectible. It defaults `IS-ALIVE`

to `NIL`

and `BLOCKS-MOVEMENT`

to `NIL`

, keeping it out of the AI processing loop and allowing the player to freely walk over it.Fixtures represent stationary, non-hostile map
objects (shrines, vendors, NPCs) that the player interacts with via a dedicated
command rather than by bumping into them. The base `FIXTURE`

class defaults
`IS-ALIVE`

to `NIL`

(excluding it from the enemy AI turn loop) and
`BLOCKS-MOVEMENT`

to `NIL`

(allowing the player
to stand on it).

The hierarchy branches out based on internal state requirements:

`SHRINE-FIXTURE`

— Adds a `USE-COUNT` slot to
track finite activations.`VENDOR-FIXTURE`

— Stateless beyond its base properties. Its "stock" is derived globally, and it requires no mutable inventory of its own.`NPC-FIXTURE`

— Likewise stateless. Quest progress is stored in the player's `GAME-STATE`

flags rather than on the NPC, ensuring the NPC remains purely shared, immutable geometry.`TRAP-FIXTURE`

— Adds a `HIDDEN-P`

slot to dictate rendering visibility, flipping to `NIL`

once triggered or spotted.`RDESCENT-COMMAND`

input handling relies on a
polymorphic Command Pattern. The WebSocket read thread parses raw JSON
into a concrete subclass of `RDESCENT-COMMAND`

(`MOVE-COMMAND`

,
`USE-ITEM-COMMAND`

, `EQUIP-COMMAND`

, etc.).

Instead of a massive `COND`

statement checking command types, the engine uses
CLOS generic functions (`EXECUTE-QUEUED-COMMAND`

). Each command class has a
specific method that invokes the appropriate state reducer (e.g., the
`DRINK-COMMAND`

method calls `DRINK-POTION`

). This makes extending the engine's
vocabulary trivial: adding a new command means defining a tiny data class and
writing exactly one generic method for it.

`GAME-MAP`

: Holds the `TILES`

array. A `TILE`

contains purely static, shared geometry (walls, floors, room-type tags). Because this never mutates based on player action, a `GAME-MAP`

can be safely memoized and shared across multiple players on the same depth via the `*DUNGEON-CACHE*`

.
`GAME-STATE`

: The server-authoritative snapshot for a specific player. It holds the `PLAYER`

entity, the list of other `ENTITIES`

on the floor, the field-of-view `EXPLORED`

bit-vector, and the `LEVELS`

FSET map (which archives `DUNGEON-LEVEL-SNAPSHOT`

s of previously visited floors).

The Imperative Shell: `RDESCENT-CLIENT`

Sitting at the very top of the
stack is `RDESCENT-CLIENT`

, a subclass of Hunchensocket's `WEBSOCKET-CLIENT`

.
This is the only place where mutability is permitted. It acts as the anchor,
holding the connection's thread-safe `INPUT-QUEUE`

for
incoming commands, and the mutable pointer to the current immutable
`GAME-STATE`

. This is where the game state is updated with
the new game state.

The LLM did a good job of vibe coding a rogue-like game in the browser. At one point I simply sat down and brainstormed a list of features that I wanted to add to the game. Then I told the LLM to design an architecture that would be amenable to adding those features. Then I told the LLM to implement the architecture. When it was done, I told the LLM to prioritize the features I wanted to add suggest an implementation order. Then I told the LLM to implement each feature in turn.

At a couple of points, the LLM was letting a monolithic file get out of hand, so I explicitly told it to refactor the code into smaller files I also made an explicit pass to make sure that the compile was giving no warnings. The LLM had a tendency to use large etypecase statements to dispatch on the type of an object. I explicitly told it to use CLOS generic functions instead, and it did so. OTher than that, I basically left the LLM to generate code how it saw fit.

If you want to try out the game, you can run it in your browser at
[https://jrm-code-project.com/rdescent.html](https://jrm-code-project.com/rdescent.html). If you just
want to peruse the source code, you can find it
at [https://github.com/jrm-code-project/jrm-code-public/tree/main/rdescent](https://github.com/jrm-code-project/jrm-code-public/tree/main/rdescent),
but try the game before you look at the code because the code
contains spoilers. No guarantees it will work on your browser. Mine has a fairly big display and a reasonably high bandwidth connection, but I don't know about yours. No way this would work on a phone.
