Development workflow, checks, test conventions and Phel details.
git clone git@github.com:Chemaclass/phel-doom.git
cd phel-doom
composer install # deps + .githooks/pre-commit + .agents/
composer play # or: make playPHP 8.5 is the floor, locally and in CI. Phel 0.53 and later require it. composer.lock is gitignored, so composer install resolves the newest ^0.54.
Work with composer test. Before a commit, the pre-commit hook runs composer ci, the same gate CI runs. Bypass with git commit --no-verify only in emergencies.
| Script | Does |
|---|---|
composer play / composer dev |
launch the game / run the CLI from source |
composer test |
run the suite (sets PHEL_DOOM_SILENT=1) |
composer ci |
full gate: format-check, lint, layers, cycles, unused, docs, deprecations (runs the suite), build |
composer format |
format hand-written .phel files |
composer format-check |
dry-run format, fails on drift |
composer format-all |
format everything, including generated data files |
composer lint |
static analysis |
composer build |
compile to out/main.php |
composer bench |
frame/cast benchmarks (tests/bench) |
composer bench-store / composer bench-ref |
save a baseline to .phel/bench-baseline.json / fail past +10% against it (same machine) |
composer repl / composer doctor |
REPL / environment check |
For reliable perf numbers use tools/bench-ab.sh <ref> (interleaved A/B against another ref) and tools/bench-flags.sh (frame cost per render feature). See performance.md.
Each check below runs through composer ci and has fixtures (tools/*-test.sh). tools/README.md has the one-line summary of each.
Format (tools/format-sources.sh). Skips the four generated data files (enemy_sprites_data, weapon_sprites_data, wall_texture_data, sound_data): phel format is quadratic in the size of one collection literal (phel-lang#3218), and enemy_sprites_data.phel alone cost 95s of a 124s check. The script fails if a skipped file is renamed. Check mode also caches content hashes that passed, keyed by composer.lock, so unchanged files are not re-checked. Write mode never uses the cache.
Unused (composer check-unused). Fails on a top-level def / defn / defmacro / defstruct under src/ that nothing in src/, tests/ or tools/ references as code. It catches dead constants whose docstrings still describe behaviour that moved elsewhere. Privates count too, since composer lint passes an unused defn-. Names in comments, docstrings or #_ forms do not count. It sees through ^:pure metadata, defstruct predicates and the render.phel re-exports. References resolve by name, not namespace, so two files defining the same name share one verdict. php tools/check-unused.php --report lists definitions referenced only from tests/, which are allowed.
Docs (composer check-docs). Fails on a relative link to a missing file, a #fragment with no matching heading, a backticked repo path (src/..., tools/...) that is not in git ls-files, or a backticked composer <script> that does not exist. Prose is not checked. Paths in docs/adr/ and CHANGELOG.md are history and exempt, but their links are checked. Generated trees (.claude/, .agents/) are not tracked, so do not cite paths inside them.
Deprecations (composer check-deprecations). The gate's only suite run. Runs the tests with PHEL_WARN_DEPRECATIONS=1 and fails on any notice. The notice that matters most: Phel infers a parameter's type from its body, so comparing a parameter to an int literal emits int $p and silently truncates a float caller ("Implicit conversion from float X to int loses precision"). See .agnostic-ai/rules/phel.md, "Type inference traps". A warm cache hides compile-time deprecations (phel-lang#3222), so the script keeps its own cache under .phel/, keyed by composer.lock and phel-config.php, and deletes it after any failing run. On failure it prints the failing test names and copies the log to /tmp/phel-doom-ci-failure.log.
Golden tests pin frame bytes and hashes but do not verify appearance. To inspect a frame:
tools/frame-shot.sh tools/shots/showcase.phel /tmp/showcase.pngtools/shots/showcase.phel renders a deterministic frame with the message line, first-run key hints, an enemy mid-windup, a red hostile reticle, the full HUD strip and the minimap. Any script that writes raw ANSI to its first CLI argument works the same way. tools/frame-to-html.php emulates the terminal cell grid, then headless Chrome takes the PNG. Without Chrome you still get the HTML.
A dotted teal outline around the gun is the :steel floor theme showing through the sprite's transparent gaps. Some fonts render keycard glyphs as boxes; use the HUD glyphs setting to change them.
.claude/and rootAGENTS.mdcome from agnostic-ai. The source of truth is.agnostic-ai/..agents/and the phel-lang skill source come fromtools/install-phel-agents.sh, run bycomposer install(see tools/README.md).
To get the Claude Code or Codex config locally:
brew install Chemaclass/tap/agnostic-ai # needs >= 0.30.0
agnostic-ai sync # rebuild .claude/ + AGENTS.mdOlder agnostic-ai versions leak files and can delete other targets' output. To change agent behaviour, edit .agnostic-ai/ and re-run agnostic-ai sync. agnostic-ai sync --check reports drift. None of this is needed to build or play.
- Assert behaviour, not implementation. Test what a fn returns, not how it walks data.
- No real audio. Use
composer test, which setsPHEL_DOOM_SILENT=1.io/soundalso mutes itself under thephel testrunner as a safety net. - Pure halves first. Test
core/against literal data (hand-built worlds and grids, seetests/core/physics-test.phel). No fakes or mocks incore/. IO wrappers that touch disk run against an isolated temp$HOME(seetests/io/scores-test.phel).
Write the test before or right after the implementation.
;; BROKEN - mutation lost
(defn- paint-into! [arr]
(php/aset arr 0 99))
(let [a (php/array)]
(paint-into! a)
(php/aget a 0)) ; => nil (the copy was mutated)
Two fixes:
- Keep the loop inline in the same
letscope that owns the array. - Let the helper create the array and return it. Callers rebind via
let.
The render layer uses pattern 2 throughout (compute-wall-shades, the paint-* helpers). php/aset inside a let binding VALUE can compile to a closure and write to a copy. Mutate in statement position.
(let [{a :foo b :bar} {:foo 1 :bar 2}]
[a b]) ; => [1 2]
Binding-first since Phel 0.51. {:keys [foo bar]} works when the local name matches the key. The old key-first order ({:foo a}) is deprecated and composer check-deprecations flags it.
A let inside a loop that destructures into a local sharing a loop-binding name drops the value: at recur, that name resolves to the loop's original binding.
;; BROKEN - recur sends the loop's old `settings`
(loop [settings s0 cursor 0]
(let [{:keys [cursor settings]} (navigate settings cursor steps)]
(recur settings cursor)))
;; FIX - destructure into a non-loop name
(loop [settings s0 cursor 0]
(let [nav (navigate settings cursor steps)]
(recur (:settings nav) (:cursor nav))))
This broke the options page once: every change applied, then reverted next frame.
def accepts (def name "doc" value). The private def- is (def- name value) only. A docstring there becomes the value and the real value is dropped. Lint stays quiet. Use a ;; comment instead. defn- does take a docstring.
;; BROKEN - bfs-steps is now the string
(def- bfs-steps
"4-connected BFS offsets."
[[1 0] [-1 0] [0 1] [0 -1]])
;; FIX
;; 4-connected BFS offsets.
(def- bfs-steps [[1 0] [-1 0] [0 1] [0 -1]])
composer lint may warn about a let binding used only by a later binding or a threading macro ((-> w (foo arg)) reads as a 1-arg call). Errors fail CI; warnings do not.
Phel persistent vectors dispatch polymorphically on every get, which is far too slow per cell. Hot paths use php-arrays through compile-time macros in src/io/render/buffer.phel:
(defmacro buf-mk [] `(php/array))
(defmacro buf-set [b i v] `(php/aset ~b ~i ~v))
(defmacro buf-get [b i] `(php/aget ~b ~i))
Call sites read as Phel and compile to plain array ops. Macros are not first-class, and the pass-by-value rule still applies. Outside hot loops, use Phel-native data.
Phel's +, <, = dispatch through NumericOperations for BigInt / Ratio. That cost adds up per ray and per cell.
- Hot loops (
cast-frame,compute-wall-shades, per-cell paint):php/+,php/<,php/===. - Everything else:
+,<,=.
Copy the shape of paint-save-flash in src/io/render/paint.phel: take [parts stats vw vh], buf-push an ANSI string when visible, return parts. Thread it into the paint chain in frame->string (src/io/render/main.phel). Any state it reads must reach stats through frame-stats (src/commands/play.phel).
Stamina is the worked example:
- State field:
:staminadefaults tomax-stamina(src/core/state.phel). - Pure step:
tick-staminainsrc/core/physics.phel. - Wiring:
tick-worldcalls it (src/commands/play.phel). - Render input:
frame-statsforwards:stamina. - Overlay:
stamina-chipinsrc/io/render/paint.phel. - Test:
tests/core/physics-test.phel.
Keep effects out of core/ so its tests stay cheap. Run composer ci before pushing.