Tuesday, September 22, 2026

Not Everyone Can Code

Not everyone can debug code

Counterfactual reasoning is a technique where you assume alternative scenarios that could have happened, but did not in fact happen. You assume something is true when it is, in fact, false. A question involving a counterfactual is "What if dinosaurs had not gone extinct?" Reasoning with counterfactuals is considered an advanced cognitive skill, and it comes in quite handy when you are debugging code.

To debug effectively, you have to entertain insulting falsehoods. You must look at your own logic and ask: "Suppose this invariant I spent three days designing failed. Suppose this pointer is null, even though I checked it two lines ago. How does that world exist?" You have to temporarily decouple the state of the machine from your own intentions.

A surprising proportion of the adult population has extreme difficulty with counterfactual reasoning. They get actively frustrated or hostile when asked to reason from a premise they know to be untrue. Psychological studies (Dulit 1972, Keating 1979, Neimark 1975) estimate that roughly 20% of adults experience severe difficulty with counterfactual thinking, and another 20% experience moderate difficulty. This means that 40% or more of people are fundamentally anchored to the concrete, observable world. These people are not unintelligent; they simply possess a cognitive architecture that rejects hypothetical fictions. But because software state is entirely abstract, they will find diagnosing bugs an agonizing, borderline impossible task.

Not everyone can debug multithreaded code

Reasoning about multithreaded code is counterfactual reasoning on steroids. In a single-threaded program, you trace a single path of what happened versus what might have happened. In concurrent systems, you must simultaneously evaluate an exponential explosion of alternate histories. You have to reason about every possible interleaving of thread execution, asymmetric cache invalidations, and preemption points that could occur, even if they have never yet been observed in testing.

To the concrete mind, if a race condition does not manifest across a thousand test runs, it does not exist. To the concurrency engineer, the untested interleaving is a lurking ghost. You are not debugging a machine; you are debugging a multiverse. It requires maintaining a swarm of mutually contradictory realities in working memory all at once—a cognitive burden so unnatural that very few humans can do it well without the aid of formal models or pure architectural restraint.

Regardless of how you feel about the slogan "Everyone should learn to code", some people will have a vastly harder times than others.


Saturday, September 19, 2026

lisp.md: System Instructions for Lisp

I have an extensive lisp.md file that I include in my system instructions when I am vibe coding Common Lisp. Feel free to use these suggestions or adapt them to your own style.

Common Lisp & Functional Programming Directives

1. Identity, Role & Operating Posture

  • Role: You are a highly capable, supportive, and exceptionally effective companion to your user ('the Boss'). Your primary goal is to assist, understand, and anticipate his needs, providing proactive and insightful support.
  • Mastery & Humility: You possess the expertise of a world-class functional programmer with absolute mastery in Common Lisp (macros, CLOS, conditions, metaprogramming, libraries, and idiomatic patterns). You are humble and recognize that the Boss is a superior programmer; however, never hesitate to point out when the Boss is making a mistake and offer alternative solutions.
  • Support-First Focus: Seamlessly integrate companionable support with deep technical competence. Focus primarily on support—do not force technical demonstrations or unprompted Common Lisp snippets into every interaction. Provide technical answers only when requested or needed.
  • Documentation: Prefer comprehensive documentation strings over inline comments across all functions, classes, and constructs. Overuse, rather than underuse, docstrings to provide complete standalone context.

2. Naming & Lexical Conventions

Adhere strictly to these semantic naming signals:

  • Low-Level / Unsafe (% Prefix): When writing low-level code that punctures abstractions or carries unexpected preconditions, prefix the symbol with % (following and strictly enforcing the Common Lisp standard library convention).
  • Side-Effects (! Suffix): When writing functions that operate primarily through mutation or side effects, suffix the function name with ! (Scheme convention).
  • Predicates (? Suffix): When writing boolean predicates, suffix the function name with ? (Scheme convention). Prefer the ? suffix over the p suffix (Common Lisp convention) for clarity and consistency.
  • Symmetrical Arguments: In binary functions with symmetrical arguments, name the parameters left and right unless domain-specific names are distinctly superior.
  • Argument Ordering (Noun-First & Variance Hierarchy): When defining function parameter lists, order arguments deliberately from primary subject to variable context:
    • The "Noun" First: The primary entity, target data structure, or receiver of the operation (the "noun") must occupy the leftmost argument position. Subsequent arguments must follow in descending order of importance, configuration, or specificity.
    • Dynamic Variance in Iteration: When designing functions intended for collection transformations or iterative loops, order arguments such that the rightmost argument changes fastest (highest variance), while leftmost arguments remain invariant (lowest variance). This guarantees natural compatibility with left-to-right partial application (curry, partial-apply-left) when passing functions into higher-order combinators.
  • String Literals for Package & Symbol Designators: When generating package forms (e.g., defpackage, in-package), consistently use literal strings rather than symbols ("MY-PACKAGE" and "MY-SYMBOL", uppercase per CL convention). For general non-symbol string designators, use literal lowercase strings (e.g., "my-string").

3. Data Structures & CLOS

Emphasize immutability and declarative object dispatch:

  • Immutability First: Always prefer immutable data structures and pure functions.
  • defstruct Standards:
    • Mark all slots as :read-only t unless explicitly intended to be mutable.
    • Always use the :conc-name argument formatted as the type name followed by a slash (type/). For example, slot bar in struct foo generates the accessor foo/bar.
  • defclass Standards:
    • Always prefer :reader methods over :accessor methods unless mutability is required.
    • Reader methods must be prefixed with get- rather than the class name. For example, slot bar in class foo uses reader get-bar.
  • Generic Dispatch over Branching:
    • Transform etypecase bodies into CLOS generic functions with methods specialized on the classes being dispatched.
    • Transform ecase bodies into generic functions with methods specialized on eql values.

4. Control Flow, Recursion & Scoping

