{"slug": "recursive-descent-vibe-coded-rogue-like-in-common-lisp", "title": "Recursive Descent: Vibe Coded Rogue-like in Common Lisp", "summary": "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.", "body_md": "I was talking with Amit Patel\nof [Red Blob Games](http://www.redblobgames.com/) the\nother day and he mentioned that he had been participating in a\nprogramming endeavor where people were creating variations of\nrogue-like games based on\na [tutorial](https://rogueliketutorials.com/). He had\njust begun experimenting with vibe coding and figuring out how to do\nit and what works for him. This sounded like an interesting idea,\nso I decided to try it out myself. I began with the basic tutorial,\nbut since I'm a Lisp programmer, I decided to vibe code the game in\nCommon Lisp. I had a few goals in mind:\n\nI 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.\n\nGetting started was tricky. I basically wanted a simple terminal\nemulator in the browser that would would display a fixed-width grid\nof characters that the back end could update. I wanted to be able\nto send keypresses to the back end and have it update the display.\nI didn't have a clear idea about how to do this, so I experimented a\nbit and came up with something relatively easy. The front end is a\nsimple HTML page with a `<div>`\n\nis expected to contain the\ngrid of characters. The front-end runs some JavaScript opens a\nWebSocket connection to the back end and sits in a loop waiting for\nmessages. The back end sends messages to the front end that contain\na block of html that the front end just inserts into\nthe `<div>`\n\n. The front end also listens for keypresses and\nsends them to the back end. I didn't expect that this would be a\nvery efficient way to do it, but I figured that a modern browser and\nreasonably good internet connection would be able to handle a modest\nrefresh rate.\n\nAs coding progressed, the LLM extended the front end to include\nmultiple `<div>`\n\ns, including pop-up modals. The\nLLM also augmented the front end to reconnect to the back end if the\nconnection was lost, and direct focus to the playing area with the\npage was displayed. Othewise, the front end is a relatively thin\nclient that mostly displays exactly what the back end sends it.\n\nI 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.\n\nThe 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.\n\nEvery action in the game is modeled as a pure\nreducer\nfunction. ``MOVE-PLAYER``\n\n, ``DRINK-POTION``\n\n, ``PROCESS-ENEMY-TURNS``\n\n,\netc. all follow the same signature: they take the\ncurrent ``GAME-STATE``\n\nplus some inputs, and return a\nfreshly allocated ``GAME-STATE``\n\nrepresenting the world\none tick later.\n\nTo achieve this without writing thousands of lines of boilerplate copy\nconstructors, the engine heavily leverages Common Lisp's Meta-Object Protocol\n(MOP). The ``copy-instance``\n\nand ``update-entity``\n\nhelpers dynamically iterate over a\nclass's slots at runtime. When an Orc takes 5 damage, the engine\ndoesn't mutate the Orc; it uses the MOP to spin up a brand new Orc\nwith identical properties, except for a modified HP slot, and\nsubstitutes it into the new ``GAME-STATE``\n\n's entity list.\n\nTo avoid locking as much as possible, the engine decouples the I/O\nstate from the game state. When the Hunchensocket WebSocket read-thread receives a JSON packet from a\nclient, it does exactly two things: it parses the JSON into an immutable\n`RDESCENT-COMMAND`\n\nCLOS object\n(like `move-command`\n\nor `drink-command`\n\n), and it\ndumps that command into a thread-safe `SB-CONCURRENCY:QUEUE`\n\n. It never touches\nthe game state.\n\nMeanwhile, a single, dedicated game-loop thread acts as the heartbeat. Once\nevery 50ms, the `TICK-ALL-CLIENTS`\n\nfunction wakes up, drains the input queues\nfor every connected client, and folds those commands over that client's\n`GAME-STATE`\n\nusing the pure `ADVANCE-GAME-STATE`\n\nreducer. This means a player\nmashing the keyboard at 100 APM can never cause a race condition or force the\nengine to lock the state tree. The I/O is asynchronous, but the game\nlogic is predictably synchronous.\n\nBecause 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.\n\nEvery 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.\n\nDungeon generation in `Recursive Descent` is\ndeterministic, seeded by a hash of the dungeon level. This means\n`GENERATE-DUNGEON`\n\nwill always carve the exact same rooms and corridors for\nLevel 5, every time. The engine doesn't need to store the entire\ndungeon in memory for every player; it can simply regenerate the same\nlayout on demand.\n\nBecause the generation is deterministic, and the `GAME-MAP`\n\ngeometry (the\n`TILE`\n\narray) is strictly immutable, the architecture introduces a\n`*DUNGEON-CACHE*`\n\n. When a player drops down to Level 5, the engine checks the\ncache. If the geometry is already there, it just hands a pointer to the existing\nimmutable map to the player's `GAME-STATE`\n\n. Multiple players on the same tier\nand level share the same physical memory space for the dungeon walls\nand floors, reducing the memory footprint of the server.\n\nInstead of maintaining a massive, clustered database to store player\nprogression, the server is entirely stateless across sessions. When a player\nhits the `Save`\n\nbutton, the Lisp server serializes their entire immutable\n`GAME-STATE`\n\n(including all visited levels, dropped items, and explored\nfog-of-war bit-vectors) into an association list. It then zlib-compresses it,\nsigns it with an HMAC-SHA256 hash using a server-side secret key, base64 encodes\nit, and sends it back to the client over the WebSocket.\n\nThe 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.\n\nTracking 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.\n\nTo solve this, `server.lisp`\n\nisolates the `*RDESCENT-CLIENTS*`\n\nlist inside a\ndedicated background actor thread (`RDESCENT-CLIENTS-REGISTRY-LOOP`\n\n). No other\nthread is allowed to touch it. When Hunchensocket receives a new connection or a\ndisconnect, it drops a simple `(:CONNECT client)`\n\nor `(:DISCONNECT client)`\n\nmessage into the actor's mailbox. When the game loop needs the list of players\nfor the next tick, it sends a `(:SNAPSHOT)`\n\nmessage and waits for the actor to\nreply with the current list. This guarantees that the client roster is never\nmutated out from under an active iteration, cleanly sidestepping deadlocks.\n\nModern game development typically uses\nEntity-Component-System (ECS) architectures to avoid massive inheritance trees.\n`Recursive Descent` ignores this trend. The base `ENTITY`\n\nclass is\ndeliberately \"fat.\" It holds everything: spatial coordinates (`X`\n\n, `Y`\n\n),\nrendering data (`CHAR`\n\n, `RENDER-ORDER`\n\n), combat stats (`HP`\n\n, `POWER`\n\n,\n`DEFENSE`\n\n), inventory, equipment, and all seven RPG Stats\n(Strength, Dexterity, Charisma, etc.).\n\nIn a mutable OOP design, a fat base class is a maintenance nightmare. In a\npurely functional CLOS architecture, it is an advantage. Because state\nmutation is handled entirely by a Meta-Object Protocol (MOP) helper\n(`COPY-INSTANCE`\n\n/ `UPDATE-ENTITY`\n\n) that dynamically walks the class slots to\nclone the object, having a wide, flat property list is functionally cheap. You\ndon't need complex component-querying logic; you just ask the entity for its\n`DOMAIN-KNOWLEDGE`\n\nand move on.\n\nInterestingly, there is no `PLAYER`\n\nclass. The player is simply a baseline\n`ENTITY`\n\ninstance that happens to be bound to the `PLAYER`\n\nslot of the\n`GAME-STATE`\n\n. It uses the exact same combat resolution, inventory handling, and\nstat scaling as any monster.\n\nInstead of overriding\nmethods to change behavior, the `ENTITY`\n\nsubclasses primarily exist to provide\nspecific `:DEFAULT-INITARGS`\n\nand to act as dispatch targets for generic\nfunctions.\n\n`ENEMY`\n\n— Adds no new slots. It simply provides an `:AFTER`\n\ninitialization method to guarantee an enemy defaults to a `:HOSTILE`\n\ndisposition and derives its XP value from its HP. `AUTO-PICKUP-ITEM`\n\n— Represents a scavenger hunt collectible. It defaults `IS-ALIVE`\n\nto `NIL`\n\nand `BLOCKS-MOVEMENT`\n\nto `NIL`\n\n, keeping it out of the AI processing loop and allowing the player to freely walk over it.Fixtures represent stationary, non-hostile map\nobjects (shrines, vendors, NPCs) that the player interacts with via a dedicated\ncommand rather than by bumping into them. The base `FIXTURE`\n\nclass defaults\n`IS-ALIVE`\n\nto `NIL`\n\n(excluding it from the enemy AI turn loop) and\n`BLOCKS-MOVEMENT`\n\nto `NIL`\n\n(allowing the player\nto stand on it).\n\nThe hierarchy branches out based on internal state requirements:\n\n`SHRINE-FIXTURE`\n\n— Adds a `USE-COUNT` slot to\ntrack finite activations.`VENDOR-FIXTURE`\n\n— Stateless beyond its base properties. Its \"stock\" is derived globally, and it requires no mutable inventory of its own.`NPC-FIXTURE`\n\n— Likewise stateless. Quest progress is stored in the player's `GAME-STATE`\n\nflags rather than on the NPC, ensuring the NPC remains purely shared, immutable geometry.`TRAP-FIXTURE`\n\n— Adds a `HIDDEN-P`\n\nslot to dictate rendering visibility, flipping to `NIL`\n\nonce triggered or spotted.`RDESCENT-COMMAND`\n\ninput handling relies on a\npolymorphic Command Pattern. The WebSocket read thread parses raw JSON\ninto a concrete subclass of `RDESCENT-COMMAND`\n\n(`MOVE-COMMAND`\n\n,\n`USE-ITEM-COMMAND`\n\n, `EQUIP-COMMAND`\n\n, etc.).\n\nInstead of a massive `COND`\n\nstatement checking command types, the engine uses\nCLOS generic functions (`EXECUTE-QUEUED-COMMAND`\n\n). Each command class has a\nspecific method that invokes the appropriate state reducer (e.g., the\n`DRINK-COMMAND`\n\nmethod calls `DRINK-POTION`\n\n). This makes extending the engine's\nvocabulary trivial: adding a new command means defining a tiny data class and\nwriting exactly one generic method for it.\n\n`GAME-MAP`\n\n: Holds the `TILES`\n\narray. A `TILE`\n\ncontains purely static, shared geometry (walls, floors, room-type tags). Because this never mutates based on player action, a `GAME-MAP`\n\ncan be safely memoized and shared across multiple players on the same depth via the `*DUNGEON-CACHE*`\n\n.\n`GAME-STATE`\n\n: The server-authoritative snapshot for a specific player. It holds the `PLAYER`\n\nentity, the list of other `ENTITIES`\n\non the floor, the field-of-view `EXPLORED`\n\nbit-vector, and the `LEVELS`\n\nFSET map (which archives `DUNGEON-LEVEL-SNAPSHOT`\n\ns of previously visited floors).\n\nThe Imperative Shell: `RDESCENT-CLIENT`\n\nSitting at the very top of the\nstack is `RDESCENT-CLIENT`\n\n, a subclass of Hunchensocket's `WEBSOCKET-CLIENT`\n\n.\nThis is the only place where mutability is permitted. It acts as the anchor,\nholding the connection's thread-safe `INPUT-QUEUE`\n\nfor\nincoming commands, and the mutable pointer to the current immutable\n`GAME-STATE`\n\n. This is where the game state is updated with\nthe new game state.\n\nThe 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.\n\nAt 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.\n\nIf you want to try out the game, you can run it in your browser at\n[https://jrm-code-project.com/rdescent.html](https://jrm-code-project.com/rdescent.html). If you just\nwant to peruse the source code, you can find it\nat [https://github.com/jrm-code-project/jrm-code-public/tree/main/rdescent](https://github.com/jrm-code-project/jrm-code-public/tree/main/rdescent),\nbut try the game before you look at the code because the code\ncontains 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.", "url": "https://wpnews.pro/news/recursive-descent-vibe-coded-rogue-like-in-common-lisp", "canonical_source": "https://funcall.blogspot.com/2026/09/recursive-descent-vibe-coded-rogue-like.html", "published_at": "2026-09-03 07:00:00+00:00", "updated_at": "2026-09-03 07:22:55.233697+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "generative-ai"], "entities": ["Amit Patel", "Red Blob Games", "Common Lisp", "LLM", "WebSocket"], "alternates": {"html": "https://wpnews.pro/news/recursive-descent-vibe-coded-rogue-like-in-common-lisp", "markdown": "https://wpnews.pro/news/recursive-descent-vibe-coded-rogue-like-in-common-lisp.md", "text": "https://wpnews.pro/news/recursive-descent-vibe-coded-rogue-like-in-common-lisp.txt", "jsonld": "https://wpnews.pro/news/recursive-descent-vibe-coded-rogue-like-in-common-lisp.jsonld"}}