Writing Lisp in PHP

Learn Phel by building DOOM

Chemaclass

Wait, what?

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

Write Lisp. Ship PHP. Even DOOM.

~11,700 lines of Phel · same raycasting idea as id Software's 1993 original · ~5 ms/frame

Writing Lisp in PHP · Chemaclass

What you'll leave with

  1. Why Phel exists and what makes it different
  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.

Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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.4. 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.

Writing Lisp in PHP · Chemaclass

ACT 1

Phel basics, for PHP developers

Ten minutes. Zero Lisp needed.

Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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."

Writing Lisp in PHP · 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]

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

Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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."

Writing Lisp in PHP · Chemaclass

ACT 2

PHP interop and tooling

But can it touch real PHP?

Writing Lisp in PHP · 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 (php/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.

Writing Lisp in PHP · Chemaclass

The REPL: try everything live

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

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

Writing Lisp in PHP · Chemaclass

ACT 3

Now let's build a game

Does immutability survive a real project?

Writing Lisp in PHP · 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]))

(def app
  (cli/application
   {:name "phel-doom" :default "play"
    :commands [play-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.

Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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
Writing Lisp in PHP · 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
; 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.

Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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/core/   src/glue/   src/io/   src/commands/

Purity is not mere taste. Structure enforces it.

Writing Lisp in PHP · 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? →

Writing Lisp in PHP · Chemaclass

DOOM (1993): Carmack's math

id Software, John Carmack. A 486. No GPU. 11 months.

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.

Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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 renderer is pure - unit-test in one line.
Once per screen column - ~120-180 rays a frame.

Writing Lisp in PHP · Chemaclass

Testing: pure = no mocks

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

Writing Lisp in PHP · Chemaclass

Dividend: pure is fast

Target < 5 ms per frame · measured 0.2 - 0.5 ms

  • Memoize paused frames: one-line cache, safe because pure.
  • Precompute view angles: -60% cast time.
  • Hot loop: raw arrays over persistent vectors: ~680x.

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

Writing Lisp in PHP · Chemaclass

Read the receipts: Phel → PHP

(defn new-player [x y angle] {:x x :y y :angle angle})

phel buildout/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);
    }
  });
Writing Lisp in PHP · Chemaclass

ACT 4

Story and verdict

So… should you use it?

Writing Lisp in PHP · 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

+400 commits. Tiny PRs, each closing one issue.

Writing Lisp in PHP · 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
Writing Lisp in PHP · Chemaclass

ACT 5

Live demo

Enough slides.

Writing Lisp in PHP · 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 10cyberdemon boss
  6. F3 debug overlay → live ~5 ms frame budget
Writing Lisp in PHP · 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.

Writing Lisp in PHP · 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. 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.