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 renderer is pure - unit-test in one line.
Once per screen column - ~120-180 rays a frame.
(deftest cast-ray-hits-wall
(is (= 1.0 (cast-ray simple-grid 1.5 1.5 0.0))))
(deftest pickup-heart-grants-life
(is (= (+ start-lives 2) (:lives (pickup-hearts world-on-heart))))) ; one heart = 2 HP
composer test # 1716 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 0.2 - 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) {
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?
+400 commits. 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. Slides 1-18 = pure Phel basics (no game code). Slides 19+ = real phel-doom code.
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.
Persistent vectors are ~680x slower in the 7,000-cell hot loop; raw arrays there.
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.