aboutsummaryrefslogtreecommitdiff
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-04-05-demos.md922
-rw-r--r--docs/superpowers/plans/2026-04-05-milestone-14-docs.md167
-rw-r--r--docs/superpowers/plans/2026-04-05-milestone-7-scene-loader.md201
-rw-r--r--docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md826
-rw-r--r--docs/superpowers/specs/2026-04-05-demos-design.md256
-rw-r--r--docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md234
6 files changed, 2606 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-04-05-demos.md b/docs/superpowers/plans/2026-04-05-demos.md
new file mode 100644
index 0000000..6fea0bb
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-05-demos.md
@@ -0,0 +1,922 @@
+# Demo Games Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build 5 self-contained demo games in `demo/` that collectively exercise every engine system and compile via `make demos`.
+
+**Architecture:** Each demo is a standalone Chicken Scheme program that imports downstroke engine modules and links against all engine `.o` files. Shared assets (tileset, levels, fonts, sounds) live in `demo/assets/` copied from macroknight. `mixer.scm` and `sound.scm` are adapted from macroknight and added to the engine build.
+
+**Tech Stack:** Chicken Scheme, SDL2 (sdl2 egg), SDL2_mixer (via mixer FFI), SDL2_ttf (sdl2-ttf egg), SDL2_image (sdl2-image egg), SRFI-64 (tests not applicable to demo programs — `make demos` compilation success is the test).
+
+---
+
+## Key API Reference
+
+Before implementing, keep these exact signatures in mind:
+
+```scheme
+;; Entity ops (downstroke/entity)
+(entity-ref entity #:key [default]) ;; read field, default=#f
+(entity-set entity #:key value) ;; returns NEW entity (immutable)
+(entity-type entity) ;; = (entity-ref entity #:type #f)
+
+;; World (downstroke/world)
+(make-scene entities: '() tilemap: #f camera: (make-camera x: 0 y: 0) tileset-texture: #f)
+(make-camera x: 0 y: 0)
+(scene-entities scene)
+(scene-entities-set! scene list) ;; mutates in place
+(scene-camera scene) ;; camera-x, camera-y, camera-x-set!, camera-y-set!
+(scene-tilemap scene)
+(scene-tileset-texture scene)
+(scene-add-entity scene entity) ;; mutates, returns scene
+(scene-filter-entities scene pred) ;; mutates, returns scene
+(scene-update-entities scene proc ...) ;; maps each proc over all entities
+
+;; Tilemap (downstroke/tilemap)
+(load-tilemap "path.tmx") ;; loads TMX + TSX + PNG; returns tilemap
+(tilemap-tileset tilemap) ;; → tileset struct
+(tileset-image tileset) ;; → SDL texture (the tileset-texture for make-scene)
+
+;; Physics (downstroke/physics)
+;; Full platformer pipeline (execute in this order):
+(apply-jump entity jump-pressed?) ;; sets #:ay if on-ground
+(apply-acceleration entity) ;; consumes #:ay → #:vy, gated on #:gravity?
+(apply-gravity entity) ;; adds *gravity*(=1) to #:vy, gated on #:gravity?
+(apply-velocity-x entity) ;; #:x += #:vx
+(resolve-tile-collisions-x entity tm) ;; snaps #:x, zeros #:vx on hit
+(apply-velocity-y entity) ;; #:y += #:vy
+(resolve-tile-collisions-y entity tm) ;; snaps #:y, zeros #:vy on hit
+(detect-ground entity tm) ;; sets #:on-ground?, gated on #:gravity?
+;; Entity-entity collision:
+(scene-resolve-collisions scene) ;; mutates scene; needs #:solid? #t on both entities
+;; AABB check:
+(aabb-overlap? x1 y1 w1 h1 x2 y2 w2 h2) ;; → bool
+
+;; Input (downstroke/input)
+(game-input game) ;; current input-state
+(input-held? state 'action) ;; true while button held
+(input-pressed? state 'action) ;; true only on first frame pressed
+;; Default action names: up down left right a b start select quit
+;; Keyboard: W/up=up, S/down=down, A/left=left, D/right=right,
+;; J/Z=a, K/X=b, return=start, escape=quit
+
+;; Engine (downstroke/engine)
+(game-run! game)
+(game-scene game), (game-scene-set! game scene)
+(game-renderer game) ;; SDL renderer — valid from preload: onward
+(game-input game) ;; current input-state
+(game-asset game key) ;; = (asset-ref (game-assets game) key)
+(game-asset-set! game key value)
+(game-camera game) ;; = (scene-camera (game-scene game))
+
+;; Renderer (downstroke/renderer)
+(render-scene! renderer scene) ;; draws tilemap + entities (when tilemap present)
+(draw-ui-text renderer font str color x y)
+
+;; Sound (downstroke/sound) — after copying from macroknight
+(init-audio!)
+(load-sounds! '((key . "path.wav") ...))
+(play-sound 'key) ;; NOTE: no bang
+(load-music! "path.ogg")
+(play-music! volume) ;; volume = 0.0–1.0
+(stop-music!) ;; added when copying sound.scm
+```
+
+---
+
+## File Structure
+
+**New files to create:**
+```
+demo/assets/ ← copied from macroknight/assets/
+ monochrome-transparent.png
+ monochrome_transparent.tsx
+ level-0.tmx
+ DejaVuSans.ttf
+ jump.wav
+ theme.ogg
+demo/platformer.scm
+demo/shmup.scm
+demo/topdown.scm
+demo/audio.scm
+demo/sandbox.scm
+mixer.scm ← adapted from macroknight (module renamed to downstroke/mixer)
+sound.scm ← adapted from macroknight (module renamed to downstroke/sound, +stop-music!)
+```
+
+**Files to modify:**
+```
+Makefile ← add mixer/sound to MODULE_NAMES, update demos: target
+CLAUDE.md ← add make demos requirement
+```
+
+---
+
+## Task 1: Infrastructure — assets, audio modules, Makefile, CLAUDE.md
+
+**Files:**
+- Create: `demo/assets/` (7 files copied from macroknight)
+- Create: `mixer.scm` (adapted from `/home/gene/src/macroknight/mixer.scm`)
+- Create: `sound.scm` (adapted from `/home/gene/src/macroknight/sound.scm`)
+- Modify: `Makefile`
+- Modify: `CLAUDE.md`
+
+- [ ] **Step 1: Create demo/assets/ and copy asset files**
+
+```bash
+mkdir -p demo/assets
+cp /home/gene/src/macroknight/assets/monochrome-transparent.png demo/assets/
+cp /home/gene/src/macroknight/assets/monochrome_transparent.tsx demo/assets/
+cp /home/gene/src/macroknight/assets/level-0.tmx demo/assets/
+cp /home/gene/src/macroknight/assets/DejaVuSans.ttf demo/assets/
+cp /home/gene/src/macroknight/assets/jump.wav demo/assets/
+cp /home/gene/src/macroknight/assets/theme.ogg demo/assets/
+```
+
+Verify: `ls demo/assets/` shows 6 files (png, tsx, tmx, ttf, wav, ogg).
+
+- [ ] **Step 2: Copy and adapt mixer.scm**
+
+Copy `/home/gene/src/macroknight/mixer.scm` to `mixer.scm` in the downstroke root. Change the module declaration from:
+```scheme
+(module mixer *
+```
+to:
+```scheme
+(module downstroke/mixer *
+```
+
+No other changes needed.
+
+- [ ] **Step 3: Copy and adapt sound.scm**
+
+Copy `/home/gene/src/macroknight/sound.scm` to `sound.scm` in the downstroke root. Make three changes:
+
+1. Change module declaration from `(module sound *` to `(module downstroke/sound *`
+2. Change `(import ... mixer)` to `(import ... downstroke/mixer)`
+3. Add `stop-music!` near the other music functions:
+
+```scheme
+(define (stop-music!) (mix-halt-music))
+```
+
+The full adapted `sound.scm` should look like:
+
+```scheme
+(module downstroke/sound *
+ (import scheme
+ (chicken base)
+ (only srfi-1 for-each)
+ downstroke/mixer)
+
+ (define *sound-registry* '())
+ (define *music* #f)
+
+ (define (init-audio!)
+ (mix-open-audio! 44100 mix-default-format 2 512))
+
+ (define (load-sounds! sound-alist)
+ (set! *sound-registry*
+ (map (lambda (pair)
+ (cons (car pair) (mix-load-chunk (cdr pair))))
+ sound-alist)))
+
+ (define (play-sound sym)
+ (let ((entry (assq sym *sound-registry*)))
+ (when (and entry (cdr entry))
+ (mix-play-channel -1 (cdr entry) 0))))
+
+ (define (load-music! path)
+ (set! *music* (mix-load-mus path)))
+
+ (define (play-music! volume)
+ (when *music*
+ (mix-play-music *music* -1)
+ (mix-volume-music (inexact->exact (round (* volume 128))))))
+
+ (define (stop-music!) (mix-halt-music))
+
+ (define (set-music-volume! volume)
+ (mix-volume-music (inexact->exact (round (* volume 128)))))
+
+ (define (cleanup-audio!)
+ (when *music*
+ (mix-halt-music)
+ (mix-free-music! *music*)
+ (set! *music* #f))
+ (for-each (lambda (pair) (mix-free-chunk! (cdr pair)))
+ *sound-registry*)
+ (set! *sound-registry* '())
+ (mix-close-audio!)))
+```
+
+- [ ] **Step 4: Update Makefile**
+
+Replace the current `MODULE_NAMES` line and add mixer/sound deps and demos target. The updated Makefile:
+
+```makefile
+.DEFAULT_GOAL := engine
+
+# Modules listed in dependency order
+MODULE_NAMES := entity tilemap world input physics renderer assets engine mixer sound
+OBJECT_FILES := $(patsubst %,bin/%.o,$(MODULE_NAMES))
+
+DEMO_NAMES := platformer shmup topdown audio sandbox
+DEMO_BINS := $(patsubst %,bin/demo-%,$(DEMO_NAMES))
+
+# Build all engine modules
+engine: $(OBJECT_FILES)
+
+bin:
+ @mkdir -p $@
+
+downstroke:
+ @mkdir -p $@
+
+# Explicit inter-module dependencies
+bin/entity.o:
+bin/tilemap.o:
+bin/world.o: bin/entity.o bin/tilemap.o
+bin/input.o: bin/entity.o
+bin/physics.o: bin/entity.o bin/world.o bin/tilemap.o
+bin/renderer.o: bin/entity.o bin/tilemap.o bin/world.o
+bin/assets.o:
+bin/engine.o: bin/renderer.o bin/world.o bin/input.o bin/assets.o
+bin/mixer.o:
+bin/sound.o: bin/mixer.o
+
+# Pattern rule: compile each module as a library unit
+bin/%.o: %.scm | bin downstroke
+ csc -c -J -unit downstroke/$* $*.scm -o bin/$*.o -I bin -L bin/downstroke
+ @mkdir -p bin/downstroke && if [ -f downstroke/$*.import.scm ]; then mv downstroke/$*.import.scm bin/downstroke/; fi
+
+.PHONY: clean test engine demos
+
+clean:
+ rm -rf bin
+ rm -f *.import.scm
+ rm -f *.log
+
+test:
+ @echo "Running unit tests..."
+ @csi -s tests/entity-test.scm
+ @csi -s tests/world-test.scm
+ @csi -s tests/tilemap-test.scm
+ @csi -s tests/physics-test.scm
+ @csi -s tests/input-test.scm
+ @csi -s tests/renderer-test.scm
+ @csi -s tests/assets-test.scm
+ @csi -s tests/engine-test.scm
+
+demos: engine $(DEMO_BINS)
+
+bin/demo-%: demo/%.scm $(OBJECT_FILES) | bin
+ csc demo/$*.scm $(OBJECT_FILES) -o bin/demo-$* -I bin
+```
+
+- [ ] **Step 5: Update CLAUDE.md**
+
+In the "Build & Test" section of `/home/gene/src/downstroke/CLAUDE.md`, update the code block:
+
+```bash
+make # compile engine + all demos in demo/
+make test # run all SRFI-64 test suites
+make demos # build demo games only (verify they compile)
+```
+
+Also add after the build section (or at end of Build & Test):
+
+> `make demos` must always succeed. A demo that fails to compile is a build failure. Run `make && make demos` to verify both engine and demos build cleanly.
+
+- [ ] **Step 6: Verify engine builds with mixer and sound**
+
+```bash
+cd /home/gene/src/downstroke
+make clean && make
+```
+
+Expected: All 10 modules compile without errors. `bin/` contains `.o` files for all MODULE_NAMES including `mixer.o` and `sound.o`.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add demo/assets/ mixer.scm sound.scm Makefile CLAUDE.md
+git commit -m "feat: add demo infrastructure (assets, mixer/sound, Makefile demos target)"
+```
+
+---
+
+## Task 2: demo/platformer.scm
+
+Exercises: input, physics (gravity + tile collision + jump), renderer (tilemap + entities), world/scene, camera follow (x), audio (sound effect).
+
+**Files:**
+- Create: `demo/platformer.scm`
+
+- [ ] **Step 1: Create demo/platformer.scm**
+
+```scheme
+(import scheme
+ (chicken base)
+ (prefix sdl2 "sdl2:")
+ (prefix sdl2-ttf "ttf:")
+ (prefix sdl2-image "img:")
+ downstroke/engine
+ downstroke/world
+ downstroke/tilemap
+ downstroke/renderer
+ downstroke/input
+ downstroke/physics
+ downstroke/assets
+ downstroke/entity
+ downstroke/sound)
+
+(define *game*
+ (make-game
+ title: "Demo: Platformer" width: 600 height: 400
+
+ preload: (lambda (game)
+ (init-audio!)
+ (load-sounds! '((jump . "demo/assets/jump.wav")))
+ (game-asset-set! game 'tilemap
+ (load-tilemap "demo/assets/level-0.tmx")))
+
+ create: (lambda (game)
+ (let* ((tm (game-asset game 'tilemap))
+ (tex (tileset-image (tilemap-tileset tm)))
+ (player (list #:type 'player
+ #:x 100 #:y 50
+ #:width 16 #:height 16
+ #:vx 0 #:vy 0
+ #:gravity? #t
+ #:on-ground? #f
+ #:tile-id 1)))
+ (game-scene-set! game
+ (make-scene
+ entities: (list player)
+ tilemap: tm
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: tex))))
+
+ update: (lambda (game dt)
+ (let* ((input (game-input game))
+ (scene (game-scene game))
+ (tm (scene-tilemap scene))
+ (player (car (scene-entities scene)))
+ ;; horizontal input
+ (player (entity-set player #:vx
+ (cond
+ ((input-held? input 'left) -3)
+ ((input-held? input 'right) 3)
+ (else 0))))
+ ;; play jump sound when jump fires
+ (_ (when (and (input-pressed? input 'a)
+ (entity-ref player #:on-ground? #f))
+ (play-sound 'jump)))
+ ;; physics pipeline
+ (player (apply-jump player (input-pressed? input 'a)))
+ (player (apply-acceleration player))
+ (player (apply-gravity player))
+ (player (apply-velocity-x player))
+ (player (resolve-tile-collisions-x player tm))
+ (player (apply-velocity-y player))
+ (player (resolve-tile-collisions-y player tm))
+ (player (detect-ground player tm)))
+ ;; camera follows player x, clamped so we don't go negative
+ (let ((cam-x (max 0 (- (entity-ref player #:x 0) 300))))
+ (camera-x-set! (scene-camera scene) cam-x))
+ ;; put player back
+ (scene-entities-set! scene (list player))))))
+
+(game-run! *game*)
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+```bash
+cd /home/gene/src/downstroke
+make demos
+```
+
+Expected: `bin/demo-platformer` is created with no errors.
+
+- [ ] **Step 3: Smoke test — run and verify it opens a window**
+
+```bash
+./bin/demo-platformer
+```
+
+Expected: A window titled "Demo: Platformer" opens. Player entity (tile sprite) appears. Left/right moves it, J/Z triggers a jump with sound. Escape quits.
+
+Note: Tile ID 1 may show the wrong sprite — that's expected. The spec says tile IDs are placeholder values to be adjusted visually.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add demo/platformer.scm
+git commit -m "feat: add platformer demo"
+```
+
+---
+
+## Task 3: demo/shmup.scm
+
+Exercises: entity spawning/removal, manual AABB collision, input, renderer (SDL colored rects in render: hook), world/scene (no tilemap), audio (sound effect on shoot).
+
+The engine's `render-scene!` draws nothing for this scene (no tilemap). All visual output comes from the `render:` hook using `sdl2:render-fill-rect!`.
+
+**Files:**
+- Create: `demo/shmup.scm`
+
+- [ ] **Step 1: Create demo/shmup.scm**
+
+```scheme
+(import scheme
+ (chicken base)
+ (prefix sdl2 "sdl2:")
+ (prefix sdl2-ttf "ttf:")
+ (prefix sdl2-image "img:")
+ downstroke/engine
+ downstroke/world
+ downstroke/physics
+ downstroke/input
+ downstroke/entity
+ downstroke/assets
+ downstroke/sound)
+
+;; Frame counter for enemy spawning
+(define *frame-count* 0)
+
+(define (make-bullet px py)
+ (list #:type 'bullet #:x px #:y py #:width 4 #:height 8 #:vx 0 #:vy -6))
+
+(define (make-enemy rx)
+ (list #:type 'enemy #:x rx #:y 0 #:width 16 #:height 16 #:vx 0 #:vy 2))
+
+(define (make-player)
+ (list #:type 'player #:x 280 #:y 360 #:width 16 #:height 16 #:vx 0 #:vy 0))
+
+;; Check collision between two entities using physics.scm's aabb-overlap?
+(define (entities-overlap? a b)
+ (aabb-overlap?
+ (entity-ref a #:x 0) (entity-ref a #:y 0)
+ (entity-ref a #:width 1) (entity-ref a #:height 1)
+ (entity-ref b #:x 0) (entity-ref b #:y 0)
+ (entity-ref b #:width 1) (entity-ref b #:height 1)))
+
+;; Returns list of entities to remove (bullets and enemies that collide)
+(define (find-collisions entities)
+ (let ((bullets (filter (lambda (e) (eq? (entity-ref e #:type) 'bullet)) entities))
+ (enemies (filter (lambda (e) (eq? (entity-ref e #:type) 'enemy)) entities))
+ (dead '()))
+ (for-each
+ (lambda (b)
+ (for-each
+ (lambda (en)
+ (when (entities-overlap? b en)
+ (set! dead (cons b (cons en dead)))))
+ enemies))
+ bullets)
+ dead))
+
+(define *game*
+ (make-game
+ title: "Demo: Shoot-em-up" width: 600 height: 400
+
+ preload: (lambda (game)
+ (init-audio!)
+ (load-sounds! '((shoot . "demo/assets/jump.wav"))))
+
+ create: (lambda (game)
+ ;; No tilemap — render-scene! will draw nothing for this scene.
+ ;; All drawing happens in the render: hook below.
+ (game-scene-set! game
+ (make-scene
+ entities: (list (make-player))
+ tilemap: #f
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: #f)))
+
+ update: (lambda (game dt)
+ (let* ((input (game-input game))
+ (scene (game-scene game))
+ (entities (scene-entities scene))
+ (player (car (filter (lambda (e) (eq? (entity-ref e #:type) 'player)) entities))))
+ (set! *frame-count* (+ *frame-count* 1))
+
+ ;; Move player left/right
+ (let ((player (entity-set player #:vx
+ (cond
+ ((input-held? input 'left) -4)
+ ((input-held? input 'right) 4)
+ (else 0)))))
+ (let ((player (apply-velocity-x player)))
+ ;; Clamp player to screen
+ (let ((player (entity-set player #:x
+ (max 0 (min 584 (entity-ref player #:x 0))))))
+ ;; Fire bullet on 'a'
+ (when (input-pressed? input 'a)
+ (play-sound 'shoot)
+ (scene-add-entity scene
+ (make-bullet (+ (entity-ref player #:x 0) 6) 340)))
+
+ ;; Spawn enemy every 60 frames
+ (when (zero? (modulo *frame-count* 60))
+ (scene-add-entity scene
+ (make-enemy (+ 20 (* (random 28) 20)))))
+
+ ;; Update scene with current player
+ (scene-entities-set! scene
+ (cons player
+ (filter (lambda (e) (not (eq? (entity-ref e #:type) 'player)))
+ (scene-entities scene)))))))
+
+ ;; Move bullets and enemies
+ (scene-update-entities scene
+ (lambda (e)
+ (if (eq? (entity-ref e #:type) 'player)
+ e
+ (entity-set (entity-set e
+ #:x (+ (entity-ref e #:x 0) (entity-ref e #:vx 0)))
+ #:y (+ (entity-ref e #:y 0) (entity-ref e #:vy 0))))))
+
+ ;; Remove bullet-enemy collisions
+ (let ((dead (find-collisions (scene-entities scene))))
+ (scene-filter-entities scene
+ (lambda (e) (not (memq e dead)))))
+
+ ;; Remove out-of-bounds bullets and enemies
+ (scene-filter-entities scene
+ (lambda (e)
+ (let ((y (entity-ref e #:y 0)))
+ (or (eq? (entity-ref e #:type) 'player)
+ (and (> y -20) (< y 420))))))))
+
+ render: (lambda (game)
+ (let* ((renderer (game-renderer game))
+ (scene (game-scene game))
+ (entities (scene-entities scene)))
+ (for-each
+ (lambda (e)
+ (let ((type (entity-ref e #:type 'unknown))
+ (x (inexact->exact (floor (entity-ref e #:x 0))))
+ (y (inexact->exact (floor (entity-ref e #:y 0))))
+ (w (entity-ref e #:width 16))
+ (h (entity-ref e #:height 16)))
+ (sdl2:render-draw-color-set! renderer
+ (case type
+ ((player) (sdl2:make-color 255 255 255 255))
+ ((bullet) (sdl2:make-color 255 255 0 255))
+ ((enemy) (sdl2:make-color 255 50 50 255))
+ (else (sdl2:make-color 100 100 100 255))))
+ (sdl2:render-fill-rect! renderer
+ (sdl2:make-rect x y w h))))
+ entities)))))
+
+(game-run! *game*)
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+```bash
+make demos
+```
+
+Expected: `bin/demo-shmup` created with no errors.
+
+- [ ] **Step 3: Smoke test**
+
+```bash
+./bin/demo-shmup
+```
+
+Expected: Black window. White player rectangle at bottom. J/Z fires yellow bullet upward (with sound). Red enemy rectangles fall from the top. Bullets disappear on contact with enemies. Escape quits.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add demo/shmup.scm
+git commit -m "feat: add shmup demo"
+```
+
+---
+
+## Task 4: demo/topdown.scm
+
+Exercises: input (8-directional), renderer (tilemap + entities), world/scene, camera follow (both axes), physics tile collision (no gravity).
+
+**Files:**
+- Create: `demo/topdown.scm`
+
+- [ ] **Step 1: Create demo/topdown.scm**
+
+```scheme
+(import scheme
+ (chicken base)
+ (prefix sdl2 "sdl2:")
+ (prefix sdl2-ttf "ttf:")
+ (prefix sdl2-image "img:")
+ downstroke/engine
+ downstroke/world
+ downstroke/tilemap
+ downstroke/renderer
+ downstroke/input
+ downstroke/physics
+ downstroke/assets
+ downstroke/entity)
+
+(define (clamp val lo hi) (max lo (min hi val)))
+
+(define *game*
+ (make-game
+ title: "Demo: Top-down Explorer" width: 600 height: 400
+
+ preload: (lambda (game)
+ (game-asset-set! game 'tilemap
+ (load-tilemap "demo/assets/level-0.tmx")))
+
+ create: (lambda (game)
+ (let* ((tm (game-asset game 'tilemap))
+ (tex (tileset-image (tilemap-tileset tm)))
+ (player (list #:type 'player
+ #:x 100 #:y 100
+ #:width 16 #:height 16
+ #:vx 0 #:vy 0
+ #:gravity? #f
+ #:tile-id 1)))
+ (game-scene-set! game
+ (make-scene
+ entities: (list player)
+ tilemap: tm
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: tex))))
+
+ update: (lambda (game dt)
+ (let* ((input (game-input game))
+ (scene (game-scene game))
+ (tm (scene-tilemap scene))
+ (player (car (scene-entities scene)))
+ ;; 8-directional velocity
+ (dx (+ (if (input-held? input 'left) -3 0)
+ (if (input-held? input 'right) 3 0)))
+ (dy (+ (if (input-held? input 'up) -3 0)
+ (if (input-held? input 'down) 3 0)))
+ (player (entity-set (entity-set player #:vx dx) #:vy dy))
+ ;; tile collision on both axes
+ (player (apply-velocity-x player))
+ (player (resolve-tile-collisions-x player tm))
+ (player (apply-velocity-y player))
+ (player (resolve-tile-collisions-y player tm))
+ ;; update camera to center on player
+ (px (entity-ref player #:x 0))
+ (py (entity-ref player #:y 0)))
+ (camera-x-set! (scene-camera scene) (max 0 (- px 300)))
+ (camera-y-set! (scene-camera scene) (max 0 (- py 200)))
+ (scene-entities-set! scene (list player))))))
+
+(game-run! *game*)
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+```bash
+make demos
+```
+
+Expected: `bin/demo-topdown` created with no errors.
+
+- [ ] **Step 3: Smoke test**
+
+```bash
+./bin/demo-topdown
+```
+
+Expected: Window with tilemap visible. Player sprite moves in 8 directions with WASD or arrow keys. Camera follows player on both axes. Escape quits.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add demo/topdown.scm
+git commit -m "feat: add topdown demo"
+```
+
+---
+
+## Task 5: demo/audio.scm
+
+Exercises: audio (sound effects + music), renderer (text via draw-ui-text), input, assets. No scene, no physics.
+
+**Files:**
+- Create: `demo/audio.scm`
+
+- [ ] **Step 1: Create demo/audio.scm**
+
+```scheme
+(import scheme
+ (chicken base)
+ (prefix sdl2 "sdl2:")
+ (prefix sdl2-ttf "ttf:")
+ (prefix sdl2-image "img:")
+ downstroke/engine
+ downstroke/renderer
+ downstroke/input
+ downstroke/assets
+ downstroke/sound)
+
+;; Music toggle state
+(define *music-on?* #f)
+
+(define *game*
+ (make-game
+ title: "Demo: Audio" width: 600 height: 400
+
+ preload: (lambda (game)
+ (init-audio!)
+ (load-sounds! '((jump . "demo/assets/jump.wav")))
+ (load-music! "demo/assets/theme.ogg")
+ (game-asset-set! game 'font
+ (ttf:open-font "demo/assets/DejaVuSans.ttf" 20)))
+
+ ;; No create: hook — no scene needed
+ ;; engine.scm guards render-scene! with (when (game-scene game) ...)
+ ;; so omitting game-scene-set! is safe.
+
+ update: (lambda (game dt)
+ (let ((input (game-input game)))
+ ;; J = play jump sound
+ (when (input-pressed? input 'a)
+ (play-sound 'jump))
+ ;; K/X = toggle music (mapped to 'b' in default input config)
+ (when (input-pressed? input 'b)
+ (if *music-on?*
+ (begin (stop-music!) (set! *music-on?* #f))
+ (begin (play-music! 0.5) (set! *music-on?* #t))))))
+
+ render: (lambda (game)
+ (let* ((renderer (game-renderer game))
+ (font (game-asset game 'font))
+ (white (sdl2:make-color 255 255 255 255))
+ (gray (sdl2:make-color 180 180 180 255)))
+ ;; Background
+ (sdl2:render-draw-color-set! renderer (sdl2:make-color 30 30 60 255))
+ (sdl2:render-fill-rect! renderer (sdl2:make-rect 0 0 600 400))
+ ;; Title
+ (draw-ui-text renderer font "Audio Demo" white 220 80)
+ ;; Instructions
+ (draw-ui-text renderer font "J / Z — play sound effect" gray 160 160)
+ (draw-ui-text renderer font "K / X — toggle music on/off" gray 160 200)
+ (draw-ui-text renderer font "Escape — quit" gray 160 240)
+ ;; Music status
+ (draw-ui-text renderer font
+ (if *music-on?* "Music: ON" "Music: OFF")
+ (if *music-on?*
+ (sdl2:make-color 100 255 100 255)
+ (sdl2:make-color 255 100 100 255))
+ 240 310)))))
+
+(game-run! *game*)
+```
+
+**Note on key bindings:** The default input config maps `j`/`z` → `'a` action and `k`/`x` → `'b` action. So pressing J plays the sound and K toggles music. The display labels say "J / Z" and "K / X" accordingly. There is no direct key-to-symbol mapping in `*default-input-config*` for arbitrary keys; use the existing action names.
+
+- [ ] **Step 2: Verify it compiles**
+
+```bash
+make demos
+```
+
+Expected: `bin/demo-audio` created with no errors.
+
+- [ ] **Step 3: Smoke test**
+
+```bash
+./bin/demo-audio
+```
+
+Expected: Dark blue window with white text showing key bindings. J plays a sound. K toggles music (status shows ON/OFF in green/red). Escape quits.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add demo/audio.scm
+git commit -m "feat: add audio demo"
+```
+
+---
+
+## Task 6: demo/sandbox.scm
+
+Exercises: physics (gravity + tile collision + entity-entity collision), renderer, world/scene. No player input.
+
+**Files:**
+- Create: `demo/sandbox.scm`
+
+- [ ] **Step 1: Create demo/sandbox.scm**
+
+```scheme
+(import scheme
+ (chicken base)
+ (prefix sdl2 "sdl2:")
+ (prefix sdl2-ttf "ttf:")
+ (prefix sdl2-image "img:")
+ downstroke/engine
+ downstroke/world
+ downstroke/tilemap
+ downstroke/renderer
+ downstroke/input
+ downstroke/physics
+ downstroke/assets
+ downstroke/entity)
+
+;; Respawn timer (accumulates dt in milliseconds)
+(define *elapsed* 0)
+(define *respawn-interval* 10000) ;; 10 seconds
+
+(define (spawn-entities)
+ (let loop ((i 0) (acc '()))
+ (if (= i 10)
+ acc
+ (loop (+ i 1)
+ (cons (list #:type 'box
+ #:x (+ 30 (* i 55))
+ #:y (+ 10 (* (random 4) 20))
+ #:width 16 #:height 16
+ #:vx 0 #:vy 0
+ #:gravity? #t
+ #:on-ground? #f
+ #:solid? #t
+ #:tile-id 1)
+ acc)))))
+
+(define *game*
+ (make-game
+ title: "Demo: Physics Sandbox" width: 600 height: 400
+
+ preload: (lambda (game)
+ (game-asset-set! game 'tilemap
+ (load-tilemap "demo/assets/level-0.tmx")))
+
+ create: (lambda (game)
+ (let* ((tm (game-asset game 'tilemap))
+ (tex (tileset-image (tilemap-tileset tm))))
+ (game-scene-set! game
+ (make-scene
+ entities: (spawn-entities)
+ tilemap: tm
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: tex))))
+
+ update: (lambda (game dt)
+ (let* ((scene (game-scene game))
+ (tm (scene-tilemap scene)))
+ ;; Advance respawn timer
+ (set! *elapsed* (+ *elapsed* dt))
+ (when (>= *elapsed* *respawn-interval*)
+ (set! *elapsed* 0)
+ (scene-entities-set! scene (spawn-entities)))
+
+ ;; Physics pipeline for all entities
+ (scene-update-entities scene
+ apply-gravity
+ apply-velocity-x
+ (lambda (e) (resolve-tile-collisions-x e tm))
+ apply-velocity-y
+ (lambda (e) (resolve-tile-collisions-y e tm))
+ (lambda (e) (detect-ground e tm)))
+
+ ;; Entity-entity collision (push-apart, requires #:solid? #t)
+ (scene-resolve-collisions scene)))))
+
+(game-run! *game*)
+```
+
+- [ ] **Step 2: Verify it compiles**
+
+```bash
+make demos
+```
+
+Expected: `bin/demo-sandbox` created with no errors. All 5 `bin/demo-*` executables exist.
+
+- [ ] **Step 3: Smoke test**
+
+```bash
+./bin/demo-sandbox
+```
+
+Expected: Window shows 10 tile sprites falling with gravity. They land on tilemap floor tiles, pile up, and push each other apart. After 10 seconds they respawn at random positions near the top. Escape quits.
+
+- [ ] **Step 4: Final verification — make demos succeeds cleanly**
+
+```bash
+cd /home/gene/src/downstroke && make clean && make && make demos
+```
+
+Expected: Full clean build succeeds. All 5 demo binaries exist in `bin/`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add demo/sandbox.scm
+git commit -m "feat: add sandbox demo"
+```
diff --git a/docs/superpowers/plans/2026-04-05-milestone-14-docs.md b/docs/superpowers/plans/2026-04-05-milestone-14-docs.md
new file mode 100644
index 0000000..0688bf5
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-05-milestone-14-docs.md
@@ -0,0 +1,167 @@
+# Milestone 14: End-User Documentation Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Write the 4 missing end-user documentation files (`docs/guide.org`, `docs/api.org`, `docs/entities.org`, `docs/physics.org`) that ship with the downstroke egg.
+
+**Architecture:** Each file is a self-contained org-mode document covering one aspect of the engine. Content is derived exclusively from the public API inventory provided in the task description and from the 5 existing demo games in `demo/`. No API fabrication -- only document what exists. Code examples should be drawn from or closely mirror the real demos.
+
+**Tech Stack:** Org-mode markup, Chicken Scheme code examples
+
+---
+
+### Task 1: docs/guide.org -- Getting Started Guide
+
+**Files:**
+- Create: `docs/guide.org`
+
+- [ ] **Step 1:** Write the file with these sections:
+ 1. **Title / Introduction** -- What downstroke is (2D tile-driven game engine for Chicken Scheme, built on SDL2)
+ 2. **Installation** -- `chicken-install downstroke`, system deps (SDL2, SDL2_mixer, SDL2_ttf, SDL2_image), egg deps
+ 3. **Hello World** -- Minimal ~20-line game: blank window that quits on Escape. Use `make-game` with just `title:`, `width:`, `height:` and an empty `update:` that does nothing. Call `game-run!`. Show the full import list needed.
+ 4. **Moving Square** -- Step up: add an entity with `#:x`, `#:y`, `#:width`, `#:height`; read input with `input-held?`; update position with `entity-set` + `apply-velocity-x`; use a custom `render:` hook to draw an SDL2 colored rect (mirror the shmup demo pattern: `sdl2:render-fill-rect!`). No tilemap needed -- set `tilemap: #f` and `tileset-texture: #f`.
+ 5. **Adding a Tilemap** -- Show how to load a TMX map in `preload:`, create a scene with it in `create:`, and let `render-scene!` handle drawing. Reference `demo/platformer.scm`.
+ 6. **Demo Overview** -- Table of the 5 demos with 1-line descriptions and what each demonstrates:
+ - `demo/platformer.scm` -- Gravity, jump, tile collision, camera follow, sound
+ - `demo/topdown.scm` -- 8-dir movement, no gravity, tilemap, camera follow
+ - `demo/sandbox.scm` -- Entity-entity collision, multi-entity physics, auto-respawn
+ - `demo/shmup.scm` -- No tilemap, entity spawning/removal, manual AABB collision, SDL colored rects
+ - `demo/audio.scm` -- Sound effects, music toggle, draw-ui-text
+ 7. **Build & Run** -- `make && make demos` to build everything; run a demo with `./bin/demo-platformer` (or however the Makefile names them)
+ 8. **Next Steps** -- Pointers to the other 3 doc files
+
+- [ ] **Step 2:** Self-review: verify every function/keyword used in code examples exists in the public API inventory. Verify import lists match what the real demos use. No fabricated API.
+
+**Key content notes:**
+- The Hello World must actually work -- the minimum viable `make-game` call needs at least the `title:` keyword; all others have defaults (width 640, height 480, frame-delay 16, input-config uses `*default-input-config*`)
+- `game-run!` handles SDL2 init, window creation, event loop, and cleanup -- the user does NOT call any SDL2 init themselves (except `init-audio!` for sound)
+- Quit is automatic: the engine checks `(input-held? input 'quit)` every frame (bound to Escape + window close)
+
+---
+
+### Task 2: docs/api.org -- Public API Reference
+
+**Files:**
+- Create: `docs/api.org`
+
+- [ ] **Step 1:** Write the file with one top-level section per module, in this order:
+ 1. **Engine** (`downstroke/engine`) -- `make-game`, `game-run!`, `game-camera`, `game-asset`, `game-asset-set!`, `make-game-state`, `game-add-state!`, `game-start-state!`, `game-scene-set!`, auto-generated accessors (`game-renderer`, `game-input`, `game-window`, `game-title`, `game-width`, `game-height`, `game-scene`)
+ 2. **World** (`downstroke/world`) -- `make-scene`, `scene-entities`, `scene-entities-set!`, `scene-tilemap`, `scene-tileset-texture`, `scene-camera`, `scene-add-entity`, `scene-update-entities`, `scene-filter-entities`, `scene-find-tagged`, `scene-find-all-tagged`, `make-camera`, `camera-x`, `camera-y`, `camera-x-set!`, `camera-y-set!`, `camera-follow!`
+ 3. **Entity** (`downstroke/entity`) -- `entity-ref`, `entity-type`, `entity-set`, `entity-update`
+ 4. **Physics** (`downstroke/physics`) -- `apply-gravity`, `apply-acceleration`, `apply-velocity-x`, `apply-velocity-y`, `apply-jump`, `resolve-tile-collisions-x`, `resolve-tile-collisions-y`, `detect-ground`, `aabb-overlap?`, `resolve-entity-collisions`, `scene-resolve-collisions`
+ 5. **Input** (`downstroke/input`) -- `create-input-state`, `input-state-update`, `input-held?`, `input-pressed?`, `input-released?`, `input-any-pressed?`, `*default-input-config*`; include the default action/binding table
+ 6. **Renderer** (`downstroke/renderer`) -- `render-scene!`, `draw-ui-text`, `entity-screen-coords`, `entity-flip`
+ 7. **Assets** (`downstroke/assets`) -- `make-asset-registry`, `asset-set!`, `asset-ref`
+ 8. **Sound** (`downstroke/sound`) -- `init-audio!`, `load-sounds!`, `play-sound`, `load-music!`, `play-music!`, `stop-music!`
+ 9. **Animation** (`downstroke/animation`) -- `set-animation`, `animate-entity`; document the `#:animations` plist format
+ 10. **Tilemap** (`downstroke/tilemap`) -- `load-tilemap`, `tilemap-tileset`, `tileset-image`; note that TMX/TSX parsing uses expat
+
+- [ ] **Step 2:** For each function entry, include:
+ - Signature line in a `#+begin_src scheme` block
+ - 1-2 sentence description
+ - Brief code example where the usage is non-obvious (e.g., `scene-update-entities` taking variadic procs, `entity-set` returning a new plist, `make-game-state` + `game-add-state!` + `game-start-state!` flow)
+
+- [ ] **Step 3:** Self-review: cross-check every entry against the API inventory. Ensure no function is missing and no function is fabricated. Verify signatures match (especially keyword args for `make-game`, `make-scene`, `make-camera`, `make-game-state`).
+
+**Key content notes:**
+- `make-game` keyword args: `title:` (default "Downstroke Game"), `width:` (640), `height:` (480), `frame-delay:` (16), `input-config:` (default `*default-input-config*`), `preload:`, `create:`, `update:`, `render:` (all default `#f`)
+- `scene-update-entities` takes a scene + rest-arg list of procs, applies them sequentially to all entities
+- `entity-set` is functional (returns new plist) -- this is critical to document clearly
+- `game-scene` is auto-generated by defstruct, not manually defined
+- `render-scene!` is called automatically by `game-run!` before the user's `render:` hook -- the user's `render:` is for overlays/UI
+
+---
+
+### Task 3: docs/entities.org -- Entity Model & Prefabs
+
+**Files:**
+- Create: `docs/entities.org`
+
+- [ ] **Step 1:** Write the file with these sections:
+ 1. **Entity Model Overview** -- Entities are plists (property lists), not objects or records. Pure data. No classes, no inheritance. Access via `entity-ref`, mutation via `entity-set` (which returns a new plist -- functional/immutable style).
+ 2. **Creating Entities** -- Show a literal plist creation: `(list #:type 'player #:x 100 #:y 200 ...)`. Explain that you just use `list` -- there is no `make-entity` constructor.
+ 3. **Plist Key Reference** -- Full table of all documented keys with type and description. Use the table from the API inventory verbatim:
+
+ | Key | Type | Description |
+ |-----|------|-------------|
+ | `#:type` | symbol | Entity type |
+ | `#:x` `#:y` | number | World position |
+ | `#:width` `#:height` | number | Bounding box size |
+ | `#:vx` `#:vy` | number | Velocity |
+ | `#:ay` | number | Y acceleration (consumed by apply-acceleration) |
+ | `#:gravity?` | bool | Whether gravity applies |
+ | `#:on-ground?` | bool | Set by detect-ground |
+ | `#:solid?` | bool | Participates in entity-entity collision |
+ | `#:tile-id` | integer | Sprite index in tileset (1-indexed) |
+ | `#:facing` | number | 1 = right, -1 = left (affects flip) |
+ | `#:tags` | list of symbols | Used for scene-find-tagged |
+ | `#:animations` | alist | Animation data |
+ | `#:anim-name` | symbol | Current animation name |
+ | `#:anim-frame` | integer | Current frame index |
+ | `#:anim-tick` | integer | Tick counter for frame advance |
+
+ 4. **Accessing & Updating Entities** -- `entity-ref` with default value, `entity-type` shortcut, `entity-set` (stress: returns NEW plist, original unchanged), `entity-update` with a proc. Show the let*-chaining pattern from the platformer demo.
+ 5. **Entities in Scenes** -- `scene-entities`, `scene-entities-set!`, `scene-add-entity`, `scene-update-entities` (show the sandbox demo pattern of passing multiple procs), `scene-filter-entities`, `scene-find-tagged` / `scene-find-all-tagged`.
+ 6. **Animation** -- The `#:animations` key format: `((idle #:frames (28) #:duration 10) (walk #:frames (27 28) #:duration 10))`. `set-animation` to switch, `animate-entity` to advance each frame. `#:tile-id` is updated automatically.
+ 7. **Tags** -- The `#:tags` key is a list of symbols. Use `scene-find-tagged` to find the first entity with a given tag, `scene-find-all-tagged` for all matches. Common pattern: tag the player with `'player`, enemies with `'enemy`.
+
+- [ ] **Step 2:** Self-review: verify all function signatures and plist keys match the API inventory exactly.
+
+**Key content notes:**
+- Do NOT document prefab/mixin internals (`make-prefab-registry`, `instantiate-prefab` from scene-loader) beyond a brief mention -- these are in `scene-loader.scm` which is not in the public API inventory provided. Mention the concept exists and point to CLAUDE.md's description, but do not fabricate API signatures.
+- The `#:animations` format uses keyword-style keys inside the alist entries -- this is unusual and worth a clear example.
+
+---
+
+### Task 4: docs/physics.org -- Physics Pipeline & Collision
+
+**Files:**
+- Create: `docs/physics.org`
+
+- [ ] **Step 1:** Write the file with these sections:
+ 1. **Overview** -- Downstroke provides a built-in physics pipeline for platformers and top-down games. All physics operates on entity plists -- functions take an entity and return a new entity. The pipeline is explicit: the user calls each step in their `update:` hook.
+ 2. **Pipeline Diagram** -- Show the 9-step pipeline order as a clear diagram:
+ ```
+ apply-jump -> apply-acceleration -> apply-gravity ->
+ apply-velocity-x -> resolve-tile-collisions-x ->
+ apply-velocity-y -> resolve-tile-collisions-y ->
+ detect-ground -> resolve-entity-collisions
+ ```
+ 3. **Pipeline Steps** -- One subsection per function. For each: signature, what entity keys it reads, what entity keys it writes/returns, and a 1-2 sentence explanation:
+ - `apply-jump` -- reads `#:on-ground?`, sets `#:ay` if pressed? and on-ground
+ - `apply-acceleration` -- reads `#:ay`, adds to `#:vy`, zeroes `#:ay`
+ - `apply-gravity` -- reads `#:gravity?`, adds gravity constant to `#:vy`
+ - `apply-velocity-x` -- reads `#:vx`, adds to `#:x`
+ - `resolve-tile-collisions-x` -- reads `#:x`, `#:width`, `#:vx`; snaps `#:x` on tile hit, zeroes `#:vx`
+ - `apply-velocity-y` -- reads `#:vy`, adds to `#:y`
+ - `resolve-tile-collisions-y` -- reads `#:y`, `#:height`, `#:vy`; snaps `#:y` on tile hit, zeroes `#:vy`
+ - `detect-ground` -- probes 1px below entity feet, sets `#:on-ground?`
+ - `resolve-entity-collisions` / `scene-resolve-collisions` -- all-pairs push-apart for entities with `#:solid? #t`
+ 4. **Tile Collision Model** -- AABB overlap detection. The physics module checks all tile cells overlapping the entity's bounding box. On collision, the entity is snapped to the nearest tile edge in the direction of travel. X and Y axes are resolved independently (hence the interleaved velocity/collision pattern).
+ 5. **Entity-Entity Collision** -- `aabb-overlap?` for manual checks; `resolve-entity-collisions` for automatic push-apart. Entities must have `#:solid? #t` to participate. `scene-resolve-collisions` is the convenience wrapper.
+ 6. **Platformer Example** -- Full `update:` lambda from `demo/platformer.scm` (verbatim or near-verbatim). Annotate the let*-chain showing each pipeline step.
+ 7. **Top-Down Example** -- Full `update:` lambda from `demo/topdown.scm`. Highlight that `apply-gravity`, `apply-jump`, `apply-acceleration`, and `detect-ground` are skipped; only velocity + tile-collision steps are used. Set `#:gravity? #f` on entities.
+ 8. **Physics Sandbox Example** -- Show the `scene-update-entities` pattern from `demo/sandbox.scm` where multiple physics procs are passed as rest args. Show `scene-resolve-collisions` for entity-entity push-apart.
+
+- [ ] **Step 2:** Self-review: verify all function names, signatures, and entity key names match the API inventory. Ensure the pipeline order matches exactly. Verify code examples are accurate against the real demo source files.
+
+**Key content notes:**
+- The pipeline is NOT automatic -- the user must call each step explicitly in their `update:` hook. This is by design (flexibility for top-down vs platformer vs custom).
+- `resolve-tile-collisions-x` and `resolve-tile-collisions-y` take `(entity tilemap)` -- the tilemap must be passed in.
+- `detect-ground` also takes `(entity tilemap)`.
+- `apply-jump` takes `(entity pressed?)` where pressed? is typically `(input-pressed? input 'a)`.
+- `scene-update-entities` with rest-arg procs is the idiomatic way to apply physics to all entities at once (sandbox pattern).
+
+---
+
+## Rejected Alternatives
+
+- **Single monolithic doc file**: Rejected. Four focused files are easier to navigate and maintain. Each serves a different reader need (tutorial vs reference vs concept guide).
+- **Auto-generated API docs from source**: Rejected. Chicken Scheme has no standard doc-generation tool that would produce org-mode output. Hand-written docs allow better examples and narrative.
+- **Markdown instead of org-mode**: Rejected. CLAUDE.md explicitly requires org-mode format.
+
+## Gaps -- follow-up investigation needed
+
+- **Tilemap public API**: The API inventory lists `load-tilemap`, `tilemap-tileset`, and `tileset-image` but the full set of tilemap accessors (e.g., `tilemap-layers`, tile GID access) was not provided. `docs/api.org` should document only the three confirmed exports; if more exist, they can be added later.
+- **Prefab/scene-loader API**: `scene-loader.scm` exports `instantiate-prefab` and likely `make-prefab-registry`, but these were not in the provided API inventory. `docs/entities.org` should mention the concept but not fabricate signatures. A follow-up investigation of `scene-loader.scm` would allow documenting these fully.
+- **Animation internals**: The exact behavior of `animate-entity` (how it advances `#:anim-tick`, when it wraps `#:anim-frame`, how it sets `#:tile-id`) was not fully specified. Document the public interface only.
diff --git a/docs/superpowers/plans/2026-04-05-milestone-7-scene-loader.md b/docs/superpowers/plans/2026-04-05-milestone-7-scene-loader.md
new file mode 100644
index 0000000..2b7f86f
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-05-milestone-7-scene-loader.md
@@ -0,0 +1,201 @@
+# Scene Loader Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Create `scene-loader.scm` -- a module that encapsulates the repeated tilemap-load / texture-create / scene-build pattern currently duplicated across platformer, topdown, and sandbox demos.
+
+**Architecture:** A new `downstroke/scene-loader` module provides `game-load-scene!` as the single entry point for the common pattern (load TMX, create texture, build scene, set on game). Two pure helpers (`tilemap-objects->entities` and `make-prefab-registry` / `instantiate-prefab`) handle object-layer entity instantiation with a simple hash-table registry. The module sits between `assets`/`world`/`tilemap` and `engine` in the dependency graph; demos import it and replace ~10 lines of boilerplate with a single call.
+
+**Tech Stack:** Chicken Scheme 5, `defstruct`, `srfi-1` (filter-map), `srfi-69` (hash-tables), `sdl2` (texture creation), existing downstroke modules (`world`, `tilemap`, `assets`).
+
+**Rejected alternatives:**
+- Putting scene-loading logic directly into `engine.scm` -- rejected because engine should stay lifecycle-only; scene loading is an opt-in convenience.
+- Making `game-load-scene!` accept an entity list parameter -- rejected because callers should add entities after via `scene-add-entity` (which already exists and mutates in place); keeps the loader focused on the tilemap/texture concern.
+
+---
+
+### Task 1: Tests for pure helpers (TDD -- red phase)
+
+**Files:**
+- Create: `tests/scene-loader-test.scm`
+
+- [ ] **Step 1:** Create `tests/scene-loader-test.scm` with `(import srfi-64)` and mock modules. Mock `downstroke/tilemap` inline (same pattern as `tests/renderer-test.scm` lines 10-18) defining the `object` defstruct (`object-type`, `object-x`, `object-y`, `object-width`, `object-height`) and `tilemap-objects` accessor. Mock `downstroke/world` with the `scene`, `camera` defstructs. Mock `sdl2` with a stub `create-texture-from-surface`. Mock `downstroke/assets` with stubs for `asset-set!` and `asset-ref`.
+- [ ] **Step 2:** Write tests for `make-prefab-registry` + `instantiate-prefab`:
+ - `make-prefab-registry` with two type/constructor pairs returns a registry.
+ - `instantiate-prefab` with a known type calls the constructor and returns the entity plist.
+ - `instantiate-prefab` with an unknown type returns `#f`.
+- [ ] **Step 3:** Write tests for `tilemap-objects->entities`:
+ - Given a mock tilemap whose `tilemap-objects` returns a list of 3 fake objects (with `object-type`, `object-x`, `object-y`, `object-width`, `object-height`), and an `instantiate-fn` that returns a plist for type `"player"` but `#f` for type `"decoration"`, verify the result is a list containing only the matched entity (i.e., `#f` results are filtered out).
+ - Given a tilemap with zero objects, verify the result is `'()`.
+- [ ] **Step 4:** Add a placeholder `(include "scene-loader.scm")` and `(import downstroke/scene-loader)` after the mocks. Run `csi -s tests/scene-loader-test.scm` and confirm it fails (module not found). This is the red phase.
+
+---
+
+### Task 2: Implement `scene-loader.scm` (green phase)
+
+**Files:**
+- Create: `scene-loader.scm`
+
+- [ ] **Step 1:** Create `scene-loader.scm` with the module declaration:
+ ```scheme
+ (module downstroke/scene-loader *
+ (import scheme
+ (chicken base)
+ (only srfi-1 filter-map)
+ (srfi 69)
+ (prefix sdl2 "sdl2:")
+ defstruct
+ downstroke/world
+ downstroke/tilemap
+ downstroke/assets)
+ ...)
+ ```
+- [ ] **Step 2:** Implement `make-prefab-registry` -- takes alternating `symbol constructor` pairs, returns an `srfi-69` hash-table mapping each symbol to its constructor lambda:
+ ```scheme
+ (define (make-prefab-registry . pairs)
+ (let ((ht (make-hash-table)))
+ (let loop ((p pairs))
+ (if (null? p) ht
+ (begin
+ (hash-table-set! ht (car p) (cadr p))
+ (loop (cddr p)))))))
+ ```
+- [ ] **Step 3:** Implement `instantiate-prefab` -- looks up `type` (a symbol) in registry, calls the constructor with `(x y w h)`, returns entity plist or `#f`:
+ ```scheme
+ (define (instantiate-prefab registry type x y w h)
+ (let ((ctor (hash-table-ref/default registry type #f)))
+ (and ctor (ctor x y w h))))
+ ```
+- [ ] **Step 4:** Implement `tilemap-objects->entities`:
+ ```scheme
+ (define (tilemap-objects->entities tilemap instantiate-fn)
+ (filter-map
+ (lambda (obj)
+ (instantiate-fn (string->symbol (object-type obj))
+ (object-x obj) (object-y obj)
+ (object-width obj) (object-height obj)))
+ (tilemap-objects tilemap)))
+ ```
+ Note: `object-type` returns a string (from TMX XML); convert to symbol so callers work with symbols.
+- [ ] **Step 5:** Implement `create-tileset-texture`:
+ ```scheme
+ (define (create-tileset-texture renderer tilemap)
+ (sdl2:create-texture-from-surface
+ renderer
+ (tileset-image (tilemap-tileset tilemap))))
+ ```
+- [ ] **Step 6:** Implement `game-load-scene!`:
+ ```scheme
+ (define (game-load-scene! game filename)
+ (let* ((tm (load-tilemap filename))
+ (tex (create-tileset-texture (game-renderer game) tm))
+ (scene (make-scene entities: '()
+ tilemap: tm
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: tex)))
+ (game-asset-set! game 'tilemap tm)
+ (game-scene-set! game scene)
+ scene))
+ ```
+ Note: `game-load-scene!` needs `game-renderer`, `game-asset-set!`, and `game-scene-set!` from `downstroke/engine`. However, importing engine from scene-loader would create a circular dependency (engine depends on world, scene-loader depends on world+tilemap, engine should not depend on scene-loader). **Resolution:** `game-load-scene!` must accept `renderer` explicitly, or scene-loader must also import `downstroke/engine`. Check: engine exports `game-renderer`, `game-asset-set!`, `game-scene-set!`. Scene-loader depends on engine; engine does NOT need to depend on scene-loader. This is safe -- add `downstroke/engine` to imports.
+- [ ] **Step 7:** Run `csi -s tests/scene-loader-test.scm`. All tests should pass (green phase).
+
+---
+
+### Task 3: Build system integration
+
+**Files:**
+- Modify: `/home/gene/src/downstroke/Makefile`
+- Modify: `/home/gene/src/downstroke/downstroke.egg`
+
+- [ ] **Step 1:** In `Makefile`, add `scene-loader` to `MODULE_NAMES` after `engine` (it depends on engine, world, tilemap, assets):
+ ```
+ MODULE_NAMES := entity tilemap world input physics renderer assets engine mixer sound animation ai scene-loader
+ ```
+- [ ] **Step 2:** Add explicit dependency line:
+ ```makefile
+ bin/scene-loader.o: bin/world.o bin/tilemap.o bin/assets.o bin/engine.o
+ ```
+- [ ] **Step 3:** Add the test to the `test:` target:
+ ```makefile
+ @csi -s tests/scene-loader-test.scm
+ ```
+- [ ] **Step 4:** In `downstroke.egg`, add the extension after the `downstroke/engine` entry:
+ ```scheme
+ (extension downstroke/scene-loader
+ (source "scene-loader.scm")
+ (component-dependencies downstroke/world downstroke/tilemap downstroke/assets downstroke/engine))
+ ```
+- [ ] **Step 5:** Run `make clean && make && make test` to verify engine compiles and all tests pass.
+
+---
+
+### Task 4: Update demos to use scene-loader
+
+**Files:**
+- Modify: `/home/gene/src/downstroke/demo/platformer.scm`
+- Modify: `/home/gene/src/downstroke/demo/topdown.scm`
+- Modify: `/home/gene/src/downstroke/demo/sandbox.scm`
+
+- [ ] **Step 1:** In `demo/platformer.scm`:
+ - Add `downstroke/scene-loader` to the imports.
+ - In `preload:`, replace the `game-asset-set!` call for tilemap with nothing (remove it; `game-load-scene!` handles this). Keep the `init-audio!` and `load-sounds!` calls.
+ - In `create:`, replace the entire `let*` body with:
+ ```scheme
+ (let* ((scene (game-load-scene! game "demo/assets/level-0.tmx"))
+ (player (list #:type 'player
+ #:x 100 #:y 50
+ #:width 16 #:height 16
+ #:vx 0 #:vy 0
+ #:gravity? #t
+ #:on-ground? #f
+ #:tile-id 1)))
+ (scene-add-entity scene player))
+ ```
+ Note: `scene-add-entity` (from `world.scm` line 41) mutates and returns the scene, which is already set on the game by `game-load-scene!`, so no further `game-scene-set!` needed.
+
+- [ ] **Step 2:** In `demo/topdown.scm`:
+ - Add `downstroke/scene-loader` to imports.
+ - Remove the `preload:` tilemap loading entirely (the `preload:` lambda can just be `(lambda (game) #f)` or removed if `make-game` allows it -- check engine.scm; if preload is optional, omit it).
+ - In `create:`, replace with:
+ ```scheme
+ (let* ((scene (game-load-scene! game "demo/assets/level-0.tmx"))
+ (player (list #:type 'player
+ #:x 100 #:y 100
+ #:width 16 #:height 16
+ #:vx 0 #:vy 0
+ #:gravity? #f
+ #:tile-id 1)))
+ (scene-add-entity scene player))
+ ```
+
+- [ ] **Step 3:** In `demo/sandbox.scm`:
+ - Add `downstroke/scene-loader` to imports.
+ - Remove the `preload:` tilemap loading.
+ - In `create:`, replace with:
+ ```scheme
+ (let ((scene (game-load-scene! game "demo/assets/level-0.tmx")))
+ (scene-entities-set! scene (spawn-entities)))
+ ```
+ Note: sandbox replaces the entire entity list rather than adding one, so use `scene-entities-set!` directly.
+
+- [ ] **Step 4:** Run `make clean && make && make demos` to verify all demos compile.
+
+---
+
+### Task 5: Verify and clean up
+
+**Files:**
+- All modified files
+
+- [ ] **Step 1:** Run the full build and test suite: `make clean && make && make demos && make test`. All must pass.
+- [ ] **Step 2:** Review that no demo still imports `(prefix sdl2 "sdl2:")` solely for `create-texture-from-surface`. If that was the only sdl2 usage in the demo's `create:` hook, the `sdl2` import can remain (it may be needed for other SDL2 calls in `update:` or elsewhere) -- do not remove imports that are still used.
+- [ ] **Step 3:** Verify the three demos no longer contain the duplicated tilemap-load/texture-create/scene-build pattern. Each demo's `create:` hook should be noticeably shorter.
+
+---
+
+## Gaps -- follow-up investigation needed
+
+- **`game-load-scene!` importing engine:** The plan assumes `scene-loader.scm` can import `downstroke/engine` without creating a circular dependency. Verify by checking that `engine.scm` does NOT import `scene-loader`. From the Makefile deps (`bin/engine.o: bin/renderer.o bin/world.o bin/input.o bin/assets.o`), this is confirmed safe.
+- **`preload:` optionality:** The plan suggests removing `preload:` from topdown and sandbox demos. Need to verify whether `make-game` requires the `preload:` keyword or treats it as optional. If required, keep it as `(lambda (game) #f)`.
+- **`object-type` return type:** The plan assumes `object-type` returns a string (from TMX XML parsing). If it already returns a symbol, remove the `string->symbol` call in `tilemap-objects->entities`. Check the TMX parser in `tilemap.scm` to confirm.
diff --git a/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md b/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md
new file mode 100644
index 0000000..0d12308
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md
@@ -0,0 +1,826 @@
+# Milestone 8 — Game Object and Lifecycle API
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Introduce `make-game` + `game-run!` as the public entry point for downstroke, backed by a minimal asset registry, and port macroknight to use them.
+
+**Architecture:** Two new modules (`assets.scm` — key/value registry; `engine.scm` — game struct, lifecycle, frame loop), plus `render-scene!` added to `renderer.scm`. `game-run!` owns SDL2 init, window/renderer creation, and the frame loop; lifecycle hooks (`preload:`, `create:`, `update:`, `render:`) are user-supplied lambdas.
+
+**Tech Stack:** Chicken Scheme, SDL2 (sdl2 egg), sdl2-ttf, sdl2-image, SRFI-64 (tests), defstruct egg
+
+**Spec:** `docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md`
+
+---
+
+## File Map
+
+| Action | File | Purpose |
+|---|---|---|
+| Create | `assets.scm` | Key→value asset registry |
+| Create | `tests/assets-test.scm` | SRFI-64 unit tests for assets.scm |
+| Modify | `renderer.scm` | Add `render-scene!` |
+| Modify | `tests/renderer-test.scm` | Add tests for `render-scene!` |
+| Create | `engine.scm` | `make-game`, `game-run!`, accessors |
+| Create | `tests/engine-test.scm` | Unit tests for make-game, accessors (SDL2 mocked) |
+| Modify | `Makefile` | Add assets + engine to build, add test targets |
+| Modify | `/home/gene/src/macroknight/game.scm` | Port to `make-game` + `game-run!` |
+
+---
+
+## Task 1: `assets.scm` — minimal key/value registry
+
+**Files:**
+- Create: `assets.scm`
+- Create: `tests/assets-test.scm`
+
+### What this module does
+
+A thin wrapper around a hash table. No asset-type logic — that's Milestone 6. Three public functions:
+
+```scheme
+(make-asset-registry) ;; → hash-table
+(asset-set! registry key val) ;; → unspecified (mutates)
+(asset-ref registry key) ;; → value or #f if missing
+```
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/assets-test.scm`:
+
+```scheme
+(import scheme (chicken base) srfi-64)
+
+(include "assets.scm")
+(import downstroke/assets)
+
+(test-begin "assets")
+
+(test-group "make-asset-registry"
+ (test-assert "returns a value"
+ (make-asset-registry)))
+
+(test-group "asset-set! and asset-ref"
+ (let ((reg (make-asset-registry)))
+ (test-equal "missing key returns #f"
+ #f
+ (asset-ref reg 'missing))
+
+ (asset-set! reg 'my-tilemap "data")
+ (test-equal "stored value is retrievable"
+ "data"
+ (asset-ref reg 'my-tilemap))
+
+ (asset-set! reg 'my-tilemap "updated")
+ (test-equal "overwrite replaces value"
+ "updated"
+ (asset-ref reg 'my-tilemap))
+
+ (asset-set! reg 'other 42)
+ (test-equal "multiple keys coexist"
+ "updated"
+ (asset-ref reg 'my-tilemap))
+ (test-equal "second key retrievable"
+ 42
+ (asset-ref reg 'other))))
+
+(test-end "assets")
+```
+
+- [ ] **Step 2: Run test to confirm it fails**
+
+```bash
+cd /home/gene/src/downstroke
+csi -s tests/assets-test.scm
+```
+
+Expected: error — `assets.scm` not found / `downstroke/assets` not defined.
+
+- [ ] **Step 3: Implement `assets.scm`**
+
+Create `assets.scm`:
+
+```scheme
+(module downstroke/assets *
+
+(import scheme
+ (chicken base)
+ (srfi 69))
+
+(define (make-asset-registry)
+ (make-hash-table))
+
+(define (asset-set! registry key value)
+ (hash-table-set! registry key value))
+
+(define (asset-ref registry key)
+ (hash-table-ref/default registry key #f))
+
+) ;; end module
+```
+
+- [ ] **Step 4: Run test to confirm it passes**
+
+```bash
+cd /home/gene/src/downstroke
+csi -s tests/assets-test.scm
+```
+
+Expected: all tests pass, no failures reported.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/gene/src/downstroke
+git add assets.scm tests/assets-test.scm
+git commit -m "feat: add assets.scm — minimal key/value asset registry"
+```
+
+---
+
+## Task 2: Add `render-scene!` to `renderer.scm`
+
+**Files:**
+- Modify: `renderer.scm` (add one function after `draw-entities`)
+- Modify: `tests/renderer-test.scm` (add one test group)
+
+### What this adds
+
+`render-scene!` draws a complete scene (all tilemap layers + all entities) given a renderer and a scene struct. It delegates to the already-tested `draw-tilemap` and `draw-entities`.
+
+Existing signatures in `renderer.scm`:
+- `(draw-tilemap renderer camera tileset-texture tilemap)` — 4 args
+- `(draw-entities renderer camera tileset tileset-texture entities)` — 5 args
+
+The `tileset` is extracted from `tilemap` via the `tilemap-tileset` accessor (from `tilemap.scm`).
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `tests/renderer-test.scm`, before `(test-end "renderer")`:
+
+```scheme
+(test-group "render-scene!"
+ ;; render-scene! calls draw-tilemap and draw-entities — both are mocked to return #f.
+ ;; We verify it doesn't crash on a valid scene and returns unspecified.
+ (let* ((cam (make-camera x: 0 y: 0))
+ (tileset (make-tileset tilewidth: 16 tileheight: 16
+ spacing: 0 tilecount: 100 columns: 10
+ image-source: "" image: #f))
+ (layer (make-layer name: "ground" width: 2 height: 2
+ map: '((1 2) (3 4))))
+ (tilemap (make-tilemap width: 2 height: 2
+ tilewidth: 16 tileheight: 16
+ tileset-source: ""
+ tileset: tileset
+ layers: (list layer)
+ objects: '()))
+ (scene (make-scene entities: '()
+ tilemap: tilemap
+ camera: cam
+ tileset-texture: #f)))
+ (test-assert "does not crash on valid scene"
+ (begin (render-scene! #f scene) #t))))
+```
+
+- [ ] **Step 2: Run test to confirm it fails**
+
+```bash
+cd /home/gene/src/downstroke
+csi -s tests/renderer-test.scm
+```
+
+Expected: error — `render-scene!` not defined.
+
+- [ ] **Step 3: Add `render-scene!` to `renderer.scm`**
+
+Add after the `draw-entities` definition (before the closing `)`):
+
+```scheme
+ ;; --- Scene drawing ---
+
+ (define (render-scene! renderer scene)
+ (let ((camera (scene-camera scene))
+ (tilemap (scene-tilemap scene))
+ (tileset-texture (scene-tileset-texture scene))
+ (tileset (tilemap-tileset (scene-tilemap scene)))
+ (entities (scene-entities scene)))
+ (draw-tilemap renderer camera tileset-texture tilemap)
+ (draw-entities renderer camera tileset tileset-texture entities)))
+```
+
+- [ ] **Step 4: Run tests to confirm they pass**
+
+```bash
+cd /home/gene/src/downstroke
+csi -s tests/renderer-test.scm
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/gene/src/downstroke
+git add renderer.scm tests/renderer-test.scm
+git commit -m "feat: add render-scene! to renderer — draw full scene in one call"
+```
+
+---
+
+## Task 3: `engine.scm` — game struct, constructor, accessors
+
+**Files:**
+- Create: `engine.scm`
+- Create: `tests/engine-test.scm`
+
+This task covers only the data structure and constructor — no `game-run!` yet. TDD applies to the parts we can unit-test (struct creation, accessors).
+
+`game-run!` requires a live SDL2 window and cannot be unit-tested; it is tested in Task 6 via macroknight integration.
+
+### Mock strategy for tests
+
+`engine.scm` imports `renderer.scm`, `input.scm`, `world.scm`, `assets.scm`, and the SDL2 sub-libraries. The test file follows the same mock-module pattern as `tests/renderer-test.scm`: define mock modules that satisfy the imports, then `(include "engine.scm")`.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/engine-test.scm`:
+
+```scheme
+(import scheme (chicken base) (chicken keyword) srfi-64 defstruct)
+
+;; --- Mocks ---
+
+(module sdl2 *
+ (import scheme (chicken base))
+ (define (set-main-ready!) #f)
+ (define (init! . args) #f)
+ (define (quit! . args) #f)
+ (define (get-ticks) 0)
+ (define (delay! ms) #f)
+ (define (pump-events!) #f)
+ (define (has-events?) #f)
+ (define (make-event) #f)
+ (define (poll-event! e) #f)
+ (define (num-joysticks) 0)
+ (define (is-game-controller? i) #f)
+ (define (game-controller-open! i) #f)
+ (define (create-window! . args) 'mock-window)
+ (define (create-renderer! . args) 'mock-renderer)
+ (define (destroy-window! . args) #f)
+ (define (render-clear! . args) #f)
+ (define (render-present! . args) #f))
+(import (prefix sdl2 "sdl2:"))
+
+(module sdl2-ttf *
+ (import scheme (chicken base))
+ (define (init!) #f))
+(import (prefix sdl2-ttf "ttf:"))
+
+(module sdl2-image *
+ (import scheme (chicken base))
+ (define (init! . args) #f))
+(import (prefix sdl2-image "img:"))
+
+;; --- Real deps (include order follows dependency order) ---
+(import simple-logger) ;; required by input.scm
+(include "entity.scm") (import downstroke/entity)
+(include "tilemap.scm") (import downstroke/tilemap)
+(include "world.scm") (import downstroke/world)
+(include "input.scm") (import downstroke/input)
+(include "assets.scm") (import downstroke/assets)
+
+;; Mock renderer (render-scene! can't run without real SDL2)
+(module downstroke/renderer *
+ (import scheme (chicken base))
+ (define (render-scene! . args) #f))
+(import downstroke/renderer)
+
+(include "engine.scm")
+(import downstroke/engine)
+
+;; --- Tests ---
+
+(test-begin "engine")
+
+(test-group "make-game defaults"
+ (let ((g (make-game)))
+ (test-equal "default title"
+ "Downstroke Game"
+ (game-title g))
+ (test-equal "default width"
+ 640
+ (game-width g))
+ (test-equal "default height"
+ 480
+ (game-height g))
+ (test-equal "default frame-delay"
+ 16
+ (game-frame-delay g))
+ (test-equal "scene starts as #f"
+ #f
+ (game-scene g))
+ (test-equal "window starts as #f"
+ #f
+ (game-window g))
+ (test-equal "renderer starts as #f"
+ #f
+ (game-renderer g))
+ (test-assert "assets registry is created"
+ (game-assets g))
+ (test-assert "input state is created"
+ (game-input g))))
+
+(test-group "make-game with keyword args"
+ (let ((g (make-game title: "My Game" width: 320 height: 240 frame-delay: 33)))
+ (test-equal "custom title" "My Game" (game-title g))
+ (test-equal "custom width" 320 (game-width g))
+ (test-equal "custom height" 240 (game-height g))
+ (test-equal "custom frame-delay" 33 (game-frame-delay g))))
+
+(test-group "game-asset and game-asset-set!"
+ (let ((g (make-game)))
+ (test-equal "missing key returns #f"
+ #f
+ (game-asset g 'no-such-asset))
+ (game-asset-set! g 'my-font 'font-object)
+ (test-equal "stored asset is retrievable"
+ 'font-object
+ (game-asset g 'my-font))
+ (game-asset-set! g 'my-font 'updated-font)
+ (test-equal "overwrite replaces asset"
+ 'updated-font
+ (game-asset g 'my-font))))
+
+(test-group "make-game hooks default to #f"
+ (let ((g (make-game)))
+ (test-equal "preload-hook is #f" #f (game-preload-hook g))
+ (test-equal "create-hook is #f" #f (game-create-hook g))
+ (test-equal "update-hook is #f" #f (game-update-hook g))
+ (test-equal "render-hook is #f" #f (game-render-hook g))))
+
+(test-group "make-game accepts hook lambdas"
+ (let* ((called #f)
+ (g (make-game update: (lambda (game dt) (set! called #t)))))
+ (test-assert "update hook is stored"
+ (procedure? (game-update-hook g)))))
+
+(test-end "engine")
+```
+
+- [ ] **Step 2: Run test to confirm it fails**
+
+```bash
+cd /home/gene/src/downstroke
+csi -s tests/engine-test.scm
+```
+
+Expected: error — `engine.scm` not found.
+
+- [ ] **Step 3: Implement `engine.scm` struct + constructor + accessors (no `game-run!`)**
+
+Create `engine.scm`. `game-run!` is a stub for now — it will be filled in Task 4.
+
+```scheme
+(module downstroke/engine *
+
+(import scheme
+ (chicken base)
+ (chicken keyword)
+ (prefix sdl2 "sdl2:")
+ (prefix sdl2-ttf "ttf:")
+ (prefix sdl2-image "img:")
+ defstruct
+ downstroke/world
+ downstroke/input
+ downstroke/assets
+ downstroke/renderer)
+
+;; ── Game struct ────────────────────────────────────────────────────────────
+;; constructor: make-game* (raw) so we can define make-game as keyword wrapper
+
+(defstruct (game constructor: make-game*)
+ title width height
+ window renderer
+ input ;; input-state record
+ input-config ;; input-config record
+ assets ;; asset registry (hash-table from assets.scm)
+ frame-delay
+ preload-hook ;; (lambda (game) ...)
+ create-hook ;; (lambda (game) ...)
+ update-hook ;; (lambda (game dt) ...)
+ render-hook ;; (lambda (game) ...) — post-render overlay
+ scene) ;; current scene struct; #f until create: runs
+
+;; ── Public constructor ─────────────────────────────────────────────────────
+
+(define (make-game #!key
+ (title "Downstroke Game")
+ (width 640) (height 480)
+ (frame-delay 16)
+ (input-config *default-input-config*)
+ (preload #f) (create #f) (update #f) (render #f))
+ (make-game*
+ title: title
+ width: width
+ height: height
+ window: #f
+ renderer: #f
+ scene: #f
+ input: (create-input-state input-config)
+ input-config: input-config
+ assets: (make-asset-registry)
+ frame-delay: frame-delay
+ preload-hook: preload
+ create-hook: create
+ update-hook: update
+ render-hook: render))
+
+;; ── Convenience accessors ──────────────────────────────────────────────────
+
+;; game-camera: derived from the current scene (only valid after create: runs)
+(define (game-camera game)
+ (scene-camera (game-scene game)))
+
+;; game-asset: retrieve an asset by key
+(define (game-asset game key)
+ (asset-ref (game-assets game) key))
+
+;; game-asset-set!: store an asset by key
+(define (game-asset-set! game key value)
+ (asset-set! (game-assets game) key value))
+
+;; ── game-run! ──────────────────────────────────────────────────────────────
+;; Stub — implemented in Task 4
+
+(define (game-run! game)
+ (error "game-run! not yet implemented"))
+
+) ;; end module
+```
+
+- [ ] **Step 4: Run tests to confirm they pass**
+
+```bash
+cd /home/gene/src/downstroke
+csi -s tests/engine-test.scm
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /home/gene/src/downstroke
+git add engine.scm tests/engine-test.scm
+git commit -m "feat: add engine.scm — game struct, make-game constructor, accessors"
+```
+
+---
+
+## Task 4: Implement `game-run!`
+
+**Files:**
+- Modify: `engine.scm` (replace stub with real implementation)
+
+`game-run!` is tested via macroknight integration in Task 6, not via unit tests (it requires a real SDL2 display).
+
+- [ ] **Step 1: Replace the `game-run!` stub in `engine.scm`**
+
+Replace the stub `(define (game-run! game) (error ...))` with:
+
+```scheme
+(define (game-run! game)
+ ;; 1. SDL2 init (audio excluded — mixer.scm not yet extracted;
+ ;; user calls init-audio! in their preload: hook)
+ (sdl2:set-main-ready!)
+ (sdl2:init! '(video joystick game-controller))
+ (ttf:init!)
+ (img:init! '(png))
+
+ ;; Open any already-connected game controllers
+ (let init-controllers ((i 0))
+ (when (< i (sdl2:num-joysticks))
+ (when (sdl2:is-game-controller? i)
+ (sdl2:game-controller-open! i))
+ (init-controllers (+ i 1))))
+
+ ;; 2. Create window + renderer
+ (game-window-set! game
+ (sdl2:create-window! (game-title game) 'centered 'centered
+ (game-width game) (game-height game) '()))
+ (game-renderer-set! game
+ (sdl2:create-renderer! (game-window game) -1 '(accelerated vsync)))
+
+ ;; 3. preload: hook — user loads assets here
+ (when (game-preload-hook game)
+ ((game-preload-hook game) game))
+
+ ;; 4. create: hook — user builds initial scene here
+ (when (game-create-hook game)
+ ((game-create-hook game) game))
+
+ ;; 5. Frame loop
+ (let loop ((last-ticks (sdl2:get-ticks)))
+ (let* ((now (sdl2:get-ticks))
+ (dt (- now last-ticks)))
+ ;; Collect all pending SDL2 events
+ (sdl2:pump-events!)
+ (let* ((events (let collect ((lst '()))
+ (if (not (sdl2:has-events?))
+ (reverse lst)
+ (let ((e (sdl2:make-event)))
+ (sdl2:poll-event! e)
+ (collect (cons e lst))))))
+ (input (input-state-update (game-input game) events
+ (game-input-config game))))
+ (game-input-set! game input)
+ (unless (input-held? input 'quit)
+ ;; update: hook — user game logic
+ (when (game-update-hook game)
+ ((game-update-hook game) game dt))
+ ;; render: engine draws world, then user overlay
+ (sdl2:render-clear! (game-renderer game))
+ (when (game-scene game)
+ (render-scene! (game-renderer game) (game-scene game)))
+ (when (game-render-hook game)
+ ((game-render-hook game) game))
+ (sdl2:render-present! (game-renderer game))
+ (sdl2:delay! (game-frame-delay game))
+ (loop now)))))
+
+ ;; 6. Cleanup
+ (sdl2:destroy-window! (game-window game))
+ (sdl2:quit!))
+```
+
+Note: `sdl2:pump-events!` processes the OS event queue; `sdl2:has-events?`/`sdl2:make-event`/`sdl2:poll-event!` drain it into a list. This matches the pattern used in macroknight's existing game loop (lines 661–667).
+
+**Spec discrepancy:** The spec document shows `(sdl2:collect-events!)` in its pseudocode — that function does not exist in the sdl2 egg. The plan's pump+collect loop above is the correct implementation. Ignore the spec's `sdl2:collect-events!` reference.
+
+- [ ] **Step 2: Verify engine-test.scm still passes**
+
+```bash
+cd /home/gene/src/downstroke
+csi -s tests/engine-test.scm
+```
+
+Expected: all tests pass (game-run! itself is not called in unit tests).
+
+- [ ] **Step 3: Commit**
+
+```bash
+cd /home/gene/src/downstroke
+git add engine.scm tests/engine-test.scm
+git commit -m "feat: implement game-run! — SDL2 init, lifecycle hooks, frame loop"
+```
+
+---
+
+## Task 5: Update the Makefile
+
+**Files:**
+- Modify: `Makefile`
+
+- [ ] **Step 1: Add `assets` and `engine` to `MODULE_NAMES`**
+
+In `Makefile`, change:
+```makefile
+MODULE_NAMES := entity tilemap world input physics renderer
+```
+to:
+```makefile
+MODULE_NAMES := entity tilemap world input physics renderer assets engine
+```
+
+- [ ] **Step 2: Add dependency declarations**
+
+Add after `bin/renderer.o: bin/entity.o bin/tilemap.o bin/world.o`:
+
+```makefile
+bin/assets.o:
+bin/engine.o: bin/renderer.o bin/world.o bin/input.o bin/assets.o
+```
+
+`assets.scm` has no inter-module dependencies. `engine.scm` depends on renderer, world, input, and assets.
+
+- [ ] **Step 3: Add test targets**
+
+In the `test:` rule, add:
+```makefile
+ @csi -s tests/assets-test.scm
+ @csi -s tests/engine-test.scm
+```
+
+- [ ] **Step 4: Verify the build**
+
+```bash
+cd /home/gene/src/downstroke
+make clean && make
+```
+
+Expected: all `.o` files created in `bin/`, including `bin/assets.o` and `bin/engine.o`. No errors.
+
+- [ ] **Step 5: Run all tests**
+
+```bash
+cd /home/gene/src/downstroke
+make test
+```
+
+Expected: all test suites pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+cd /home/gene/src/downstroke
+git add Makefile
+git commit -m "build: add assets and engine modules to Makefile"
+```
+
+---
+
+## Task 6: Port macroknight to `make-game` + `game-run!`
+
+**Files:**
+- Modify: `/home/gene/src/macroknight/game.scm`
+
+This is the integration test for the whole milestone. macroknight's current `game.scm` is ~678 lines with SDL2 init, the frame loop, and all game state mixed together. After this task it should be ≤50 lines: only `make-game`, the three lifecycle hooks, and `game-run!`.
+
+### What moves vs. what stays
+
+**Moves to engine (delete from game.scm):**
+- Lines 87–137: SDL2 init block — from `(sdl2:set-main-ready!)` through `(define *title-font* ...)` inclusive. This covers: `sdl2:set-main-ready!`, `sdl2:init!`, `init-game-controllers!` call, `ttf:init!`, `img:init!`, audio init calls, `on-exit` handlers, exception handler wrapper, `sdl2:set-hint!`, `sdl2:create-window!`, `sdl2:create-renderer!`, `ttf:open-font` calls.
+- Lines 659–673: The main `let/cc` game loop
+
+**Stays in macroknight (wrapped in lifecycle hooks):**
+- `init-audio!` + `load-sounds!` + `load-music!` → `preload:` hook
+- `ttf:open-font` calls (fonts) → `preload:` hook
+- `make-initial-game-state` → `create:` hook
+- `update-game-state` dispatch → `update:` hook
+- `render-frame` mode dispatch (minus clear/present) → `render:` hook
+
+**macroknight-specific state** (`game-state` struct: mode, menu-cursor, scene, input, etc.) stays as the module-level `*gs*` variable — set in `create:`, mutated in `update:`.
+
+**`render-frame` split:** The existing `render-frame` calls `sdl2:render-clear!` and `sdl2:render-present!` — those are now owned by the engine. Modify `render-frame` to remove those two calls, keeping only the draw-color set and the mode dispatch. The modified `render-frame` becomes the body of the `render:` hook. Since the background color is black (`0 0 0`) and the engine's clear already clears to black, there is no visual difference.
+
+### Implementation steps
+
+- [ ] **Step 1: Add `downstroke/engine` to macroknight's imports**
+
+In `/home/gene/src/macroknight/game.scm`, add to the import list:
+```scheme
+downstroke/engine
+downstroke/assets
+```
+
+- [ ] **Step 2: Restructure as `make-game` call**
+
+Replace the top-level SDL2 init block and the game loop at the bottom of `game.scm` with a `make-game` call. The three lifecycle hooks capture the existing functions from the rest of the file.
+
+**First, modify `render-frame`** to remove the SDL2 clear/present calls (the engine now owns those). Change lines 378–385 of `game.scm` from:
+
+```scheme
+(define (render-frame gs)
+ (set! (sdl2:render-draw-color *renderer*) +background-color+)
+ (sdl2:render-clear! *renderer*)
+ (case (game-state-mode gs)
+ ((main-menu) (draw-main-menu gs))
+ ((stage-select) (draw-stage-select gs))
+ ((playing) (draw-playing gs)))
+ (sdl2:render-present! *renderer*))
+```
+
+to:
+
+```scheme
+(define (render-frame gs)
+ (set! (sdl2:render-draw-color *renderer*) +background-color+)
+ (case (game-state-mode gs)
+ ((main-menu) (draw-main-menu gs))
+ ((stage-select) (draw-stage-select gs))
+ ((playing) (draw-playing gs))))
+```
+
+**Then add module-level state and the engine entry point** at the bottom of `game.scm` (replacing the old `let/cc` loop):
+
+```scheme
+;; ── Module-level game state ────────────────────────────────────────────────
+
+(define *gs* #f) ;; macroknight game state, set in create:
+
+;; ── Engine entry point ─────────────────────────────────────────────────────
+
+(define *the-game*
+ (make-game
+ title: "MacroKnight"
+ width: +screen-width+
+ height: +screen-height+
+ frame-delay: 10
+
+ preload: (lambda (game)
+ ;; Audio (mixer not yet extracted — call directly)
+ (init-audio!)
+ (load-sounds! '((jump . "assets/jump.wav")))
+ (load-music! "assets/theme.ogg")
+ (play-music! 0.6)
+ ;; Fonts
+ (set! *font* (ttf:open-font "assets/DejaVuSans.ttf" 12))
+ (set! *title-font* (ttf:open-font "assets/DejaVuSans.ttf" 32))
+ ;; Make the SDL2 renderer available to macroknight functions
+ (set! *renderer* (game-renderer game)))
+
+ create: (lambda (game)
+ (set! *gs* (make-initial-game-state)))
+
+ update: (lambda (game dt)
+ ;; update-game-state uses a continuation for the "quit" menu item;
+ ;; pass a no-op since the engine handles Escape→quit automatically.
+ (update-game-state *gs* (lambda () #f))
+ (maybe-advance-level *gs*))
+
+ render: (lambda (game)
+ ;; engine has already called render-clear! and render-scene!
+ ;; (game-scene is #f so render-scene! is a no-op for now);
+ ;; render-frame draws the mode-specific content.
+ (render-frame *gs*))))
+
+(game-run! *the-game*)
+```
+
+**Notes:**
+- `*renderer*`, `*font*`, `*title-font*` remain module-level variables (set in `preload:`). Keep their `(define ...)` declarations at the top of `game.scm` as `(define *font* #f)` etc. (uninitialized — they'll be set in `preload:`).
+- The engine's input handles Escape → quit via `input-held? input 'quit`; the old `let/cc exit-main-loop!` mechanism and `exit-main-loop!` call in `update-game-state` are no longer needed. **However, do not change `update-game-state` in this milestone** — the `(lambda () #f)` passed as `exit!` means the "Quit" menu item is a no-op for now. Fix it in a later milestone.
+- `(game-scene game)` is never set from macroknight, so the engine's `render-scene!` is always a no-op. All rendering happens in the `render:` hook via `render-frame`. This is intentional for this port milestone.
+
+- [ ] **Step 3: Remove the old SDL2 init block from `game.scm`**
+
+Delete lines 87–137 — everything from `(sdl2:set-main-ready!)` through `(define *title-font* (ttf:open-font ...))` inclusive.
+
+This also removes the `on-exit` cleanup handlers (lines 107–108) and the custom exception handler (lines 113–117). **This is intentional for this milestone.** The engine's `game-run!` calls `sdl2:destroy-window!` and `sdl2:quit!` at cleanup, but does not install an exception handler. If macroknight crashes mid-run, SDL2 will not be shut down cleanly. This will be addressed in a later milestone (either by adding error handling to `game-run!` or by macroknight wrapping `game-run!` in a guard).
+
+After deletion, add these uninitialized stubs near the top of `game.scm` (after the imports) so the rest of the file can still reference the variables. Note: `*window*` only appears inside the deleted range, so its stub is precautionary only.
+
+```scheme
+(define *window* #f) ;; owned by engine; not used after this port
+(define *renderer* #f) ;; set in preload:
+(define *font* #f) ;; set in preload:
+(define *title-font* #f) ;; set in preload:
+(define *text-color* (sdl2:make-color 255 255 255))
+```
+
+- [ ] **Step 4: Remove the old main loop from `game.scm`**
+
+Delete lines 659–678 (the `let/cc` game loop through the end of the file including the `"Bye!\n"` print). The engine's `game-run!` replaces it. The new `(define *the-game* ...)` and `(game-run! *the-game*)` from Step 2 are the new end of the file.
+
+- [ ] **Step 5: Build macroknight to verify it compiles**
+
+```bash
+cd /home/gene/src/macroknight
+make clean && make
+```
+
+Expected: successful compilation. Fix any symbol-not-found or import errors before continuing.
+
+- [ ] **Step 6: Run macroknight to verify it plays**
+
+```bash
+cd /home/gene/src/macroknight
+./bin/game
+```
+
+Expected: game launches, title screen appears, gameplay works correctly (title → stage select → play a level). Press Escape to quit.
+
+**Known regression (intentional):** Selecting "Quit" from the main menu is a no-op in this milestone. The old `exit-main-loop!` continuation no longer exists; the `update:` hook passes `(lambda () #f)` in its place. This will be fixed in a later milestone.
+
+- [ ] **Step 7: Verify game.scm is ≤50 lines**
+
+```bash
+wc -l /home/gene/src/macroknight/game.scm
+```
+
+If still over 50 lines, extract helper functions that belong in other modules. Game-specific logic (menu rendering, level loading) can stay; SDL2 boilerplate must be gone.
+
+- [ ] **Step 8: Commit**
+
+```bash
+cd /home/gene/src/macroknight
+git add game.scm
+git commit -m "feat: port macroknight to use make-game + game-run! (Milestone 8)"
+```
+
+Then commit the downstroke side:
+
+```bash
+cd /home/gene/src/downstroke
+git add -A
+git commit -m "feat: Milestone 8 complete — make-game + game-run! engine entry point"
+```
+
+---
+
+## Acceptance Criteria
+
+- [ ] `make test` in downstroke passes all suites including assets and engine
+- [ ] `make` in downstroke builds all modules including `bin/assets.o` and `bin/engine.o`
+- [ ] `make && ./bin/game` in macroknight launches and plays correctly
+- [ ] macroknight `game.scm` is ≤50 lines with no SDL2 init or frame-loop boilerplate
+- [ ] `make-game` + `game-run!` are the sole entry point — no top-level SDL2 calls outside them
diff --git a/docs/superpowers/specs/2026-04-05-demos-design.md b/docs/superpowers/specs/2026-04-05-demos-design.md
new file mode 100644
index 0000000..78ed3f4
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-05-demos-design.md
@@ -0,0 +1,256 @@
+# Downstroke Demo Games Design
+
+**Date:** 2026-04-05
+**Status:** Approved
+**Scope:** `demo/` folder, 5 demo games, Makefile `make demos` target, CLAUDE.md update
+
+---
+
+## Goal
+
+Provide 5 small self-contained demo games in `demo/` that collectively exercise every engine system. Each demo compiles to its own executable (`bin/demo-*`). They replace the macroknight integration test for Milestone 8 and serve as living documentation of the engine API.
+
+---
+
+## File Layout
+
+```
+demo/
+ assets/ ← copied from macroknight/assets (not symlinked)
+ monochrome-transparent.png ← tileset spritesheet
+ monochrome_transparent.tsx ← tileset metadata (TSX)
+ level-0.tmx ← level used by platformer, topdown, sandbox
+ DejaVuSans.ttf ← font for audio demo text
+ jump.wav ← sound effect (platformer jump, shmup shoot)
+ theme.ogg ← music (audio demo)
+ platformer.scm
+ shmup.scm
+ topdown.scm
+ audio.scm
+ sandbox.scm
+```
+
+**Omitted from copy:** `prefabs.scm`, `macroknight.tiled-project`, `macroknight.tiled-session` — macroknight-specific files not needed by any demo.
+
+**Audio modules:** `mixer.scm` and `sound.scm` are copied from `macroknight/` into the downstroke repo root and added to the build. They are engine-level modules and are required by any demo that uses audio.
+
+When copying `sound.scm`, add one function that macroknight's version omits:
+```scheme
+(define (stop-music!) (mix-halt-music))
+```
+The audio demo uses `stop-music!` to toggle music off.
+
+---
+
+## Build
+
+### Makefile additions
+
+```makefile
+DEMO_NAMES := platformer shmup topdown audio sandbox
+DEMO_BINS := $(patsubst %,bin/demo-%,$(DEMO_NAMES))
+
+demos: engine $(DEMO_BINS)
+
+bin/demo-%: demo/%.scm $(OBJECT_FILES) | bin
+ csc demo/$*.scm $(OBJECT_FILES) -o bin/demo-$* -I bin
+```
+
+- `make` — builds engine modules only (unchanged)
+- `make demos` — builds all 5 demo executables; depends on engine being built first
+- Demos are compiled as programs (not units), linked against all engine `.o` files
+- `$(OBJECT_FILES)` includes `mixer` and `sound` once those modules are added to `MODULE_NAMES`
+
+### `render-scene!` nil guards
+
+`render-scene!` in `renderer.scm` is updated so that entity drawing is **nested inside** the tilemap guard:
+
+- Tilemap drawing only fires if `(scene-tilemap scene)` is not `#f`
+- Entity drawing only fires if **both** `(scene-tilemap scene)` AND `(scene-tileset-texture scene)` are not `#f`
+
+```scheme
+(when tilemap
+ (draw-tilemap renderer camera tileset-texture tilemap)
+ (when tileset-texture
+ (let ((tileset (tilemap-tileset tilemap)))
+ (draw-entities renderer camera tileset tileset-texture entities))))
+```
+
+**Consequence for shmup:** since shmup has no tilemap, the engine will not draw its entities. Shmup draws its player, bullets, and enemies as **colored SDL rectangles** in its `render:` hook — matching the original "colored rects" intent. This is acceptable because shmup entities are simple geometric shapes, not sprites.
+
+Audio (no scene at all) works because `engine.scm` guards `render-scene!` with `(when (game-scene game) ...)`.
+
+### CLAUDE.md update
+
+Add to the Build & Test section:
+
+> `make demos` must always succeed. A demo that fails to compile is a build failure. Run `make && make demos` to verify both engine and demos build cleanly.
+
+---
+
+## Demo Code Pattern
+
+Every demo follows this ~30-line structure:
+
+```scheme
+(import (prefix sdl2 "sdl2:")
+ (prefix sdl2-ttf "ttf:")
+ (prefix sdl2-image "img:")
+ downstroke/engine
+ downstroke/world
+ downstroke/tilemap
+ downstroke/renderer
+ downstroke/input
+ downstroke/physics
+ downstroke/assets)
+
+(define *game*
+ (make-game
+ title: "Demo: <Name>" width: 600 height: 400
+ preload: (lambda (game) ...) ;; load tilemap, tileset texture, sounds
+ create: (lambda (game) ...) ;; build scene, place entities
+ update: (lambda (game dt) ...) ;; input dispatch, physics calls
+ render: (lambda (game) ...))) ;; HUD overlay (optional)
+
+(game-run! *game*)
+```
+
+Tile IDs in entity plists are placeholder values — to be adjusted visually after first run.
+
+---
+
+## The 5 Demos
+
+### 1. `demo/platformer.scm` — Platformer
+
+**Systems exercised:** `input`, `physics` (gravity + tile collision), `renderer` (tilemap + entities), `world`/scene, camera follow, audio (sound effect)
+
+**Mechanics:**
+- Player entity with gravity, left/right movement, jump
+- Tile collision via `apply-physics` from `physics.scm`
+- Camera follows player horizontally
+- Jump sound via `(play-sound 'jump)` (loaded in preload: as `'(jump . "demo/assets/jump.wav")`)
+- Level: `demo/assets/level-0.tmx`
+- Tile IDs: placeholder (user adjusts)
+
+**Key entity plist:**
+```scheme
+(list #:type 'player
+ #:x 100 #:y 50
+ #:width 16 #:height 16
+ #:vx 0 #:vy 0
+ #:gravity? #t
+ #:on-ground? #f
+ #:tile-id 1)
+```
+
+**Update logic:** read input → set `#:vx` from left/right → jump sets `#:vy` → call physics step → update camera x to follow player x.
+
+---
+
+### 2. `demo/shmup.scm` — Shoot-em-up
+
+**Systems exercised:** `entity` (spawning/removal), manual AABB entity-entity collision (removal-based, not `physics.scm`), `input`, `renderer` (SDL colored rects), `world`/scene (no tilemap)
+
+**Mechanics:**
+- Player ship at bottom, moves left/right
+- Space bar fires bullet upward (new entity added to scene)
+- Enemies spawn from top at random x positions every N frames, move downward
+- Bullet-enemy collision: both entities removed from scene
+- No tilemap — plain background (black/SDL clear)
+- No gravity on any entity
+- `jump.wav` plays on shoot
+
+**Key entity plists:** (no `#:tile-id` — entities are drawn as colored rects)
+```scheme
+;; player
+(list #:type 'player #:x 280 #:y 360 #:width 16 #:height 16 #:vx 0 #:vy 0)
+;; bullet
+(list #:type 'bullet #:x px #:y 340 #:width 4 #:height 8 #:vx 0 #:vy -5)
+;; enemy
+(list #:type 'enemy #:x rx #:y 0 #:width 16 #:height 16 #:vx 0 #:vy 2)
+```
+
+**Rendering:** shmup has no tilemap, so `render-scene!` draws nothing for this scene. Shmup implements its own `render:` hook using `sdl2:render-fill-rect!` with distinct colors (player = white, bullet = yellow, enemy = red). The camera is at (0,0) so screen coords == world coords.
+
+**Update logic:** move entities by vx/vy each frame → manual AABB collision check between bullets and enemies (not `resolve-entity-collisions` — shmup uses removal, not push-apart) → filter removed entities from scene → spawn new enemy every 60 frames → read input for player movement and shoot.
+
+---
+
+### 3. `demo/topdown.scm` — Top-down explorer
+
+**Systems exercised:** `input` (8-directional), `renderer` (tilemap + entity), `world`/scene, camera follow (both axes), `physics` (no gravity)
+
+**Mechanics:**
+- Player entity moves in 8 directions (WASD or arrows)
+- No gravity (`#:gravity? #f`)
+- Camera follows player on both x and y axes
+- Level: `demo/assets/level-0.tmx` (same tilemap, different movement feel)
+- Tileset texture loaded in preload: — required for entity sprite rendering (tilemap + tileset-texture both present so render-scene! draws entities via tileset)
+- No audio
+
+**Update logic:** read input → set `#:vx` and `#:vy` from direction keys → apply tile collision (no gravity component) → update camera to center on player.
+
+---
+
+### 4. `demo/audio.scm` — Audio showcase
+
+**Systems exercised:** audio (sound effects + music), `renderer` (text via `draw-ui-text`), `input`, `assets`
+
+**Mechanics:**
+- Static screen with text instructions rendered via `draw-ui-text`
+- Press **J** → play `jump.wav`
+- Press **M** → toggle `theme.ogg` music on/off
+- Press **Escape** → quit
+- No tilemap, no physics, no moving entities
+- Uses `DejaVuSans.ttf` for text
+- Audio calls via `sound.scm` functions: `load-sounds!`, `play-sound`, `load-music!`, `play-music!`, `stop-music!` (added when copying sound.scm — see File Layout)
+
+**Display:** colored rectangle background + text labels for each key binding.
+
+---
+
+### 5. `demo/sandbox.scm` — Physics sandbox
+
+**Systems exercised:** `physics` (gravity + tile collision + entity-entity collision), `renderer`, `world`/scene, no player input
+
+**Mechanics:**
+- 10 entities spawned at random x positions near the top of the screen
+- All have `#:gravity? #t` and `#:solid? #t` (required for `resolve-entity-collisions` to participate)
+- Physics step runs each frame (gravity accelerates, tile collision stops them)
+- Entities rest on floor tiles or bounce (depending on physics.scm behavior)
+- No player — pure observation of physics pipeline
+- Level: `demo/assets/level-0.tmx`
+- After all entities settle (or after 10 seconds), loop: despawn all, respawn at new random positions
+
+---
+
+## Systems Coverage Matrix
+
+| System / Module | platformer | shmup | topdown | audio | sandbox |
+|---|---|---|---|---|---|
+| `engine` (make-game, game-run!) | ✓ | ✓ | ✓ | ✓ | ✓ |
+| `input` | ✓ | ✓ | ✓ | ✓ | — |
+| `physics` (gravity) | ✓ | — | — | — | ✓ |
+| `physics` (tile collision) | ✓ | — | ✓ | — | ✓ |
+| `physics` (entity collision) | — | — | — | — | ✓ |
+| manual AABB (removal) | — | ✓ | — | — | — |
+| `renderer` (tilemap) | ✓ | — | ✓ | — | ✓ |
+| `renderer` (entities) | ✓ | — | ✓ | — | ✓ |
+| `renderer` (SDL colored rects) | — | ✓ | — | — | — |
+| `renderer` (text) | — | — | — | ✓ | — |
+| `world` / scene | ✓ | ✓ | ✓ | — | ✓ |
+| `assets` registry | ✓ | ✓ | ✓ | ✓ | ✓ |
+| audio (sound) | ✓ | ✓ | — | ✓ | — |
+| audio (music) | — | — | — | ✓ | — |
+| camera follow | ✓ (x) | — | ✓ (xy) | — | — |
+
+---
+
+## Out of Scope
+
+- Animation state machine (`animation.scm`) — not yet extracted to downstroke
+- AI (`ai.scm`) — not yet extracted
+- Prefab system — not yet extracted
+- Scene transitions — Milestone 9
+- Asset-type-specific load helpers (`game-load-tilemap!` etc.) — Milestone 6
diff --git a/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md b/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md
new file mode 100644
index 0000000..d6e71be
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md
@@ -0,0 +1,234 @@
+# Milestone 8 — The Game Object and Lifecycle API
+
+**Date:** 2026-04-05
+**Status:** Approved
+**Scope:** `engine.scm`, `assets.scm`, macroknight port
+**Milestone numbering:** Follows `downstroke.org` / `TODO-engine.org` (Milestone 8). CLAUDE.md refers to the same work as "Milestone 7".
+
+---
+
+## Goal
+
+Introduce `make-game` and `game-run!` as the public entry point for downstroke. A minimal game becomes ~20 lines of Scheme. This is the "Phaser moment" — the API stabilises around a game struct with lifecycle hooks.
+
+---
+
+## Architecture
+
+Two new files:
+
+- **`engine.scm`** — `make-game` struct, `game-run!`, the frame loop, and all public accessors.
+- **`assets.scm`** — minimal key→value asset registry. No asset-type-specific logic; that is Milestone 6's domain.
+
+`engine.scm` imports: `renderer.scm`, `input.scm`, `world.scm`, `assets.scm`, the sdl2 egg (prefix `sdl2:`), `sdl2-ttf` (prefix `ttf:`), and `sdl2-image` (prefix `img:`).
+
+**Audio / mixer is not initialised by the engine in this milestone.** `mixer.scm` and `sound.scm` remain in macroknight and are not yet extracted. macroknight's `preload:` hook calls `init-audio!` directly from `sound.scm` until those modules are extracted.
+
+`physics.scm` is **not** imported by the engine. Physics is a library; the user requires it if needed and calls physics functions from their `update:` hook.
+
+### New function in `renderer.scm`
+
+`render-scene!` does not yet exist. It must be added to `renderer.scm` as part of this milestone. The module uses `export *` so no explicit export update is needed.
+
+Existing signatures in `renderer.scm` that `render-scene!` calls:
+- `(draw-tilemap renderer camera tileset-texture tilemap)` — 4 args
+- `(draw-entities renderer camera tileset tileset-texture entities)` — 5 args
+- `tilemap-tileset` is a field accessor on the `tilemap` struct (defined in `tilemap.scm`)
+
+```scheme
+(define (render-scene! renderer scene)
+ (let ((camera (scene-camera scene))
+ (tilemap (scene-tilemap scene))
+ (tileset-texture (scene-tileset-texture scene))
+ (tileset (tilemap-tileset (scene-tilemap scene)))
+ (entities (scene-entities scene)))
+ (draw-tilemap renderer camera tileset-texture tilemap)
+ (draw-entities renderer camera tileset tileset-texture entities)))
+```
+
+### Build order (Makefile)
+
+Only modules that currently exist in downstroke are listed. Modules still in macroknight (`animation`, `ai`, `prefabs`, `mixer`, `sound`) are not prerequisites for this milestone.
+
+```
+entity → tilemap → world → input → renderer → assets → engine
+```
+
+`physics` is not a link dependency of `engine` and is omitted from this chain. It is a separate build target.
+
+---
+
+## Data Structures
+
+### `game` struct (`engine.scm`)
+
+`defstruct` is called with `constructor: make-game*` so the generated raw constructor does not collide with the public keyword constructor `make-game`.
+
+```scheme
+(defstruct (game constructor: make-game*)
+ title width height
+ window renderer
+ input ;; input-state record (accessor: game-input)
+ input-config ;; input-config record (accessor: game-input-config)
+ assets ;; asset registry hash table (from assets.scm)
+ frame-delay
+ preload-hook ;; (lambda (game) ...)
+ create-hook ;; (lambda (game) ...)
+ update-hook ;; (lambda (game dt) ...)
+ render-hook ;; (lambda (game) ...) — post-render overlay
+ scene) ;; current scene struct (accessor: game-scene)
+```
+
+**Note:** There is no separate `camera` field. The camera lives inside the scene (`scene-camera`). `game-camera` is a convenience accessor (see Accessors section).
+
+### `make-game` keyword constructor
+
+```scheme
+(define (make-game #!key
+ (title "Downstroke Game")
+ (width 640) (height 480)
+ (frame-delay 16)
+ (input-config *default-input-config*)
+ (preload #f) (create #f) (update #f) (render #f))
+ (make-game*
+ title: title width: width height: height
+ window: #f renderer: #f scene: #f
+ input: (create-input-state input-config) ;; create-input-state takes one arg: config
+ input-config: input-config
+ assets: (make-asset-registry)
+ frame-delay: frame-delay
+ preload-hook: preload
+ create-hook: create
+ update-hook: update
+ render-hook: render))
+```
+
+### Asset registry (`assets.scm`)
+
+```scheme
+(define (make-asset-registry) (make-hash-table))
+(define (asset-set! reg key val) (hash-table-set! reg key val))
+(define (asset-ref reg key) (hash-table-ref reg key (lambda () #f)))
+```
+
+---
+
+## Lifecycle: `game-run!`
+
+```scheme
+(define (game-run! game)
+ ;; 1. SDL2 init (no audio — mixer not yet extracted, user calls init-audio! in preload:)
+ (sdl2:init! '(video joystick game-controller))
+ (ttf:init!)
+ (img:init! '(png))
+
+ ;; 2. Create window + renderer
+ (game-window-set! game
+ (sdl2:create-window! (game-title game) 'centered 'centered
+ (game-width game) (game-height game) '()))
+ (game-renderer-set! game
+ (sdl2:create-renderer! (game-window game) -1 '(accelerated vsync)))
+
+ ;; 3. preload hook
+ (when (game-preload-hook game) ((game-preload-hook game) game))
+
+ ;; 4. create hook
+ (when (game-create-hook game) ((game-create-hook game) game))
+
+ ;; 5. Frame loop
+ (let loop ((last-ticks (sdl2:get-ticks)))
+ (let* ((now (sdl2:get-ticks))
+ (dt (- now last-ticks))
+ (events (sdl2:collect-events!))
+ (input (input-state-update (game-input game) events (game-input-config game))))
+ (game-input-set! game input)
+ (unless (input-held? input 'quit)
+ (when (game-update-hook game) ((game-update-hook game) game dt))
+ (sdl2:render-clear! (game-renderer game))
+ (when (game-scene game)
+ (render-scene! (game-renderer game) (game-scene game)))
+ (when (game-render-hook game) ((game-render-hook game) game))
+ (sdl2:render-present! (game-renderer game))
+ (sdl2:delay! (game-frame-delay game))
+ (loop now))))
+
+ ;; 6. Cleanup
+ (sdl2:destroy-window! (game-window game))
+ (sdl2:quit!))
+```
+
+**Notes:**
+- `sdl2:collect-events!` pumps the SDL2 event queue and returns a list of events (from the sdl2 egg).
+- `input-state-update` is the existing function in `input.scm`: `(input-state-update state events config)`. It is purely functional — it returns a new `input-state` record and does not mutate the existing one.
+- `create-input-state` in `input.scm` takes one argument: `(create-input-state config)` — it initialises all actions to `#f`.
+- Quit is detected via `(input-held? input 'quit)` — the `'quit` action is in `*default-input-config*` mapped to `escape` key: `(escape . quit)` in `keyboard-map`.
+- `sdl2:render-clear!` and `sdl2:render-present!` are called directly from the sdl2 egg (no wrapper needed).
+- The loop exits when `input-held?` returns true for `'quit`. No user-settable `game-running?` flag in this milestone.
+
+---
+
+## `render:` Hook Semantics
+
+Following Phaser 2.x: the engine draws the full scene first via `render-scene!` (tilemap layers + entities), then calls the user's `render:` hook. The hook is for post-render overlays only — debug info, HUD elements, custom visuals. It does not replace or suppress the engine's render pass.
+
+---
+
+## Physics
+
+Physics (`physics.scm`) is a library, not a pipeline. `game-run!` does not call any physics function automatically. Users who want physics `(require-extension downstroke/physics)` and call functions from their `update:` hook.
+
+---
+
+## Public Accessors
+
+All `game-*` readers and `game-*-set!` mutators are generated by `defstruct` and exported. Additional convenience accessors:
+
+| Accessor | Description |
+|---|---|
+| `game-renderer` | SDL2 renderer — available from `preload:` onward |
+| `game-window` | SDL2 window |
+| `game-scene` / `game-scene-set!` | Current scene struct; user sets this in `create:` |
+| `game-camera` | Convenience: `(scene-camera (game-scene game))` — derived, not a struct field. Only valid after `create:` sets a scene; calling before that is an error. |
+| `game-assets` | Full asset registry hash table |
+| `game-input` | Current `input-state` record |
+| `game-input-config` | Current `input-config` record |
+| `(game-asset game key)` | Convenience: `(asset-ref (game-assets game) key)` |
+| `(game-asset-set! game key val)` | Convenience: `(asset-set! (game-assets game) key val)` |
+
+`game-camera` is not a struct field — it is a derived accessor:
+```scheme
+(define (game-camera game)
+ (scene-camera (game-scene game)))
+```
+
+---
+
+## Scene-Level Preloading
+
+Out of scope for this milestone. A `preload:` slot will be reserved in the scene struct when `scene-loader.scm` is implemented (Milestone 7), and the two-level asset registry (global + per-scene) will be designed then.
+
+---
+
+## macroknight Port
+
+macroknight's `game.scm` must be ported to use `make-game` + `game-run!` as part of this milestone. It is the primary integration test.
+
+**What moves to the engine:** SDL2 init block (lines 87–137), the main loop (line 659+), and all frame-level rendering.
+
+**What stays in macroknight `game.scm`:** Only the three lifecycle hooks and `game-run!`:
+- `preload:` — load fonts, sounds, tilemaps (currently scattered across lines 87–137)
+- `create:` — build initial scene (currently `make-initial-game-state`, line 196)
+- `update:` — game logic, input dispatch, physics calls (currently the per-frame update functions)
+
+**Acceptance criterion:** macroknight compiles (`make` succeeds), the game runs and plays correctly (title screen → stage select → gameplay), and `game.scm` is ≤50 lines with no SDL2 init or frame-loop boilerplate.
+
+---
+
+## Out of Scope
+
+- Scene-level preloading (Milestone 7)
+- Asset-type-specific load functions (`game-load-tilemap!`, `game-load-font!`, etc.) — Milestone 6
+- `game-running?` quit flag
+- Scene state machine (Milestone 9)
+- AI tag lookup (Milestone 10)
+- Extraction of `animation`, `ai`, `prefabs`, `mixer`, `sound` modules into downstroke