Prioritize explicit, descriptive, and well-scoped functional constructs over unstructured jumps:

  • No Imperative Loops: Strictly avoid imperative loop constructs. Do not use the loop macro.
  • Named let for Recursion: For iterative or stateful processes that cannot be expressed via higher-order functions, use tail-recursive named let expressions.
    • Syntax: (let name ((var1 expr1) (var2 expr2)) ...body...)
    • Semantics: The symbolic name directly follows let and binds the enclosing lambda, enabling self-referential tail calls within the body.
    • Constraints: This is part of the standard let macro syntax here (not a separate named-let macro). Never use loop as the loop name (it won't work); use next where applicable.
  • Proper Tail Recursion (TCO) & Constant Stack Space: Assume the underlying Common Lisp implementation (such as SBCL) guarantees proper tail-call optimization (TCO). Tail-recursive calls—particularly within named let expressions—execute in constant $O(1)$ stack space without accumulating stack frames or risking stack overflow. Write tail-recursive algorithms with full confidence; when required to enforce or guarantee elimination across compiler policies, supply appropriate optimization declarations, such as (declare (optimize (speed 3) (safety 1) (debug 1))).
  • No Unstructured Control Flow: Avoid generic labels or unstructured control-flow mechanisms (tagbody/go). Keep bindings clear, defined, and tightly scoped.
  • Continuation-Passing Style (CPS): Employ CPS when it is the most natural paradigm for the problem. When using CPS, always pass the continuation function as the final argument, invoking it with the computed result upon completion.
  • Local Boilerplate (macrolet): When generating repetitive boilerplate that does not escape the file, encapsulate it cleanly within a macrolet.
  • Macro Hygiene & Single Evaluation: When writing macros that take expressions as arguments, always use alexandria:with-gensyms to prevent variable capture and alexandria:once-only to guarantee arguments are evaluated exactly once and in left-to-right order.
  • Expressive Destructuring: Avoid deep accessor chains like car, cadr, or cddr. Favor destructuring-bind or multiple-value-bind to unpack compound structures into clearly named bindings at the entry of the computation.
  • Structured Conditions: When defining domain failure modes or invariant violations, prefer signaling typed conditions via define-condition and cerror / signal over generic raw-string (error "...") calls. This preserves restartability and structured inspection.
  • Native Multiple Values over Consing: When a function computes multiple related results, always use Common Lisp’s native values mechanism rather than consing intermediate lists or ad-hoc tuples. Callers should capture them cleanly via multiple-value-bind or nth-value.
  • Defensive Type Checking: Favor check-type at public function boundaries for defensive parameter verification, keeping invariant checks concise and declarative.

5. Collection Transformations & Higher-Order Functions

Transform collections purely using higher-order combinators and pre-defined functional libraries:

  • Pre-Loaded Libraries: Alexandria, FUNCTION, and the fold-left primitive are pre-defined and ready for use. Do not emit their implementations.
  • Aggregation via fold-left: Always choose fold-left over the general reduce function when collapsing a collection to an accumulated value, ensuring explicit left-associative reduction.
  • Selection via remove (Inverted Logic): Instead of a standard filter function, use remove paired with the negation of the selection predicate (e.g., using the :test-not keyword argument) to retain matching elements.
  • Partial Application: Utilize Alexandria’s curry and rcurry, or FUNCTION's partial-apply-left and partial-apply-right for clean, point-free partial function application.
  • Higher-Order Callback Signatures (The Echo Principle): When designing higher-order functions that accept a callable (combiner, transformer, predicate, or reducer) alongside domain arguments (collections, accumulators, seeds, or context), the parameters accepted by the callback must be an **exact positional echo** of the corresponding arguments in the enclosing higher-order signature:
    • Relative Positional Invariant:The relative left-to-right order of domain arguments in the outer call dictates the parameter order passed into the inner callable.
    • Exemplar — fold-left vs. fold-right:
      • In (fold-left function initial list &rest lists), the seed accumulator (initial) appears to the left of the sequences. Therefore, the folding function must accept arguments ordered as (state item1 ... itemN): the accumulated state on the left, followed by the sequence elements.
      • In fold-right, the signature is semantically n-ary with the base accumulator at the terminal position: (fold-right function list1 ... listN final) (where &rest args is an implementation detail to capture the trailing final). Therefore, the folding function must accept arguments ordered as (item1 ... itemN state): the sequence elements from left to right, followed by the accumulated state at the far right.
    • Self-Documenting Invariant: The outer call signature serves as an immediate, visual specification for the callback signature, completely eliminating ambiguity regarding whether an accumulator or an element comes first.
  • List Termination & Complexity: Never check for an empty list using (zerop (length ...)) or (= (length ...) 0). Always use endp or null? for constant-time $O(1)$ boundary checks in recursive traversals.

Functional Delegation: Thunks & Receivers

Utilize nullary and callback closures to decouple computation, delay evaluation, and manage scope cleanly:

  • Thunks (Zero-Argument Closures):
    • Use thunks ((lambda () ...)) to represent suspended, lazy, or deferred computations.
    • When writing higher-order control functions (e.g., custom transaction wrappers, retry logic, timeout runners, or timing harnesses), accept a thunk rather than relying on complex macro body expansion.
    • Accompany such functions with an ergonomic caller macro (e.g., call-with-... pattern paired with with-... macro) that wraps the user body in (lambda () ...) and delegates execution to the functional core.
  • Receivers (Consumer Callbacks):
    • When a procedure produces complex, streaming, or multiple values that shouldn't escape as bare untyped lists, accept a receiver function ((lambda (value ...) ...)).
    • Use receivers to cleanly decouple producers from consumers, process iterative elements without intermediate list allocations, and pass results forward in continuation-passing style.
  • Naming Conventions:
    • Functions accepting a thunk should follow the canonical Lisp standard library convention: prefix with call-with- (e.g., call-with-retry, call-with-transaction).
    • Argument names in higher-order signatures should explicitly be named thunk or receiver to make the operational contract immediately clear.

6. Interactive Lisp Environment Introspection

You are connected directly to an active Common Lisp runtime and can utilize it for:

  • Arithmetic & Expressions: Evaluating standalone calculations and symbolic expressions.
  • Environment Introspection: Inspecting defined symbols, classes, packages, variables, and runtime state.
  • Macro Expansion: Expanding macros to reveal their underlying forms.
  • Compile and Disassemble: Compiling and disassembling functions to inspect their generated code.

Sunday, September 13, 2026

FDES: Fast DES in LMI Lambda Microcode

Back in the 80s, Bob Baldwin was hacking cryptography at MIT and one hack he built was a streamlined DES implementation which tried to trim as many clock cycles as possible to do a DES encryption. Unix machines were using a salted DES password hashing scheme with a hardcoded constant of 64 zero bits that was encrypted 25 times.

In traditional Unix crypt(3), the salt—a 12-bit value derived from a 2-character ASCII string—permutes the expansion function E in the DES algorithm, swapping 24 bits of the round expansion so that hardware DES chips couldn't be used to accelerate password cracking.

Bob wrote a C implementation of the Unix crypt(3) DES that was fast enough to run a dictionary attack on a password file in a few hours. This got me inspired.

The LMI Lambda processor had basically the same data paths as the CADR and the TI Explorer; in fact, both the Lambda and Explorer had a CADR compatibility mode so that they could run exactly the same microcode. Like the CADR, the ALU took inputs from two sources: an M-source (scratchpad and functional registers) and an A-source (main register memory and constants). But when the LMI Lambda was built, memory prices had dropped and the memory used for the register sets had an extra address line. This address line wasn't just grounded, it was tied to a register bit which was never changed by standard microcode. If you changed the bit, you would swap the stack cache to the extra, unused memory space. You had to be careful: if a micro page fault occurred, the handler would try to push something and clobber the M registers which were stored in the low part of that memory space. I realized that I could put the DES S-boxes in the unused memory space and then write microcode to do a DES encryption. Like the CADR, the LMI Lambda had a barrel shifter which comes in pretty handy for something like DES.

The LMI Lambda had pageable microcode, so you could write microcode at the REPL and dynamically load it into the machine. You'd call the microcode as you would any other function.

The macro define-micro-function defines a microcode function, in this case called des-loop. The declare statement indicates that this microcode should be compiled as a miscellaneous instruction (one of the unassigned miscellaneous instructions would be allocated). There was a compiler directive that caused it to emit one of these miscellaneous instructions when the runtime system compiled a call to your function.

The code is written with the assumption that the swapped out memory has been laid out in a certain way, with the S-boxes in one block and the key schedule in another, etc. The code just refers to these memory locations freely, as if they were global variables. Input and output blocks are handled through the global block buffer: registers m-65 and m-64 hold the 32-bit left and right halves of the block. The microcode assumes that no other processes will be touching the memory while it is running. The microcode does not check interrupts while running, so expect stutters.

A quick note on CADR/Lambda microinstruction syntax: in general, a microinstruction takes the form ((destination) operation m-source a-source). The register in parentheses is the destination, the rightmost term specifies the A-bus source, and the second-to-rightmost term specifies the M-bus source.

(define-micro-function des-loop ()
  (declare (:compile-as-misc-instruction t))

These lines save the stack pointer, set the mode bit to zero, thus swapping the stack cache and the hidden memory, then set the stack pointer to 100 (octal. It is the convention on the Lisp machines that numbers are in octal unless they end with a decimal point.) so that micro page faults won't clobber the M registers which were stored in the low part of the same memory.

  ;; Work in hidden memory.
  ((a-saved-pdl-pointer) pdl-pointer)
  ((dp-mode) m-zero)
  ((pdl-pointer) (a-constant 100)) ;; octal

Then we load the m-a register with a tagged fixnum zero. This register serves as the master round counter across all 400 rounds of DES (25 iterations of 16 rounds each, as required by crypt(3)). We loop until the counter reaches 399 (decimal).

  ;; Initialize count.
  ((m-a) (a-constant (byte-value q-data-type dtp-fix))) ;m-a holds counter.

 des-round

Registers m-65 and m-64 hold the left and right 32-bit halves of the cipher block. What follows is the round expansion: using the barrel shifter and byte extraction/deposit instructions, the 32-bit right half in m-64 is expanded into 48 bits across m-66 and m-67, then salted according to the Unix salt permutation.

  ;; Expand the right half.
  ((m-66)  ldb (byte 1. 31.) m-64 a-zero)
  ((m-tem) ldb (byte 5.  0.) m-64 a-zero)
  ((m-66)  dpb (byte 5.  1.) m-tem a-66)
  ((m-tem) ldb (byte 6.  3.) m-64 a-zero)
  ((m-66)  dpb (byte 6.  6.) m-tem a-66)
  ((m-tem) ldb (byte 6.  7.) m-64 a-zero)
  ((m-66)  dpb (byte 6. 12.) m-tem a-66)
  ((m-tem) ldb (byte 6. 11.) m-64 a-zero)
  ((m-66)  dpb (byte 6. 18.) m-tem a-66)

  ((m-67)  ldb (byte 6. 15.) m-64 a-zero)
  ((m-tem) ldb (byte 6. 19.) m-64 a-zero)
  ((m-67)  dpb (byte 6.  6.) m-tem a-67)
  ((m-tem) ldb (byte 6. 23.) m-64 a-zero)
  ((m-67)  dpb (byte 6. 12.) m-tem a-67)
  ((m-tem) ldb (byte 5. 27.) m-64 a-zero)
  ((m-67)  dpb (byte 5. 18.) m-tem a-67)
  ((m-67)  dpb (byte 1. 23.) m-64 a-67)

  ;; Salt the expansion
  ;; Swap the bits by xoring the bits to swap, masking out
  ;; the non-swappping bits, and xoring the result back in.
  ((m-tem) xor m-66  a-67)                      ;find bits to swap
  ((m-tem) and m-tem a-57)                      ;mask non swapping bits
  ((m-66) xor m-66 a-tem)
  ((m-67) xor m-67 a-tem)

  ;; Xor in the key
  ((m-2) ldb (byte 4. 0.) m-a a-zero)           ;get key number.

The low 4 bits of the round counter in m-a are extracted into m-2, giving the current round key number (0 to 15). The functional source c-pdl-buffer-index reads the PDL buffer at the offset in the pdl-index register (indexing into our hidden memory space). The m-66 and m-67 registers hold the salted, expanded right half, and we XOR in the low and high halves of the scheduled key.

  ((pdl-index) add m-2 (a-constant 220))        ;read low key half
  ((m-66) xor c-pdl-buffer-index a-66)
  ((pdl-index) add pdl-index (a-constant 20))   ;read high key half
  ((m-67) xor c-pdl-buffer-index a-67)

This was the trick that made this implementation of DES so fast. The S-boxes are stored in the hidden memory, and we use the stack cache to do the lookups. The pdl-index register indexes into the stack cache, and c-pdl-buffer-index reads the 32-bit table entry. The 4-bit S-box outputs and the P-permutation were pre-compiled directly into these table words, accumulating into m-1 via ior.

In the CADR/Lambda byte-extractor (ldb), when given an A-source constant like 3000 (octal) and a 6-bit byte, it deposits the extracted 6 bits directly into the low bits of the base address in a single cycle without an ALU add. This is why the table base addresses were aligned to octal boundaries 3000, 3100, 3200, etc. (each table being 100 octal / 64 words long).

Here you also see a fundamental feature of the Knight architecture: the M-registers write through to the A-memory registers so that their values are available on both the A-bus and M-bus. When we accumulate S-box values in ((m-1) ior c-pdl-buffer-index a-1), we write to m-1, which instantly writes through to a-1, allowing the next cycle to read that accumulated value back in off the A-bus.

 ;; Pass through the s-boxes
  ((pdl-index) ldb (byte 6.  0.) m-66 (a-constant 3000))
  ((m-1) c-pdl-buffer-index)
  ((pdl-index) ldb (byte 6.  6.) m-66 (a-constant 3100))
  ((m-1) ior c-pdl-buffer-index a-1)
  ((pdl-index) ldb (byte 6. 12.) m-66 (a-constant 3200))
  ((m-1) ior c-pdl-buffer-index a-1)
  ((pdl-index) ldb (byte 6. 18.) m-66 (a-constant 3300))
  ((m-1) ior c-pdl-buffer-index a-1)

  ((pdl-index) ldb (byte 6.  0.) m-67 (a-constant 3400))
  ((m-1) ior c-pdl-buffer-index a-1)
  ((pdl-index) ldb (byte 6.  6.) m-67 (a-constant 3500))
  ((m-1) ior c-pdl-buffer-index a-1)
  ((pdl-index) ldb (byte 6. 12.) m-67 (a-constant 3600))
  ((m-1) ior c-pdl-buffer-index a-1)
  ((pdl-index) ldb (byte 6. 18.) m-67 (a-constant 3700))
  ((m-1) ior c-pdl-buffer-index a-1)

  ;; Xor the stuff in and swap halves.
  ((m-tem) m-65)                                ;m-tem<-L
  ((m-65) m-64)                                 ;L<-R

The LMI Lambda micro-engine has visible delayed branches. In jump-not-equal-xct-next, the instruction following the jump is executed unconditionally in the delay slot. For rounds 0 through 14, we jump ahead to check-if-done while computing the Feistel XOR ((m-64) xor m-tem a-1) in the delay slot (again reading the full 32-bit S-box result from a-1). On round 15 (the end of a 16-round pass), we fall through and execute the 3-instruction swap on m-65 and m-64 to set up the block for the next iteration.

  (jump-not-equal-xct-next m-2 (a-constant 15.) check-if-done)
 ((m-64) xor m-tem a-1)                 ;R<-gunk xor L

  ;; Swap halves every 16 iterations.
  ((m-tem) m-65)
  ((m-65) m-64)
  ((m-64) m-tem)

Finally, we check whether all 400 rounds have finished. The round counter increment in ((m-a) add m-a (a-constant 1.)) is executed in the delay slot of the branch back to des-round. Once the counter hits 399, we fall through.

 check-if-done
  (jump-not-equal-xct-next m-a (a-constant (plus (byte-value q-data-type dtp-fix)
                                                 399.))
                           des-round)
 ((m-a) add m-a (a-constant 1.))

We set the DP mode back to 1 to restore the stack cache to normal, restore the saved stack pointer, and return to Lisp. Returning with (jump xfalse) yields nil; the resulting ciphertext remains safely in the block buffer ready for retrieval.

  ;; Go back to lisp.
  ((dp-mode) (a-constant 1.))
  ((pdl-pointer) a-saved-pdl-pointer)
  (jump xfalse))

Usage

To use this microcode, you would first need to load it into the LMI Lambda processor (pretend you have one at your REPL). In all these examples, I assume a salt of zero. If you want to use a different salt, you call load-salt to load the salt mask into register m-57 (which writes through to a-57).

Next, you call load-key-char to load the key into the key buffer.

    (dotimes (i 8.) (load-key-char i (elt "foobar\0\0" i)))

Then you generate-c0-and-d0 to generate the initial key halves. Once you have the key halves, you call generate-scheduled-key according to the DES key schedule in user::key-shift-schedule.

    (generate-c0-and-d0)
    (do ((shift-list user::key-shift-schedule (rest shift-list))
         (n          0                        (1+ n)))
        ((null shift-list) nil)
      (generate-scheduled-key n (first shift-list)))

Finally, you call (load-block 0 0) to initialize the 64 zero bits into the block buffer (registers m-65 and m-64). Since the plaintext block is all zeros, we don't bother with the initial permutation (IP(0) = 0, though Unix crypt(3) deliberately omits both IP and FP anyway). Then we call (des-loop) to run the 400-round encryption.

    (load-block 0 0)
    (des-loop)

The encryption of the empty block will be left in the block buffer to be retrieved by retrieve-block and assembled into the password hash.

Conclusion

The custom microcode achieved about parity with Bob Baldwin's C implementation on the VAX 11/780, so it was more of a hack value than a necessary implementation. Yes, I did run a dictionary attack on a password file with it and discovered that it was easily able to crack the passwords in a few hours. No, I didn't do anything too nefarious with it before patching that security hole.


Friday, September 11, 2026

When I did interviews for Google, one of my go-to interview questions was to ask the candidate to determine if someone had won a game of tic-tac-toe. The candidate could choose the board representation and the language to use; I just wanted to see them write the code that checked for a winner. Inevitably, the candidate would choose a 2D array to represent the board. They might use an enum to represent the X and O pieces or they might use literal `X' and `O` characters, but they would always use a 2d array.

Then they'd write the code to check for a winner. Almost every time they would write a doubly nested loop to check each row and column for a winner. This has a hidden trap. The inner loop, which checks if all the pieces in a row or column are the same, will break out of the loop as soon as it finds a piece that is different. But the outer loop will break out of the loop as soon as the inner loop finds that all pieces are the same. This difference in the break conditions of the inner and outer loops would trip up the candidates and they'd have to go back and rework their logic. In languages like C, Java, or Python, breaking out of a nested loop on a success condition requires either a label-break, an awkward boolean sentinal flag, or wrapping the whole mess in a helper function just so you can use an early `return`.

It generally would take a candidate the entire interview to get the code right. If they did get it right, it would go a long way to convincing me that they were a good candidate, but candidates that couldn't complete the task within the hour interview were not good candidates. It was surprising at how many failed to complete the task.

If you naively vibe code a tic-tac-toe game, the model will choose either a 2-d array or a 1-d row-major array to hold the game. It will, no doubt, get the loops correct, but if you are looking for a well engineered solution, you will not get it by simply asking for a tic-tac-toe game. When I vibe code, I don't ask the model to just throw together a solution. I try to constrain the problem so that the model has no choice but to produce a well engineered solution.

Let's start with the board/game representation. A 2-d or 1-d array is obvious, but what are some other options. I posed this question to the model:

Prompt: I am creating a tic-tac-toe game. Give me options for modeling the board/game and the pros and cons of each option.
  Here are the primary ways to model it, along with the real engineering pros
and cons:

  ---

  ### 1. The Naive 2D Array / Matrix (`board[3][3]`) The textbook, beginner
approach. A 3x3 grid of characters, enums, or `nil`/`:x`/`:o`.

* **Pros:**
  * Maps 1:1 to visual human intuition and Cartesian coordinates `(row, col)`.
  * Trivial to render in a terminal or UI without index math.
* **Cons:**
  * **The "Interview Trap":** Checking win conditions is a slog of
  index loops. If you write loops to check rows, columns, and
  diagonals, breaking out early requires awkward dual-break conditions
  or clumsy state flags.
  * In languages with arrays-of-arrays (like Java or Python), you get
  pointer-chasing and extra allocations. 
  * Deep-copying state for minimax or tree search incurs unnecessary
  allocation overhead. 

  ---

  ### 2. The Flat 1D Vector / Array of 9 Elements (`board[0..8]`) A contiguous
9-element array where `index = row * 3 + col`.

* **Pros:**
  * Single contiguous memory block. Cheap shallow copies/clones.
  * Win conditions stop being dynamic loops and become a static list
  of 8 index triples: 

    ```lisp
    '((0 1 2) (3 4 5) (6 7 8)   ; rows
      (0 3 6) (1 4 7) (2 5 8)   ; cols
      (0 4 8) (2 4 6))          ; diags
    ```

    Win checking is just an `any?` over those 8 triples.
  * Trivial iteration for move generation (`position = 0..8` where
  value is empty). 
* **Cons:**
  * Requires div/mod arithmetic (`row = floor(i/3)`, `col = i % 3`)
  whenever mapping back to 2D UI coordinates. 
  * Still incurs allocation/array-copy overhead if doing brute-force
  search unless using a mutable backtracking board. 

  ---

  ### 3. Dual Bitboards (The Systems / Speed-Demon Approach) Represent the board
as two 9-bit integers: one bitmask for **X** and one for **O**. (Fits inside a
single 16- or 32-bit register). Bit $k$ is `1` if the player occupies cell $k$.

* **Pros:**
  * **Blistering fast:** Win-checking is pure bitwise arithmetic. You
  have a constant mask for each win line (e.g., top row is `7`
  (`#b000000111`)): 

    ```lisp
    (some (lambda (mask) (= (logand player-bits mask) mask)) *win-masks*)
    ```

  * Available moves is a single instruction: `empty = (lognot (logior
  x-bits o-bits)) & #x1FF`. 
  * Zero memory allocation, zero GC pressure. State is a tiny
  primitive integer passed in a register. 
  * Instant hashing/memoization for minimax or transposition tables.
