Running DOOM with Phel

Building a raycasting engine with functional Phel

Chemaclass

Wait, what?

This is DOOM. In a terminal.
Written in a Lisp. That compiles to PHP 8.5.

Write Lisp. Ship PHP. Even DOOM.

~20,500 lines of Phel · the raycasting trick id Software used before DOOM · ~5 ms/frame

Running DOOM with Phel · Chemaclass

What you'll leave with

  1. Why Phel exists and the functional ideas behind it
  2. How to write it from zero: syntax, data, state
  3. How it plugs into PHP: all of PHP, the REPL, one CLI
  4. How immutability shapes a real project: DOOM

We learn by building the game. No spec-reading.

Running DOOM with Phel · Chemaclass

What is Phel?

  • A functional Lisp that compiles to PHP
  • Clojure -> JVM :: Phel -> PHP
  • Immutability, macros, clean pipelines.
  • All within the PHP ecosystem.
  • Just a Composer package. No new runtime, no new server.
composer require phel-lang/phel-lang

🧠 "New language = new runtime + new hires"? No. It's a Composer package.🧠 "New language = new runtime + new hires"? No.
It installs like any dependency, into a PHP stack.

Running DOOM with Phel · Chemaclass

It's still PHP under the hood

vendor/bin/phel build    # compiles .phel → out/*.php
  • Ahead-of-time compiled. Not interpreted at runtime.
  • Output is plain PHP 8.5. Any host, zero Phel in production.
  • OPcache + JIT apply for free.
  • Ships as vendor/bin/phel. One CLI for everything.

Write Lisp. Ship PHP. Server needs nothing new.

Running DOOM with Phel · Chemaclass

Functional programming in five ideas

  1. Immutable data. A value never changes. An update builds a new one.
  2. Pure functions. Same input, same output. No hidden reads or writes.
  3. Functions are values. Pass them in, return them, store them.
  4. Composition. Join small functions into a pipeline to build big ones.
  5. Effects at the edges. Screen, clock, random, files: one small place.

🧠 PHP has the pieces: readonly, fn() =>, array_map. Phel makes them the default.🧠 TS has the pieces: const, readonly, .map. Phel makes them the default.

Running DOOM with Phel · Chemaclass

Why it pays off

function heal(Player $p): Player {
    $p->hp += 10;   // the caller's $p changed too
    return $p;
}
function heal(p: Player): Player {
  p.hp += 10;   // the caller's p changed too
  return p;
}
(defn heal [player] (update player :hp + 10))
(heal player)   ; => new map, player is untouched
  • Easy to test: call with an input, check the output. No mocks.
  • Easy to follow: nothing changes behind your back.
  • Safe to cache and replay: same input, same result.
Running DOOM with Phel · Chemaclass

ACT 1

Phel basics, for PHPTS developers

Ten minutes. Zero Lisp needed.

Running DOOM with Phel · Chemaclass

The whole syntax, in 30 seconds

; operator comes FIRST, no precedence rules
(+ 1 2 3)              ; =>  6 (PHP: 1 + 2 + 3)
(str "Hello " "Phel")  ; =>  "Hello Phel"
(> 5 3)                ; =>  true (PHP: 5 > 3)
; every form is: ( operator  arg1  arg2 ... )
(+ 1 (* 2 3))  ; =>  7
(= "a" "a")    ; =>  true
(not false)    ; =>  true

That's the entire syntax. Everything else is just functions.

🧠 1 + 2 * 3 has precedence rules. In Phel: always (op args), no exceptions.

Running DOOM with Phel · Chemaclass

Variables

def: immutable top-level binding (PHP: $x = 5)

(def x 5)
(def greeting "Hello!")
(def active? true)  ; ? suffix = boolean convention

let: local bindings, scoped to the block

