Running DOOM with Phel

Building a raycasting engine with functional Phel

Chemaclass

DOOM in a terminal

A DOOM-inspired game written in Phel, a Lisp that compiles to PHP 8.5.

~20,500 lines of Phel · raycasting, as used by id Software 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 will learn Phel through the game.

Running DOOM with Phel · Chemaclass

What is Phel?

  • A functional Lisp that compiles to PHP
  • Clojure -> JVM :: Phel -> PHP
  • Immutable data, macros, and pipelines
  • Installs as a Composer package in the PHP ecosystem
composer require phel-lang/phel-lang

🧠 Add Phel to an existing PHP project with Composer.

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.
  • The CLI is available at vendor/bin/phel.

Phel compiles to PHP before deployment.

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

Updating a player without mutation

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: the original player stays unchanged.
  • Safe to cache and replay: same input, same result.
Running DOOM with Phel · Chemaclass

ACT 1

Phel basics, for PHPTS developers

A short introduction to Phel.

Running DOOM with Phel · Chemaclass

Function calls

; 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

Function calls put the operator before its arguments.

🧠 Nested calls make the order of operations explicit.

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 variables can be reassigned. A Phel def creates an immutable binding.🧠 A Phel def creates an immutable binding, like a module-level const.

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 can hold lists and key-value pairs. Phel has maps, vectors, and sets.🧠 TS has objects, arrays, and Set. Phel has maps, vectors, and sets.

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

🧠 Unlike switch, cond is an expression: you can assign its result.

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

Use loop and recur to repeat with new bindings.

; 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

Call PHP from Phel.

Running DOOM with Phel · Chemaclass

Calling PHP from Phel

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

🧠 Phel can call PHP functions and use Composer packages.

Running DOOM with Phel · Chemaclass

Try Phel in the REPL

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 a namespace and call its functions without rebuilding the project.

Running DOOM with Phel · Chemaclass

ACT 3

Now let's build a game

Apply these ideas to the game.

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

The game loop in play-command

main wires the CLI. The game loop runs ~60 times per 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.

🧠 Each frame uses the previous world and the player's input to produce a new world.

Running DOOM with Phel · Chemaclass

Game state in an immutable map

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

The world map holds the game state:

  • 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

🧠 assoc returns a new map and leaves the original unchanged.

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

Each subsystem receives a world and returns an updated world.

🧠 Each step can be tested with a world as input and its updated world as output.

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/

The directory structure separates game logic from input and output.

Running DOOM with Phel · Chemaclass

Fake 3D: a flat 2D map

The world is a maze on a 2D grid:

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

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

Raycasting turns the grid into the view on the right.

Running DOOM with Phel · Chemaclass

Raycasting in Wolfenstein 3D (1992)

id Software, John Carmack. A raycasting technique used before DOOM.

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.

🧠 A 2D map and ray distances are enough to draw this view.

Running DOOM with Phel · Chemaclass

From rays to wall strips

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

Casting a ray with loop/recur

; 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

The raycaster uses a pure function with no mutable counter.
Once per screen column - ~120-180 rays a frame.

Running DOOM with Phel · Chemaclass

Testing pure functions

(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

These tests call pure functions and check their output without mocks.

Running DOOM with Phel · Chemaclass

Frame timing and optimizations

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 functions return the same output for the same input, so their results can be cached.

Running DOOM with Phel · Chemaclass

Compiled Phel output

(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

The project and its development.

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

Run the game.

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

Phel brings immutable data and a REPL to PHP projects.

https://chemaclass.com/phel-doom

Questions?

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.