* **Cons:**
  * Unreadable in raw form during interactive REPL debugging without a
  custom pretty-printer. 
  * Feels like overkill for Tic-Tac-Toe (unless you want to flex on
  branch predictors). 

  ---

  ### 4. The 3x3 Magic Square (The Pure Math / Lo Shu Trick) Map the 9 cells of
the board to the numbers 1 through 9 arranged in a Lo Shu Magic Square:

```text
 8 | 1 | 6
---+---+---
 3 | 5 | 7
---+---+---
 4 | 9 | 2
```

  In a magic square, **every row, column, and diagonal sums to exactly 15**, and
*no other combination of three cells does*.

  Each player simply holds a set/list of the numbers they've claimed.

* **Pros:**
  * **Board geometry completely disappears!** There is no grid, no
  coordinates, and no win-mask table. 
  * Win condition reduces to the classic 3-SUM problem: *Does any
  3-element subset of the player's numbers sum to 15?* 
  * Since a player can hold at most 5 numbers, there are at most
  $\binom{5}{3} = 10$ triplets to check. 
* **Cons:**
  * Translating between user input (e.g., clicking square `(0, 1)`)
  and the magic numbers requires a bidirectional translation layer. 
  * Move generation requires set difference (`available = {1..9} - (X ∪ O)`). 

  ---

  ### 5. Single Integer Base-3 / Ternary Encoding ($3^9$) Each cell has 3
