Writing Lisp in PHP

We learn by building a DOOM clone

Chemaclass

Wait, what?

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

~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 Phel, from zero
  3. How it plugs into your PHP tooling and runtime
  4. When to use it, and when not to

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 learns nothing new.

Writing Lisp in PHP · Chemaclass

ACT 1

Phel basics, for PHP developers

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

State: opt-in and explicit

(def score (atom 0))    ; wrap a value in a labeled, mutable box
expression result
read @score 0
apply fn (swap! score + 10) 10
increment (swap! score inc) 11
reset (reset! score 0) 0

! suffix = side effect. @ = dereference (read current value).

Mutation is rare, named, and visible.
Not hidden in a property.

🧠 Any PHP property mutates from anywhere. In Phel: one labeled box.

Writing Lisp in PHP · Chemaclass

ACT 2

PHP interop and tooling

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

One CLI for everything

phel run …     phel repl      phel test
phel build     phel format    phel doctor
  • Lives in vendor/bin/phel. Installed as a normal Composer dependency.
  • Wire into your CI, git hooks, Docker. No new toolchain.
  • Prod: phel build emits plain PHP. No Phel at runtime.

Editors: PhpStorm plugin · VS Code extension · Vim · Emacs
Syntax highlighting, REPL actions, LSP + nREPL.

Your workflow unchanged. Phel just plugs in.

Writing Lisp in PHP · Chemaclass

ACT 3

Now let's build a game

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 call each other, mutate shared state. Here: one function in, one function 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

The trick, in one breath

The world is really a flat maze on graph paper:

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

One ray per column → how far to a wall.

Near = TALL strip, far = short. Stack them → the right view.

Writing Lisp in PHP · Chemaclass

DOOM (1993): Carmack's math

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

The same intuition, made exact:

; per column:  angle = player_angle + column_offset
;   march the ray to the first wall  →  distance d
;   strip_height = screen_height / d        ; smaller d → taller strip

One division per ray. No matrix math. No 3D engine.

🧠 PHP instinct: reach for a 3D rendering library. Carmack: constrain the world, trust the math.

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. Pure. Unit-test in one line.
Once per screen column - ~120-180 rays a frame.

Writing Lisp in PHP · Chemaclass

Macros: name an idiom, pay nothing

