flan.
A statically typed Lisp for making games. Clojure's brackets. C's memory. No garbage collector. A REPL into the running process.
Flan is what happens when you want Odin's memory model and Common Lisp's debugger at the same time and refuse to pick.
There are no object headers, so a Flan struct is its C struct. There is no collector, so nothing runs between your frames that you did not write. And the program you are running is not a build artifact you replace — it is a thing you can edit while it is still going.
Edit the code, keep the sand.
The twenty-millisecond loop
Run flan dev sand.flan. A window opens, sand falls, and a socket appears next to
your source file. Open Emacs, hit C-c C-z, and you are attached to the live process.
Now put your cursor in a function and press C-c C-c.
That function is recompiled and installed into the running program at its next frame
boundary. The whole round trip is about 19 milliseconds, of which the actual
hot-swap — dlopen plus dlsym — is 0.04 ms. The rest is the compiler doing
its job. It reads your buffer, not your saved file, so there is no ceremony.
The window does not blink. The grid does not reset. Your sand keeps falling.
C-c C-c |
recompile this function into the live program |
C-x C-e |
run this expression inside the running process — (len enemies) returns the real number |
C-u C-c C-c |
set a breakpoint without editing the buffer |
C-c C-m |
expand a macro, one step or to the fixpoint |
C-c C-b |
open the break loop when something goes wrong |
C-c C-r |
a REPL, scoped to the program |
When it breaks, it does not die
Most languages give you two options when something goes wrong: crash, or have guessed in advance what you wanted. Flan has Common Lisp's third option.
flan: unhandled Missing — stopped, not dead.
0. restart: carry-on
1. restart: use-placeholder
The program is sitting on the frame where the error happened, with everything it had still in scope, waiting for you to decide. Pick a restart and it carries on — it never unwound, so there is nothing to reconstruct.
Handlers run on the signalling frame, without unwinding, which means you can also just… not have an error:
;; A handler that returns normally accumulates and lets the signaller run on.
(handler-bind [(AssetMissing [c] (set seen (+ seen (i64 (.id c)))))]
(load-all))
No monad. No Result threading. No early return. The signaller carries on.
A breakpoint, incidentally, is not a feature. pause is an ordinary function that
signals a Pause condition, and a breakpoint is just a condition nobody handled.
The language
Four container types, four honest ownership stories. [n T] is a fixed array
and it is a value — it copies. [T] is a slice: a pointer and a length that owns
nothing. (Vec T) and (Map K V) own their storage and move rather than copy.
;; No initialiser means all-bytes-zero, so this lives in BSS and costs nothing.
(defvar grid [rows [cols i32]])
(let [row (slice (at grid 1) 0 cols)] ; ptr+len, borrows
(set (at row 0) 5)
(println (at grid 1 0))) ; 5 — the same storage
(set grid (zeroed)) ; a memset, not an allocation
defer is a compile-time construct, copied into the exit paths. Innermost
first. There is no runtime stack of thunks to pay for.
(defn work [n i32] i32
(defer (println "second"))
(defer (println "first"))
(when (< n 0)
(return 0)) ; runs both defers above it
n)
Generics are monomorphised, and checked once. The body is verified abstractly at the definition, so an unsupported operation is an error where you wrote it — not at whichever call site happened to pass a type that worked.
(defn clamp-to [x $t lo $t hi $t] $t
{:where (ordered? $t)}
(min (max x lo) hi))
Macros are compiled, dlopened, and called. There is no interpreter in this project and there is not going to be one — compiling is the only way a form is ever run, so there is no second evaluator to disagree with the first. Running a file that calls a macro means the compiler built a shared object and loaded it into itself before parsing your first line.
The FFI is one line per function. No wrapper, no shim.c:
(declare-c init-window [width i32 height i32 title string] "InitWindow")
(declare-c window-should-close? [] bool "WindowShouldClose")
Color crosses by value. Vector2 comes back by value. The compiler writes the
flattening C so you don't. There are more than 470 raylib bindings in the box, and
29 of raylib's own examples ported.
Keywords are enums with an integer's ABI:
(defenum Key [space 32 escape 256 left 263 right 262])
(key-pressed? :space) ; resolved at compile time; a typo is an error here
The demo
sand.flan is a falling-sand toy: 180×120 grains at 120fps. Hold the mouse and
sand pours out of the cursor, release and the colour cycles, R clears it.
It is 206 lines, and it deliberately uses almost nothing — no Vec, no Map, no
generics, no macros of its own, no allocator beyond the stack and static storage.
The grid is [rows [cols u32]]: flat, unboxed, in BSS, exactly rows*cols*4 bytes.
The same memory the Odin port has. Nothing in the frame loop allocates.
The same file is also the regression test. sand-headless.flan imports it as a
package and — because the linker follows what the program actually reaches — pulls
in neither a window nor libraylib. It runs N frames and hashes the grid:
15595743031174623232
That number is byte-identical on native x86-64 and on wasm32-wasi, at -O2 and at
-O0. The random number generator is written in Flan rather than borrowed from
libc precisely so that it would be.
Two backends that agree
There is the LLVM backend, and there is a second one — about 3,400 lines of OCaml
that emits x86-64 machine code directly, byte by byte, because llc was most of
those 19 milliseconds and that was annoying.
Every program in the test corpus produces byte-identical stdout, stderr and exit status under both backends. Not similar. Identical. There is a script that checks this and it is the only reason anyone trusts the second one.
It also has a rule, stated in capital letters in two separate file headers:
There is still no aggregate classifier and there must not be one.
What works, and what doesn't
Works, and is tested at -O2, -O0 and --dev — often under Valgrind and
ASan too: structs, unions, enums, match, generics, Option, macros, packages,
conditions and restarts, defer, arenas and explicit allocators, all four
containers, handles and pools, bounds checking, arithmetic errors as conditions,
the raylib FFI, UTF-8 strings, compile-time embed, the whole Emacs loop, a
wasm32 target, and DWARF debug info you can step through in lldb.
Designed, refused by name, not built yet: (Result T E) and try, errdefer,
handler-case, find-restart, user-written allocators, threading macros, and
await. The compiler will tell you which milestone each belongs to rather than
producing a confusing parse error.
Honest limits. Redefinition cannot patch a frame that is currently executing and resume at the same instruction — its register allocation belonged to the old compilation. "Resume" means re-entering from a restart. And changing a function's signature is rejected rather than applied; changing its body is always safe, because old code is never unloaded.
Try it
dune build # the compiler
dune test # 232 checks
flan run sand.flan # falling sand
flan dev sand.flan # falling sand you can edit
Then in Emacs: M-x flan-dev, or C-c C-z to attach to the one you just started.
Reading further
plan.org— the design and the open decisionsspec-memory.md— ownership, containers, genericsspec-conditions.md— what a restart actually isemacs/MANUAL.md— every key binding and what it doesweb/index.html— the language reference, including a table of everything that is not implemented and the exact words the compiler uses to refuse it
Licence
MIT. See LICENSE.
The vendor/ directory carries other people's work under their own terms —
raylib is zlib/libpng, and the committed raylib-5.5.h stays under it.
Flan is a custard.