states: `0` = Empty, `1` = X, `2` = O. The entire game state is a single integer
from $0$ to $3^9 - 1 = 19,682$.

* **Pros:**
  * Total state space is so laughably small (19,683 states) that you
  can **precompute the entire game into a static lookup table**. 
  * Perfect play AI becomes an $O(1)$ array lookup: `best-move =
  precomputed-table[current-state-int]`. 
* **Cons:**
  * Extracting or setting a cell requires division/modulo by powers of
  3 (or ternary bit manipulation), which is more tedious than binary
  bit shifting. 

  ---

  ### 6. Pure Move History / Event Sourcing (The Declarative Approach) The game
state is just an immutable list of moves played so far: `'((:x . 4) (:o . 0) (:x
. 8) ...)`

* **Pros:**
  * Instant, zero-cost **undo/redo** (just `cdr` the list).
  * Turn order and move count are strictly derived from list length
  (`oddp`/`evenp`). 
  * Pure functional semantics; zero mutable state. Perfect for
  serialization, replays, and network protocols. 
* **Cons:**
  * Querying "who owns cell 4?" requires scanning the list (though
  with a maximum list length of 9, $O(9)$ is practically $O(1)$). 

  ---

### My Verdict
* If you want **pure semantic elegance and quick hacking**, go with
  **#2 (Flat 1D array of 9 with 8 static win triples)** or **#6
  (Event-sourced move list)**. 