(let [a 10
      b 20
      total (+ a b)]
  total)  ; =>  30   (a, b, total don't exist outside)

🧠 PHP: any var is reassignable from anywhere. def says "this never changes."🧠 TS: let reassigns from anywhere. def is like a module-level const: this never changes.

Running DOOM with Phel · Chemaclass

Functions

;; defn name args body  (last expression = return value, no `return`)
(defn greet [name]
  (str "Hello, " name))

(greet "PHP Conf")  ; =>  "Hello, PHP Conf"
  • defn = public · defn- = private (unexported)
  • Shorthand: #(* % 2) = (fn [x] (* x 2)) - like fn($x) => $x * 2
(map #(* % 2) [1 2 3])  ; =>  (2 4 6)  a lazy list

🧠 PHP: function greet($n) { return "Hello, ".$n; } · fn($x) => $x * 2🧠 TS: function greet(n) { return "Hello, " + n; } · (x) => x * 2

Running DOOM with Phel · Chemaclass

Data structures

{:name "Chema" :age 33}  ; map   (PHP: ["name" => "Chema", "age" => 33])
[:a :b :c]               ; vector, PHP: [0 => "a", 1 => "b", 2 => "c"]
#{:red :green :blue}     ; set   , unique values only

Access with get:

(get {:name "Chema"} :name)  ; =>  "Chema"
(get [:a :b :c] 1)           ; =>  :b

Keywords (:name, :active?): typed constants,
like PHP string keys but faster to compare

🧠 PHP arrays do everything. Phel separates the concept: map / vector / set.🧠 TS splits object / array / Set already. Phel makes the split first-class and immutable.

Running DOOM with Phel · Chemaclass

Working with collections

(def nums [1 2 3 4 5])

(map #(* % 2) nums)  ; =>  (2 4 6 8 10)
(filter even? nums)  ; =>  (2 4)
(reduce + 0 nums)    ; =>  15
;; for comprehension: build a new vector with conditions
(for [x :in nums :when (odd? x)] (* x x))  ; =>  [1 9 25]

🧠 array_map / array_filter / array_reduce - same patterns, first-class in Phel.🧠 .map / .filter / .reduce - same patterns, first-class in Phel.

Running DOOM with Phel · Chemaclass

Branching

;; if is an expression, it returns a value
(if true "yes" "no")      ; =>  "yes"
(if (> 5 3) :big :small)  ; =>  :big
;; cond = multi-branch, also an expression
(defn grade [score]
  (cond
    (>= score 90) :A
    (>= score 75) :B
    :else         :C))

(grade 82)  ; =>  :B

🧠 PHP switch is a statement. cond IS the value, assign it directly.🧠 TS: switch is a statement, cond IS the value. No nested ternaries.

Running DOOM with Phel · Chemaclass

Threading: pipelines, not nesting

; PHP: strtoupper(trim("  hello  "))
; nested = read inside-out. -> = read top-to-bottom:
(-> "  hello  "
    php/trim           ; =>  "hello"
    php/strtoupper)    ; =>  "HELLO"
; -> passes the value as the FIRST arg of the next call
(-> 10
    (+ 5)     ; (+ 10 5)  =>  15
    (* 2)     ; (* 15 2)  =>  30
    str)      ; (str 30)  =>  "30"

🧠 $obj->method()->chain() needs fluent objects. -> works on any value.🧠 obj.method().chain() needs fluent objects. -> works on any value.

Running DOOM with Phel · Chemaclass

Loops without mutation

No while. No $i++. No mutable variables.

; loop declares the bindings, recur restarts with new values
(loop [i 0, sum 0]
  (if (= i 5)
    sum                           ; done, return sum
    (recur (inc i) (+ sum i))))   ; =>  10  (0+1+2+3+4)
  • loop [i 0, sum 0]: initial state
  • recur (inc i) (+ sum i): next iteration, no stack growth
  • Last branch with no recur: the return value

🧠 loop = "start here". recur = "next iteration, no new stack frame."

Running DOOM with Phel · Chemaclass

ACT 2

PHP interop and tooling

But can it touch real PHP?

Running DOOM with Phel · Chemaclass

No wall. All of PHP is your stdlib

; prefix any PHP function with php/
(php/strtoupper "hello")      ; =>  "HELLO"
(php/strlen "doom")           ; =>  4
(php/date "Y-m-d")            ; =>  "YYYY-MM-DD"
; PHP objects and methods work too
(def dt (new DateTime "2024-01-01"))
(.format dt "Y-m-d")          ; =>  "2024-01-01"   ($dt->format("Y-m-d"))
DateTime/ATOM                 ; =>  "Y-m-d\TH:i:sP"  (::ATOM static const)

Any Composer package works.
Symfony Console powers this game's CLI.

🧠 "But does it have a library for X?" Yes. All of them. It's PHP.

Running DOOM with Phel · Chemaclass

The REPL: try everything live

vendor/bin/phel repl
user:1> (+ 1 2 3)
6
user:2> (defn greet [n] (str "Hello, " n))
#'user/greet
user:3> (greet "PHP Conf")
"Hello, PHP Conf"
user:4> {:x 10 :y 20}
{:x 10, :y 20}
user:5> (-> "  hello  " php/trim php/strtoupper)
"HELLO"

Load any namespace. Probe any function. No rebuild, no restart.

Running DOOM with Phel · Chemaclass

ACT 3

Now let's build a game

Does immutability survive a real project?

Running DOOM with Phel · Chemaclass

The entry point

(ns phel-doom.main                ; namespace, dot mirrors the path
  (:require phel.cli :as cli)     ; Symfony Console, wrapped in Phel
  (:require phel-doom.commands.play :refer [play-command])
  (:require phel-doom.commands.demo :refer [demo-command]))
(def app
  (cli/application
   {:name "phel-doom" :default "play"
    :commands [play-command demo-command]}))
(when-not *build-mode*            ; skip side-effects during phel build
  (php/exit (cli/run app (cli/argv *argv*))))

src/main.phel: the bootstrap (version wiring trimmed)

🧠 ns = namespace. :require … :as = use X as Y. :refer = use function X. Same idea, Lisp syntax.🧠 ns = module. :require :as = import * as Y. :refer = import {x}.

Running DOOM with Phel · Chemaclass

...and play-command is one loop, forever

main just wires the CLI. The whole game is a loop, ~60×/second:

  input  ──→  tick-world  ──→  render!  ──┐
  keys        world→world     world→ANSI  │
  held        (pure)          (io)        │
    ▲                                     │
    └─────────── new world ───────────────┘

Each frame: world' = (tick-world world keys dt). Draw, discard, repeat.

🧠 The whole game is a fold over keystrokes - a fresh world each frame.

Running DOOM with Phel · Chemaclass

The whole game is one immutable value

(defn new-world [grid player]
  {:grid    grid
   :player  player       ; {:x :y :angle}
   :enemies []
   :lives   max-lives
   :weapon  :pistol
   :kills   0})          ; ...ammo, armor, doors, etc.

This single map IS the game.

  • diff it to find what changed
  • save it to disk for quick-save
  • replay it from a seed for deterministic demos
Running DOOM with Phel · Chemaclass

Functions create data, not objects

class Player { // PHP - class + mutation
    public function __construct(
        public float $x, public float $y, public float $angle
    ) {}
}
$p = new Player(2.5, 3.5, 0.0);
$p->angle = 1.57;   // mutates in-place
class Player { // TS - class + mutation
  constructor(
    public x: number, public y: number, public angle: number,
  ) {}
}
const p = new Player(2.5, 3.5, 0.0);
p.angle = 1.57;   // mutates in-place
; Phel - function + immutable update
(defn new-player [x y angle] {:x x :y y :angle angle})
(new-player 2.5 3.5 0.0)         ; => {:x 2.5 :y 3.5 :angle 0.0}
(assoc player :angle 1.57)        ; => new map, original unchanged

🧠 Same semantics, half the code. assoc returns a new map - original never touched.

Running DOOM with Phel · Chemaclass

One pure frame: tick-world

(defn tick-world [world keys dt edges]
  (-> world
      (apply-physics dt)     ; movement + collision
      (pickup-hearts)        ; items on the floor
      (tick-enemies dt)      ; enemy AI
      (tick-projectiles dt)  ; fireballs in flight
      (tick-shooting edges)  ; your weapon
      (damage-step dt)))     ; resolve damage

Every subsystem is world -> world. Old world discarded.
A bug stays trapped in its subsystem - it can't corrupt the rest.

🧠 PHP: subsystems mutate shared state. Here: one in, one out. Nothing else can touch it.🧠 TS: shared mutable state drifts. Here: one in, one out. Nothing else can touch it.

Running DOOM with Phel · Chemaclass

Architecture: effects in exactly one place

io/  →  glue/  →  core/        (dependencies go one way only)
  • core/: pure logic (engine, combat, physics). No print, time, or rand.
  • glue/: pure wiring (controls, input parsing).
  • io/: side effects only (render!, audio, files).
tree src/
# src/commands/  src/core/  src/demo/  src/glue/  src/io/

Purity is not mere taste. Structure enforces it.

Running DOOM with Phel · Chemaclass

Fake 3D: a flat 2D map

The world is really a maze on graph paper:

#######
#.@...#
#.....#
#######

You move on a flat grid, top-down. The 3D is an illusion.

So how does a flat map become that view? →

Running DOOM with Phel · Chemaclass

Wolfenstein 3D (1992): Carmack's math

id Software, John Carmack. The trick before DOOM. No GPU.

One ray per column, marched to the first wall → distance d.

strip height = screen height / d

Small d = tall strip. One division per ray. No 3D engine.

🧠 PHP instinct: reach for a 3D rendering library. Carmack: constrain the world, trust the math.🧠 JS instinct: reach for WebGL or three.js. Carmack: constrain the world, trust the math.

Running DOOM with Phel · Chemaclass

See it: rays in, walls out

player top-down map rendered screen →

Left: one ray per column, distance to the wall.
Right: each distance becomes a strip. Near = tall, far = short.

Running DOOM with Phel · Chemaclass

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.

Running DOOM with Phel · Chemaclass

Testing: pure = no mocks

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

Running DOOM with Phel · Chemaclass

Dividend: pure is fast

Target < 5 ms per frame · measured ~4.5 ms (game logic 0.3 - 0.5 ms)

  • Memoize view tables per screen size: safe because pure.
  • Precompute view angles: -60% cast time.
  • Hot loops read raw PHP arrays, not persistent vectors: level generation 3x faster.

🧠 Pure: same input, same output. Cache can't be wrong.

Running DOOM with Phel · Chemaclass

Read the receipts: Phel → PHP

(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);
    }
  });
