loop/recur: the heart of DOOM; march a ray forward until it hits a wall
(defn cast-ray [grid x y angle]
(let [dx (php/cos angle)
dy (php/sin angle)]
(loop [dist 0.0]
(cond
(> dist max-depth) max-depth ; nothing hit
(wall? grid (+ x (* dist dx))
(+ y (* dist dy))) dist ; hit!
:else (recur (+ dist 0.05)))))) ; keep stepping
No mutable counter. Even the raycaster is pure - unit-test in one line.
Once per screen column - ~120-180 rays a frame.
(deftest test-fov-proj-dist-flat-below-clamp
(is (= proj-dist (fov-proj-dist 80))))
(deftest test-pickup-hearts-consumes-cell-and-heals-two
(let [w (merge (player-at 5.0 5.0) {:hearts [{:x 5.0 :y 5.0}] :lives 4})
w' (pickup-hearts w)]
(is (= 0 (count (:hearts w')))) ; heart consumed
(is (= 6 (:lives w'))))) ; one heart = 2 half-hearts
composer test # 4,152 tests, green the whole way
No mocks. No fake terminal. No DI container.
Pure functions: call with input, check output. Done.
Target < 5 ms per frame · measured ~4.5 ms (game logic 0.3 - 0.5 ms)
Pure: same input, same output. Cache can't be wrong.
(defn new-player [x y angle] {:x x :y y :angle angle})
phel build → out/phel_doom/core/state.php
\Phel::addDefinition("phel_doom.core.state", "new-player",
new class() extends \Phel\Lang\AbstractFn {
public function __invoke($x, $y, $angle): PersistentMapInterface {
static $__phel_const_0, $__phel_const_1, $__phel_const_2;
return \Phel::map(
($__phel_const_0 ??= \Phel\Lang\Keyword::create("x")), $x,
($__phel_const_1 ??= \Phel\Lang\Keyword::create("y")), $y,
($__phel_const_2 ??= \Phel\Lang\Keyword::create("angle")), $angle);
}
});
So… should you use it?
700+ commits, 20 releases. Tiny PRs, each closing one issue.
Enough slides.
phel run phel-doom.main playF), pickup, door → next level.--god --armory -l 10 → cyberdemon bossF3 debug overlay → live ~5 ms frame budgetReach for it when immutability + a REPL pay off.
Questions? The REPL is open.
PRESENTER DECK, Marp. npx @marp-team/marp-cli deck.marp.md -o deck.html --allow-local-files (interactive, press p for notes) npx @marp-team/marp-cli deck.marp.md -o deck.pdf --allow-local-files (clean PDF) npx @marp-team/marp-cli -p -w deck.marp.md --allow-local-files (live preview) (--allow-local-files is REQUIRED: the deck embeds assets/screenshot.png) STRUCTURE: simple → complex. Intro + functional ideas + Acts 1-2 = Phel basics (no game code). Act 3+ = real phel-doom code.
Before any syntax: what "functional" means. Phel is a Lisp, but the Lisp part is only the syntax. These five ideas are what change how you design code. Every one shows up later in DOOM: immutable world map, pure tick-world, map/filter, the -> pipeline, the io/ folder.
The PHP/TS version looks harmless. Anyone else holding $p now sees hp changed. In Phel the old value stays as it was, so a bug in one function cannot leak into another. We will see each benefit with real numbers in Act 3: 4,152 tests with no mocks, memoized frame data.
DEMO: phel run phel-doom.main demo --phase 1 (bare raycaster: 3D left, 2D map right, walls only)
Curved/bulging walls? We use perpendicular distance, not raw, which kills the fisheye.
Simplified step-march; the real engine uses DDA. DEMO: phel run phel-doom.main demo --phase 2 (+pistol) / --phase 3 (+enemies) / --phase 4 (+cover walls)
Nothing to mock: no clock, RNG, or renderer to fake.
composer bench, 2026-09-24, PHP 8.5, no JIT, 120x30: frame 4.3-4.5 ms, step 0.31-0.47 ms, cast 0.055 ms. Drawing is ~98% of the frame.
Live: less out/phel_doom/core/state.php (run `phel build` first). ??= interns each keyword once; defn compiles to AbstractFn + __invoke.
Prep: big terminal font, boss command in shell history, fallback video ready.