* If you want **raw algorithmic flex**, go with **#3 (Bitboards)** or
  **#4 (The Magic Square)**.  

The model is obviously capable of `thinking` up representations other than the obvious 2-d or 1-d array. If we add further conditions on the environment in which we will play the game, the model can determine the best representation.

Prompt: I am running a tic-tac-toe tournament on a web server with a massive number of simultaneous players. Expert commentators will opine about ongoing games and discuss speculative moves and potential alternative move histories. Games will be stored and played back. Choose a representation suitable for this use case.
  For this scenario, there is only one representation that doesn't collapse
under its own weight: **Event Sourcing via an Immutable Singly-Linked List (or
Tree/DAG) of Moves.**

  A game state is not a grid; **a game state is a historical fact.**
Specifically, a game is simply an immutable sequence of moves:

```lisp
;; A move is just a cell index (0..8) and a player.
;; A game is a persistent list of moves:
(cons current-move parent-history)
```

  Here is why this completely blows every grid- or array-based model out of the
water for your exact requirements:

  ---

  ### 1. Zero-Cost Speculative Branching for Commentators When an expert
commentator says, *"What if on turn 4, O had taken the bottom-left instead of
the center?"*, an array-based engine has to clone mutable boards and maintain
stateful divergence.

With an immutable, persistent move list, **branching is literally just a `cons` cell:**
* The commentator grabs the node representing turn 4.
* They append their speculative move: `(cons alternative-move turn-4-node)`.
* **Zero copy. Instant branch.** 
* The alternate timeline shares 100% of the memory of the original
  4-turn prefix with the live game (structural sharing). You can have
  thousands of commentators exploring wildly branching "what-if" trees
  simultaneously without burning RAM or spinning up isolated game
  engines. 

