Dev workflow, test conventions, and Phel quirks.
git clone git@github.com:Chemaclass/phel-doom.git
cd phel-doom
composer install # installs deps + wires .githooks/pre-commit
make play # or: composer playcomposer install sets core.hooksPath to .githooks/. Every commit then runs composer ci (format-check + lint + test + build) before landing. Bypass with git commit --no-verify only in emergencies.
composer dev # run CLI from source
composer play # launch game
composer test # run tests (PHEL_DOOM_SILENT=1)
composer format # auto-format .phel files
composer format-check # dry-run, fails CI on drift
composer lint # static analysis
composer build # compile to out/main.php
composer ci # full pre-push gate
composer repl # REPL
composer doctor # env diagnosticsCI runs composer ci on PHP 8.5. PHP 8.4 works locally but isn't CI-tested.
The per-tool agent config is generated, not tracked. Only the specs are.
.claude/and rootAGENTS.mdcome from agnostic-ai. Source of truth lives in.agnostic-ai/. Hook scripts live in.agnostic-ai/scripts/..agents/comes fromvendor/bin/phel agent-install, run automatically bycomposer install.
If you use an AI agent (Claude Code, Codex) and want its config locally, install the tool and sync:
brew install Chemaclass/tap/agnostic-ai # one-time, needs >= 0.30.0
agnostic-ai sync # rebuild .claude/ + AGENTS.md from .agnostic-ai/Use agnostic-ai 0.30.0 or newer: earlier versions leak .claude/README.md as untracked, can delete other targets' files on a scoped sync --only, and list the gitignore block file-by-file instead of /.claude/.
This is a contributor convenience only. It is not needed to run, build, or play the game.
To change agent behavior, edit specs under .agnostic-ai/ (never the generated files) and re-run agnostic-ai sync. CI gate: agnostic-ai sync --check.
- Assert behavior, not implementation. Test WHAT a fn returns, not how it walks data or which shape it uses.
- No real audio.
composer testsetsPHEL_DOOM_SILENT=1(short-circuitsplay-sfx!). Always use composer script, not barevendor/bin/phel test. - No real filesystem. Pure halves get tested; IO wrappers don't. E.g.,
merge-runinscores.phelis tested;update-scores!is not. - No fakes/mocks in
core/. Test pure code against literal data (hand-built worlds, grids). Seetests/core/physics-test.phel.
Add tests before or right after implementation. Keep test count accurate.
;; BROKEN - mutation lost
(defn- paint-into! [arr]
(php/aset arr 0 99))
(let [a (php/array)]
(paint-into! a)
(php/aget a 0)) ; => nil (copy was mutated, not original)
Mutating a passed array only changes the local copy. Two fixes:
-
Keep the loop inline in the same
letscope:(let [a (php/array)] (loop [...] (php/aset a ...)) a) ; works -
Helper owns creation and returns the array:
(defn- build-arr [] (let [a (php/array)] (loop [...] (php/aset a ...)) a)) (let [a (build-arr)] ...) ; works
See src/io/render.phel for pattern 2 in the paint-overlay chain.
(let [{:foo a :bar b} {:foo 1 :bar 2}]
[a b]) ; => [1 2]
Opposite of Clojure. {:keys [foo bar]} works when local name matches key. Clojure-style syntax throws Cannot destructure Phel\Lang\Keyword.
A let inside a loop that destructures into a local sharing a loop-binding name silently drops the value: at the recur site that name resolves to the loop's ORIGINAL binding, not the shadow.
;; BROKEN - recur sends the loop's old `settings`, not the navigated one
(loop [settings s0 cursor 0]
(let [{:keys [cursor settings]} (navigate settings cursor steps)]
(recur settings cursor))) ; every edit lost next iteration
;; FIX - destructure into a non-loop name, read via accessors
(loop [settings s0 cursor 0]
(let [nav (navigate settings cursor steps)]
(recur (:settings nav) (:cursor nav))))
Bit the start-menu options page (settings-screen!): every change applied live then reverted on the next frame.
def accepts (def name "doc" value), but the private def- is (def- name value) only. Pass a docstring and it silently becomes the VALUE; the real value is dropped. Lint stays quiet, so it surfaces as a runtime type error far from the def.
;; BROKEN - bfs-steps is now the STRING, not the vector
(def- bfs-steps
"4-connected BFS offsets."
[[1 0] [-1 0] [0 1] [0 -1]]) ; dropped -> later (+ int bfs-steps) blows up
;; FIX - move the note to a line comment
;; 4-connected BFS offsets.
(def- bfs-steps [[1 0] [-1 0] [0 1] [0 -1]])
defn- DOES take a docstring; only def- is the odd one out.
vendor/bin/phel lint warns on:
- Unused let bindings referenced by later bindings. Phel
letis sequential; linter checks each binding independently. - Threading macros.
(-> w (foo arg) (bar))looks like 1-arg calls; emits spurious arity errors.
Errors fail CI; warnings don't. If you need threading, use sequential let instead to keep lint quiet.
Phel persistent vectors use polymorphic dispatch on every get; render at 180×40 would drop from 60+ fps to <2 fps. We use php-arrays with compile-time macros:
;; 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: (buf-set tops col top) - reads Phel, compiles to php/aset with zero overhead. Caveats: macros aren't first-class; php-arrays still pass-by-value at fn boundaries (must return from builders).
Outside hot loops: use Phel-native data (maps, vectors, keywords).
Phel's +, -, *, <, =, ... wrap PHP operators with NumericOperations dispatch for BigInt / Ratio. The overhead adds up per-cell and per-ray.
- Hot loops (cast-ray, compute-wall-shades, per-cell paint):
php/+,php/<,php/===(direct, no dispatch). - Everything else:
+,<,=(idiomatic, overhead invisible).
New kill-counter banner at top-right?
-
Write
paint-kill-counterinsrc/io/render/paint.phel:(defn- paint-kill-counter [parts stats vw] (let [k (or (get stats :kills) 0)] (php/array_push parts (str "\e[1;" (php/- vw 12) "H kills: " k "\e[0m"))) parts) -
Thread into the paint chain (integrate with existing overlays in
paint.phel). -
Optional: test if logic is non-trivial (
tests/io/render-test.phel). -
Run
composer ci.
New stamina meter that drains while sprinting?
-
Add
:stamina 1.0tonew-world(src/core/state.phel). -
Add
tick-staminainsrc/core/physics.phel:(defn tick-stamina [world ^float dt] (let [s' (php/max 0.0 (php/min 1.0 (php/+ (:stamina world) (if (:sprinting world) (php/* -0.4 dt) (php/* 0.2 dt)))))] (assoc world :stamina s'))) -
Wire into
tick-world(src/commands/play.phel). -
Forward
:staminathroughframe-statsto render. -
Test in
tests/core/physics-test.phel:(deftest test-tick-stamina-drains-while-sprinting (let [w (tick-stamina {:stamina 1.0 :sprinting true} 0.5)] (is (< (:stamina w) 1.0)))) -
Render the HUD bar as a separate overlay.
Loop: state field → core fn → tick-world wiring → frame-stats → overlay. Keep effects out of core; tests stay easy.