cd /news/developer-tools/recursive-descent-vibe-coded-rogue-l… · home topics developer-tools article
[ARTICLE · art-119931] src=funcall.blogspot.com ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Recursive Descent: Vibe Coded Rogue-like in Common Lisp

A developer used 'vibe coding' to create a rogue-like game in Common Lisp, leveraging an LLM to write most of the code while making targeted adjustments. The game features a real-time, turn-based hybrid loop with a functional core and stateful wrapper, running at 20Hz to avoid input aliasing. The front end is a thin HTML/JavaScript client that communicates with the back end via WebSocket.

read12 min views2 publishedSep 3, 2026

I was talking with Amit Patel of Red Blob Games 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. 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, 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.

── more in #developer-tools 4 stories · sorted by recency
── more on @amit patel 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/recursive-descent-vi…] indexed:0 read:12min 2026-09-03 ·