### 2. Time-Travel and Scrubbing are Trivial
* **Undo / Rewind:** To step backward in time to any point in the
  game, you don't calculate reverse moves or pop undo stacks. You
  simply take the `cdr` (parent pointer) of the current state. 
* **Playback:** Storing a replay is just storing the list of moves. A
  full game is at most 9 moves long. Replaying it on the client or
  server is a deterministic fold/reduction over the list. 

  ### 3. Web-Scale Concurrency Without Locks A mutable 3x3 board on a web server
handling massive concurrent games requires synchronization, mutexes, or
defensive copying across threads.

With immutable event-sourced states:
* Game states are pure, thread-safe values.
* Making a move in an active game is simply an atomic compare-and-swap
  (CAS) on a single pointer: updating the game's head from
  `old-history` to `(cons new-move old-history)`. 
* Readers (thousands of spectators polling the current state) never
  block writers, and writers never block readers. 

### 4. Absurdly Compact Storage
Because Tic-Tac-Toe is bounded at 9 moves, and each move is an index
  from `0..8` (which fits in 4 bits): 
* An entire game’s move history can be packed into **36 bits** (less
  than 5 bytes!). 
* Even stored as raw JSON or database rows, an append-only log of
  moves is dirt cheap to index, shard, cache at the CDN edge, and
  persist indefinitely. 

  ### 5. But what about rendering and win-checking? The classic objection to
event sourcing is: *"How do you know who won or where pieces are without
scanning?"*

  In Tic-Tac-Toe, the maximum history length is **9**. Replaying a 9-element
list into a bitmask or flat array to project the board for a client UI or check
for 3-in-a-row takes less than a microsecond. The projection cost is effectively
zero, while the architectural leverage gained for time-travel, commentary trees,
and concurrency is infinite.

  ---

  ### The Verdict
Model each state as a node in a **Directed Acyclic Graph of
moves**. The live match is just the leading edge of a path; commentator
speculation is just a branch off an existing ancestor node. It turns your game
server into a Git-like tree of move hashes.

With the representation nailed down, we can now get to the task of coding.

Prompt:Code up methods to test if a move is legal, to make a move, and to test if a player has won the game.
  Here is the pure, immutable event-sourced implementation in Common Lisp.

A game state is modeled as a persistent list of integer moves `(0..8)`.
* Since moves alternate strictly starting with **X**, the length of
  the history deterministically tells us whose turn it is: 
  * Even length $\rightarrow$ **X**'s turn
  * Odd length $\rightarrow$ **O**'s turn
* Moves are prepended so the latest move is at the head (`car`),
  giving $O(1)$ branching and time-travel via structural sharing
  (`cdr`). 

```lisp
;;;; tic-tac-toe.lisp — Event-Sourced Immutable Engine

(defpackage :ttt
  (:use :cl)
  (:export :make-initial-game
           :legal-move-p
           :make-move
           :current-player
           :winner
           :game-over-p))

(in-package :ttt)

;;; Board representation:
;;;
;;;  0 | 1 | 2
;;; ---+---+---
;;;  3 | 4 | 5
;;; ---+---+---
;;;  6 | 7 | 8
;;;
;;; Precomputed win masks (9-bit integers):
(defparameter *win-masks*
  (list #b000000111   ; row 0: (0 1 2)
        #b000111000   ; row 1: (3 4 5)
        #b111000000   ; row 2: (6 7 8)
        #b001001001   ; col 0: (0 3 6)
        #b010010010   ; col 1: (1 4 7)
        #b100100100   ; col 2: (2 5 8)
        #b100010001   ; diag:  (0 4 8)
        #b001010100)) ; diag:  (2 4 6)

(defun make-initial-game ()
  "An empty game history."
  nil)

(defun current-player (history)
  "Returns :X if an even number of moves have been played, otherwise :O."
  (if (evenp (length history)) :x :o))

(defun player-bitmask (history player)
  "Projects the move history into a 9-bit bitmask for the specified player.
   X moved on turns 0, 2, 4... (from the start), O on turns 1, 3, 5..."
  (let ((mask 0)
        (total (length history)))
    ;; Moves in history are in reverse order (newest first).
    ;; The move at index i from the start was played on turn (- total i 1).
    (loop for move in history
          for turn from (1- total) downto 0
          when (eq player (if (evenp turn) :x :o))
            do (setf mask (logior mask (ash 1 move))))
    mask))

(defun winner (history)
  "Returns :X or :O if that player has completed a winning line, or NIL."
  (flet ((wins-p (player)
           (let ((pmask (player-bitmask history player)))
             (some (lambda (wmask)
                     (= (logand pmask wmask) wmask))
                   *win-masks*))))
    (cond ((wins-p :x) :x)
          ((wins-p :o) :o)
          (t nil))))

(defun game-over-p (history)
  "A game is over if someone won, or all 9 squares are occupied."
  (or (winner history)
      (>= (length history) 9)))

(defun legal-move-p (history move)
  "A move is legal if:
   1. It is an integer in the range 0..8.
   2. The square has not been occupied in this timeline.
   3. The game is not already won or full."
  (and (integerp move)
       (<= 0 move 8)
       (not (member move history))
       (not (game-over-p history))))

(defun make-move (history move)
  "Returns a new game state branch with the move prepended.
   Signals an error if the move is illegal."
  (unless (legal-move-p history move)
    (error "Illegal move ~A for state ~A (Current player: ~A)"
           move history (current-player history)))
  (cons move history))
```

  ---

  ### Why this shines for the Commentators & Speculation

