aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGene Pasquet <dev@etenil.net>2026-04-07 23:36:12 +0100
committerGene Pasquet <dev@etenil.net>2026-04-07 23:36:12 +0100
commit19a5db8606a82830a5ccd0ed46d8e0cf3c95db0a (patch)
tree241e7376014068ab9fc7a1bc8fa7a29cc1b62490
parent618ed5fd6f5ae9c9f275c1e3cfb74762d7d51a01 (diff)
Work on demos
-rw-r--r--demo/platformer.scm2
-rw-r--r--demo/sandbox.scm159
-rw-r--r--demo/tweens.scm98
-rw-r--r--docs/api.org28
-rw-r--r--docs/entities.org10
-rw-r--r--docs/guide.org6
-rw-r--r--docs/physics.org40
-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
-rw-r--r--engine.scm12
-rw-r--r--physics.scm93
-rw-r--r--renderer.scm45
-rw-r--r--scene-loader.scm9
-rw-r--r--tests/engine-test.scm8
-rw-r--r--tests/physics-test.scm65
-rw-r--r--tests/renderer-test.scm15
-rw-r--r--tests/scene-loader-test.scm2
-rw-r--r--tests/world-test.scm10
-rw-r--r--world.scm4
23 files changed, 459 insertions, 2753 deletions
diff --git a/demo/platformer.scm b/demo/platformer.scm
index 77fef72..7d289f6 100644
--- a/demo/platformer.scm
+++ b/demo/platformer.scm
@@ -59,7 +59,7 @@
(player (resolve-tile-collisions-x player tm))
(player (apply-velocity-y player))
(player (resolve-tile-collisions-y player tm))
- (player (detect-ground player tm)))
+ (player (detect-on-solid player tm)))
(scene-entities-set! scene (list player))))))
(game-run! *game*)
diff --git a/demo/sandbox.scm b/demo/sandbox.scm
index f585a6a..2feb69e 100644
--- a/demo/sandbox.scm
+++ b/demo/sandbox.scm
@@ -1,60 +1,153 @@
(import scheme
(chicken base)
(chicken random)
+ (only srfi-1 drop iota take)
(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-scene-loader)
-(define *elapsed* 0)
-(define *respawn-interval* 10000)
-
-(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 (* (pseudo-random-integer 4) 20))
- #:width 16 #:height 16
- #:vx 0 #:vy 0
- #:gravity? #t
- #:on-ground? #f
- #:solid? #t
- #:tile-id 1)
- acc)))))
+(define *demo-t* 0.0)
+
+;; Programmatic level: same geometry as the old static-tile floor + mid shelf,
+;; but as a real tile layer so tile collisions and detect-on-solid work.
+(define (make-sandbox-tilemap ts tw th gw gh)
+ (let* ((ncols (inexact->exact (ceiling (/ gw tw))))
+ (nrows (inexact->exact (ceiling (/ gh th))))
+ (floor-tile 20)
+ (shelf-tile 20)
+ (air (map (lambda (_) (map (lambda (_) 0) (iota ncols))) (iota nrows)))
+ (floor-row (map (lambda (_) floor-tile) (iota ncols)))
+ (with-floor (append (take air (- nrows 1)) (list floor-row)))
+ ;; Shelf top at same Y as before: gh - 6*th pixels from top
+ (shelf-r (inexact->exact (floor (/ (- gh (* 6 th)) th))))
+ (shelf-c0 10)
+ (shelf-n 10)
+ (row-before (list-ref with-floor shelf-r))
+ (shelf-row
+ (map (lambda (c)
+ (if (and (>= c shelf-c0) (< c (+ shelf-c0 shelf-n)))
+ shelf-tile
+ (list-ref row-before c)))
+ (iota ncols)))
+ (map-data (append (take with-floor shelf-r)
+ (list shelf-row)
+ (drop with-floor (+ shelf-r 1))))
+ (layer (make-layer name: "ground"
+ width: ncols height: nrows
+ map: map-data)))
+ (make-tilemap width: ncols height: nrows
+ tilewidth: tw tileheight: th
+ tileset-source: ""
+ tileset: ts
+ layers: (list layer)
+ objects: '())))
+
+(define (spawn-boxes tw th)
+ (map (lambda (i)
+ (list #:type 'box
+ #:x (+ 30 (* i 55)) #:y (+ 10 (* (pseudo-random-integer 4) 20))
+ #:width tw #:height th
+ #:vx 0 #:vy 0
+ #:gravity? #t #:on-ground? #f
+ #:solid? #t #:immovable? #f
+ #:tile-id 29))
+ (iota 8)))
+
+;; #:demo-id offsets phase; #:demo-since-jump accumulates ms for jump cadence.
+(define (make-demo-bot x y tw th id)
+ (list #:type 'demo-bot
+ #:x x #:y y
+ #:width tw #:height th
+ #:vx 0 #:vy 0
+ #:gravity? #t #:on-ground? #f
+ #:solid? #t #:immovable? #f
+ #:tile-id 1
+ #:demo-id id
+ #:demo-since-jump 0))
+
+(define (update-demo-bot e dt tm)
+ (let* ((id (entity-ref e #:demo-id 0))
+ (cycle 2600.0)
+ (phase (modulo (+ *demo-t* (* id 400.0)) cycle))
+ (vx (if (< phase (/ cycle 2.0)) 3.0 -3.0))
+ (e (entity-set e #:vx vx))
+ ;; Set last frame by final pass: detect-on-solid after entity–entity resolve.
+ (ground? (entity-ref e #:on-ground? #f))
+ (since (+ (entity-ref e #:demo-since-jump 0) dt))
+ (jump-every 720.0)
+ (do-jump? (and ground? (>= since jump-every)))
+ (since (if do-jump? 0 since))
+ (e (entity-set e #:demo-since-jump since))
+ (e (apply-jump e do-jump?))
+ (e (apply-acceleration e))
+ (e (apply-gravity e))
+ (e (apply-velocity-x e))
+ (e (resolve-tile-collisions-x e tm))
+ (e (apply-velocity-y e))
+ (e (resolve-tile-collisions-y e tm)))
+ e))
+
+(define (update-box e tm)
+ (let* ((e (apply-gravity e))
+ (e (apply-velocity-x e))
+ (e (resolve-tile-collisions-x e tm))
+ (e (apply-velocity-y e))
+ (e (resolve-tile-collisions-y e tm)))
+ e))
(define *game*
(make-game
title: "Demo: Physics Sandbox" width: 600 height: 400
create: (lambda (game)
- (let ((scene (game-load-scene! game "demo/assets/level-0.tmx")))
- (scene-entities-set! scene (spawn-entities))))
+ (let* ((ts (game-load-tileset! game 'tileset
+ "demo/assets/monochrome_transparent.tsx"))
+ (tw (tileset-tilewidth ts))
+ (th (tileset-tileheight ts))
+ (tex (create-texture-from-tileset (game-renderer game) ts))
+ (gw (game-width game))
+ (gh (game-height game))
+ (tm (make-sandbox-tilemap ts tw th gw gh))
+ (bots
+ (list (make-demo-bot 80 80 tw th 0)
+ (make-demo-bot 220 60 tw th 1)
+ (make-demo-bot 380 100 tw th 2)))
+ (entities (append (spawn-boxes tw th) bots))
+ (scene (make-scene
+ entities: entities
+ tilemap: tm
+ tileset: #f
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: tex
+ camera-target: #f
+ background: '(32 34 40))))
+ (game-scene-set! game scene)))
update: (lambda (game dt)
+ (set! *demo-t* (+ *demo-t* dt))
(let* ((scene (game-scene game))
(tm (scene-tilemap scene)))
- (set! *elapsed* (+ *elapsed* dt))
- (when (>= *elapsed* *respawn-interval*)
- (set! *elapsed* 0)
- (scene-entities-set! scene (spawn-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)))
- (scene-resolve-collisions scene)))))
+ (lambda (e)
+ (cond
+ ((eq? (entity-type e) 'demo-bot)
+ (update-demo-bot e dt tm))
+ ((eq? (entity-type e) 'box)
+ (update-box e tm))
+ (else e))))
+ (scene-resolve-collisions scene)
+ (let ((post (scene-entities scene)))
+ (scene-update-entities scene
+ (lambda (e)
+ (if (entity-ref e #:gravity? #f)
+ (detect-on-solid e tm post)
+ e))))))))
(game-run! *game*)
diff --git a/demo/tweens.scm b/demo/tweens.scm
index e9e40f3..ad9c80b 100644
--- a/demo/tweens.scm
+++ b/demo/tweens.scm
@@ -3,15 +3,12 @@
(only srfi-1 iota map)
(prefix sdl2 "sdl2:")
(prefix sdl2-ttf "ttf:")
- (prefix sdl2-image "img:")
downstroke-engine
downstroke-world
- downstroke-tilemap
downstroke-renderer
downstroke-physics
downstroke-entity
- downstroke-tween
- downstroke-scene-loader)
+ downstroke-tween)
;; One row per easing symbol: #(entity tween left-x right-x ease-sym to-right?)
(define *ease-cells* #f)
@@ -27,16 +24,47 @@
'(linear quad-in quad-out quad-in-out cubic-in cubic-out cubic-in-out
sine-in-out expo-in expo-out expo-in-out back-out))
+;; Distinct RGB triples for each easing row (no tileset).
+(define *ease-colors*
+ '((220 90 90)
+ (240 140 60)
+ (240 200 60)
+ (180 220 70)
+ (80 200 120)
+ (70 180 200)
+ (100 140 240)
+ (160 100 220)
+ (220 80 180)
+ (100 100 110)
+ (140 180 200)
+ (200 120 80)))
+
(define *label-font* #f)
(define *title-font* #f)
-(define +tile-ids+ '#(24 73 122 171 220))
+(define (clamp-entity-to-screen e gw gh)
+ "Clamp position and zero velocity on edges; set #:on-ground? on bottom when using gravity."
+ (let* ((w (entity-ref e #:width 0))
+ (h (entity-ref e #:height 0))
+ (x (entity-ref e #:x 0))
+ (y (entity-ref e #:y 0))
+ (vx (entity-ref e #:vx 0))
+ (vy (entity-ref e #:vy 0))
+ (nx (max 0 (min (- gw w) x)))
+ (ny (max 0 (min (- gh h) y)))
+ (ground? (and (entity-ref e #:gravity? #f) (= ny (- gh h))))
+ (e (entity-set e #:x nx))
+ (e (entity-set e #:y ny))
+ (e (entity-set e #:vx (if (= nx x) vx 0)))
+ (e (entity-set e #:vy (if (= ny y) vy 0)))
+ (e (entity-set e #:on-ground? ground?)))
+ e))
-(define (make-ease-cell ease-sym y tile-id)
+(define (make-ease-cell ease-sym y rgb)
(let* ((left 20)
(right (+ left 120))
(ent (list #:type 'tween-demo #:x left #:y y #:width 14 #:height 14
- #:vx 0 #:vy 0 #:gravity? #f #:solid? #f #:tile-id tile-id))
+ #:vx 0 #:vy 0 #:gravity? #f #:solid? #f #:color rgb))
(tw (make-tween ent props: `((#:x . ,right)) duration: 2600 ease: ease-sym)))
(vector ent tw left right ease-sym #t)))
@@ -58,7 +86,7 @@
(vector-set! cell 5 next-to-right?)))
(else (vector-set! cell 1 tw2))))))
-(define (update-knockback! dt tm)
+(define (update-knockback! dt tm gw gh)
(set! *knock-cd* (+ *knock-cd* dt))
(when (and *knock-ent* (not *knock-tw*) (>= *knock-cd* 3200))
(set! *knock-cd* 0)
@@ -76,23 +104,31 @@
(set! *knock-ent* e2)))
(when *knock-ent*
(set! *knock-ent*
- (let* ((e *knock-ent*)
- (e (apply-jump e #f))
- (e (apply-acceleration e))
- (e (apply-gravity e))
- (e (apply-velocity-x e))
- (e (resolve-tile-collisions-x e tm))
- (e (apply-velocity-y e))
- (e (resolve-tile-collisions-y e tm))
- (e (detect-ground e tm)))
- e))))
+ (if tm
+ (let* ((e *knock-ent*)
+ (e (apply-jump e #f))
+ (e (apply-acceleration e))
+ (e (apply-gravity e))
+ (e (apply-velocity-x e))
+ (e (resolve-tile-collisions-x e tm))
+ (e (apply-velocity-y e))
+ (e (resolve-tile-collisions-y e tm))
+ (e (detect-on-solid e tm)))
+ e)
+ (let* ((e *knock-ent*)
+ (e (apply-jump e #f))
+ (e (apply-acceleration e))
+ (e (apply-gravity e))
+ (e (apply-velocity-x e))
+ (e (apply-velocity-y e)))
+ (clamp-entity-to-screen e gw gh))))))
(define (tweens-demo-render-labels! renderer)
(let ((white (sdl2:make-color 255 255 255 255)))
(draw-ui-text renderer *title-font*
- "Tween demo — easing rows + knockback / skip-pipelines" white 12 6)
+ "Tween demo - easing rows + knockback / skip-pipelines" white 12 6)
(draw-ui-text renderer *label-font*
- "Each box loops on X; bottom crate tweens right with physics integration skipped, tiles still collide."
+ "Each box loops on X; bottom crate tweens right with physics skipped, screen bounds only."
white 12 32)
(do ((i 0 (+ i 1))) ((>= i (vector-length *ease-cells*)))
(let* ((cell (vector-ref *ease-cells* i))
@@ -107,28 +143,38 @@
(set! *title-font* (ttf:open-font "demo/assets/DejaVuSans.ttf" 22))
(set! *label-font* (ttf:open-font "demo/assets/DejaVuSans.ttf" 13)))
create: (lambda (game)
- (let ((scene (game-load-scene! game "demo/assets/level-0.tmx")))
+ (let ((scene (make-scene entities: '()
+ tilemap: #f
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: #f
+ camera-target: #f
+ background: '(26 28 34))))
(set! *ease-cells*
(list->vector
(map (lambda (ease i)
(make-ease-cell ease (+ 52 (* i 20))
- (vector-ref +tile-ids+ (modulo i (vector-length +tile-ids+)))))
+ (list-ref *ease-colors* i)))
*ease-syms*
(iota (length *ease-syms*)))))
(set! *knock-ent*
(list #:type 'knock-crate #:x 200 #:y 80 #:width 18 #:height 18
- #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f #:solid? #f #:tile-id 220))
+ #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f #:solid? #f
+ #:color '(140 110 70)))
(set! *knock-tw* #f)
(set! *knock-cd* 2500)
(scene-entities-set! scene
(append (map (lambda (i) (vector-ref (vector-ref *ease-cells* i) 0))
(iota (vector-length *ease-cells*)))
- (list *knock-ent*)))))
+ (list *knock-ent*)))
+ (game-scene-set! game scene)))
update: (lambda (game dt)
- (let* ((scene (game-scene game)) (tm (scene-tilemap scene)))
+ (let* ((scene (game-scene game))
+ (tm (scene-tilemap scene))
+ (gw (game-width game))
+ (gh (game-height game)))
(do ((i 0 (+ i 1))) ((>= i (vector-length *ease-cells*)))
(advance-ease-cell! (vector-ref *ease-cells* i) dt))
- (update-knockback! dt tm)
+ (update-knockback! dt tm gw gh)
(scene-entities-set! scene
(append (map (lambda (i) (vector-ref (vector-ref *ease-cells* i) 0))
(iota (vector-length *ease-cells*)))
diff --git a/docs/api.org b/docs/api.org
index f6cfe50..1f15945 100644
--- a/docs/api.org
+++ b/docs/api.org
@@ -55,7 +55,7 @@ Lifecycle order within each frame:
1. Collect SDL2 events
2. Update input state
3. Call ~update:~ hook (or active state's ~update~)
-4. Clear renderer
+4. Set the renderer clear color from the current scene's ~background:~ (see ~make-scene~), then clear the framebuffer (~#f~ or invalid value uses opaque black)
5. Call ~render-scene!~ (if scene is set)
6. Call ~render:~ hook (or active state's ~render~)
7. Present renderer
@@ -153,9 +153,11 @@ Auto-generated by defstruct. Use keyword arguments:
(make-scene #!key
(entities '())
(tilemap #f)
+ (tileset #f)
(camera #f)
(tileset-texture #f)
- (camera-target #f))
+ (camera-target #f)
+ (background #f))
#+end_src
Creates a scene record representing the current level state.
@@ -164,9 +166,11 @@ Creates a scene record representing the current level state.
|-----------+------+---------+-------------|
| ~entities~ | list | ~'()~ | List of entity plists |
| ~tilemap~ | tilemap/false | #f | Tile grid and collisions |
+| ~tileset~ | tileset/false | #f | Tileset metadata (from ~load-tileset~) when there is no tilemap; required with ~tileset-texture~ to draw ~#:tile-id~ sprites without a TMX map |
| ~camera~ | camera/false | #f | Viewport position |
| ~tileset-texture~ | SDL2 texture/false | #f | Rendered tileset image |
| ~camera-target~ | symbol/false | #f | Tag symbol of the entity to follow (see ~scene-camera-target-set!~) |
+| ~background~ | list/false | #f | Framebuffer clear color: ~(r g b)~ or ~(r g b a)~ (0–255). ~#f~ means opaque black. Set each frame in ~game-run!~ before ~SDL_RenderClear~. |
** ~make-camera~
@@ -358,7 +362,7 @@ Returns true if ~step-symbol~ appears in the entity’s ~#:skip-pipelines~ list
(define-pipeline (procedure-name skip-symbol) (entity-formal extra-formal ...) body ...)
#+end_src
-Syntax for authors of per-entity pipeline steps: expands to a ~define~ that returns the **first** formal (the entity) unchanged when ~skip-symbol~ is listed in ~#:skip-pipelines~; otherwise runs ~body ...~ inside ~(let () ...)~. Used throughout ~downstroke-physics~; other modules can use it for consistent skip behavior. The procedure name and skip symbol differ when needed (e.g. ~detect-ground~ vs ~ground-detection~).
+Syntax for authors of per-entity pipeline steps: expands to a ~define~ that returns the **first** formal (the entity) unchanged when ~skip-symbol~ is listed in ~#:skip-pipelines~; otherwise runs ~body ...~ inside ~(let () ...)~. Used throughout ~downstroke-physics~; other modules can use it for consistent skip behavior. The procedure name and skip symbol differ when needed (e.g. ~detect-on-solid~ vs ~on-solid~).
** Shared Entity Keys
@@ -403,7 +407,7 @@ The built-in physics functions are normally run in this order each frame (after
5. ~resolve-tile-collisions-x~ — snap against horizontal tile collisions
6. ~apply-velocity-y~ — move by ~#:vy~
7. ~resolve-tile-collisions-y~ — snap against vertical tile collisions
-8. ~detect-ground~ — set ~#:on-ground?~ if standing on a tile
+8. ~detect-on-solid~ — set ~#:on-ground?~ if standing on a tile and/or another solid entity (optional third argument)
9. ~resolve-entity-collisions~ — push apart solid entities (whole list)
Entities may list ~#:skip-pipelines~ to omit specific steps; see ~entity-skips-pipeline?~ under ~downstroke-entity~ and ~docs/physics.org~.
@@ -466,13 +470,13 @@ Detects and resolves collisions between the entity's bounding box and solid tile
Detects and resolves collisions between the entity's bounding box and solid tiles along the Y axis. Snaps the entity's ~#:y~ to the near/far tile edge and sets ~#:vy~ to 0. Returns a new entity.
-** ~detect-ground~
+** ~detect-on-solid~
#+begin_src scheme
-(detect-ground entity tilemap)
+(detect-on-solid entity tilemap #!optional other-entities)
#+end_src
-Probes one pixel below the entity's feet to detect if it is standing on a solid tile. Sets ~#:on-ground?~ to true or false. Only applies if ~#:gravity?~ is true. Returns a new entity.
+Sets ~#:on-ground?~ to true if the entity is supported by a solid tile (probe below the feet) and/or, when ~other-entities~ is a list, by another solid's top surface (e.g. moving platforms). Omit the third argument for tile-only detection. Only applies if ~#:gravity?~ is true. Returns a new entity (the ~?~-suffix denotes call-site readability, not a boolean return value). Prefer calling after all collision resolution when using the entity list.
** ~apply-jump~
@@ -629,7 +633,7 @@ The renderer module provides SDL2 drawing abstractions.
(render-scene! renderer scene)
#+end_src
-Draws the entire scene: first the tilemap layers, then all entities. Called automatically by ~game-run!~ before the user's ~render:~ hook. Does nothing if the scene is ~#f~.
+Draws the entire scene: tilemap layers (if any), then every entity. Sprites use ~#:tile-id~ and the scene's tileset texture when both are available; otherwise an entity with ~#:color~ ~(r g b)~ or ~(r g b a)~ is drawn as a filled rectangle. Called automatically by ~game-run!~ before the user's ~render:~ hook. Does nothing if the scene is ~#f~.
** ~entity-screen-coords~
@@ -1207,6 +1211,14 @@ Loads a TSX tileset file, stores it in the game asset registry under ~key~, and
Opens a TTF font at ~size~ points, stores it in the game asset registry under ~key~, and returns the font. Wraps ~ttf:open-font~.
+** ~create-texture-from-tileset~
+
+#+begin_src scheme
+(create-texture-from-tileset renderer tileset)
+#+end_src
+
+Creates an SDL2 texture from a tileset struct's image surface (after ~load-tileset~ / ~game-load-tileset!~). Use with ~make-scene~ when ~tilemap~ is ~#f~ but entities use ~#:tile-id~.
+
** ~create-tileset-texture~
#+begin_src scheme
diff --git a/docs/entities.org b/docs/entities.org
index f6e870c..a29ebf6 100644
--- a/docs/entities.org
+++ b/docs/entities.org
@@ -111,10 +111,12 @@ The engine recognizes these standard keys. Use them to integrate with the physic
| ~#:vx~, ~#:vy~ | number | Velocity in pixels per frame. ~#:vx~ is updated by ~apply-velocity-x~; ~#:vy~ is updated by ~apply-velocity-y~. Both consumed by collision resolvers. |
| ~#:ay~ | number | Y acceleration (e.g., from jumping or knockback). Consumed by ~apply-acceleration~, which adds it to ~#:vy~. Optional; default is 0. |
| ~#:gravity?~ | boolean | Whether gravity applies to this entity. Set to ~#t~ for platformers (gravity pulls down), ~#f~ for top-down or flying entities. Used by ~apply-gravity~. |
-| ~#:on-ground?~ | boolean | Whether the entity is touching a solid tile below (set by ~detect-ground~). Use this to gate jump input: only allow jumping if ~#:on-ground?~ is true. |
+| ~#:on-ground?~ | boolean | Whether the entity is supported from below (set by ~detect-on-solid~): solid tile under the feet and/or standing on another solid entity when you pass the scene entity list. Use this to gate jump input. |
| ~#:solid?~ | boolean | Whether this entity participates in entity-entity collision. If ~#t~, ~resolve-entity-collisions~ will check it against other solid entities. |
+| ~#:immovable?~ | boolean | If ~#t~ with ~#:solid? #t~, entity–entity resolution only moves the *other* entity (static platforms). Two overlapping immovable solids are not separated. |
| ~#:skip-pipelines~ | list of symbols | Optional. Each symbol names a physics step to skip for this entity (e.g. ~gravity~, ~velocity-x~). See ~docs/physics.org~. |
-| ~#:tile-id~ | integer | Sprite index in the tileset (1-indexed). Required for rendering with ~draw-sprite~. Updated automatically by animation (~animate-entity~). |
+| ~#:tile-id~ | integer | Sprite index in the tileset (1-indexed). Used by ~render-scene!~ when the scene has a tileset texture and tile metadata (from the tilemap or ~scene-tileset~). Updated automatically by animation (~animate-entity~). |
+| ~#:color~ | list | Optional ~(r g b)~ or ~(r g b a)~ (0–255 each). When ~#:tile-id~ is not drawn as a sprite (missing ~#:tile-id~, or no tileset texture), ~render-scene!~ fills the entity rect with this color. |
| ~#:facing~ | number | Horizontal flip direction: ~1~ = right (default), ~-1~ = left. Used by renderer to flip sprite horizontally. Update when changing direction. |
| ~#:tags~ | list of symbols | List of tag symbols, e.g., ~'(player solid)~. Used by ~scene-find-tagged~ and ~scene-find-all-tagged~ for fast lookups. |
| ~#:animations~ | alist | Animation definitions (see Animation section). Keys are animation names (symbols); values are animation specs. |
@@ -124,7 +126,7 @@ The engine recognizes these standard keys. Use them to integrate with the physic
* Entities in Scenes
-A **scene** is a level state: it holds a tilemap, a camera, and a list of entities. These functions manipulate scene entities:
+A **scene** is a level state: it holds a tilemap (optional), an optional standalone tileset for ~#:tile-id~ drawing without TMX, a camera, and a list of entities. These functions manipulate scene entities:
** ~scene-entities scene~
@@ -362,7 +364,7 @@ Here is a full example showing entity creation, initialization in the scene, and
(player (resolve-tile-collisions-x player tm))
(player (apply-velocity-y player))
(player (resolve-tile-collisions-y player tm))
- (player (detect-ground player tm))
+ (player (detect-on-solid player tm))
;; Update animation
(player (set-animation player
(cond
diff --git a/docs/guide.org b/docs/guide.org
index de07c82..380f04b 100644
--- a/docs/guide.org
+++ b/docs/guide.org
@@ -125,7 +125,7 @@ csc square.scm -o square
Press arrow keys to move the yellow square around. Here are the key ideas:
-- **Scenes**: =make-scene= creates a container for entities, tilemaps, and the camera. It holds the game state each frame.
+- **Scenes**: =make-scene= creates a container for entities, tilemaps, and the camera. It holds the game state each frame. Optional =background:= ~(r g b)~ or ~(r g b a)~ sets the color used to clear the window each frame (default is black).
- **Entities**: Entities are plists (property lists). They have no class; they're pure data. Access properties with =entity-ref=, and update with =entity-set= (which returns a *new* plist — always bind the result).
- **Input**: =input-held?= returns =#t= if an action is currently pressed. Actions are symbols like ='left=, ='right=, ='up=, ='down= (from the default input config).
- **Update & Render**: The =update:= hook runs first and updates entities. The =render:= hook runs after the default rendering pipeline and is used for custom drawing like this colored rectangle.
@@ -193,7 +193,7 @@ For a real game, you probably want tilemaps, gravity, and collision detection. D
(player (resolve-tile-collisions-x player tm))
(player (apply-velocity-y player))
(player (resolve-tile-collisions-y player tm))
- (player (detect-ground player tm)))
+ (player (detect-on-solid player tm)))
;; Update camera to follow player
(let ((cam-x (max 0 (- (entity-ref player #:x 0) 300))))
(camera-x-set! (scene-camera scene) cam-x))
@@ -210,7 +210,7 @@ Key points:
- **=play-sound=** plays a loaded sound effect.
- **Physics pipeline**: Functions like =apply-gravity=, =apply-velocity-x=, =resolve-tile-collisions-x= form the physics pipeline. Apply them in order each frame to get correct platformer behavior.
- **Tile collisions**: =resolve-tile-collisions-x= and =resolve-tile-collisions-y= snap entities to tile edges, preventing clipping.
-- **Ground detection**: =detect-ground= sets the =#:on-ground?= flag so you know if a jump is valid.
+- **On-solid check**: =detect-on-solid= sets the =#:on-ground?= flag from tiles below the feet and, if you pass other scene entities, from standing on solids (moving platforms, crates). Call it after collisions; use the flag next frame to gate jumps. (Despite the =?=-suffix, it returns an updated entity, not a boolean.)
See =demo/platformer.scm= in the engine source for a complete working example.
diff --git a/docs/physics.org b/docs/physics.org
index 2a82210..0eb9265 100644
--- a/docs/physics.org
+++ b/docs/physics.org
@@ -16,7 +16,7 @@ The =tilemap= argument is required by collision functions, since collision data
- =apply-gravity= — add gravity to falling entities
- =apply-velocity-x=, =apply-velocity-y= — move entity by velocity
- =resolve-tile-collisions-x=, =resolve-tile-collisions-y= — snap entity off tiles on collision
-- =detect-ground= — probe 1px below feet to set #:on-ground?
+- =detect-on-solid= — set =#:on-ground?= from tiles below feet and/or solid entities underfoot
- =resolve-entity-collisions= — all-pairs AABB push-apart for solid entities
- =aabb-overlap?= — pure boolean collision test (for queries, not resolution)
@@ -39,7 +39,7 @@ apply-velocity-y (add #:vy to #:y)
resolve-tile-collisions-y (snap off vertical tiles, zero #:vy)
-detect-ground (probe 1px below feet, set #:on-ground?)
+detect-on-solid (tiles and/or other solids underfoot, set #:on-ground?)
resolve-entity-collisions (push apart overlapping solid entities; whole list)
#+end_src
@@ -65,7 +65,7 @@ An entity may include ~#:skip-pipelines=, a list of **symbols** naming steps to
| ~velocity-y~ | ~apply-velocity-y~ |
| ~tile-collisions-x~ | ~resolve-tile-collisions-x~ |
| ~tile-collisions-y~ | ~resolve-tile-collisions-y~ |
-| ~ground-detection~ | ~detect-ground~ |
+| ~on-solid~ | ~detect-on-solid~ |
| ~entity-collisions~ | participation in ~resolve-entity-collisions~ / ~resolve-pair~ |
**Entity–entity collisions:** if *either* entity in a pair lists ~entity-collisions~ in ~#:skip-pipelines=, that pair is not resolved (no push-apart). Use this for “ghost” actors or scripted motion that should not participate in mutual solid resolution.
@@ -76,7 +76,7 @@ Helper: ~(entity-skips-pipeline? entity step-symbol)~ (from ~downstroke-entity~)
** ~define-pipeline~ (~downstroke-entity~)
-Physics steps are defined with ~(define-pipeline (procedure-name skip-symbol) (formals ...) body ...)~ from the entity module. The first formal must be the entity. The procedure name and skip symbol are separate so names like ~detect-ground~ can use the skip key ~ground-detection~. ~apply-velocity~ is still written by hand because it consults ~velocity-x~ and ~velocity-y~ independently.
+Physics steps are defined with ~(define-pipeline (procedure-name skip-symbol) (formals ...) body ...)~ from the entity module. The first formal must be the entity. The procedure name and skip symbol are separate (e.g. ~detect-on-solid~ vs ~on-solid~). ~apply-velocity~ is still written by hand because it consults ~velocity-x~ and ~velocity-y~ independently.
The renderer and other subsystems do **not** use ~#:skip-pipelines~ today; they run after your ~update:~ hook. If you add render-phase or animation-phase skips later, reuse the same plist key and helpers from ~downstroke-entity~ and document the new symbols alongside physics.
@@ -193,19 +193,19 @@ Velocity is zeroed to stop the entity from sliding. Call this immediately after
Velocity is zeroed. Call this immediately after =apply-velocity-y=.
-** detect-ground
+** detect-on-solid
#+begin_src scheme
-(detect-ground entity tilemap)
+(detect-on-solid entity tilemap #!optional other-entities)
#+end_src
-**Reads**: =#:gravity?=, =#:x=, =#:y=, =#:width=, =#:height=
+**Reads**: =#:gravity?=, =#:x=, =#:y=, =#:width=, =#:height=, =#:vy=; when =other-entities= is passed, each other entity's =#:solid?=, position, and size
-**Writes**: =#:on-ground?= (set to #t if tile found 1px below feet, else #f)
+**Writes**: =#:on-ground?= (set to =#t= if supported by a solid tile probe and/or by another solid's top surface, else =#f=)
-**Description**: Probes 1 pixel directly below the entity's feet (bottom edge) to see if it's standing on a tile. The probe checks both corners of the entity's width to handle partial overlaps. Only works if =#:gravity?= is true.
+**Description**: Despite the =?=-suffix, this returns an **updated entity** (it sets =#:on-ground?=), not a boolean. Ground is **either** (1) a solid tile one pixel below the feet (probe at both lower corners of the AABB, same as before), **or** (2) when =other-entities= is a non-=#f= list, resting on another solid entity's **top** (horizontal overlap, feet within a few pixels of that entity's top, and vertical speed small enough that we treat the body as supported—so moving platforms and crates count). Omit =other-entities= (or pass =#f=) to keep tile-only behavior. Only runs when =#:gravity?= is true. If =tilemap= is =#f=, the tile probe is skipped and only entity support applies when a list is provided.
-Used by =apply-jump= to decide whether jump is allowed. Call this at the end of each physics frame, after all collision resolution.
+Used by =apply-jump= (via =#:on-ground?= on the **next** frame). Call **after** tile collision and **after** =resolve-entity-collisions= when using entity support, so positions and velocities are settled.
** resolve-entity-collisions
@@ -219,11 +219,7 @@ Used by =apply-jump= to decide whether jump is allowed. Call this at the end of
**Writes**: For colliding pairs: =#:x=, =#:y=, =#:vx=, =#:vy= (pushed apart, velocities set to ±1)
-**Description**: Performs all-pairs AABB overlap detection. For each pair of entities where BOTH have =#:solid? #t=, if they overlap:
-
-1. Calculate overlap on both X and Y axes
-2. Push apart along the axis with smaller overlap (to avoid getting stuck in corners)
-3. Set each entity's velocity along that axis to push them in opposite directions
+**Description**: Performs all-pairs AABB overlap detection. For each pair of entities where BOTH have =#:solid? #t=, if they overlap: if one has =#:immovable? #t=, only the other entity is displaced along the shallow overlap axis (and its velocity on that axis is zeroed); if both are immovable, the pair is skipped. Otherwise the two bodies are pushed apart along the smaller overlap axis and their velocities on that axis are set to ±1.
Entities without =#:solid?= or with =#:solid? #f= are skipped. Returns a new entity list with collisions resolved.
@@ -334,7 +330,7 @@ update: (lambda (game dt)
;; Resolve tile collisions on y-axis
(player (resolve-tile-collisions-y player tm))
;; Check if standing on ground
- (player (detect-ground player tm)))
+ (player (detect-on-solid player tm)))
;; Update camera to follow player
(let ((cam-x (max 0 (- (entity-ref player #:x 0) 300))))
(camera-x-set! (scene-camera scene) cam-x))
@@ -353,7 +349,7 @@ update: (lambda (game dt)
7. **resolve-tile-collisions-x**: if hit a tile, snap #:x and zero #:vx
8. **apply-velocity-y**: add #:vy to #:y (move up/down)
9. **resolve-tile-collisions-y**: if hit a tile, snap #:y and zero #:vy
-10. **detect-ground**: probe 1px below feet, set #:on-ground? for next frame's jump check
+10. **detect-on-solid**: set #:on-ground? from tiles and/or other solids (optional entity list), for next frame's jump check
* Top-Down Example
@@ -412,7 +408,7 @@ update: (lambda (game dt)
(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)))
+ (lambda (e) (detect-on-solid e tm)))
;; Then resolve entity-entity collisions
(scene-resolve-collisions scene)))
#+end_src
@@ -423,7 +419,7 @@ update: (lambda (game dt)
- =apply-gravity= (all entities fall)
- =apply-velocity-x=, =resolve-tile-collisions-x= (move and collide on x-axis)
- =apply-velocity-y=, =resolve-tile-collisions-y= (move and collide on y-axis)
- - =detect-ground= (set #:on-ground? for next frame)
+ - =detect-on-solid= (set #:on-ground? for next frame)
2. **scene-resolve-collisions**: after all entities are moved and collided with tiles, resolve entity-entity overlaps (boxes pushing apart)
@@ -451,11 +447,11 @@ Same for other functions that take tilemap. The =scene-update-entities= macro ap
;; ... rest of pipeline
#+end_src
-You only need =apply-jump=, =apply-acceleration=, and =apply-gravity=. =detect-ground= is needed to prevent double-jumping.
+You only need =apply-jump=, =apply-acceleration=, and =apply-gravity=. =detect-on-solid= is needed to prevent double-jumping.
** No Gravity
-For top-down or shmup games, skip =apply-jump=, =apply-acceleration=, =apply-gravity=, and =detect-ground=. Just do velocity + tile collision.
+For top-down or shmup games, skip =apply-jump=, =apply-acceleration=, =apply-gravity=, and =detect-on-solid=. Just do velocity + tile collision.
** Knockback
@@ -510,7 +506,7 @@ For large games, consider spatial partitioning (grid, quadtree) to cull entity p
** Double-Jump / Can't Jump
-- Ensure =detect-ground= is called after all collision resolution
+- Ensure =detect-on-solid= is called after tile collision and, when using the optional entity list, after =resolve-entity-collisions=
- Verify =#:on-ground?= is checked in =apply-jump= (not hardcoded)
- Make sure you're checking =input-pressed?= (not =input-held?=) for jump
diff --git a/docs/superpowers/plans/2026-04-05-demos.md b/docs/superpowers/plans/2026-04-05-demos.md
deleted file mode 100644
index 6fea0bb..0000000
--- a/docs/superpowers/plans/2026-04-05-demos.md
+++ /dev/null
@@ -1,922 +0,0 @@
-# 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
deleted file mode 100644
index 0688bf5..0000000
--- a/docs/superpowers/plans/2026-04-05-milestone-14-docs.md
+++ /dev/null
@@ -1,167 +0,0 @@
-# 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
deleted file mode 100644
index 2b7f86f..0000000
--- a/docs/superpowers/plans/2026-04-05-milestone-7-scene-loader.md
+++ /dev/null
@@ -1,201 +0,0 @@
-# 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
deleted file mode 100644
index 0d12308..0000000
--- a/docs/superpowers/plans/2026-04-05-milestone-8-game-object-lifecycle.md
+++ /dev/null
@@ -1,826 +0,0 @@
-# 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
deleted file mode 100644
index 78ed3f4..0000000
--- a/docs/superpowers/specs/2026-04-05-demos-design.md
+++ /dev/null
@@ -1,256 +0,0 @@
-# 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
deleted file mode 100644
index d6e71be..0000000
--- a/docs/superpowers/specs/2026-04-05-milestone-8-game-object-lifecycle-design.md
+++ /dev/null
@@ -1,234 +0,0 @@
-# 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
diff --git a/engine.scm b/engine.scm
index 0318616..38ebfed 100644
--- a/engine.scm
+++ b/engine.scm
@@ -99,6 +99,17 @@
(create (state-hook state #:create)))
(when create (create game))))
+;; Set renderer draw color for SDL_RenderClear (called every frame before clear).
+(define (renderer-set-clear-color! renderer scene)
+ (let ((bg (and scene (scene-background scene))))
+ (if (and (list? bg) (>= (length bg) 3))
+ (let ((r (list-ref bg 0))
+ (g (list-ref bg 1))
+ (b (list-ref bg 2))
+ (a (if (>= (length bg) 4) (list-ref bg 3) 255)))
+ (set! (sdl2:render-draw-color renderer) (sdl2:make-color r g b a)))
+ (set! (sdl2:render-draw-color renderer) (sdl2:make-color 0 0 0 255)))))
+
;; ── game-run! ──────────────────────────────────────────────────────────────
;; Main event loop and lifecycle orchestration
@@ -167,6 +178,7 @@
target
(game-width game)
(game-height game)))))))
+ (renderer-set-clear-color! (game-renderer game) (game-scene game))
(sdl2:render-clear! (game-renderer game))
(when (game-scene game)
(render-scene! (game-renderer game) (game-scene game)))
diff --git a/physics.scm b/physics.scm
index f3cc3bb..979eb4b 100644
--- a/physics.scm
+++ b/physics.scm
@@ -2,7 +2,7 @@
(scene-resolve-collisions resolve-entity-collisions resolve-pair
aabb-overlap? push-apart push-along-axis aabb-overlap-on-axis
entity-center-on-axis push-entity axis->velocity axis->dimension
- index-pairs list-set apply-jump detect-ground
+ index-pairs list-set apply-jump detect-on-solid
resolve-tile-collisions-y resolve-tile-collisions-x resolve-tile-collisions-axis
tile-push-pos entity-tile-cells pixel->tile build-cell-list
apply-velocity apply-velocity-y apply-velocity-x apply-gravity apply-acceleration
@@ -10,7 +10,7 @@
(import scheme
(chicken base)
(chicken keyword)
- (only srfi-1 fold iota)
+ (only srfi-1 any fold iota)
defstruct
downstroke-tilemap
downstroke-entity
@@ -23,6 +23,11 @@
;; Jump force: vertical acceleration applied on jump (one frame)
(define *jump-force* 15)
+ ;; Feet may be this far (pixels) from another solid's top and count as standing on it.
+ (define *entity-ground-contact-tolerance* 5)
+ ;; If |vy| is above this, another entity does not count as ground (mid-air / fast fall).
+ (define *entity-ground-vy-max* 12)
+
;; Per-entity steps use define-pipeline from downstroke-entity (see docs/physics.org
;; for #:skip-pipelines symbol names).
@@ -138,20 +143,52 @@
(resolve-tile-collisions-axis entity tilemap #:vy #:y
(lambda (v col row) (tile-push-pos v row th h)))))
- ;; Detect if entity is standing on ground by probing 1px below feet
- (define-pipeline (detect-ground ground-detection) (entity tilemap)
+ ;; True if ~self~ is supported by another solid's top surface (moving platforms, crates, …).
+ (define (entity-solid-support-below? self others)
+ (let* ((bx (entity-ref self #:x 0))
+ (bw (entity-ref self #:width 0))
+ (by (entity-ref self #:y 0))
+ (bh (entity-ref self #:height 0))
+ (bottom (+ by bh))
+ (vy (abs (entity-ref self #:vy 0))))
+ (and (<= vy *entity-ground-vy-max*)
+ (any (lambda (o)
+ (and (not (eq? self o))
+ (entity-ref o #:solid? #f)
+ (let* ((ox (entity-ref o #:x 0))
+ (oy (entity-ref o #:y 0))
+ (ow (entity-ref o #:width 0)))
+ (and (< bx (+ ox ow))
+ (< ox (+ bx bw))
+ (<= (abs (- bottom oy)) *entity-ground-contact-tolerance*)))))
+ others))))
+
+ ;; Standing on ground = solid tile 1px below feet and/or feet on top of another solid.
+ ;; Optional ~other-entities~: when non-#f, must be a list of scene entities (include movers).
+ ;; Call after tile and entity-entity collision so positions and ~#:vy~ are settled.
+ ;; Name ends in ~?~ for call-site readability; it still returns an updated entity (not a boolean).
+ (define-pipeline (detect-on-solid on-solid)
+ (entity tilemap #!optional (other-entities #f))
(if (not (entity-ref entity #:gravity? #f))
entity
- (let* ((x (entity-ref entity #:x 0))
- (w (entity-ref entity #:width 0))
- (tw (tilemap-tilewidth tilemap))
- (th (tilemap-tileheight tilemap))
- (probe-y (+ (entity-ref entity #:y 0) (entity-ref entity #:height 0) 1))
- (row (pixel->tile probe-y th))
- (col-left (pixel->tile x tw))
- (col-right (pixel->tile (- (+ x w) 1) tw))
- (on-ground? (or (not (zero? (tilemap-tile-at tilemap col-left row)))
- (not (zero? (tilemap-tile-at tilemap col-right row))))))
+ (let* ((tile-ground?
+ (and tilemap
+ (let* ((x (entity-ref entity #:x 0))
+ (w (entity-ref entity #:width 0))
+ (tw (tilemap-tilewidth tilemap))
+ (th (tilemap-tileheight tilemap))
+ (probe-y (+ (entity-ref entity #:y 0)
+ (entity-ref entity #:height 0)
+ 1))
+ (row (pixel->tile probe-y th))
+ (col-left (pixel->tile x tw))
+ (col-right (pixel->tile (- (+ x w) 1) tw)))
+ (or (not (zero? (tilemap-tile-at tilemap col-left row)))
+ (not (zero? (tilemap-tile-at tilemap col-right row)))))))
+ (entity-ground?
+ (and other-entities
+ (entity-solid-support-below? entity other-entities)))
+ (on-ground? (or tile-ground? entity-ground?)))
(entity-set entity #:on-ground? on-ground?))))
;; Set vertical acceleration for jump (consumed next frame by apply-acceleration)
@@ -218,6 +255,23 @@
(push-along-axis #:x a b ovx)
(push-along-axis #:y a b ovy))))
+ ;; Move ~m~ out of ~s~ along the shallow penetration axis; ~s~ is unchanged.
+ ;; Used when ~s~ has #:immovable? #t.
+ (define (separate-movable-from-static m s)
+ (let* ((ovx (aabb-overlap-on-axis #:x m s))
+ (ovy (aabb-overlap-on-axis #:y m s)))
+ (if (<= ovx ovy)
+ (let* ((mc (entity-center-on-axis m #:x))
+ (sc (entity-center-on-axis s #:x))
+ (dir (if (< mc sc) -1 1))
+ (mx (entity-ref m #:x 0)))
+ (entity-set (entity-set m #:x (+ mx (* dir ovx))) #:vx 0))
+ (let* ((mc (entity-center-on-axis m #:y))
+ (sc (entity-center-on-axis s #:y))
+ (dir (if (< mc sc) -1 1))
+ (my (entity-ref m #:y 0)))
+ (entity-set (entity-set m #:y (+ my (* dir ovy))) #:vy 0)))))
+
;; Check if two axis-aligned bounding boxes overlap.
;; Returns #t if they overlap, #f if they don't (including edge-touching).
(define (aabb-overlap? x1 y1 w1 h1 x2 y2 w2 h2)
@@ -228,6 +282,7 @@
;; Resolve AABB collision between two solid entities.
;; Returns (a2 . b2) with positions/velocities adjusted, or #f if no collision.
+ ;; #:immovable? #t marks static geometry; only the other entity is displaced.
(define (resolve-pair a b)
(and (not (downstroke-entity#entity-skips-pipeline? a 'entity-collisions))
(not (downstroke-entity#entity-skips-pipeline? b 'entity-collisions))
@@ -237,7 +292,15 @@
(entity-ref a #:width 0) (entity-ref a #:height 0)
(entity-ref b #:x 0) (entity-ref b #:y 0)
(entity-ref b #:width 0) (entity-ref b #:height 0))
- (push-apart a b)))
+ (let ((ia (entity-ref a #:immovable? #f))
+ (ib (entity-ref b #:immovable? #f)))
+ (cond
+ ((and ia ib) #f)
+ (ia (let ((b2 (separate-movable-from-static b a)))
+ (and b2 (cons a b2))))
+ (ib (let ((a2 (separate-movable-from-static a b)))
+ (and a2 (cons a2 b))))
+ (else (push-apart a b))))))
;; Detect and resolve AABB overlaps between all pairs of solid entities.
;; Returns a new entity list with collisions resolved.
diff --git a/renderer.scm b/renderer.scm
index e7daf11..b048bad 100644
--- a/renderer.scm
+++ b/renderer.scm
@@ -119,15 +119,26 @@
;; --- Entity drawing ---
+ ;; #:color is (r g b) or (r g b a); used when no tile sprite is drawn.
(define (draw-entity renderer camera tileset tileset-texture entity)
- (let ((tile-id (entity-ref entity #:tile-id #f)))
- (when tile-id
- (sdl2:render-copy-ex! renderer tileset-texture
- (tile-rect (tileset-tile tileset tile-id))
- (entity->screen-rect entity camera)
- 0.0
- #f
- (entity-flip entity)))))
+ (let ((tile-id (entity-ref entity #:tile-id #f))
+ (color (entity-ref entity #:color #f)))
+ (cond
+ ((and tile-id tileset tileset-texture)
+ (sdl2:render-copy-ex! renderer tileset-texture
+ (tile-rect (tileset-tile tileset tile-id))
+ (entity->screen-rect entity camera)
+ 0.0
+ #f
+ (entity-flip entity)))
+ ((and (list? color) (>= (length color) 3))
+ (let ((r (list-ref color 0))
+ (g (list-ref color 1))
+ (b (list-ref color 2))
+ (a (if (>= (length color) 4) (list-ref color 3) 255)))
+ (set! (sdl2:render-draw-color renderer) (sdl2:make-color r g b a))
+ (sdl2:render-fill-rect! renderer (entity->screen-rect entity camera))))
+ (else #f))))
(define (draw-entities renderer camera tileset tileset-texture entities)
(for-each
@@ -161,15 +172,17 @@
;; --- Scene drawing ---
(define (render-scene! renderer scene)
- (let ((camera (scene-camera scene))
- (tilemap (scene-tilemap scene))
- (tileset-texture (scene-tileset-texture scene))
- (entities (scene-entities scene)))
+ (let* ((camera (scene-camera scene))
+ (tilemap (scene-tilemap scene))
+ (scene-ts (scene-tileset scene))
+ (tileset-texture (scene-tileset-texture scene))
+ (entities (scene-entities scene))
+ (tileset
+ (and tileset-texture
+ (or scene-ts (and tilemap (tilemap-tileset tilemap))))))
(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))))))
+ (draw-tilemap renderer camera tileset-texture tilemap))
+ (draw-entities renderer camera tileset tileset-texture entities)))
;; --- Debug drawing ---
diff --git a/scene-loader.scm b/scene-loader.scm
index 9b5545e..387bf38 100644
--- a/scene-loader.scm
+++ b/scene-loader.scm
@@ -24,11 +24,13 @@
(object-width obj) (object-height obj)))
(tilemap-objects tilemap)))
+ ;; Create an SDL2 texture from a tileset's embedded image surface.
+ (define (create-texture-from-tileset renderer tileset)
+ (sdl2:create-texture-from-surface renderer (tileset-image tileset)))
+
;; Create an SDL2 texture from the tileset image embedded in a tilemap.
(define (create-tileset-texture renderer tilemap)
- (sdl2:create-texture-from-surface
- renderer
- (tileset-image (tilemap-tileset tilemap))))
+ (create-texture-from-tileset renderer (tilemap-tileset tilemap)))
;; Load a TMX tilemap file and store it in the game asset registry.
;; Returns the loaded tilemap struct.
@@ -63,6 +65,7 @@
(scene (make-scene
entities: '()
tilemap: tm
+ tileset: #f
camera: (make-camera x: 0 y: 0)
tileset-texture: tex
camera-target: #f)))
diff --git a/tests/engine-test.scm b/tests/engine-test.scm
index 85481ac..f886165 100644
--- a/tests/engine-test.scm
+++ b/tests/engine-test.scm
@@ -19,6 +19,8 @@
(define (create-window! . args) 'mock-window)
(define (create-renderer! . args) 'mock-renderer)
(define (destroy-window! . args) #f)
+ (define (make-color r g b #!optional (a 255)) (list r g b a))
+ (define render-draw-color (getter-with-setter (lambda (r) #f) (lambda (r c) #f)))
(define (render-clear! . args) #f)
(define (render-present! . args) #f)
(define (make-rect x y w h) (list x y w h))
@@ -82,7 +84,7 @@
(import scheme (chicken base) defstruct)
(import downstroke-entity)
(defstruct camera x y)
- (defstruct scene entities tilemap camera tileset-texture camera-target)
+ (defstruct scene entities tilemap tileset camera tileset-texture camera-target background)
;; Mock camera-follow! - just clamps camera position
(define (camera-follow! camera entity viewport-w viewport-h)
(camera-x-set! camera (max 0 (- (entity-ref entity #:x 0) (/ viewport-w 2))))
@@ -193,9 +195,11 @@
(let* ((cam (make-camera x: 10 y: 20))
(scene (make-scene entities: '()
tilemap: #f
+ tileset: #f
camera: cam
tileset-texture: #f
- camera-target: #f))
+ camera-target: #f
+ background: #f))
(g (make-game)))
(game-scene-set! g scene)
(test-equal "returns scene camera"
diff --git a/tests/physics-test.scm b/tests/physics-test.scm
index b40f8d1..04ec6bb 100644
--- a/tests/physics-test.scm
+++ b/tests/physics-test.scm
@@ -75,7 +75,7 @@
(e (resolve-tile-collisions-x e tm))
(e (apply-velocity-y e))
(e (resolve-tile-collisions-y e tm))
- (e (detect-ground e tm)))
+ (e (detect-on-solid e tm)))
e))
;; Test: apply-gravity
@@ -383,8 +383,8 @@
(test-equal "a x unchanged" 0 (entity-ref (list-ref result 0) #:x 0))
(test-equal "b x unchanged" 5 (entity-ref (list-ref result 1) #:x 0)))))
-;; New tests for detect-ground and apply-jump
-(test-group "detect-ground"
+;; New tests for detect-on-solid and apply-jump
+(test-group "detect-on-solid"
(test-group "entity standing on solid tile"
;; Tilemap: 3 rows, row 2 is solid (tile=1), rows 0-1 empty (tile=0)
;; tilewidth=tileheight=16
@@ -392,7 +392,7 @@
(let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (1 1 1))))
(e (list #:type 'player #:x 0 #:y 16 #:width 16 #:height 16
#:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f))
- (result (detect-ground e tm)))
+ (result (detect-on-solid e tm)))
(test-assert "on-ground? is #t" (entity-ref result #:on-ground? #f))))
(test-group "entity in mid-air"
@@ -400,7 +400,7 @@
(let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (1 1 1))))
(e (list #:type 'player #:x 0 #:y 0 #:width 16 #:height 16
#:vx 0 #:vy 0 #:gravity? #t #:on-ground? #t))
- (result (detect-ground e tm)))
+ (result (detect-on-solid e tm)))
(test-assert "on-ground? is #f" (not (entity-ref result #:on-ground? #f)))))
(test-group "entity probe spans two tiles, left is solid"
@@ -409,7 +409,7 @@
(let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (1 0 0))))
(e (list #:type 'player #:x 0 #:y 16 #:width 16 #:height 16
#:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f))
- (result (detect-ground e tm)))
+ (result (detect-on-solid e tm)))
(test-assert "on-ground? is #t (left foot on solid)" (entity-ref result #:on-ground? #f))))
(test-group "entity probe spans two tiles, right is solid"
@@ -418,8 +418,27 @@
(let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 1 0))))
(e (list #:type 'player #:x 8 #:y 16 #:width 16 #:height 16
#:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f))
- (result (detect-ground e tm)))
- (test-assert "on-ground? is #t (right foot on solid)" (entity-ref result #:on-ground? #f)))))
+ (result (detect-on-solid e tm)))
+ (test-assert "on-ground? is #t (right foot on solid)" (entity-ref result #:on-ground? #f))))
+
+ (test-group "standing on solid entity (no tile): moving platform / crate"
+ ;; All-air tilemap; wide platform top at y=32; player feet (bottom) at y=32
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 0 0))))
+ (platform (list #:type 'platform #:x 0 #:y 32 #:width 64 #:height 16
+ #:solid? #t #:vx 0 #:vy 0 #:gravity? #f))
+ (player (list #:type 'player #:x 8 #:y 16 #:width 16 #:height 16
+ #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f))
+ (ents (list platform player))
+ (result (detect-on-solid player tm ents)))
+ (test-assert "on-ground? from entity top" (entity-ref result #:on-ground? #f))))
+
+ (test-group "two-arg detect-on-solid skips entity list (backward compatible)"
+ (let* ((tm (make-test-tilemap '((0 0 0) (0 0 0) (0 0 0))))
+ (platform (list #:type 'platform #:x 0 #:y 32 #:width 64 #:height 16 #:solid? #t))
+ (player (list #:type 'player #:x 8 #:y 16 #:width 16 #:height 16
+ #:gravity? #t #:on-ground? #f))
+ (result (detect-on-solid player tm)))
+ (test-assert "no third arg → not on ground" (not (entity-ref result #:on-ground? #f))))))
(test-group "apply-jump"
(test-group "on-ground and pressed → impulse applied"
@@ -631,7 +650,35 @@
(result (resolve-pair a b)))
(test-assert "result is a pair" (pair? result))
(test-assert "a2 is an entity" (pair? (car result)))
- (test-assert "b2 is an entity" (pair? (cdr result))))))
+ (test-assert "b2 is an entity" (pair? (cdr result)))))
+
+ (test-group "immovable"
+ (define (make-static x y)
+ (list #:type 'wall #:x x #:y y #:width 16 #:height 16 #:solid? #t #:immovable? #t))
+ (define (make-box x y)
+ (list #:type 'box #:x x #:y y #:width 16 #:height 16 #:solid? #t))
+ (test-group "both immovable and overlapping: #f"
+ (let* ((a (make-static 0 0))
+ (b (make-static 8 0)))
+ (test-assert "no resolution" (not (resolve-pair a b)))))
+ (test-group "wall(a) left, box(b) overlaps: only box moves"
+ (let* ((wall (make-static 0 0))
+ (box (make-box 8 0))
+ (r (resolve-pair wall box))
+ (a2 (car r))
+ (b2 (cdr r)))
+ (test-assert "result is pair" (pair? r))
+ (test-equal "a2 is wall (unchanged x)" 0 (entity-ref a2 #:x))
+ (test-assert "b2 is box (pushed right)" (> (entity-ref b2 #:x) 8))))
+ (test-group "box(a) first, wall(b) second"
+ (let* ((wall (make-static 0 0))
+ (box (make-box 8 0))
+ (r (resolve-pair box wall))
+ (a2 (car r))
+ (b2 (cdr r)))
+ (test-assert "result is pair" (pair? r))
+ (test-equal "b2 is wall (unchanged x)" 0 (entity-ref b2 #:x))
+ (test-assert "a2 is box (pushed right)" (> (entity-ref a2 #:x) 8))))))
(test-group "aabb-overlap?"
(test-group "two boxes clearly overlapping"
diff --git a/tests/renderer-test.scm b/tests/renderer-test.scm
index 8ebeedf..2829348 100644
--- a/tests/renderer-test.scm
+++ b/tests/renderer-test.scm
@@ -22,7 +22,7 @@
(module sdl2 *
(import scheme (chicken base))
(define (make-rect x y w h) (list x y w h))
- (define (make-color r g b) (list r g b))
+ (define (make-color r g b #!optional (a 255)) (list r g b a))
(define (render-copy! . args) #f)
(define (render-copy-ex! . args) #f)
(define (create-texture-from-surface . args) #f)
@@ -113,7 +113,18 @@
tileset-texture: #f
camera-target: #f)))
(test-assert "does not crash on valid scene"
- (begin (render-scene! #f scene) #t))))
+ (begin (render-scene! #f scene) #t)))
+
+ (let* ((cam (make-camera x: 0 y: 0))
+ (box (list #:x 4 #:y 8 #:width 10 #:height 12 #:color '(200 40 90)))
+ (scene (make-scene entities: (list box)
+ tilemap: #f
+ camera: cam
+ tileset-texture: #f
+ camera-target: #f))
+ (renderer #f))
+ (test-assert "no tilemap: draws #:color entities without crashing"
+ (begin (render-scene! renderer scene) #t))))
(test-group "sprite-font"
(test-group "make-sprite-font*"
diff --git a/tests/scene-loader-test.scm b/tests/scene-loader-test.scm
index 137d7ed..22de396 100644
--- a/tests/scene-loader-test.scm
+++ b/tests/scene-loader-test.scm
@@ -43,7 +43,7 @@
(module downstroke-world *
(import scheme (chicken base) defstruct)
(defstruct camera x y)
- (defstruct scene entities tilemap camera tileset-texture camera-target)
+ (defstruct scene entities tilemap tileset camera tileset-texture camera-target background)
(define (scene-add-entity scene entity)
(scene-entities-set! scene (cons entity (scene-entities scene)))
scene))
diff --git a/tests/world-test.scm b/tests/world-test.scm
index 90c26c4..b8c1a98 100644
--- a/tests/world-test.scm
+++ b/tests/world-test.scm
@@ -98,7 +98,15 @@
(let ((scene (make-scene entities: '() tilemap: #f camera-target: #f)))
(test-assert "scene is a record" (scene? scene))
(test-equal "entities list is empty" '() (scene-entities scene))
- (test-equal "tilemap is #f" #f (scene-tilemap scene))))
+ (test-equal "tilemap is #f" #f (scene-tilemap scene))
+ (test-equal "background defaults to #f" #f (scene-background scene))
+ (test-equal "tileset defaults to #f" #f (scene-tileset scene)))
+ (let ((s (make-scene entities: '() tilemap: #f camera-target: #f
+ background: '(40 44 52))))
+ (test-equal "background RGB stored" '(40 44 52) (scene-background s)))
+ (let ((s (make-scene entities: '() tilemap: #f camera-target: #f
+ background: '(1 2 3 200))))
+ (test-equal "background RGBA stored" '(1 2 3 200) (scene-background s))))
;; Test: scene with entities and tilemap
(test-group "scene-with-data"
diff --git a/world.scm b/world.scm
index d33a3eb..1230c89 100644
--- a/world.scm
+++ b/world.scm
@@ -35,9 +35,11 @@
(defstruct scene
entities
tilemap
+ tileset ; optional tileset struct when ~tilemap~ is ~#f~ (see renderer)
camera
tileset-texture
- camera-target) ; symbol tag or #f
+ camera-target ; symbol tag or #f
+ background) ; #f or (r g b) / (r g b a) for framebuffer clear
(define (scene-add-entity scene entity)
(scene-entities-set! scene (append (scene-entities scene) (list entity)))