Running DOOM with Phel · Chemaclass

ACT 4

Story and verdict

So… should you use it?

Running DOOM with Phel · Chemaclass

Built in days, shipped in small steps

  • May 22: first playable: raycaster + FPS combat
  • May 24: sprint, 3 weapons, locked doors, boss
  • May 25: enemy AI state machine + Docker
  • May 27: 60% faster cast, secret walls, chainsaw
  • Jun 1: BFG, quick-save, record + replay
  • Jun 2: super shotgun, rocket, incinerator
  • Jun 3: distributable PHAR + checksums
  • Jun 8: real Freedoom sprites: enemies, guns, pickups + OST loop

700+ commits, 20 releases. Tiny PRs, each closing one issue.

Running DOOM with Phel · Chemaclass

The full picture

  • Engine: raycaster + Freedoom sprites, ~5 ms/frame
  • Levels: 10, locked doors, secret walls
  • Combat: 7 weapons, berserk, fire resists
  • Enemies: 10 types → cyberdemon boss
  • Systems: quick-save, record/replay, audio
phel-doom gameplay
Running DOOM with Phel · Chemaclass

ACT 5

Live demo

Enough slides.

Running DOOM with Phel · Chemaclass

Demo: phel run phel-doom.main play

  1. Level 1: move (WASD), turn, strafe, sprint. Walls + minimap.
  2. Fire: pistol → shotgun → chaingun. Muzzle flash + hit-stop.
  3. Take a hit: damage-direction HUD, armor, lives.
  4. Secret wall (F), pickup, door → next level.
  5. (if time) --god --armory -l 10 → cyberdemon boss
  6. F3 debug overlay → live ~5 ms frame budget
Running DOOM with Phel · Chemaclass

Thank you

Write Lisp. Ship PHP. Even DOOM.

Reach for it when immutability + a REPL pay off.

https://chemaclass.com/phel-doom

Questions? The REPL is open.

Running DOOM with Phel · Chemaclass

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.