1. **Branching / What-If Scenarios:**

   ```lisp
   ;; Live game after 3 moves: X played 4, O played 0, X played 8
   (defvar *live-game* "(8 0 4))

   ;; Commentator A wonders: &dquo;What if O had picked 2 on turn 1 instead of 0?&dquo;
   ;; We rewind to turn 0 (cdr *live-game*) and branch:
   (defvar *speculation* (make-move (cdr *live-game*) 2))
   ;; => (2 4)
   ```

   Zero cloning of boards. Both timelines structurally share the root `(4)`.

2. **Playback and Time Travel:**
   * Stepping back one turn is simply `(cdr history)`.
   * Fast-forwarding or inspecting any moment in the game requires no reverse-action undo logic.

Naive vibe coding leads to AI slop: the statistical average of all the code that the model has been trained upon. Sure it might work, but it is hardly going to be a solution that is engineered for the problem domain. Instead, we have vibe coded by first asking the model to present a number of solution options with the pros and cons of each one. We could select one ourselves, but in this case I let the model select the representation based on constraints derived from our use case. We constrain the solution space to starve out the model from bad solutions. The model has no choice but to output code tailored for our use case.


Saturday, September 5, 2026

Githack: A Persistent Object Store for Lisp Based on Git

Git has a built-in persistent store for objects based on Merkle trees. It is tailored to store files and directories, but these are just specializations of trees of blobs. There is no reason it couldn't be used to store Lisp objects.

Githack is a Lisp object store that uses Git as its backend. It is a simple library that provides persistent objects for Lisp and a transactional interface for manipulating them. Simple atomic objects are stored as blobs and composite objects are stored as trees. Standard composite Lisp objects, such as lists, vectors, and hash tables, are supported. Custom composite objects can be created through DEFINE-PERSISTENT-STRUCT or DEFCLASS with a :STANDARD-PERSISTENT-METACLASS.

WITH-REPOSITORY is used to specify which repository to use for storing objects. WITH-TRANSACTION sets up a transaction for manipulating objects and retrieves the root object. You use standard slot accessors to walk the object tree. When you are done, you commit the transaction, and modifications are atomically written to the repository with a new root object being placed in a Git branch.

By placing the database in an orphan Git branch, you can store it right beside your source code without tangling the histories. You can use Git to manage the history of the database, branch it, and share it with others. Githack even stores object docstrings as README.md files inside the repository trees, so the stored objects are natively self-documenting in the Git web UI.

Githack comes with example code and an example database living on its own orphan branch, so if you clone the repository, you'll clone the working example database as well.

Because database states are strictly maintained in Git refs, Githack supports sophisticated transactional topologies. You can nest WITH-TRANSACTION blocks to create savepoints, allowing speculative Lisp execution that easily rolls back on error. Furthermore, Githack dynamically tracks repository mutations during a transaction. If a transaction spans multiple orphan branches or entirely separate repositories, it automatically upgrades to a fault-tolerant Two-Phase Commit (2PC) using Git Annotated Tags as the transaction ledger, ensuring data consistency even if the Lisp process crashes mid-commit.


Friday, September 4, 2026

Will it DEFMACRO?

Why write boilerplate code when you can ask an LLM can do it for you? But even LLMs will avoid boilerplate if they can. I recently vibe coded a rogue-like game in Common Lisp. While I gave the model specific directions at certain points, I mostly let the model generate the code as it saw fit. I found some interesting suprises in the generated code.

One feature of these sorts of games is that there is a lot of varied `stuff` you can encounter. This keeps the game interesting and maintains the novelty and sense of discovery as it takes a long time for the player to discover everything about the game. While it is fun to think of all of the different things to put in the game, it is a bit tedious to actually implement them all. You want a large variety of things, they should all be different in more than just their description, and the effect of encountering and using an item should be appropriate to the kind of item it is. It would be boring if every food item simply retored 5 health, but much more entertaining if some food restored health, some restored stamina, some made the player stronger, etc., and even better if it were spinach that restored strength, an elixir that restored health, and an energy drink that restored stamina.

So I prompted the model with a few examples of the kinds of things I wanted to see in the game and told it to extend my list with its own ideas, and to implement them in the game.

Typically, this is where the tedium sets in because you need to write a lot of boilerplate code that just has subtle variations. LLMs are good at boilerplate, so I expected to find that. But an experienced Common Lisp programmer would write a macro to generate the boilerplate based on a few customization parameters. I was pleasently surprised to see that the model did exactly this.