(defmacro buf-set [b i v]
  `(php/aset ~b ~i ~v))    ; ` = template, ~ = splice value in

(buf-set frame i cell)at compile time(php/aset frame i cell)

A named Phel-level op that compiles straight to raw PHP. Zero call overhead in a ~7200-cell/frame render loop - a function would cost a call per cell.

🧠 Macros rewrite code at compile time. Here, the humblest case: zero-cost inlining. PHP's nearest is eval(). Don't.

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: cast + render < 5 ms.
Measured cast-frame: 0.21 / 0.32 / 0.51 ms

  • Memoize cast-frame on paused frames. Legal because it is pure.
  • Precompute view angles in an atom: -60% cast time.
  • Raw PHP arrays for the grid: Phel's persistent vectors are ~680x slower here.
  • Type hints + raw operators: ~5x faster in the innermost loop.

🧠 Pure function = same input, same output. Caching is always safe.

Writing Lisp in PHP · Chemaclass

Determinism: free features

(rng/seed! seed)              ; one seed per run
(recur … (rng/next-raw!) …)   ; advance deterministically

Same seed → identical levels, enemies, loot.

  • R on death = replay the exact same map
  • --record / --demo = record and replay

🧠 PHP global rand() = non-reproducible. Thread the seed → same input, same run, every time.

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

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

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

Writing Lisp in PHP · Chemaclass

The full picture

  • Engine: raycaster, sprite occlusion, ~5 ms/frame
  • Levels: 10 generated levels, locked doors, secret walls, automap
  • Combat: 8 weapons, reload, berserk, hit-stop, fire damage + resists
  • Enemies: 10 types - imps, demons, pinkies, spectres, fireball casters (caco / baron / archvile), revenants, mancubi, cyberdemon boss
  • Systems: lives, armor, difficulty, quick-save, record/replay, audio
Writing Lisp in PHP · Chemaclass

Phel: the honest scorecard

Use it when Skip it when
Pure domain logic You rely on framework magic
Data pipelines, game loops Team prefers familiar syntax
You enjoy functional thinking You need a mature ecosystem today

Loved: pure functions, macros, threading, full PHP interop

Watch out: small ecosystem, learning curve, unfamiliar syntax

Writing Lisp in PHP · Chemaclass

ACT 5

Live demo

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

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.

20s. "40 minutes: I'll teach you a Lisp - and prove it's not a toy by showing a DOOM clone I built with it." Eye contact. No notes.

3s silence. Let it breathe. Say nothing. Let the room realize what they're looking at. Then click to the claim.

Click in. 3s silence. Let the GIF breathe. "DOOM. In a terminal. Written in Lisp. Compiled to PHP 8.4." Name the room: "You're thinking: that can't work in PHP."

30s. "Four things: why it exists, how to write it, how it plugs in, when to use it." Don't read the list. Signpost and move.

Clojure folks: "Same idea, PHP runtime." Everyone else: "PHP under the hood, written like a Lisp." Land on: "you require a dependency, not a platform."

"You write Lisp. You ship PHP. Server learns nothing new." Tease: "We'll open the compiled files live later - no magic."

Zero Lisp experience assumed. All examples standalone, no game code yet.

"If you can read (+ 1 2), you can read every line in this game." Tackle parens: "Yes, parentheses. You stop seeing them in ten minutes." Don't dodge it - name it, defuse it, move.

"? suffix - convention, like PHP's is_ prefix. Not special syntax." "let is a scoped block. a, b, total vanish after the closing ]."

"No return keyword. Last expression IS the value." "defn = public, defn- = private. That's the whole access model." "#() is shorthand for a one-liner fn. % is the argument. You'll see it everywhere."

PHP translation: "map = assoc array, vector = indexed array, set = unique values." ":name is a keyword - interned, faster to compare than a plain string."

"map, filter, reduce - you already know these. Same concept, cleaner syntax." "for is like a list comprehension. :when is the filter condition."

"if and cond return a value. No temp variable needed." Walk grade(82) aloud → :B. One beat. Move.

"PHP fluent chains need objects. -> works on any value." Give this a full breath - most useful daily concept.

"loop = initial state. recur = next iteration, no new stack frame." Foreshadow: "Same shape casts one ray per screen column, ~120-180 a frame in the raycaster. Coming up."

"Functional doesn't mean no state. 11,700 lines, a handful of atoms." "swap! applies a function atomically. @ reads the current value."

Bridge from Phel concepts to their PHP world.

"Every PHP function, prefixed with php/. There is no wall." "Objects: php/new to construct. .method to call. ClassName/CONST for statics." Segue: "Next slide: the REPL - let's try it live."

DO IT LIVE. 3-4 expressions. Invite: "throw me something." Breather - don't rush. 90s max, then back to slides.

"phel doctor checks your setup. phel format auto-formats. phel lint catches errors early." "PhpStorm and VS Code have first-class plugins - syntax, REPL actions, LSP + nREPL." "Phel joins your workflow, not replaces it."

Real project code. They know the primitives. Now see them at scale.

"ns dot-separated mirrors the file path - same idea as PSR-4 namespaces." "*build-mode* stops top-level effects during phel build. First clue about compilation."

VERY high level - don't read code. "main wired the CLI; play IS the game: a loop." Trace the cycle with your finger: input → tick (pure) → render (io) → back to the top with a NEW world. "Hold this picture. Next: what's actually IN a world? Then: how one frame transforms it."

"Everything you see, shoot, or pick up is in this one map." Point at :grid, :player, :enemies. "Diff it. Save it. Replay it."

"PHP: class, constructor, getters. Phel: 4-line function, plain map." Pause after the image. That contrast is the laugh.

"Physics, pickups, enemies, projectiles, shooting, damage - each just world→world." "Nothing can corrupt anything else. All testable without a terminal."

"core/ literally cannot call print or rand. No require, no access." DO LIVE: tree src/ - point at the three directories.

SET UP the real slide. Say it plain BEFORE the code. "Forget 3D engines. A maze seen from above, drawn one column at a time." Gesture: hands wide for tall=close, narrow for far. Then: "now the real version." DEMO > bare raycaster (split layout: left 3D, right 2D map, walls only): phel run phel-doom.main demo --phase 1

Bridge from the previous slide: "that hand-wave, now a formula." "This is the entire trick - one division per ray." "Carmack's constraint: no looking up or down, flat floors per room. That keeps the math simple." Pause. "Now the code. Same algorithm, in Phel. Loop and recur."

"Same loop/recur from slide 13 - now casting one ray per screen column, 120-180 a frame." "SIMPLIFIED: step-march. Real engine uses DDA." Say it once, move on. Energy: "DOOM 1993. In a terminal. Written in Lisp." DEMO > build it up live, same engine, one subsystem at a time: phel run phel-doom.main demo --phase 2 ; + the pistol phel run phel-doom.main demo --phase 3 ; + enemies phel run phel-doom.main demo --phase 4 ; + interior cover walls

"Macros rewrite code at compile time. This is the humblest use - zero-cost inlining." Trace buf-set → php/aset. "A function would pay a call per cell; this pays nothing." One beat. Pause.

"Every pure function is a test waiting to happen." "What you'd normally mock: clock, RNG, renderer. Nothing to mock here."

Point at chart: 2.04 → 0.51 ms. "Memoize was one line. Legal because it's pure - same input, same output." Honest: "Persistent vectors are beautiful. ~680x slower in a 7,000-cell hot loop."

"Same seed → same levels, same enemies, same loot. Entire run is reproducible." If time: DO LIVE - run --demo replay.

DO LIVE: less out/phel_doom/core/state.php "No magic. PHP you'd recognize. ??= interns keyword once. defn → AbstractFn + __invoke." BEFORE TALK: run phel build so the file is fresh.

Rising energy. Story → verdict → demo.

"Not a big bang. One issue, one PR, every day." "Pure architecture enabled this - each feature bolted on without breaking anything."

Sweep fast. Rising energy into demo. "Enough slides. Let me show you."

"Start on a pure-logic module. Don't rewrite your framework." Candor here buys credibility. Don't rush - last word before demo.

Deep breath. Terminal is the star now.

~5 min. Narrate: "every frame, a brand-new immutable world." End on F3: "real budget - callback to slide 28." BEFORE TALK: terminal font BIG. Boss command in shell history. Fallback video loaded.

Leave REPL running on screen. If pause: "We covered why, how to write it, how it integrates, when to use it." Link stays up. Let people scan or type it.