(defmacro define-armory-equippable-item (class-name display-name equip-slot stat-bonuses documentation
                                          &key (weapon-reach 1) (weapon-hits-per-turn 1) on-hit-effect)
  "Define a stateless EQUIPPABLE-ITEM subclass CLASS-NAME and its
matching MAKE-CLASS-NAME factory. The repetitive §13 armory content is
all fixed-data leaf classes like STACK-OF-UNREAD-MEMOS, so a single
macro keeps their definitions uniform without introducing any runtime
registry or mutable catalog layer. The generated factory accepts a
&KEY MODIFIER (default :NORMAL), passed straight through as the new
instance's own ITEM-MODIFIER -- see EQUIPPABLE-ITEM's own docstring
for what :CURSED/:BLESSED do -- and &KEY CLOAKED (default T), passed
straight through as the new instance's own ITEM-CLOAKED-P (see
EQUIPPABLE-ITEM's own docstring for what cloaking hides)."
  (let ((factory-name (intern (format nil "MAKE-~A" (symbol-name class-name)) (symbol-package class-name))))
    `(progn
       (defclass ,class-name (equippable-item)
         ()
         (:default-initargs :name ,display-name
                            :equip-slot ,equip-slot
                            :stat-bonuses ,stat-bonuses
                            :weapon-reach ,weapon-reach
                            :weapon-hits-per-turn ,weapon-hits-per-turn
                            :on-hit-effect ,on-hit-effect)
         (:documentation ,documentation))
       (defun ,factory-name (&key (modifier :normal) max-durability durability (cloaked t))
         ,(format nil "Pure factory: return a fresh ~A. MODIFIER (default :NORMAL) is passed
straight through as the new instance's own ITEM-MODIFIER. CLOAKED
(default T) is passed straight through as the new instance's own
ITEM-CLOAKED-P (see EQUIPPABLE-ITEM's own class
docstring). MAX-DURABILITY/DURABILITY (default NIL, meaning \"use
EQUIPPABLE-ITEM's own class default/derive from MAX-DURABILITY\" --
see its class docstring) are only forwarded to MAKE-INSTANCE when
explicitly supplied, so a direct/test call keeps this item's usual
deterministic *RDESCENT-DEFAULT-ITEM-DURABILITY*." display-name)
         (apply #'make-instance ',class-name :modifier modifier :cloaked cloaked
                (append (when max-durability (list :max-durability max-durability))
                        (when durability (list :durability durability))))))))

This macro generates the class definition for the item and a factory function to create instances of the item. The required arguments are common to all equippable items, and the optional arguments are modifiers that are only relevant to certain items. This macro is used to define each item that can be equipped by the player.

(define-armory-equippable-item branded-corporate-yeti-mug
  "Branded Corporate Yeti Mug"
  :off-hand
  (list :caffeine-tolerance 3)
  "Off-hand mug granting +3 :CAFFEINE-TOLERANCE, which today feeds the
already-wired kombucha healing formula through EFFECTIVE-CAFFEINE-
TOLERANCE. Its planned refill/drain-rate behavior is deliberately
deferred because no passive CAFFEINE-TOLERANCE depletion system exists
yet."
  )

(define-armory-equippable-item lanyard-of-the-vip
  "Lanyard of the VIP"
  :head
  (list :seniority 2)
  "Neck-flavored accessory implemented in the shared :HEAD slot, with a
real +2 :SENIORITY bonus feeding deflection/detection formulas. Its
planned \"SecOps Auditor aggro radius to zero\" behavior is deliberately
deferred because that monster/archetype-specific aggro mechanic does
not exist yet."
  )

Simple uses of the macro are straightforward you specify class name, the location in which it can be equipped, what stats are modified by equipping the item, and a docstring.

 (define-armory-equippable-item red-swingline-stapler
  "Red Swingline Stapler"
  :weapon
  (list :power 2)
  "Low-damage weapon with a 10% on-hit :BLEED effect. :BLEED is wired
for real as a small damage-over-time effect via STATUS-EFFECT's own
MAGNITUDE slot; the plan text's additional \"panic the target\" rider
is deliberately deferred because the current AI has no temporary panic
status that can cleanly override disposition/pathing."
  :on-hit-effect (list :kind :bleed :turns *rdescent-bleed-ticks*
                       :magnitude *rdescent-bleed-damage-per-tick* :chance 0.10))

This item has a special effect that is applied when it hits an enemy. The optional argument to the macro allows you to specify the effect. Note that the model understood the semantic context of the weapon (staples puncture, punctures bleed) and it invented the `:bleed` effect on its own and implemented the mechanics of it within the game.

(define-armory-equippable-item three-foot-ethernet-cable
  "3-Foot Ethernet Cable (Cat 6)"
  :weapon
  (list :power 2)
  "Fast whip-style weapon: low damage, two hits per attack action, and
real reach 2 through WEAPON-REACH/WEAPON-HITS-PER-TURN. The plan text's
crowd-control flavor is therefore approximated through the existing
combat scheduler rather than a new knockback or entangling subsystem."
  :weapon-reach 2
  :weapon-hits-per-turn 2)

This weapon uses the optional arguments to specify a longer reach and faster attack speed than standard weapons. Again, note that this is appropriate for the item.

The model generated twenty-eight different equippable items of various types with varying bonuses and effects. This illustrates the model's ability to effectively use macros to reduce the boilerplate code that would otherwise be necessary to implement items. It also illustrates that the model understands both the theme of the game and the nature of the items being implemented.

Most items in the game can be discovered just sitting around on the ground, so most items have `ground` wrapper that provides an object that occupies a tile on the map.

(defmacro define-ground-armory-item (name item-factory char color)
  "Define the MAKE-GROUND-* wrapper corresponding to ITEM-FACTORY for a
§13 equippable item."
  (let* ((item-name (symbol-name item-factory))
         (prefix-length (length "MAKE-"))
         (suffix (subseq item-name prefix-length))
         (ground-name (intern (format nil "MAKE-GROUND-~A" suffix) (symbol-package item-factory))))
    `(defun ,ground-name (x y level)
       ,(format nil "Pure factory: return a fresh GROUND-ITEM wrapping ~A." name)
       (make-ground-equippable-item x y level ,char ,name ,color (,item-factory)))))

(define-ground-armory-item "Red Swingline Stapler" make-red-swingline-stapler #\) "#d08770")
(define-ground-armory-item "3-Foot Ethernet Cable (Cat 6)" make-three-foot-ethernet-cable #\) "#d08770")
(define-ground-armory-item "Lanyard of the VIP" make-lanyard-of-the-vip #\] "#b48ead")
(define-ground-armory-item "Branded Corporate Yeti Mug" make-branded-corporate-yeti-mug
  *rdescent-corporate-trinket-char* "#8fbcbb")

This macro generates the function name for the ground item based on the name of the factory function for the item. So if `make-red-swingline-stapler` is the factory for the stapler, then `make-ground-red-swingline-stapler` is the factory for the ground item that wraps the stapler. The macro also generates a docstring for the function.

I told the model that it was allowed to use the Latin-1 character set so that it would have a larger set of characters to choose from when selecting a character to represent the item on the map. In the case of the Branded Corporate Yeti Mug, it chose the *rdescent-corporate-trinket-char*, which is, quite appropriately, the registered trademark symbol ®. Again this indicates that the model understood the theme of the game.

The model of course saved hours of tedious typing, but by generating macros to define items, it reduced the actual boilerplate and increased the maintainability of the code.


Thursday, September 3, 2026

Recursive Descent: Vibe Coded Rogue-like in Common Lisp

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 wanted to see if I (actually the LLM) could code up a rogue-like game in the browser
  • I wanted to see what sort of interesting code the LLM would produce: Would it be a good design? Would it use macros? Would it use CLOS? Would it get overly complex and "hit the wall" at some point?
  • How would it handle being told to work with functional programming style given that this sort of game is traditionally written in stateful, object-encapsulated style?

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.

Beginning

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.

Real time back end

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.

Decoupled I/O State

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.

Procedural Dungeon Caching

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.

Save Games Stored on the Client

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.

Client Registry

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.

Fat Base Class

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.

Entity Subclasses

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.

Fixture Hierarchy

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.

Command Pattern

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.

State Containers

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-SNAPSHOTs 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.

Conclusion

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. 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.