aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/animation.org369
-rw-r--r--docs/api.org1385
-rw-r--r--docs/audio.org335
-rw-r--r--docs/entities.org549
-rw-r--r--docs/guide.org439
-rw-r--r--docs/input.org345
-rw-r--r--docs/physics.org1002
-rw-r--r--docs/rendering.org440
-rw-r--r--docs/scenes.org432
-rw-r--r--docs/tweens.org432
10 files changed, 3290 insertions, 2438 deletions
diff --git a/docs/animation.org b/docs/animation.org
new file mode 100644
index 0000000..4a3939e
--- /dev/null
+++ b/docs/animation.org
@@ -0,0 +1,369 @@
+#+TITLE: Animation
+
+Downstroke animates sprites by swapping an entity's =#:tile-id= on a
+timed cycle. You declare a table of named animations on the entity
+under =#:animations=, pick one by name with =#:anim-name=, and the
+engine's last pipeline stage — =apply-animation= — advances the frame
+counter every tick.
+
+There is no interpolation, no blending, no tween layer: each frame is
+a whole tile from the tileset, selected by index, held for a fixed
+number of ticks. The model is small enough to describe in one line of
+data and small enough to disable per-entity when you want to drive
+=#:tile-id= by hand.
+
+This file assumes you already know how entities are shaped (see
+[[file:entities.org][entities.org]]) and how the update pipeline runs
+(see [[file:physics.org][physics.org]]).
+
+* The minimum you need
+
+An animated entity needs three things:
+
+1. A =#:tile-id= (so there is something to render before the first
+ tick).
+2. A non-empty =#:animations= list.
+3. A =#:anim-name= that names one of those animations.
+
+The simplest hand-built entity looks like this:
+
+#+begin_src scheme
+(list (cons #:type 'player)
+ (cons #:x 80) (cons #:y 80)
+ (cons #:width 16) (cons #:height 16)
+ (cons #:tile-id 28)
+ (cons #:anim-name 'walk)
+ (cons #:anim-frame 0)
+ (cons #:anim-tick 0)
+ (cons #:animations
+ (list (list (cons #:name 'walk)
+ (cons #:frames '(28 29))
+ (cons #:duration 10)))))
+#+end_src
+
+In practice you never write this by hand. You declare the animation
+in a prefab data file and let =load-prefabs= + =instantiate-prefab=
+build the alist for you:
+
+#+begin_src scheme
+;; demo/assets/animation-prefabs.scm
+((mixins)
+ (prefabs
+ (hero animated
+ #:type hero #:anim-name walk
+ #:animations ((#:name walk #:frames (28 29) #:duration 10)))))
+#+end_src
+
+Two things are happening here that are not obvious:
+
+- The =animated= mixin (from =engine-mixins=) supplies the
+ =#:anim-frame 0=, =#:anim-tick 0=, and =#:tile-id 0= defaults so you
+ don't have to.
+- The prefab loader deep-converts the plist-shaped =#:animations=
+ value into a list of alists (see
+ [[file:entities.org][entities.org]] for why prefab data is written
+ as plists but entities are alists). =#:animations= is one of the
+ keys in =+nested-plist-list-keys+= that gets this treatment.
+
+Once the prefab is in a scene, the engine does the rest — you don't
+need to call anything per frame just to make the animation play.
+
+* Core concepts
+
+** Animation data shape
+
+An animation is an alist with three keys:
+
+| Key | Required | Meaning |
+|--------------+----------+---------------------------------------------------|
+| =#:name= | yes | Symbol used to select this animation. |
+| =#:frames= | yes | List of frames (see below). |
+| =#:duration= | no | Default ticks-per-frame. Used when a frame |
+| | | does not carry its own duration. Defaults to 10. |
+
+=#:frames= can take two forms, and you can mix them inside one
+animation only if you're careful about what each element means.
+
+*Simple form* — a list of bare tile ids. Each frame holds for the
+animation's =#:duration= ticks (or 10 if no duration is given):
+
+#+begin_src scheme
+(#:name attack #:frames (28 29) #:duration 10)
+#+end_src
+
+*Timed form* — a list of =(tile-id duration-ticks)= pairs. Each frame
+carries its own duration; the animation's top-level =#:duration= is
+ignored for that frame:
+
+#+begin_src scheme
+(#:name walk #:frames ((28 10) (29 1000)))
+#+end_src
+
+In the timed form above, tile 28 shows for 10 ticks and tile 29 shows
+for 1000 ticks — a deliberately uneven cycle useful for things like
+blink-idle animations.
+
+The =#:animations= entity key holds a *list* of these animation
+records, one per named animation:
+
+#+begin_src scheme
+#:animations ((#:name idle #:frames (10))
+ (#:name walk #:frames (28 29) #:duration 10)
+ (#:name jump #:frames (30)))
+#+end_src
+
+For a real working example of both forms side by side, see the
+=timed-frames= and =std-frames= prefabs in
+=demo/assets/animation-prefabs.scm=.
+
+** The apply-animation pipeline step
+
+=apply-animation= is the *last* stage of =default-engine-update=,
+after physics, ground detection, entity collisions, and group
+sync. You can see the wiring in =engine.scm=:
+
+#+begin_src scheme
+(scene-map-entities _ (cut apply-animation <> <> dt))
+#+end_src
+
+It is defined with =define-pipeline= under the pipeline name
+=animation=:
+
+#+begin_src scheme
+(define-pipeline (apply-animation animation) (scene entity dt)
+ guard: (entity-ref entity #:animations #f)
+ ...)
+#+end_src
+
+Two things fall out of this definition:
+
+1. *The guard.* If an entity has no =#:animations= value, the
+ pipeline returns the entity unchanged. You do not have to opt out
+ of animation for non-animated entities — not declaring the key is
+ the opt-out.
+2. *Skip support.* Because the step is registered under the pipeline
+ name =animation=, any entity with =#:skip-pipelines= containing
+ =animation= is also returned unchanged. Same mechanism as the
+ physics skips described in [[file:physics.org][physics.org]].
+
+When the step does run, =animate-entity= looks up the current
+animation by =#:anim-name= in the entity's =#:animations= list, and
+=advance-animation= does the actual work:
+
+- Increment =#:anim-tick= by 1.
+- If tick exceeds the current frame's duration, advance
+ =#:anim-frame= (modulo the number of frames), reset =#:anim-tick=
+ to 0, and write the new frame's =#:tile-id= onto the entity.
+- Otherwise, just write the current frame's =#:tile-id= and the
+ incremented tick.
+
+The renderer reads =#:tile-id= directly — so as long as the pipeline
+ran, what ends up on screen is always the current frame's tile.
+
+Running the =animated= pipeline after physics means your visual state
+reflects the entity's *post-physics* position and flags for the
+frame. If you want to swap animations based on state (e.g. walking vs
+jumping), your user =update:= hook — which runs after physics too —
+is the right place.
+
+** Switching animations
+
+You change what an entity is playing by setting =#:anim-name=. The
+helper for this is =set-animation=:
+
+#+begin_src scheme
+(set-animation entity 'walk)
+#+end_src
+
+=set-animation= has one important subtlety: *if the requested name
+matches the entity's current =#:anim-name=, it is a no-op*. The
+entity is returned unchanged — tick and frame counters keep their
+values. This is what you want almost all the time: calling
+=(set-animation entity 'walk)= every frame while the player is
+walking should *not* restart the walk cycle on frame 0 each tick.
+
+If the name is different, =set-animation= resets both =#:anim-frame=
+and =#:anim-tick= to 0 so the new animation plays from its first
+frame.
+
+The typical usage pattern is in your =update:= hook, branching on
+input or physics state:
+
+#+begin_src scheme
+update: (lambda (game dt)
+ (let* ((scene (game-scene game))
+ (input (game-input game))
+ (p0 (car (scene-entities scene)))
+ (anim (cond ((not (entity-ref p0 #:on-ground? #f)) 'jump)
+ ((input-held? input 'left) 'walk)
+ ((input-held? input 'right) 'walk)
+ (else 'idle)))
+ (p1 (set-animation p0 anim)))
+ (game-scene-set! game (update-scene scene entities: (list p1)))))
+#+end_src
+
+Because =set-animation= returns a fresh entity alist (it calls
+=entity-set= three times internally), the result must be written
+back into the scene. See [[file:entities.org][entities.org]] for the
+immutable-update convention.
+
+** Interaction with the =animated= mixin
+
+=engine-mixins= in =prefabs.scm= includes a convenience mixin named
+=animated=:
+
+#+begin_src scheme
+(animated #:anim-name idle
+ #:anim-frame 0
+ #:anim-tick 0
+ #:tile-id 0
+ #:animations #t)
+#+end_src
+
+Listing =animated= in a prefab gets you all the bookkeeping keys for
+free. You almost always want this — it saves repeating
+=#:anim-frame 0 #:anim-tick 0= on every animated prefab.
+
+Two of these defaults are booby-traps:
+
+1. *=#:animations #t=* — a placeholder. The pipeline guard only
+ checks that the value is truthy, so =#t= is enough to make the
+ step run; but the =#t= itself is obviously not a valid animation
+ list. You must override =#:animations= with a real list on any
+ prefab that uses =animated=. If you forget, =animation-by-name=
+ will fail (it calls =filter= on a non-list) the first time the
+ step runs.
+2. *=#:anim-name idle=* — also a default. The pipeline will then look
+ up an animation named =idle= in your =#:animations= list. If you
+ don't define one, =animation-by-name= returns =#f=,
+ =animate-entity= returns the entity unchanged, and *=#:tile-id=
+ is never written* by the pipeline. Your entity will render with
+ whatever static =#:tile-id= it happens to have — which, since the
+ mixin sets =#:tile-id 0=, is usually tile 0 (a blank or unexpected
+ tile). The symptom is "my sprite is the wrong tile and never
+ animates" with no error.
+
+The animation demo's prefabs show both ways to avoid this. Neither
+defines an =idle= animation; both override =#:anim-name= to match
+the animation they do define:
+
+#+begin_src scheme
+(timed-frames animated #:type timed-frames #:anim-name walk
+ #:animations ((#:name walk #:frames ((28 10) (29 1000)))))
+(std-frames animated #:type std-frames #:anim-name attack
+ #:animations ((#:name attack #:frames (28 29) #:duration 10)))
+#+end_src
+
+The rule is simple: either define an =idle= animation, or override
+=#:anim-name= to a name you actually defined.
+
+* Common patterns
+
+** Declaring walk/idle/jump in a prefab
+
+The usual player or enemy prefab declares all the animations it
+might switch between in one =#:animations= list:
+
+#+begin_src scheme
+(player animated physics-body
+ #:type player
+ #:width 16 #:height 16
+ #:tile-id 28
+ #:anim-name idle
+ #:animations ((#:name idle #:frames (28))
+ (#:name walk #:frames (28 29) #:duration 8)
+ (#:name jump #:frames (30))))
+#+end_src
+
+Note that this prefab *does* define an =idle= animation, so the
+=animated= mixin's default =#:anim-name idle= works as-is — no
+override needed. If the player is standing still and =update:= never
+calls =set-animation=, the engine will happily play the single-frame
+=idle= cycle forever.
+
+A single-frame animation (like =jump= above) is the idiomatic way to
+hold a static pose: the cycle advances, the modulo wraps back to
+frame 0 every tick, and =#:tile-id= stays pinned to the one tile.
+
+** Switching animation based on input
+
+Put the decision in your =update:= hook, after you've read input
+state but before you hand the entity back to the scene:
+
+#+begin_src scheme
+(define (choose-anim entity input)
+ (cond ((not (entity-ref entity #:on-ground? #f)) 'jump)
+ ((or (input-held? input 'left)
+ (input-held? input 'right)) 'walk)
+ (else 'idle)))
+
+update: (lambda (game dt)
+ (let* ((scene (game-scene game))
+ (input (game-input game))
+ (p0 (car (scene-entities scene)))
+ (p1 (set-animation p0 (choose-anim p0 input))))
+ (game-scene-set! game (update-scene scene entities: (list p1)))))
+#+end_src
+
+Two details worth noticing:
+
+- =set-animation= is safe to call every frame with the same name; it
+ won't reset the cycle.
+- =apply-animation= runs as part of =default-engine-update=, which
+ runs *before* your user =update:= hook. An =#:anim-name= change you
+ make in =update:= takes effect on the next frame's
+ =apply-animation= call, not this one — a one-frame latency that is
+ imperceptible in practice.
+
+** Per-frame durations for non-uniform timing
+
+Use the =(tile-id duration)= frame form when you want a cycle where
+one frame lingers and another flashes past. The canonical example
+(from =demo/assets/animation-prefabs.scm=) is a blink-heavy idle:
+
+#+begin_src scheme
+(#:name idle #:frames ((28 10) (29 1000)))
+#+end_src
+
+Tile 28 shows for 10 ticks (a quick flash), then tile 29 holds for
+1000 ticks (a long eye-open pose), then the cycle repeats. You can
+mix this with the simple form across different animations in the
+same entity — it's the frame shape that matters, not the animation.
+
+Per-frame durations override any top-level =#:duration= on the
+animation; in fact the top-level =#:duration= is only consulted when
+=advance-animation= falls back to the default of 10 (see
+=frame->duration= in =animation.scm=).
+
+** Disabling animation on an entity without touching =#:animations=
+
+If you want to freeze an animated entity on its current frame — for
+example, pausing an enemy during a cutscene — the cheapest way is
+to list =animation= in its =#:skip-pipelines=:
+
+#+begin_src scheme
+(entity-set entity #:skip-pipelines '(animation))
+#+end_src
+
+This leaves =#:animations=, =#:anim-name=, =#:anim-frame=, and
+=#:anim-tick= untouched. When you remove =animation= from
+=#:skip-pipelines= later, the cycle resumes exactly where it left
+off. Contrast with =(entity-set entity #:animations #f)=, which
+strips the animation data entirely and would require you to rebuild
+it to resume.
+
+You can combine =animation= with any of the physics pipeline names
+in the same skip list — they all share one =#:skip-pipelines= key.
+See [[file:physics.org][physics.org]] for the full list of step
+names.
+
+* See also
+
+- [[file:guide.org][guide.org]] — the minimal-game walkthrough.
+- [[file:entities.org][entities.org]] — entity alists, the prefab
+ system, and how =#:animations= is deep-converted from plist data.
+- [[file:physics.org][physics.org]] — the rest of the update
+ pipeline and the =#:skip-pipelines= mechanism.
+- [[file:tweens.org][tweens.org]] — when you want interpolation
+ rather than discrete frame-swapping.
+- [[file:../demo/animation.scm][Animation]] (=bin/demo-animation=) — prefab data in [[file:../demo/assets/animation-prefabs.scm][demo/assets/animation-prefabs.scm]].
diff --git a/docs/api.org b/docs/api.org
deleted file mode 100644
index 673749f..0000000
--- a/docs/api.org
+++ /dev/null
@@ -1,1385 +0,0 @@
-#+TITLE: Downstroke API Reference
-
-This document describes the public API for the Downstroke game engine. All exported functions are organized by module.
-
-* Engine (~downstroke-engine~)
-
-#+begin_src scheme
-(import downstroke-engine)
-#+end_src
-
-The engine module provides the top-level game lifecycle and state management.
-
-** ~make-game~
-
-#+begin_src scheme
-(make-game #!key
- (title "Downstroke Game")
- (width 640)
- (height 480)
- (scale 1)
- (frame-delay 16)
- (input-config *default-input-config*)
- (preload #f)
- (create #f)
- (update #f)
- (engine-update 'default)
- (render #f)
- (debug? #f))
-#+end_src
-
-Creates and initializes a game object. All parameters are optional keywords.
-
-| Parameter | Type | Default | Description |
-|-----------+------+---------+-------------|
-| ~title~ | string | "Downstroke Game" | Window title |
-| ~width~ | integer | 640 | Logical game width in pixels |
-| ~height~ | integer | 480 | Logical game height in pixels |
-| ~scale~ | positive integer | 1 | Whole-game pixel scaling factor (window = width×scale by height×scale) |
-| ~frame-delay~ | integer | 16 | Delay between frames in milliseconds (30 FPS ≈ 33) |
-| ~input-config~ | input-config | *default-input-config* | Keyboard/controller mappings |
-| ~preload~ | procedure/false | #f | Hook: ~(lambda (game) ...)~ called once before create |
-| ~create~ | procedure/false | #f | Hook: ~(lambda (game) ...)~ called once at startup |
-| ~engine-update~ | procedure/false / ~'default~ | ~'default~ | Built-in physics pipeline (~default-engine-update~), ~#f~ to disable, or ~(lambda (game dt) ...)~ to replace |
-| ~update~ | procedure/false | #f | Hook: ~(lambda (game dt) ...)~ called each frame *after* ~engine-update~ (see ~game-run!~) |
-| ~render~ | procedure/false | #f | Hook: ~(lambda (game) ...)~ called after render-scene! |
-| ~debug?~ | boolean | #f | Enable debug overlay drawing (collision boxes) |
-
-The game object is the central hub. Use it to store/retrieve assets, manage scenes, and access the current input state.
-
-*** Scaling
-
-The ~scale:~ parameter controls integer pixel scaling for the entire game. When ~scale:~ is greater than 1, the OS window is created at ~width×scale~ by ~height×scale~ pixels, but SDL2's logical renderer size is set to ~width~ by ~height~. This means all game code (rendering, physics, coordinates) works in the logical resolution — SDL2 handles the upscaling automatically.
-
-This affects everything uniformly: tiles, sprites, text, colored rectangles, and debug overlays. Mouse/touch input coordinates are also automatically mapped back to the logical resolution.
-
-#+begin_src scheme
-;; 320×240 game rendered in a 640×480 window (2× pixel scaling)
-(make-game title: "Pixel Art Game" width: 320 height: 240 scale: 2)
-
-;; 256×224 NES-style with 3× scaling → 768×672 window
-(make-game title: "Retro Game" width: 256 height: 224 scale: 3)
-#+end_src
-
-Only positive integers are accepted; fractional or zero values signal an error.
-
-*** ~engine-update:~ and ~default-engine-update~
-
-Omit ~engine-update:~ (or pass ~engine-update: 'default~) to use ~default-engine-update~: the standard per-frame physics pipeline on the current scene. Pass ~engine-update: #f~ if your game does not use the built-in pipeline (for example a shmup or menu that moves entities entirely in ~update:~). Pass a custom procedure ~(lambda (game dt) ...)~ to run your own integration step instead; it should read and write the scene via ~(game-scene game)~ and ~(game-scene-set! game scene)~ like ~default-engine-update~ does.
-
-#+begin_src scheme
-(default-engine-update game dt)
-#+end_src
-
-Runs the built-in pipeline once. Order: ~step-tweens~ → ~apply-acceleration~ → ~apply-gravity~ → ~apply-velocity-x~ → ~resolve-tile-collisions-x~ → ~apply-velocity-y~ → ~resolve-tile-collisions-y~ → ~detect-on-solid~ → ~resolve-entity-collisions~ (whole entity list) → ~sync-groups~ (whole entity list). Per-entity steps use signature ~(entity scene dt)~; bulk steps use ~(entities)~ with ~scene-transform-entities~.
-
-**Typical ~update:~ pattern:** treat ~update:~ as game logic only: read input, set *intent* on entities (~#:vx~, ~#:vy~, one-shot ~#:ay~ for jumps, flags), animation, and sound. Each frame, ~engine-update~ runs *before* ~update:~, so your hook sees positions and ~#:on-ground?~ after that frame’s integration and collisions. The ~#:vx~ / ~#:vy~ / ~#:ay~ you set in ~update:~ are applied starting on the following frame’s ~engine-update~.
-
-*** Fullscreen
-
-Downstroke does not provide a built-in ~fullscreen:~ keyword, but you can make any game fullscreen by setting the SDL2 window flag after the game starts. Use the ~preload:~ hook (the window exists by then):
-
-#+begin_src scheme
-(make-game
- title: "My Game" width: 320 height: 240 scale: 2
- preload: (lambda (game)
- ;; 'fullscreen-desktop scales to fill the screen without changing resolution
- (sdl2:window-fullscreen-set! (game-window game) 'fullscreen-desktop)))
-#+end_src
-
-The two fullscreen modes are:
-
-| Mode | SDL2 symbol | Behavior |
-|------+-------------+-----------|
-| Desktop fullscreen | ~'fullscreen-desktop~ | Fills the screen at the desktop resolution; SDL2 handles scaling and letterboxing. Best for most games. |
-| Exclusive fullscreen | ~'fullscreen~ | Changes the display resolution to match ~width×scale~ by ~height×scale~. Use only when you need exclusive display control. |
-
-Combine with ~scale:~ to get pixel-perfect fullscreen: set your logical resolution small (e.g. 320×240), use ~scale:~ for the default windowed size, and let ~'fullscreen-desktop~ handle the rest.
-
-** ~game-run!~
-
-#+begin_src scheme
-(game-run! game)
-#+end_src
-
-Starts the main event loop. Initializes SDL2, opens the window (at ~width×scale~ by ~height×scale~ pixels), sets the logical render size when ~scale~ > 1, and runs the frame loop indefinitely until the user quits or the ~quit~ action is pressed. Never returns.
-
-Lifecycle order within each frame (while not quitting):
-1. Collect SDL2 events
-2. Update input state
-3. Call ~engine-update-hook~ if set (~default-engine-update~ runs the physics pipeline: tweens, acceleration, gravity, velocity, collisions, ground detection, entity collisions, group sync)
-4. Call ~update:~ hook (or active state's ~update~) — runs *after* physics, so logic sees resolved positions and ~#:on-ground?~
-5. Apply camera follow (~update-scene~ with ~camera-target~, if set)
-6. 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)
-7. Call ~render-scene!~ (if scene is set)
-8. Call ~render:~ hook (or active state's ~render~)
-9. Present renderer
-10. Apply frame delay
-
-** ~game-camera~
-
-#+begin_src scheme
-(game-camera game)
-#+end_src
-
-Returns the current scene's camera struct. Only valid after ~create:~ runs. Returns a camera record with ~x~ and ~y~ fields for the top-left viewport corner in world coordinates.
-
-** ~game-asset~
-
-#+begin_src scheme
-(game-asset game key)
-#+end_src
-
-Retrieves an asset from the game's registry by key. Returns ~#f~ if the key is not found.
-
-** ~game-asset-set!~
-
-#+begin_src scheme
-(game-asset-set! game key value)
-#+end_src
-
-Stores an asset in the game's registry. Overwrites any existing value at the key.
-
-** ~make-game-state~
-
-#+begin_src scheme
-(make-game-state #!key (create #f) (update #f) (render #f))
-#+end_src
-
-Creates a state record (plist) with optional lifecycle hooks. Used with ~game-add-state!~ and ~game-start-state!~ to build a state machine within the game.
-
-| Parameter | Type | Default | Description |
-|-----------+------+---------+-------------|
-| ~create~ | procedure/false | #f | Called when entering this state |
-| ~update~ | procedure/false | #f | Called each frame while active |
-| ~render~ | procedure/false | #f | Called each frame after rendering (overlay) |
-
-** ~game-add-state!~
-
-#+begin_src scheme
-(game-add-state! game name state)
-#+end_src
-
-Registers a named state (created with ~make-game-state~) in the game's state table. ~name~ must be a symbol.
-
-** ~game-start-state!~
-
-#+begin_src scheme
-(game-start-state! game name)
-#+end_src
-
-Transitions to a named state, activating its lifecycle hooks. Calls the state's ~create:~ hook (if present) immediately.
-
-** Example: Game with State Machine
-
-#+begin_src scheme
-(define my-game (make-game title: "My Game"))
-
-(game-add-state! my-game 'playing
- (make-game-state
- create: (lambda (game) (print "Game started"))
- update: (lambda (game dt) (print "Updating..."))
- render: (lambda (game) (print "Rendering overlay"))))
-
-(game-add-state! my-game 'paused
- (make-game-state
- create: (lambda (game) (print "Paused"))))
-
-; Start the game in 'playing state
-(game-start-state! my-game 'playing)
-
-; Later, transition to paused
-(game-start-state! my-game 'paused)
-#+end_src
-
-* World (~downstroke-world~)
-
-#+begin_src scheme
-(import downstroke-world)
-#+end_src
-
-The world module provides the scene (level) abstraction and camera management.
-
-** ~make-scene~
-
-Auto-generated by defstruct. Use keyword arguments:
-
-#+begin_src scheme
-(make-scene #!key
- (entities '())
- (tilemap #f)
- (tileset #f)
- (camera #f)
- (tileset-texture #f)
- (camera-target #f)
- (background #f))
-#+end_src
-
-Creates a scene record representing the current level state.
-
-| Parameter | Type | Default | Description |
-|-----------+------+---------+-------------|
-| ~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 (set via ~update-scene~) |
-| ~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~
-
-Auto-generated by defstruct. Use keyword arguments:
-
-#+begin_src scheme
-(make-camera #!key (x 0) (y 0))
-#+end_src
-
-Creates a camera record. ~x~ and ~y~ are the pixel coordinates of the viewport's top-left corner in world space.
-
-** ~camera-x~, ~camera-y~
-
-#+begin_src scheme
-(camera-x camera)
-(camera-y camera)
-#+end_src
-
-Accessors for camera position.
-
-** ~camera-x-set!~, ~camera-y-set!~
-
-#+begin_src scheme
-(camera-x-set! camera x)
-(camera-y-set! camera y)
-#+end_src
-
-Mutate camera position (in-place).
-
-** ~camera-follow~
-
-#+begin_src scheme
-(camera-follow camera entity viewport-w viewport-h)
-#+end_src
-
-Returns a new camera centered on an entity, clamping to stay within world bounds (never negative). ~viewport-w~ and ~viewport-h~ are the game window dimensions. The original camera is not modified.
-
-** ~scene-add-entity~
-
-#+begin_src scheme
-(scene-add-entity scene entity)
-#+end_src
-
-Appends an entity to the scene's entity list. Returns a new scene; the original is not modified.
-
-** ~scene-map-entities~
-
-#+begin_src scheme
-(scene-map-entities scene proc1 proc2 ...)
-#+end_src
-
-Applies each procedure in sequence to all entities in the scene. Each procedure takes a single entity and returns a modified entity. Returns a new scene with the updated entity list; the original is not modified.
-
-Example:
-
-#+begin_src scheme
-(define (increment-x entity)
- (entity-set entity #:x (+ 1 (entity-ref entity #:x 0))))
-
-;; Per-entity physics steps need scene and dt (e.g. from your update: hook):
-(define dt 16) ; example: ms since last frame
-
-(scene-map-entities scene
- increment-x
- (lambda (e) (apply-gravity e scene dt)))
-; Each entity is passed through increment-x, then apply-gravity with fixed scene/dt
-#+end_src
-
-** ~scene-transform-entities~
-
-#+begin_src scheme
-(scene-transform-entities scene proc)
-#+end_src
-
-Applies ~proc~ to the scene’s full entity list. ~proc~ must have signature ~(entities → entities)~: it receives the current list and returns a new list. Returns a new scene with that entity list; the original is not modified. Use this to run whole-list steps such as ~sync-groups~ or ~resolve-entity-collisions~ after ~scene-map-entities~ (or in any order your game needs).
-
-** ~sync-groups~
-
-#+begin_src scheme
-(sync-groups entities)
-#+end_src
-
-For every entity with ~#:group-id~ that is not an origin (~#:group-origin?~ is false), sets ~#:x~ and ~#:y~ to the corresponding origin’s position plus that entity’s ~#:group-local-x~ and ~#:group-local-y~. Origins are read from the **entity list** argument (typically ~(scene-entities scene)~ when you compose with ~scene-transform-entities~), so after a tween or other motion that returns a *new* origin plist, replace that origin in the list (match on ~#:group-id~ / ~#:group-origin?~) before calling ~sync-groups~. Call after updating origin positions and before per-entity physics so platforms and collisions see a consistent pose. Returns a new entity list.
-
-Typical usage:
-
-#+begin_src scheme
-(scene-transform-entities scene sync-groups)
-#+end_src
-
-** ~scene-filter-entities~
-
-#+begin_src scheme
-(scene-filter-entities scene predicate)
-#+end_src
-
-Keeps only entities that satisfy the predicate. Returns a new scene; the original is not modified.
-
-Example:
-
-#+begin_src scheme
-; Remove all entities with type 'enemy
-(scene-filter-entities scene
- (lambda (e) (not (eq? (entity-type e) 'enemy))))
-#+end_src
-
-** ~scene-find-tagged~
-
-#+begin_src scheme
-(scene-find-tagged scene tag)
-#+end_src
-
-Returns the first entity whose ~#:tags~ list (a list of symbols) contains the given tag, or ~#f~ if not found.
-
-** ~scene-find-all-tagged~
-
-#+begin_src scheme
-(scene-find-all-tagged scene tag)
-#+end_src
-
-Returns a list of all entities whose ~#:tags~ list contains the given tag. Returns ~'()~ if none found.
-
-** Accessor functions (auto-generated by defstruct)
-
-Read accessors:
-- ~scene-entities~, ~scene-tilemap~, ~scene-camera~, ~scene-tileset-texture~, ~scene-camera-target~, ~scene-background~
-
-Functional updater (returns a new scene with the specified fields changed):
-
-#+begin_src scheme
-(update-scene scene entities: new-entities camera-target: 'player)
-#+end_src
-
-Mutating setters (~scene-entities-set!~, etc.) are also generated but should be avoided in favour of ~update-scene~ and the pure pipeline functions above. Use ~game-scene-set!~ at the boundary to store the final scene back on the game struct.
-
-** ~tilemap-tile-at~
-
-#+begin_src scheme
-(tilemap-tile-at tilemap col row)
-#+end_src
-
-Returns the tile ID at grid position ~(col, row)~ across all layers. Returns ~0~ if the position is out of bounds or all layers have a 0 tile at that cell. Used internally by the physics module; also useful for manual tile queries.
-
-* Entity (~downstroke-entity~)
-
-#+begin_src scheme
-(import downstroke-entity)
-#+end_src
-
-The entity module provides property list (plist) accessors for game objects. Entities are immutable plists, never modified in place. It also defines ~entity-skips-pipeline?~ and the ~define-pipeline~ macro for frame pipeline steps that respect ~#:skip-pipelines~ (see ~docs/physics.org~ for the built-in physics step names).
-
-** ~entity-ref~
-
-#+begin_src scheme
-(entity-ref entity key #!optional default)
-#+end_src
-
-Retrieves the value of a key from an entity plist. Returns ~default~ (or ~#f~) if the key is not found. If ~default~ is a procedure, it is called with no arguments to produce the default.
-
-Example:
-
-#+begin_src scheme
-(entity-ref player #:x 0) ; Get x, defaulting to 0
-(entity-ref player #:tags '()) ; Get tags, defaulting to empty list
-#+end_src
-
-** ~entity-type~
-
-#+begin_src scheme
-(entity-type entity)
-#+end_src
-
-Shorthand for ~(entity-ref entity #:type #f)~. Returns the entity's ~#:type~ field or ~#f~.
-
-** ~entity-set~
-
-#+begin_src scheme
-(entity-set entity key value)
-#+end_src
-
-Returns a new plist with the key/value updated. **Does not modify the original entity.** This is a functional (immutable) operation.
-
-Example:
-
-#+begin_src scheme
-(define player (list #:x 100 #:y 200 #:type 'player))
-(define moved (entity-set player #:x 150))
-; player is still (list #:x 100 #:y 200 #:type 'player)
-; moved is (list #:x 150 #:y 200 #:type 'player)
-#+end_src
-
-** ~entity-update~
-
-#+begin_src scheme
-(entity-update entity key proc #!optional default)
-#+end_src
-
-Functional update: applies ~proc~ to the current value of ~key~ and updates the entity with the result. Returns a new plist.
-
-Example:
-
-#+begin_src scheme
-(entity-update player #:x (lambda (x) (+ x 10)))
-; Equivalent to (entity-set player #:x (+ 10 (entity-ref player #:x 0)))
-#+end_src
-
-** ~entity-skips-pipeline?~
-
-#+begin_src scheme
-(entity-skips-pipeline? entity step-symbol)
-#+end_src
-
-Returns true if ~step-symbol~ appears in the entity’s ~#:skip-pipelines~ list (and that list is non-empty). The built-in physics step names are documented in ~docs/physics.org~; other engine modules may reserve additional symbols for their own frame phases (rendering, animation, etc.) using the same plist key.
-
-** ~define-pipeline~
-
-#+begin_src scheme
-(define-pipeline (procedure-name skip-symbol) (entity-formal extra-formal ...)
- guard: guard-expr
- body ...)
-#+end_src
-
-The ~guard:~ clause is optional. When present, ~guard-expr~ is evaluated first; if it is false, the entity is returned unchanged and ~body ...~ does not run. When absent, the body applies to all entities (subject only to the skip-symbol check below).
-
-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, if a guard is present and fails, returns the entity unchanged; otherwise runs ~body ...~ inside ~(let () ...)~. Used throughout ~downstroke-physics~ and ~step-tweens~ in ~downstroke-tween~; other modules can use it for consistent skip behavior. Extra formals after the entity are typically ~scene~ and ~dt~ so steps match ~(entity scene dt)~. The procedure name and skip symbol differ when needed (e.g. ~detect-on-solid~ vs ~on-solid~).
-
-** Shared Entity Keys
-
-All entities can have these keys. Not all are required:
-
-| Key | Type | Description |
-|-----|------|-------------|
-| ~#:type~ | symbol | Entity type (e.g., 'player, 'enemy) |
-| ~#:x~ | number | X position in pixels |
-| ~#:y~ | number | Y position in pixels |
-| ~#:width~ | number | Bounding box width in pixels |
-| ~#:height~ | number | Bounding box height in pixels |
-| ~#:vx~ | number | X velocity in pixels/frame |
-| ~#:vy~ | number | Y velocity in pixels/frame |
-| ~#:tile-id~ | integer | Sprite index in tileset (1-indexed) |
-| ~#:tags~ | list | List of symbols for lookup (e.g., '(player)) |
-| ~#:gravity?~ | boolean | Apply gravity to this entity? |
-| ~#:on-ground?~ | boolean | Is entity touching a solid tile below? |
-| ~#:facing~ | integer | 1 (right) or -1 (left) |
-| ~#:solid?~ | boolean | Participate in AABB entity collisions? |
-| ~#:skip-pipelines~ | list | Symbols naming pipeline steps to skip; physics defines the built-in set (~docs/physics.org~); ~'tweens~ skips ~step-tweens~ |
-| ~#:tween~ | tween/false | Active tween struct, auto-advanced by ~step-tweens~ |
-| ~#:anim-name~ | symbol | Current animation name |
-| ~#:anim-frame~ | integer | Current frame index |
-| ~#:anim-tick~ | integer | Ticks in current frame |
-
-* Physics (~downstroke-physics~)
-
-#+begin_src scheme
-(import downstroke-physics)
-#+end_src
-
-The physics module provides functions for movement, collision detection, and ground sensing. With the default ~engine-update:~ hook (~default-engine-update~), they run automatically each frame in a fixed order. You can still call them yourself when building a custom ~engine-update:~ or when experimenting in the REPL (see ~docs/physics.org~).
-
-** Physics Pipeline Order (~default-engine-update~)
-
-The built-in pipeline runs this order each frame (inside ~engine-update~, after input, before ~update:~):
-
-1. ~step-tweens~ — advance ~#:tween~ (see ~downstroke-tween~)
-2. ~apply-acceleration~ — consume ~#:ay~ into ~#:vy~
-3. ~apply-gravity~ — add gravity to ~#:vy~
-4. ~apply-velocity-x~ — move by ~#:vx~
-5. ~resolve-tile-collisions-x~ — snap against horizontal tile collisions (uses ~scene-tilemap~)
-6. ~apply-velocity-y~ — move by ~#:vy~
-7. ~resolve-tile-collisions-y~ — snap against vertical tile collisions
-8. ~detect-on-solid~ — set ~#:on-ground?~ from tiles below and/or other solids in ~scene-entities~
-9. ~resolve-entity-collisions~ — push apart solid entities (whole list; use ~scene-transform-entities~)
-10. ~sync-groups~ — align grouped entities to origins (whole list)
-
-Per-entity steps take ~(entity scene dt)~. Jumping is *not* a built-in step: set ~#:ay~ in your ~update:~ hook (e.g. ~(entity-set player #:ay (- *jump-force*))~) when the player jumps; ~apply-acceleration~ consumes it on the following ~engine-update~.
-
-Entities may list ~#:skip-pipelines~ to omit specific steps; see ~entity-skips-pipeline?~ under ~downstroke-entity~ and ~docs/physics.org~.
-
-(This separation ensures smooth sliding along walls.)
-
-** ~apply-acceleration~
-
-#+begin_src scheme
-(apply-acceleration entity scene dt)
-#+end_src
-
-Consumes ~#:ay~ (one-shot acceleration) into ~#:vy~ and clears ~#:ay~ to 0. Only applies if ~#:gravity?~ is true. ~scene~ and ~dt~ are accepted for pipeline uniformity; this step does not use them.
-
-** ~apply-gravity~
-
-#+begin_src scheme
-(apply-gravity entity scene dt)
-#+end_src
-
-Adds the gravity constant (1 pixel/frame²) to ~#:vy~. Only applies if ~#:gravity?~ is true.
-
-** ~apply-velocity-x~
-
-#+begin_src scheme
-(apply-velocity-x entity scene dt)
-#+end_src
-
-Updates ~#:x~ by adding ~#:vx~. Returns a new entity.
-
-** ~apply-velocity-y~
-
-#+begin_src scheme
-(apply-velocity-y entity scene dt)
-#+end_src
-
-Updates ~#:y~ by adding ~#:vy~. Returns a new entity.
-
-** ~apply-velocity~
-
-#+begin_src scheme
-(apply-velocity entity)
-#+end_src
-
-Legacy function: updates both ~#:x~ and ~#:y~ by their respective velocities.
-
-** ~resolve-tile-collisions-x~
-
-#+begin_src scheme
-(resolve-tile-collisions-x entity scene dt)
-#+end_src
-
-Detects and resolves collisions between the entity's bounding box and solid tiles along the X axis using ~(scene-tilemap scene)~. If there is no tilemap, the step is skipped. Snaps the entity's ~#:x~ to the near/far tile edge and sets ~#:vx~ to 0. Returns a new entity.
-
-** ~resolve-tile-collisions-y~
-
-#+begin_src scheme
-(resolve-tile-collisions-y entity scene dt)
-#+end_src
-
-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-on-solid~
-
-#+begin_src scheme
-(detect-on-solid entity scene dt)
-#+end_src
-
-Sets ~#:on-ground?~ to true if the entity is supported by a solid tile (probe below the feet) and/or by another solid's top surface in ~(scene-entities scene)~ (e.g. moving platforms). Only applies if ~#:gravity?~ is true. Returns a new entity (the ~?~-suffix denotes call-site readability, not a boolean return value). Runs after tile and entity collision resolution in ~default-engine-update~.
-
-** ~resolve-entity-collisions~
-
-#+begin_src scheme
-(resolve-entity-collisions entities)
-#+end_src
-
-Detects and resolves AABB collisions between all pairs of entities with ~#:solid?~ true. Pushes overlapping entities apart along the axis of minimum penetration and sets their velocities in the push direction. Returns a new entity list.
-
-There is no scene-level wrapper; apply ~resolve-entity-collisions~ to the entity list via ~scene-transform-entities~:
-
-#+begin_src scheme
-(scene-transform-entities scene resolve-entity-collisions)
-#+end_src
-
-** Physics Constants
-
-- ~*gravity*~ = 1 (pixels per frame per frame)
-- ~*jump-force*~ = 15 (conventional magnitude for a one-frame jump impulse; set ~#:ay~ to ~(- *jump-force*)~ in ~update:~ when jumping — there is no ~apply-jump~ helper)
-
-* Input (~downstroke-input~)
-
-#+begin_src scheme
-(import downstroke-input)
-#+end_src
-
-The input module handles keyboard, joystick, and game controller events. It maintains the current and previous input state to support pressed/released detection.
-
-** ~*default-input-config*~
-
-The default input configuration record. Use as the ~input-config:~ parameter to ~make-game~, or create a custom one with ~make-input-config~.
-
-| Action | Keyboard | Joystick | Controller |
-|--------|----------|----------|------------|
-| ~up~ | W or Up | Y-axis negative | DPad Up, Left-Y negative |
-| ~down~ | S or Down | Y-axis positive | DPad Down, Left-Y positive |
-| ~left~ | A or Left | X-axis negative | DPad Left, Left-X negative |
-| ~right~ | D or Right | X-axis positive | DPad Right, Left-X positive |
-| ~a~ | J or Z | Button 0 | A button |
-| ~b~ | K or X | Button 1 | B button |
-| ~start~ | Return | Button 7 | Start button |
-| ~select~ | (unmapped) | Button 6 | Back button |
-| ~quit~ | Escape | (unmapped) | (unmapped) |
-
-** ~input-held?~
-
-#+begin_src scheme
-(input-held? state action)
-#+end_src
-
-Returns true if the action is currently held down. ~action~ is a symbol like ~'left~ or ~'a~.
-
-** ~input-pressed?~
-
-#+begin_src scheme
-(input-pressed? state action)
-#+end_src
-
-Returns true if the action was pressed in this frame (held now but not in the previous frame).
-
-** ~input-released?~
-
-#+begin_src scheme
-(input-released? state action)
-#+end_src
-
-Returns true if the action was released in this frame (held previously but not now).
-
-** ~create-input-state~
-
-#+begin_src scheme
-(create-input-state config)
-#+end_src
-
-Initializes an input state record from a configuration. All actions start as unpressed.
-
-** ~input-any-pressed?~
-
-#+begin_src scheme
-(input-any-pressed? state config)
-#+end_src
-
-Returns true if any action in the configuration was pressed this frame. Useful for "press any key to continue" prompts.
-
-** ~input-state->string~
-
-#+begin_src scheme
-(input-state->string state config)
-#+end_src
-
-Returns a human-readable string of all currently held actions, e.g., ~"[Input: left a]"~. Useful for debug displays.
-
-** ~set-facing-from-vx~
-
-#+begin_src scheme
-(set-facing-from-vx entity vx)
-#+end_src
-
-Sets ~#:facing~ to ~1~ if ~vx > 0~, ~-1~ if ~vx < 0~, and returns the entity unchanged if ~vx = 0~. Convenience helper for keeping the facing direction in sync with horizontal velocity.
-
-** ~apply-input-to-entity~
-
-#+begin_src scheme
-(apply-input-to-entity entity held?)
-#+end_src
-
-Applies an input map stored on the entity to compute a new ~#:vx~. The entity must have an ~#:input-map~ key: an alist of ~(action . (dvx . dvy))~ pairs. ~held?~ is a predicate ~(lambda (action) ...)~ (typically ~(lambda (a) (input-held? input a))~). Also updates ~#:facing~ from the resulting velocity.
-
-Example:
-
-#+begin_src scheme
-;; Entity with embedded input map:
-(list #:type 'player
- #:input-map '((left . (-3 . 0)) (right . (3 . 0)))
- #:move-speed 1
- ...)
-
-;; In update:
-(apply-input-to-entity player (lambda (a) (input-held? input a)))
-#+end_src
-
-** Example: Player Movement
-
-#+begin_src scheme
-(define (update-player game dt)
- (let* ((input (game-input game))
- (player (car (scene-entities (game-scene game))))
- (jump? (and (input-pressed? input 'a)
- (entity-ref player #:on-ground? #f))))
- (if jump?
- (entity-set player #:ay (- *jump-force*))
- player)))
-#+end_src
-
-~#:on-ground?~ is set by ~detect-on-solid~ during ~engine-update~, before ~update:~ runs. ~*jump-force*~ is exported from ~downstroke-physics~ (default 15).
-
-* Renderer (~downstroke-renderer~)
-
-#+begin_src scheme
-(import downstroke-renderer)
-#+end_src
-
-The renderer module provides SDL2 drawing abstractions.
-
-** ~render-scene!~
-
-#+begin_src scheme
-(render-scene! renderer scene)
-#+end_src
-
-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~
-
-#+begin_src scheme
-(entity-screen-coords entity camera)
-#+end_src
-
-Returns a list ~(x y width height)~ of the entity's bounding box in screen (viewport) coordinates. Pure function, testable without SDL2.
-
-Example:
-
-#+begin_src scheme
-(entity-screen-coords player (make-camera x: 100 y: 50))
-; If player is at world (200, 100) with size (16, 16):
-; Returns (100 50 16 16) -- offset by camera position
-#+end_src
-
-** ~entity-flip~
-
-#+begin_src scheme
-(entity-flip entity)
-#+end_src
-
-Returns an SDL2 flip list based on the entity's ~#:facing~ field. Returns ~'(horizontal)~ if facing is -1, ~'()~ otherwise.
-
-** ~draw-ui-text~
-
-#+begin_src scheme
-(draw-ui-text renderer font text color x y)
-#+end_src
-
-Renders a single line of text to the screen at the given pixel coordinates. ~color~ is an SDL2 color struct. Positions are in screen (viewport) space, not world space. Does not cache; call once per frame for each text element.
-
-** Debug Drawing
-
-Debug drawing displays collision boxes and tile grid overlays to visualize physics during development. Enable with the ~debug?:~ keyword on ~make-game~.
-
-*** ~render-debug-scene!~
-
-#+begin_src scheme
-(render-debug-scene! renderer scene)
-#+end_src
-
-Renders debug overlays for the entire scene: tile grid boundaries and entity collision boxes. Call this in your render hook to see what the physics engine sees.
-
-*** ~draw-debug-tiles~
-
-#+begin_src scheme
-(draw-debug-tiles renderer camera tilemap)
-#+end_src
-
-Draws a grid outline around all non-zero tiles in the tilemap. Useful for understanding tilemap layout and collision geometry. Color: purple (~+debug-tile-color+~).
-
-*** ~draw-debug-entities~
-
-#+begin_src scheme
-(draw-debug-entities renderer camera scene)
-#+end_src
-
-Draws colored bounding boxes around all entities in the scene. Also draws attack hitboxes when the entity's ~#:attack-timer~ is active. Entity colors are:
-
-| Type | Color | Value |
-|------|-------|-------|
-| Player | Blue (~+debug-player-color+~) | rgb(64, 128, 255) |
-| Enemy | Red (~+debug-enemy-color+~) | rgb(220, 40, 40) |
-| Attack Hitbox | Green (~+debug-attack-color+~) | rgb(0, 200, 80) |
-| Tile | Purple (~+debug-tile-color+~) | rgb(140, 0, 220) |
-
-*** Example: Enable Debug Mode
-
-#+begin_src scheme
-(define my-game
- (make-game
- title: "My Game" width: 600 height: 400
- debug?: #t)) ;; Enable debug overlay
-
-(game-run! my-game)
-#+end_src
-
-With debug mode enabled, the game renders the normal scene first, then overlays collision boxes and tile boundaries on top. This is useful for finding collision bugs and understanding the physics layout.
-
-** Menus
-
-The ~draw-menu-items~ helper renders a list of menu options with a cursor indicator.
-
-*** ~draw-menu-items~
-
-#+begin_src scheme
-(draw-menu-items renderer font items cursor x y-start y-step
- #!key (label-fn identity) (color #f) (prefix "> ") (no-prefix " "))
-#+end_src
-
-Renders a vertical menu with a cursor indicator on the currently selected item.
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| ~renderer~ | renderer | (required) | SDL2 renderer |
-| ~font~ | TTF font | (required) | Font for rendering text |
-| ~items~ | list | (required) | List of menu items (any values) |
-| ~cursor~ | integer | (required) | Index of highlighted item (0-based) |
-| ~x~ | integer | (required) | X position in screen space |
-| ~y-start~ | integer | (required) | Y position of first item |
-| ~y-step~ | integer | (required) | Pixel spacing between items |
-| ~label-fn~ | procedure | ~identity~ | Function to convert item to string (called for each item) |
-| ~color~ | SDL2 color | white (255, 255, 255) | Text color |
-| ~prefix~ | string | "~> ~" | Prefix for highlighted item |
-| ~no-prefix~ | string | "~ ~" | Prefix for non-highlighted items |
-
-*** Example: Menu State
-
-#+begin_src scheme
-(import downstroke-engine
- downstroke-world
- (prefix sdl2 "sdl2:")
- (prefix sdl2-ttf "ttf:"))
-
-(define menu-items '(("Start Game") ("Options") ("Quit")))
-(define cursor (make-parameter 0))
-
-(define my-game
- (make-game
- title: "Menu Game" width: 600 height: 400
-
- preload: (lambda (game)
- (let ((font (ttf:open-font "assets/font.ttf" 24)))
- (game-asset-set! game 'menu-font font)))
-
- create: (lambda (game)
- (game-add-state! game 'menu
- (make-game-state
- render: (lambda (game)
- (let ((font (game-asset game 'menu-font)))
- (draw-menu-items (game-renderer game) font menu-items
- (cursor)
- 100 100 40))))))))
-
-(game-run! my-game)
-#+end_src
-
-** Sprite Fonts
-
-Sprite fonts render text using a bitmap tileset instead of system fonts. Characters are stored as tiles in your tileset image, making them pixel-perfect and zero-dependency on TTF files.
-
-*** ~make-sprite-font*~
-
-#+begin_src scheme
-(make-sprite-font* #!key tile-size spacing ranges)
-#+end_src
-
-Creates a sprite font from character ranges.
-
-| Parameter | Type | Default | Description |
-|-----------|------|---------|-------------|
-| ~tile-size~ | integer | (required) | Pixel width/height of each character tile |
-| ~spacing~ | integer | 1 | Pixels between characters when rendered |
-| ~ranges~ | list | (required) | List of ~(start-char end-char first-tile-id)~ triples |
-
-The ~ranges~ parameter defines which tile IDs correspond to which characters. For example, ~(list (#\A #\M 917) (#\N #\Z 966) (#\0 #\9 868))~ maps characters A–M to tile IDs 917–929, N–Z to 966–978, and 0–9 to 868–877.
-
-Characters are automatically uppercased when rendered (all lookups use uppercase).
-
-*** ~sprite-font-char->tile-id~
-
-#+begin_src scheme
-(sprite-font-char->tile-id font ch)
-#+end_src
-
-Returns the tile ID for a character, or ~#f~ if not found. The character is automatically uppercased before lookup.
-
-*** ~sprite-text-width~
-
-#+begin_src scheme
-(sprite-text-width font text)
-#+end_src
-
-Computes the pixel width of a string when rendered with the given font. Useful for centering text. Formula: ~(* n tile-size) + (* (- n 1) spacing)~ where ~n~ is the string length.
-
-*** ~draw-sprite-text~
-
-#+begin_src scheme
-(draw-sprite-text renderer tileset-texture tileset font text x y)
-#+end_src
-
-Renders sprite-font text to the screen. ~tileset-texture~ is the SDL2 texture of your tileset image, and ~tileset~ is the tilemap structure containing tile layout information. Characters not in the font are silently skipped.
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| ~renderer~ | renderer | SDL2 renderer |
-| ~tileset-texture~ | SDL2 texture | Rendered tileset image |
-| ~tileset~ | tileset struct | Tileset from loaded tilemap |
-| ~font~ | sprite-font struct | Font created with ~make-sprite-font*~ |
-| ~text~ | string | Text to render |
-| ~x, y~ | integers | Screen position in pixels |
-
-*** Example: Sprite Font for Score Display
-
-#+begin_src scheme
-(import downstroke-engine
- downstroke-scene-loader
- downstroke-renderer)
-
-;; Create a sprite font with A-Z at tiles 917–942, 0-9 at tiles 868–877
-(define score-font
- (make-sprite-font*
- tile-size: 16
- spacing: 1
- ranges: (list
- (list #\A #\Z 917)
- (list #\0 #\9 868))))
-
-(define my-game
- (make-game
- title: "Score Game" width: 600 height: 400
-
- create: (lambda (game)
- (game-load-scene! game "assets/level.tmx"))
-
- render: (lambda (game)
- (let ((scene (game-scene game)))
- ;; Display "SCORE: 1500" using sprite font
- (draw-sprite-text (game-renderer game)
- (scene-tileset-texture scene)
- (tilemap-tileset (scene-tilemap scene))
- score-font
- "SCORE: 1500"
- 10 10)))))
-
-(game-run! my-game)
-#+end_src
-
-* Assets (~downstroke-assets~)
-
-#+begin_src scheme
-(import downstroke-assets)
-#+end_src
-
-The assets module provides a simple registry for game resources.
-
-** ~make-asset-registry~
-
-#+begin_src scheme
-(make-asset-registry)
-#+end_src
-
-Creates an empty asset registry (hash-table). Returned by ~make-game~ automatically.
-
-** ~asset-set!~
-
-#+begin_src scheme
-(asset-set! registry key value)
-#+end_src
-
-Stores a value in the registry by key (any hashable value). Overwrites any existing value.
-
-** ~asset-ref~
-
-#+begin_src scheme
-(asset-ref registry key)
-#+end_src
-
-Retrieves a value from the registry by key. Returns ~#f~ if not found.
-
-** Example: Storing Fonts
-
-#+begin_src scheme
-(define (preload game)
- (let ((font (ttf:open-font "assets/font.ttf" 16)))
- (game-asset-set! game 'main-font font)))
-
-(define (render-overlay game)
- (let ((font (game-asset game 'main-font)))
- (draw-ui-text (game-renderer game) font "Score: 100"
- (sdl2:make-color 255 255 255) 10 10)))
-#+end_src
-
-* Sound (~downstroke-sound~)
-
-#+begin_src scheme
-(import downstroke-sound)
-#+end_src
-
-The sound module provides music and sound effect playback via SDL_mixer.
-
-** ~init-audio!~
-
-#+begin_src scheme
-(init-audio!)
-#+end_src
-
-Initializes SDL_mixer with default settings (44100 Hz, stereo, 512 buffer). Call once in the ~preload:~ hook.
-
-** ~load-sounds!~
-
-#+begin_src scheme
-(load-sounds! sound-alist)
-#+end_src
-
-Loads sound effects from an alist of ~(name . path)~ pairs, where ~name~ is a symbol and ~path~ is a file path. Stores them globally for playback.
-
-Example:
-
-#+begin_src scheme
-(load-sounds! '((jump . "assets/jump.wav")
- (hit . "assets/hit.wav")))
-#+end_src
-
-** ~play-sound~
-
-#+begin_src scheme
-(play-sound name)
-#+end_src
-
-Plays a loaded sound effect by name (symbol). Plays on the first available channel. No-op if the sound is not found.
-
-** ~load-music!~
-
-#+begin_src scheme
-(load-music! path)
-#+end_src
-
-Loads background music from a file. Replaces any previously loaded music.
-
-** ~play-music!~
-
-#+begin_src scheme
-(play-music! volume)
-#+end_src
-
-Plays the loaded music on an infinite loop. ~volume~ is a number from 0.0 to 1.0.
-
-** ~stop-music!~
-
-#+begin_src scheme
-(stop-music!)
-#+end_src
-
-Stops the currently playing music immediately.
-
-** ~set-music-volume!~
-
-#+begin_src scheme
-(set-music-volume! volume)
-#+end_src
-
-Changes the music volume while it is playing. ~volume~ is 0.0 to 1.0.
-
-** ~cleanup-audio!~
-
-#+begin_src scheme
-(cleanup-audio!)
-#+end_src
-
-Releases all audio resources. Call at shutdown or in a cleanup hook.
-
-* Tweens (~downstroke-tween~)
-
-#+begin_src scheme
-(import downstroke-tween)
-#+end_src
-
-Time-based interpolation of numeric entity properties. ~step-tweens~ runs inside ~default-engine-update~ before acceleration/gravity; you can still call ~tween-step~ from ~update:~ for manual tweening — see ~docs/tweens.org~ for patterns with ~#:skip-pipelines~.
-
-** ~make-tween~
-
-#+begin_src scheme
-(make-tween entity #!key props duration (delay 0) ease
- (on-complete #f) (repeat 0) (yoyo? #f))
-#+end_src
-
-| Keyword | Description |
-|---------+-------------|
-| ~props~ | Alist ~((#:key . target-number) ...)~ |
-| ~duration~ | Milliseconds of interpolation after ~delay~ |
-| ~delay~ | Initial wait in ms (default 0, first cycle only) |
-| ~ease~ | Symbol (e.g. ~quad-in-out~) or ~(lambda (t) ...)~ with ~t~ in [0,1] |
-| ~on-complete~ | Optional ~(lambda (entity) ...)~ once at final completion (not called with ~repeat: -1~) |
-| ~repeat~ | ~0~ = play once (default), ~N~ = replay N extra times, ~-1~ = loop forever |
-| ~yoyo?~ | ~#f~ (default) = same direction, ~#t~ = reverse each cycle |
-
-** ~tween-step~
-
-#+begin_src scheme
-(tween-step tween entity dt)
-#+end_src
-
-Returns two values: updated tween struct and updated entity. ~dt~ is elapsed milliseconds for this frame.
-
-** ~tween-finished?~ / ~tween-active?~
-
-** ~step-tweens~
-
-#+begin_src scheme
-(step-tweens entity scene dt)
-#+end_src
-
-Pipeline step: auto-advances ~#:tween~ on an entity. No-op if ~#:tween~ is absent. Removes ~#:tween~ when the tween finishes. Skipped when ~'tweens~ is in ~#:skip-pipelines~. ~scene~ is accepted for uniformity with other pipeline steps. See ~docs/tweens.org~ for patterns.
-
-** Easing exports
-
-~ease-linear~, ~ease-quad-in~, ~ease-quad-out~, ~ease-quad-in-out~, ~ease-cubic-in~, ~ease-cubic-out~, ~ease-cubic-in-out~, ~ease-sine-in-out~, ~ease-expo-in~, ~ease-expo-out~, ~ease-expo-in-out~, ~ease-back-out~, ~ease-named~, ~ease-resolve~.
-
-* Animation (~downstroke-animation~)
-
-#+begin_src scheme
-(import downstroke-animation)
-#+end_src
-
-The animation module provides simple frame-based sprite animation.
-
-** ~animation-frames~
-
-#+begin_src scheme
-(animation-frames anim)
-#+end_src
-
-Returns the ~#:frames~ list from an animation plist. Each element is a 0-indexed tile ID.
-
-** ~animation-duration~
-
-#+begin_src scheme
-(animation-duration anim)
-#+end_src
-
-Returns the ~#:duration~ value from an animation plist (ticks per frame).
-
-** ~set-animation~
-
-#+begin_src scheme
-(set-animation entity name)
-#+end_src
-
-Switches the entity to a named animation, resetting frame and tick counters to 0. No-op if the animation is already active (prevents restarting mid-loop). Returns a new entity.
-
-** ~animate-entity~
-
-#+begin_src scheme
-(animate-entity entity animations)
-#+end_src
-
-Advances the entity's animation frame based on elapsed ticks. ~animations~ is an alist of ~(name . animation-plist)~ pairs. Each animation plist has ~#:frames~ (list of tile IDs, 0-indexed) and ~#:duration~ (ticks per frame). Returns a new entity with updated ~#:anim-tick~, ~#:anim-frame~, and ~#:tile-id~.
-
-Example:
-
-#+begin_src scheme
-(define player-animations
- '((idle #:frames (28) #:duration 10)
- (walk #:frames (27 28) #:duration 10)
- (jump #:frames (29) #:duration 1)))
-
-(define player (list #:anim-name 'walk #:anim-frame 0 #:anim-tick 0))
-(animate-entity player player-animations)
-; After 10 ticks, frame advances to 1 (wraps to 0 after reaching length)
-#+end_src
-
-** ~frame->tile-id~
-
-#+begin_src scheme
-(frame->tile-id frames frame-idx)
-#+end_src
-
-Converts a frame index to a tile ID (1-indexed). Used internally by ~animate-entity~.
-
-* Tilemap (~downstroke-tilemap~)
-
-#+begin_src scheme
-(import downstroke-tilemap)
-#+end_src
-
-The tilemap module parses Tiled TMX and TSX files using the expat XML library, and provides struct accessors for tile and map data.
-
-** ~load-tilemap~
-
-#+begin_src scheme
-(load-tilemap filename)
-#+end_src
-
-Loads and parses a TMX file. Automatically resolves and loads the referenced TSX tileset and its image. Returns a ~tilemap~ struct.
-
-** ~load-tileset~
-
-#+begin_src scheme
-(load-tileset filename)
-#+end_src
-
-Loads and parses a TSX tileset file. Loads the image referenced in the tileset. Returns a ~tileset~ struct.
-
-** ~tileset-tile~
-
-#+begin_src scheme
-(tileset-tile tileset tile-id)
-#+end_src
-
-Returns a ~tile~ struct for the given 1-indexed tile ID. The tile struct has ~tile-id~ and ~tile-rect~ (an SDL2 rect) fields with the source rectangle in the tileset image.
-
-** ~tileset-rows~
-
-#+begin_src scheme
-(tileset-rows tileset)
-#+end_src
-
-Returns the number of rows in the tileset image (computed from ~tilecount / columns~).
-
-** Tileset Accessors (auto-generated by defstruct)
-
-- ~tileset-tilewidth~, ~tileset-tileheight~ — tile dimensions in pixels
-- ~tileset-spacing~ — pixel gap between tiles in the source image
-- ~tileset-tilecount~ — total number of tiles
-- ~tileset-columns~ — number of tile columns
-- ~tileset-image-source~ — path to the tileset image file
-- ~tileset-image~ — loaded SDL2 surface
-
-** Tilemap Accessors (auto-generated by defstruct)
-
-- ~tilemap-width~, ~tilemap-height~ — map dimensions in tiles
-- ~tilemap-tilewidth~, ~tilemap-tileheight~ — tile dimensions in pixels
-- ~tilemap-tileset~ — the embedded ~tileset~ struct
-- ~tilemap-layers~ — list of ~layer~ structs
-- ~tilemap-objects~ — list of ~object~ structs (from the Tiled object layer)
-
-** Layer Accessors (auto-generated by defstruct)
-
-- ~layer-name~ — layer name string
-- ~layer-width~, ~layer-height~ — layer dimensions in tiles
-- ~layer-map~ — 2D list of tile GIDs (rows × columns)
-
-** Object Accessors (auto-generated by defstruct)
-
-- ~object-name~, ~object-type~ — object name and type (strings from XML)
-- ~object-x~, ~object-y~ — position in pixels
-- ~object-width~, ~object-height~ — size in pixels
-- ~object-properties~ — alist of custom properties from Tiled
-
-* Scene Loader (~downstroke-scene-loader~)
-
-#+begin_src scheme
-(import downstroke-scene-loader)
-#+end_src
-
-The scene-loader module provides utilities for loading Tiled maps and instantiating entities from prefabs.
-
-** ~game-load-scene!~
-
-#+begin_src scheme
-(game-load-scene! game filename)
-#+end_src
-
-Loads a TMX (Tiled) map from a file and creates a scene. Steps:
-
-1. Loads the tilemap from the file
-2. Creates an SDL2 texture from the tileset image
-3. Creates an empty scene with the tilemap and camera
-4. Stores the tilemap in game assets under key ~'tilemap~
-5. Sets the scene on the game
-6. Returns the scene
-
-Example:
-
-#+begin_src scheme
-(define (create-game game)
- (game-load-scene! game "assets/level1.tmx")
- ; Now add entities to the scene...
- )
-#+end_src
-
-** ~game-load-tilemap!~
-
-#+begin_src scheme
-(game-load-tilemap! game key filename)
-#+end_src
-
-Loads a TMX tilemap file, stores it in the game asset registry under ~key~, and returns the tilemap struct.
-
-** ~game-load-tileset!~
-
-#+begin_src scheme
-(game-load-tileset! game key filename)
-#+end_src
-
-Loads a TSX tileset file, stores it in the game asset registry under ~key~, and returns the tileset struct.
-
-** ~game-load-font!~
-
-#+begin_src scheme
-(game-load-font! game key filename size)
-#+end_src
-
-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
-(create-tileset-texture renderer tilemap)
-#+end_src
-
-Creates an SDL2 texture from the tileset image embedded in a tilemap struct. Useful when you need the texture independently of ~game-load-scene!~.
-
-** ~load-prefabs~
-
-#+begin_src scheme
-(load-prefabs filename engine-mixin-table user-hooks)
-#+end_src
-
-Loads a prefab definition file and returns a ~prefab-registry~ struct. The file must contain a Scheme expression with ~mixins~ and ~prefabs~ sections; an optional ~group-prefabs~ section defines multi-entity assemblies (see ~docs/entities.org~).
-
-| Parameter | Type | Description |
-|-----------|------|-------------|
-| ~filename~ | string | Path to the prefab data file (e.g., ~"assets/prefabs.scm"~) |
-| ~engine-mixin-table~ | alist | Extra engine-level mixins to merge; pass ~'()~ if none |
-| ~user-hooks~ | alist | Extra instantiation hooks; pass ~'()~ if none |
-
-Example:
-
-#+begin_src scheme
-(define *prefabs* (load-prefabs "assets/prefabs.scm" '() '()))
-#+end_src
-
-** ~reload-prefabs!~
-
-#+begin_src scheme
-(reload-prefabs! registry)
-#+end_src
-
-Reloads the prefab file that the registry was originally loaded from. Returns a new registry. Useful for hot-reloading prefab data during development without restarting the game.
-
-** ~instantiate-prefab~
-
-#+begin_src scheme
-(instantiate-prefab registry type x y w h)
-#+end_src
-
-Looks up a prefab by type symbol in the registry and returns a fresh entity plist at the given position and size. Returns ~#f~ if the type is not registered. If the resulting entity has an ~#:on-instantiate~ hook, it is called with the entity before returning.
-
-** ~instantiate-group-prefab~
-
-#+begin_src scheme
-(instantiate-group-prefab registry type origin-x origin-y)
-#+end_src
-
-Looks up a *group prefab* by type symbol and returns a list ~(origin member ...)~: one origin entity plus one entity per part. Optional group-level flags ~#:pose-only-origin?~ and ~#:static-parts?~ select origin/part profiles (see ~docs/entities.org~); defaults are ~#f~ (physics-driving origin, non-static parts). Each instance receives a fresh gensym ~#:group-id~ shared by the origin and all members. Returns ~#f~ if the type is not in ~group-prefabs~. After moving origins (tween and/or physics), ensure updated origins are stored in the scene’s entity list, then ~(scene-transform-entities scene sync-groups)~ so member ~#:x~ / ~#:y~ match ~origin + #:group-local-x/y~.
-
-** ~tilemap-objects->entities~
-
-#+begin_src scheme
-(tilemap-objects->entities tilemap registry)
-#+end_src
-
-Converts the TMX object list (from Tiled) into entity plists using the prefab registry. Each object's type string is converted to a symbol and passed to ~instantiate-prefab~. Objects whose type has no registered prefab are silently filtered out.
-
-Example:
-
-#+begin_src scheme
-(let* ((registry (load-prefabs "assets/prefabs.scm" '() '()))
- (entities (tilemap-objects->entities tilemap registry)))
- (update-scene scene entities: entities))
-#+end_src
diff --git a/docs/audio.org b/docs/audio.org
new file mode 100644
index 0000000..7d83e44
--- /dev/null
+++ b/docs/audio.org
@@ -0,0 +1,335 @@
+#+TITLE: Downstroke Audio
+
+Downstroke's audio stack is a thin wrapper around SDL2's =SDL_mixer= library.
+It gives you two things: short /sound effects/ (one-shots triggered on input,
+collisions, pickups, etc.) and a single streaming /music/ track (a looping
+background song). The friendly API lives in the =downstroke-sound= module and
+is the only thing most games ever need to touch.
+
+Audio is deliberately kept outside the [[file:guide.org][=game= record]]. The sound module
+holds its own module-level registry of loaded chunks and a reference to the
+currently-loaded music. You call into it imperatively from your lifecycle
+hooks — there is no =(game-audio game)= accessor, and the engine does not
+manage audio for you.
+
+* The minimum you need
+
+#+begin_src scheme
+(import downstroke-engine
+ downstroke-sound)
+
+(define *music-on?* #f)
+
+(define my-game
+ (make-game
+ title: "Audio Example" width: 320 height: 240
+
+ preload: (lambda (game)
+ (init-audio!)
+ (load-sounds! '((jump . "assets/jump.wav")
+ (hit . "assets/hit.wav")))
+ (load-music! "assets/theme.ogg")
+ (play-music! 0.6)
+ (set! *music-on?* #t))
+
+ update: (lambda (game dt)
+ (let ((input (game-input game)))
+ (when (input-pressed? input 'a)
+ (play-sound 'jump))))))
+
+(game-run! my-game)
+#+end_src
+
+Four calls cover ~95% of real usage:
+
+- =(init-audio!)= — open the device (once, at startup).
+- =(load-sounds! '((name . path) ...))= — preload WAV effects.
+- =(play-sound name)= — trigger a one-shot.
+- =(play-music! volume)= — start the looping music track.
+
+* Core concepts
+
+** The two-layer audio API
+
+Audio is split into two modules, and you pick the one that matches what you
+are doing:
+
+- =downstroke-sound= — the friendly, high-level API. Symbolic sound names, an
+ internal registry, volume given as =0.0..1.0=, music that just loops. This
+ is what the rest of this document documents, and what every demo uses.
+- =downstroke-mixer= — raw FFI bindings to =SDL_mixer=
+ (=Mix_OpenAudio=, =Mix_LoadWAV=, =Mix_PlayChannel=,
+ =Mix_LoadMUS=, =Mix_PlayMusic=, =Mix_VolumeMusic=, …). No registry, no
+ convenience, no type conversions. Values are raw C-level integers (volumes
+ are =0..128=, channels are integers, loops is an integer count with =-1=
+ for forever).
+
+Reach for =downstroke-mixer= only when you need something the high-level
+wrapper does not expose — for example, playing more than one concurrent music
+track via channel groups, or fading effects. In practice, =downstroke-sound=
+covers 99% of cases; you almost never =import downstroke-mixer= directly in
+game code.
+
+*** Module-level state (be aware of this)
+
+=downstroke-sound= keeps two global variables inside the module:
+
+- =*sound-registry*= — an association list of =(symbol . Mix_Chunk*)= pairs
+ populated by =load-sounds!=.
+- =*music*= — the currently-loaded =Mix_Music*= pointer (or =#f=).
+
+This means:
+
+- There is exactly one audio device, one music track, and one sound registry
+ per process.
+- Calling =load-sounds!= /replaces/ the registry (it does not append). If
+ you need to add sounds after the initial load, pass the full combined
+ alist.
+- Calling =load-music!= replaces =*music*= without freeing the previous
+ track — use =cleanup-audio!= if you need to swap tracks cleanly, or drop
+ into =downstroke-mixer= and call =mix-free-music!= yourself.
+- Two games in the same process would share this state. That is not a
+ supported configuration; one =game-run!= per process is the expectation.
+
+** Initialization & cleanup
+
+*Audio is not managed by the engine.* =game-run!= initializes SDL's =video=,
+=joystick=, =game-controller=, =ttf=, and =image= subsystems, but it does
+/not/ call =init-audio!= or =cleanup-audio!=. You are responsible for both.
+
+*** =init-audio!=
+
+Opens the mixer device at 44.1 kHz, default format, stereo, with a 512-sample
+buffer. Must be called /before/ =load-sounds!=, =load-music!=, =play-sound=,
+or =play-music!= — otherwise =SDL_mixer= has no device to play on and every
+load/play call silently fails.
+
+The canonical place to call it is the top of your =preload:= hook:
+
+#+begin_src scheme
+preload: (lambda (game)
+ (init-audio!)
+ (load-sounds! ...)
+ (load-music! ...))
+#+end_src
+
+=init-audio!= returns the raw =Mix_OpenAudio= result (=0= on success,
+negative on failure). The high-level API does not check this; if you want to
+surface an error, capture the return value yourself.
+
+*** =cleanup-audio!=
+
+Halts music, frees the loaded music pointer, frees every chunk in the
+registry, clears the registry, and closes the mixer. After this call the
+module globals are back to their empty state; you could call =init-audio!=
+again to restart.
+
+*There is no teardown hook.* =game-run!= ends by calling =sdl2:quit!=, but
+it does not invoke any user code on exit. If you care about cleanly shutting
+down the audio device (you usually don't — the OS reclaims everything when
+the process exits), you have to arrange it yourself after =game-run!=
+returns:
+
+#+begin_src scheme
+(game-run! my-game)
+(cleanup-audio!) ;; only runs after the game loop exits
+#+end_src
+
+In the common case of a game that runs until the user presses Escape, this
+is harmless but optional. The audio demo, notably, does not call
+=cleanup-audio!= at all.
+
+** Sound effects
+
+Sound effects are short WAV chunks — jumps, hits, pickups, UI blips. They are
+loaded once up front, kept resident in memory, and triggered by name.
+
+*** =(load-sounds! alist)=
+
+Takes an association list of =(symbol . path)= pairs. Each path is loaded
+via =Mix_LoadWAV= and stored under its symbol key. Replaces the existing
+registry wholesale.
+
+#+begin_src scheme
+(load-sounds! '((jump . "assets/jump.wav")
+ (hit . "assets/hit.wav")
+ (coin . "assets/coin.wav")
+ (death . "assets/death.wav")))
+#+end_src
+
+There is no error handling — if a file is missing, =Mix_LoadWAV= returns
+=NULL= and the entry's =cdr= will be a null pointer. =play-sound= checks
+for null and becomes a no-op in that case, so a missing asset gives you
+silence rather than a crash.
+
+*** =(play-sound symbol)=
+
+Looks up =symbol= in the registry and plays the chunk on the first available
+channel (=Mix_PlayChannel -1=), zero extra loops (one-shot). Unknown symbols
+and null chunks are silently ignored.
+
+#+begin_src scheme
+(when (input-pressed? (game-input game) 'a)
+ (play-sound 'jump))
+#+end_src
+
+SDL_mixer defaults to 8 simultaneous channels. If all channels are busy,
+the new sound is dropped. For most 2D games this is plenty; if you need
+more, use =downstroke-mixer= directly and call =Mix_AllocateChannels=.
+
+Volume on individual effects is not exposed by the high-level API — every
+chunk plays at the mixer's current chunk volume. If you need per-sound
+volume control, reach for the raw =mix-play-channel= and the =SDL_mixer=
+=Mix_VolumeChunk= function.
+
+** Music
+
+Exactly one music track is playable at a time. "Music" in this API means a
+streamed file (OGG, MP3, etc. — whatever =Mix_LoadMUS= accepts on your
+system), as opposed to a fully-loaded WAV chunk.
+
+*** =(load-music! path)=
+
+Loads the track via =Mix_LoadMUS= and stores it in =*music*=. Does not play
+it. Replaces any previously-loaded track /without freeing/ the previous one
+(see the warning in [[*Module-level state (be aware of this)][Module-level state]]).
+
+#+begin_src scheme
+(load-music! "assets/theme.ogg")
+#+end_src
+
+*** =(play-music! volume)=
+
+Starts the currently-loaded music with =Mix_PlayMusic *music* -1= — the
+=-1= means loop forever — and sets the music volume.
+
+=volume= is a real number in =0.0..1.0=. It is mapped to =SDL_mixer='s
+=0..128= range via =(round (* volume 128))=. Values outside the range are
+not clamped; =0.0= is silent, =1.0= is full volume.
+
+#+begin_src scheme
+(play-music! 0.6) ;; ~77 on SDL_mixer's 0..128 scale
+#+end_src
+
+If =load-music!= has not been called (or failed), =play-music!= is a
+no-op.
+
+*** =(stop-music!)=
+
+Calls =Mix_HaltMusic=. The music track remains loaded — a subsequent
+=play-music!= will start it again from the beginning. Pair with your own
+=*music-on?*= flag if you want a toggle (see [[*Mute / toggle music][Mute / toggle music]] below).
+
+*** =(set-music-volume! volume)=
+
+Same mapping as =play-music!= (=0.0..1.0= → =0..128=) but only changes the
+current volume; does not start, stop, or load anything. Safe to call while
+music is playing, stopped, or not yet loaded — it just sets a mixer
+property.
+
+* Common patterns
+
+** Minimal audio setup in =preload:=
+
+Initialize, load two or three effects, load and start the music track:
+
+#+begin_src scheme
+preload: (lambda (game)
+ (init-audio!)
+ (load-sounds! '((jump . "assets/jump.wav")
+ (coin . "assets/coin.wav")
+ (death . "assets/death.wav")))
+ (load-music! "assets/theme.ogg")
+ (play-music! 0.6))
+#+end_src
+
+=preload:= is the right place because it runs once, before =create:= and
+before the first frame of the game loop.
+
+** Play a jump sound on input press
+
+Trigger a one-shot on the transition from "not held" to "held" (=input-pressed?=,
+not =input-held?=), so holding the button does not spam the sound:
+
+#+begin_src scheme
+update: (lambda (game dt)
+ (let ((input (game-input game)))
+ (when (input-pressed? input 'a)
+ (play-sound 'jump))))
+#+end_src
+
+See [[file:input.org][input.org]] for the difference between =input-pressed?= and
+=input-held?=.
+
+** Mute / toggle music
+
+=stop-music!= halts playback; =play-music!= restarts from the top. If you
+want a mute that preserves position, use the volume instead:
+
+#+begin_src scheme
+;; Mute (preserves position):
+(set-music-volume! 0.0)
+
+;; Unmute:
+(set-music-volume! 0.6)
+#+end_src
+
+A full on/off toggle, as seen in the audio demo, looks like this:
+
+#+begin_src scheme
+(define *music-on?* #t)
+
+;; In update:, on the B button:
+(when (input-pressed? (game-input game) 'b)
+ (if *music-on?*
+ (begin (stop-music!) (set! *music-on?* #f))
+ (begin (play-music! 0.5) (set! *music-on?* #t))))
+#+end_src
+
+*Audio demo* — run with =bin/demo-audio=, source in =demo/audio.scm=. Press
+=j= or =z= to fire a sound effect, =k= or =x= to toggle music on and off.
+
+** Swapping the music track between scenes
+
+To change songs cleanly, halt the current one, free it, and load the new
+one. The high-level API does not free for you, so either call
+=cleanup-audio!= (which also closes the device — probably not what you want
+mid-game) or drop to =downstroke-mixer=:
+
+#+begin_src scheme
+(import downstroke-mixer)
+
+(define (swap-music! path volume)
+ (stop-music!)
+ ;; mix-free-music! is the raw FFI free; the high-level API doesn't expose it.
+ ;; Skip this line and you'll leak the previous Mix_Music*.
+ (load-music! path)
+ (play-music! volume))
+#+end_src
+
+Most small games get away with loading one track up front and never swapping.
+
+** Cleanup at game end
+
+There is no engine teardown hook. If you care about shutting audio down
+cleanly (for example, in a test that constructs and tears down many games in
+one process), call =cleanup-audio!= after =game-run!= returns:
+
+#+begin_src scheme
+(game-run! *game*) ;; blocks until the user quits
+(cleanup-audio!) ;; now safe to tear down
+#+end_src
+
+For a normal standalone game binary this is optional — the process exits
+immediately after =game-run!= returns and the OS reclaims everything. The
+audio demo omits it.
+
+* See also
+
+- [[file:guide.org][guide.org]] — =make-game=, lifecycle hooks, =preload:= / =create:= /
+ =update:=.
+- [[file:input.org][input.org]] — triggering sounds from button presses; =input-pressed?= vs
+ =input-held?=.
+- [[file:animation.org][animation.org]] — synchronizing sound effects with animation frame events is
+ a common pattern but is /not/ built in; drive =play-sound= from your own
+ =update:= code when animation state changes.
diff --git a/docs/entities.org b/docs/entities.org
index 4e451d3..94e572c 100644
--- a/docs/entities.org
+++ b/docs/entities.org
@@ -1,399 +1,392 @@
-#+TITLE: Entity Model
-#+AUTHOR: Downstroke
-#+DESCRIPTION: How to create, update, and manage entities in the Downstroke game engine
+#+TITLE: Entities
-* Overview
+Downstroke entities are the moving (and non-moving) things in your
+scene: the player, enemies, coins, bullets, platforms, invisible
+triggers. Internally each entity is an *alist* — a list of
+=(keyword . value)= pairs — so an entity is just plain data that every
+pipeline step reads, transforms, and returns a fresh copy of. There
+are no classes, no inheritance, no hidden state. You build entities
+either by hand as a plist and converting with =plist->alist=, or from a
+*prefab* data file that composes named mixins with inline fields.
-Entities in Downstroke are plain Scheme **plists** (property lists) — alternating keyword/value pairs with no special structure or classes. An entity is just a list:
+The module that owns this vocabulary is =downstroke-entity= (all the
+=entity-*= procedures and the =define-pipeline= macro). The companion
+module =downstroke-prefabs= loads prefab data files and instantiates
+them. Most games will touch both.
-#+begin_src scheme
-(list #:type 'player
- #:x 100 #:y 200
- #:width 16 #:height 16
- #:vx 0 #:vy 0
- #:gravity? #t
- #:on-ground? #f
- #:tile-id 1)
-#+end_src
-
-This minimal approach keeps the engine lean: your game defines whatever keys it needs. The shared keys listed below are *conventions* for physics, rendering, and animation — use them to integrate with the engine's built-in systems. Custom keys are always allowed.
-
-* Pipeline skips (~#:skip-pipelines~)
-
-The optional key ~#:skip-pipelines~ holds a list of **symbols** naming frame pipeline steps that should be skipped for that entity. The physics module defines the built-in step names (see ~docs/physics.org~). The predicate ~entity-skips-pipeline?~ and the syntax ~define-pipeline~ (with optional ~guard:~ clause per step) live in ~downstroke-entity~ so any subsystem (physics now; rendering or animation later if you extend the engine) can use the same mechanism without a separate “core pipeline” module.
-
-* Creating Entities
+* The minimum you need
-There is a basic ~make-entity~ constructor, which carries positional data (x y w h); its type is ='none=:
+The simplest entity is a plist of keyword keys converted to an alist.
+From the getting-started demo (=demo/getting-started.scm=):
#+begin_src scheme
- (define my-entity (make-entity 200 150 16 16))
+(import (only (list-utils alist) plist->alist)
+ downstroke-entity)
+
+(define (make-player)
+ (plist->alist
+ (list #:type 'player
+ #:x 150 #:y 100
+ #:width 32 #:height 32
+ #:color '(100 160 255))))
#+end_src
-
-However creating an entity is as simple as a plain list:
+Read a value with =entity-ref=, update it (functionally) with
+=entity-set=:
#+begin_src scheme
-(define my-entity
- (list #:type 'enemy
- #:x 200 #:y 150
- #:width 16 #:height 16
- #:vx 0 #:vy 0
- #:gravity? #t
- #:tile-id 42))
+(entity-ref player #:x) ; → 150
+(entity-set player #:x 200) ; → new entity, player still has #:x 150
#+end_src
-Add whatever additional keys your game needs — tags, state, custom data, anything. Entities are pure data.
-
-* Accessing and Updating Entities
+*Getting-started demo* — run with =bin/demo-getting-started=, source in
+=demo/getting-started.scm=.
-Entities are **immutable**. Use these three functions to read and update them:
+* Core concepts
-** ~entity-ref entity key [default]~
+** Entities are alists of CHICKEN keywords
-Returns the value associated with ~key~ in the entity plist, or ~default~ if the key is absent.
+An entity is an association list whose keys are CHICKEN keywords
+(=#:type=, =#:x=, =#:vx=, etc.). For example, the platformer's player
+after =plist->alist= looks like:
#+begin_src scheme
-(entity-ref player #:x 0) ; → 100
-(entity-ref player #:vx) ; → 0 (if #:vx is absent)
-(entity-ref player #:custom #f) ; → #f (no #:custom key)
+((#:type . player)
+ (#:x . 100) (#:y . 50)
+ (#:width . 16) (#:height . 16)
+ (#:vx . 0) (#:vy . 0)
+ (#:gravity? . #t) (#:on-ground? . #f)
+ (#:tile-id . 1) (#:tags . (player)))
#+end_src
-If ~default~ is a procedure, it is called to compute the default:
+The engine defines a handful of *shared keys* (=#:type=, =#:x=, =#:y=,
+=#:width=, =#:height=, =#:vx=, =#:vy=, =#:tile-id=, =#:tags=, and a few
+more per subsystem) that the built-in pipelines read and write. Your
+game is free to add any other keys it needs — they're just data and
+the engine ignores what it doesn't know.
+
+There is also a minimal constructor for the positional fields, which
+sets =#:type= to ='none=:
#+begin_src scheme
-(entity-ref player #:x (lambda () (error "x is required")))
+(make-entity x y w h)
+;; → ((#:type . none) (#:x . x) (#:y . y) (#:width . w) (#:height . h))
#+end_src
-** ~entity-type entity~
+In practice most entities are built via =plist->alist= (for ad-hoc
+inline data) or via =instantiate-prefab= (for data-file-driven
+composition).
-Shorthand for ~(entity-ref entity #:type #f)~. Returns the entity's ~#:type~ or ~#f~.
+Since an entity is a regular alist, you can inspect it the usual way
+at the REPL: =(entity-ref e #:vx)=, =(assq #:tags e)=, =(length e)=.
-#+begin_src scheme
-(entity-type player) ; → 'player
-#+end_src
+** The entity API
+
+All entity operations are *pure and immutable*: every call returns a
+fresh alist; your input is never mutated. The full surface is small.
-** ~entity-set entity key val~
+*** =entity-ref entity key [default]=
-Returns a **new** plist with the key/value pair set (or replaced). The original entity is unchanged — this is functional, immutable update.
+Looks up =key=. If absent, returns =default= (or calls =default= when
+it's a procedure, so you can raise an error lazily):
#+begin_src scheme
-(define updated-player (entity-set player #:vx 5))
-;; original player is still unchanged:
-(entity-ref player #:vx) ; → 0
-(entity-ref updated-player #:vx) ; → 5
+(entity-ref player #:x) ; → 150
+(entity-ref player #:missing #f) ; → #f
+(entity-ref player #:x (lambda () (error "no x"))) ; default is a thunk
#+end_src
-** ~entity-set-many entity pairs~
+*** =entity-type entity=
-Sets multiple attributes of an entity at once.
+Shorthand for =(entity-ref entity #:type #f)=.
-#+begin_src scheme
- (define updated-player (entity-set-many player '((#:vx 5) (#:vy 10))))
- ;; original player is still unchanged:
- (entity-ref player #:vx) ; → 0
- (entity-ref player #:vy) ; → 0
- (entity-ref updated-player #:vx) ; → 5
- (entity-ref updated-player #:vy) ; → 10
-#+end_src
-
-** ~entity-update entity key proc [default]~
+*** =entity-set entity key val=
-Returns a new entity with ~key~ set to ~(proc (entity-ref entity key default))~. Useful for incrementing or transforming a value:
+Returns a new entity with =key= bound to =val= (replacing any prior
+binding). Guaranteed to leave at most one entry for that key:
#+begin_src scheme
-(entity-update player #:x (lambda (x) (+ x 3))) ; move right 3 pixels
+(define moved (entity-set player #:x 200))
+(entity-ref player #:x) ; → 150 (unchanged)
+(entity-ref moved #:x) ; → 200
#+end_src
-** Chaining Updates: The let* Pattern
+*** =entity-set-many entity pairs=
-Since each update returns a new entity, chain updates with ~chain~ (srfi-197):
+Applies a list of =(key . val)= pairs in order:
#+begin_src scheme
- (chain player
- (entity-set _ #:vx 3)
- (apply-velocity-x _ scene dt)
- (resolve-tile-collisions-x _ scene dt))
+(entity-set-many player '((#:vx . 3) (#:facing . 1)))
#+end_src
-With the default =engine-update=, you normally set =#:vx= / =#:ay= in =update:= and do not chain physics steps yourself. This =let*= shape is for custom =engine-update= hooks or tests; per-entity steps take =(entity scene dt)=.
-
-* Plist Key Reference
-
-The engine recognizes these standard keys. Use them to integrate with the physics pipeline, rendering, and animation systems. Custom keys are always allowed.
-
-| Key | Type | Description |
-|--------------------------------------+----------------------+----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| ~#:type~ | symbol | Entity type, e.g., ~'player~, ~'enemy~, ~'coin~. No built-in enforcement; use for ~entity-type~ checks and scene queries. |
-| ~#:x~, ~#:y~ | number | World position in pixels (top-left corner of bounding box). Updated by ~apply-velocity-x~, ~apply-velocity-y~, and collision resolvers. |
-| ~#:width~, ~#:height~ | number | Bounding box size in pixels. Used for AABB tile collision checks and entity-entity collision. Required for physics. |
-| ~#: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 supported from below (set by ~detect-on-solid~ in the default pipeline): solid tile under the feet and/or standing on another solid entity from ~(scene-entities scene)~. Use this in ~update:~ to gate jump input (~#:ay~). |
-| ~#: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 pipeline step to skip (e.g. ~gravity~, ~velocity-x~, ~tweens~). See ~docs/physics.org~ and ~docs/tweens.org~. |
-| ~#:tween~ | tween struct or ~#f~ | Optional. When present, ~step-tweens~ auto-advances the tween each frame. Removed automatically when the tween finishes. See ~docs/tweens.org~. |
-| ~#: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. |
-| ~#:anim-name~ | symbol | Currently active animation name, e.g., ~'walk~, ~'jump~. Set with ~set-animation~; reset by ~animate-entity~. |
-| ~#:anim-frame~ | integer | Current frame index within the animation (0-indexed). Updated automatically by ~animate-entity~. |
-| ~#:anim-tick~ | integer | Tick counter for frame timing (0 to ~#:duration - 1~). Incremented by ~animate-entity~; resets when frame advances. |
-| ~#:group-id~ | symbol | Shared id for one rigid assembly (from ~instantiate-group-prefab~). All parts and the origin share the same symbol. |
-| ~#:group-origin?~ | boolean | When ~#t~, this entity is the assembly’s pose origin; world ~#:x~ / ~#:y~ drive the group. Members should not set this. |
-| ~#:group-local-x~, ~#:group-local-y~ | number | Offset from the origin’s top-left corner; members’ world position is origin + local (updated by ~sync-groups~ on the entity list, e.g. ~(scene-transform-entities scene sync-groups)~). |
-| ~#:skip-render~ | boolean | When ~#t~, ~render-scene!~ skips drawing this entity (used for invisible origins). |
+Used internally by =instantiate-prefab= to layer all prefab fields onto
+a fresh =make-entity= base.
-* Entity groups (prefab assemblies)
+*** =entity-update entity key proc [default]=
-A **group prefab** describes one *origin* entity plus several *parts* with local offsets. Data lives in the optional ~group-prefabs~ section of the prefab file (alongside ~mixins~ and ~prefabs~). Each group entry has the shape ~(name #:type-members SYMBOL #:parts (part ...) ...)~ with two optional flags:
+Shortcut for "read, transform, write":
-- ~#:pose-only-origin?~ — when ~#t~ (typical for tweened platforms), the origin is invisible, does not run physics pipelines, and is driven by tweens or scripts. When ~#f~ (default), the origin uses a small *physics-driving* profile (~#:gravity? #t~, no ~#:skip-pipelines~): integrate the origin like a mover, then ~(scene-transform-entities scene sync-groups)~ so parts stay glued as a rigid body. For that case, set ~#:origin-width~ and ~#:origin-height~ to the full assembly size (same box as the combined parts); otherwise the origin stays 0×0 and tile collision only sees a point at the reference corner, which can leave the raft overlapping solid floor tiles.
-- ~#:static-parts?~ — when ~#t~, each part gets static rigid-body defaults (no gravity on parts; pose comes from the origin). When ~#f~ (default), parts only have what you put in each part plist.
-
-Each ~part~ is a plist using ~#:local-x~ / ~#:local-y~ (or ~#:group-local-x~ / ~#:group-local-y~) and the usual ~#:width~, ~#:height~, ~#:tile-id~, physics keys, etc.
+#+begin_src scheme
+(entity-update player #:x (lambda (x) (+ x 1))) ; → x incremented
+(entity-update player #:score add1 0) ; with default 0
+#+end_src
-Use ~(instantiate-group-prefab registry 'name origin-x origin-y)~ from ~downstroke-prefabs~ to obtain ~(origin member ...)~. Append all of them to the scene. After moving origins (tweens and/or physics), ensure updated origins are in ~scene-entities~, then ~(scene-transform-entities scene sync-groups)~ so every part’s ~#:x~ / ~#:y~ matches the origin plus local offsets (see ~docs/api.org~ for ordering).
+Because everything is immutable, update chains are usually written as
+=let*= or =chain= (SRFI-197):
-* Entities in Scenes
+#+begin_src scheme
+(let* ((p (entity-set player #:vx 3))
+ (p (entity-set p #:facing 1))
+ (p (animate-entity p anims)))
+ p)
+#+end_src
-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:
+** Prefabs and mixins
-** ~scene-entities scene~
+Hand-writing a long plist for every enemy gets old fast. The
+=downstroke-prefabs= module loads a data file that declares reusable
+*mixins* (named bundles of keys) and *prefabs* (named entities built
+by combining mixins and inline overrides).
-Returns the list of all entities in the scene.
+A prefab data file is a single sexp with =mixins=, =prefabs=, and an
+optional =group-prefabs= section. Here is the animation demo's file
+(=demo/assets/animation-prefabs.scm=):
#+begin_src scheme
-(define all-entities (scene-entities scene))
+((mixins)
+ (prefabs
+ (timed-frames animated #:type timed-frames #:anim-name walk
+ #:animations ((#:name walk #:frames ((28 10) (29 1000)))))
+ (std-frames animated #:type std-frames #:anim-name attack
+ #:animations ((#:name attack #:frames (28 29) #:duration 10)))))
#+end_src
-** ~update-scene~
+Each prefab entry has the shape =(name mixin-name ... #:k v #:k v ...)=.
+Before the first keyword, identifiers name *mixins* to pull in; from
+the first keyword onward you write *inline fields*.
-Returns a new scene with the specified fields changed. Preferred over the mutating ~scene-entities-set!~.
+The engine ships a small mixin table via =(engine-mixins)=:
#+begin_src scheme
-(update-scene scene entities: (list updated-player updated-enemy))
+(engine-mixins)
+;; → ((physics-body #:vx 0 #:vy 0 #:ay 0 #:gravity? #t #:solid? #t #:on-ground? #f)
+;; (has-facing #:facing 1)
+;; (animated #:anim-name idle #:anim-frame 0 #:anim-tick 0
+;; #:tile-id 0 #:animations #t))
#+end_src
-** ~scene-add-entity scene entity~
+User-defined mixins go in the =(mixins ...)= section of the data file
+and take precedence if they share a name with an engine mixin.
-Returns a new scene with the entity appended to the entity list.
+*** Merge semantics — inline wins
-#+begin_src scheme
-(scene-add-entity scene new-enemy)
-#+end_src
+When a prefab is composed, the merge order is:
-** ~scene-map-entities scene proc1 proc2 ...~
+1. Inline fields on the prefab entry (highest priority).
+2. Each mixin named in the entry, in the order written.
-Maps each procedure over the scene's entities, applying them in sequence. Each proc must be a function of one entity, returning a new entity.
+=alist-merge= is *earlier-wins*, so inline fields always override mixin
+defaults. Using the entry:
#+begin_src scheme
-;; Example: map per-entity physics steps (need scene + dt in scope):
-(scene-map-entities scene
- (lambda (e) (apply-gravity e scene dt))
- (lambda (e) (apply-velocity-x e scene dt))
- (lambda (e) (apply-velocity-y e scene dt)))
+(timed-frames animated #:type timed-frames #:anim-name walk ...)
#+end_src
-The result is equivalent to chaining three =scene-map-entities= passes, one per step.
+=animated= contributes =#:anim-name idle= among other things, but the
+inline =#:anim-name walk= wins.
-With default =engine-update=, the engine applies the full pipeline for you; use this pattern inside a custom =engine-update= or tools.
+Nested plist-valued keys (currently =#:animations= and =#:parts=) are
+deep-converted to alists at load time, so =#:animations= ends up as a
+list of alists like =((#:name walk #:frames (28 29) #:duration 10))=.
-** ~scene-filter-entities scene pred~
+*** =load-prefabs= and =instantiate-prefab=
-Keeps only entities satisfying the predicate; returns a new scene. Use to despawn dead enemies, collected items, etc.
+Load a prefab file once at =create:= time, then instantiate as many
+entities as you need:
#+begin_src scheme
-;; Remove all entities with #:health <= 0:
-(scene-filter-entities scene
- (lambda (e) (> (entity-ref e #:health 1) 0)))
+(import downstroke-prefabs)
+
+(define registry
+ (load-prefabs "demo/assets/animation-prefabs.scm"
+ (engine-mixins) ; engine's built-in mixins
+ '())) ; no user hooks
+
+(define e1 (instantiate-prefab registry 'std-frames 80 80 16 16))
+(define e2 (instantiate-prefab registry 'timed-frames 220 60 16 16))
#+end_src
-** ~scene-find-tagged scene tag~
+=instantiate-prefab= signature: =(registry type x y w h) → entity= (or
+=#f= if the prefab isn't registered; also =#f= if =registry= itself is
+=#f=). The =x y w h= arguments seed =make-entity= and are then
+overwritten by any corresponding fields from the prefab.
-Returns the first entity whose ~#:tags~ list contains ~tag~, or ~#f~ if none found.
+If an entity carries an =#:on-instantiate= key — either a procedure or
+a symbol naming a *user hook* passed into =load-prefabs= — the hook is
+invoked on the fresh entity and its result replaces it. That's how
+prefabs run per-type setup logic (e.g. computing sprite frames from
+size) without the engine baking a policy in.
-#+begin_src scheme
-(scene-find-tagged scene 'player) ; → player entity or #f
-#+end_src
+Group prefabs (=group-prefabs= section, instantiated via
+=instantiate-group-prefab=) return a list =(origin member ...)= for
+rigid assemblies like moving platforms; see the existing entity-groups
+material in this file's future revisions and the sandbox demo
+(=demo/assets/sandbox-groups.scm=) for a worked example.
-** ~scene-find-all-tagged scene tag~
+** Skipping pipeline steps
-Returns a list of all entities whose ~#:tags~ list contains ~tag~.
+Each frame the engine runs a sequence of per-entity *pipeline steps*
+(acceleration, gravity, velocity-x, tile-collisions-x, velocity-y,
+tile-collisions-y, on-solid, tweens, animation, entity-collisions,
+…). An individual entity can opt out of any of these by listing the
+step's symbol in its =#:skip-pipelines= key:
#+begin_src scheme
-(scene-find-all-tagged scene 'enemy) ; → (enemy1 enemy2 enemy3) or ()
+(entity-set player #:skip-pipelines '(gravity velocity-x))
+;; → player now ignores gravity and horizontal motion integration
#+end_src
-* Animation
+The predicate is =entity-skips-pipeline?=:
-Entities with the ~#:animations~ key can cycle through sprite frames automatically. Animation is data-driven: define sprite sequences once, then switch between them in your update logic.
-
-** Animation Data Format
+#+begin_src scheme
+(entity-skips-pipeline? player 'gravity) ; → #t / #f
+#+end_src
-The ~#:animations~ key holds an **alist** (association list) of animation entries. Each entry is ~(name #:frames (frame-indices...) #:duration ticks-per-frame)~:
+Every built-in step is defined with the =define-pipeline= macro
+(=downstroke-entity=), which wraps the body in the skip check. The
+macro has two shapes:
#+begin_src scheme
-(list #:type 'player
- ...
- #:animations
- '((idle #:frames (28) #:duration 10)
- (walk #:frames (27 28) #:duration 6)
- (jump #:frames (29) #:duration 10)
- (fall #:frames (30) #:duration 10))
- #:anim-name 'idle
- #:anim-frame 0
- #:anim-tick 0)
-#+end_src
+(define-pipeline (identifier name) (scene entity dt)
+ body)
-- ~idle~, ~walk~, ~jump~, ~fall~ are animation **names** (symbols).
-- ~#:frames (28)~ means frame 0 of the animation displays sprite tile 28 (1-indexed in tileset).
-- ~#:frames (27 28)~ means frame 0 displays tile 27, frame 1 displays tile 28, then loops back to frame 0.
-- ~#:duration 6~ means each frame displays for 6 game ticks before advancing to the next frame.
+(define-pipeline (identifier name) (scene entity dt)
+ guard: guard-expr
+ body)
+#+end_src
-** Switching Animations
+- =identifier= is the procedure name (e.g. =apply-gravity=).
+- =name= is the symbol users put into =#:skip-pipelines= (e.g. =gravity=).
+- =guard-expr=, when given, must evaluate truthy for the body to run;
+ otherwise the entity is returned unchanged.
-Use ~set-animation~ to switch to a new animation, resetting frame and tick counters. If the animation is already active, it is a no-op (avoids restarting mid-loop):
+Example from =physics.scm=:
#+begin_src scheme
-(set-animation player 'walk) ; switch to walk animation
-;; → entity with #:anim-name 'walk, #:anim-frame 0, #:anim-tick 0
+(define-pipeline (apply-gravity gravity) (scene entity dt)
+ guard: (entity-ref entity #:gravity? #f)
+ (entity-set entity #:vy (+ (entity-ref entity #:vy) *gravity*)))
#+end_src
-** Advancing Animation
+Reading the shape: the procedure is =apply-gravity=; adding =gravity=
+to =#:skip-pipelines= disables it on one entity; =guard:= means the
+step is skipped entity-wide when =#:gravity?= is false.
-Call ~animate-entity~ once per game frame to step the animation. Pass the entity and its animation table:
+For the full list of built-in step symbols, see
+[[file:physics.org][physics.org]].
-#+begin_src scheme
-(define animations
- '((idle #:frames (28) #:duration 10)
- (walk #:frames (27 28) #:duration 6)))
-
-(let* ((player (set-animation player 'walk))
- (player (animate-entity player animations)))
- player)
-#+end_src
+* Common patterns
-~animate-entity~ does four things:
-1. Increments ~#:anim-tick~ by 1.
-2. If ~#:anim-tick~ reaches ~#:duration~, advances ~#:anim-frame~ and resets tick to 0.
-3. Updates ~#:tile-id~ to the sprite ID for the current frame.
-4. Returns the updated entity (or unchanged entity if ~#:anim-name~ is not set).
+** Build an ad-hoc entity inline with =plist->alist=
-** Typical Animation Update Pattern
+Good for one-offs, tiny demos, prototypes, and scripts where pulling
+in a data file is overkill. The getting-started and scaling demos do
+this exclusively:
#+begin_src scheme
-(define (update-player-animation player input)
- (let* ((anim (if (input-held? input 'left) 'walk 'idle))
- (player (set-animation player anim)))
- (animate-entity player player-animations)))
+(plist->alist
+ (list #:type 'box
+ #:x (/ +width+ 2) #:y (/ +height+ 2)
+ #:width +box-size+ #:height +box-size+
+ #:vx 0 #:vy 0
+ #:color '(255 200 0)))
#+end_src
-* Tags
+*Scaling demo* — run with =bin/demo-scaling=, source in
+=demo/scaling.scm=.
+*Getting-started demo* — run with =bin/demo-getting-started=, source in
+=demo/getting-started.scm=.
-The ~#:tags~ key is a list of symbols used to label and query entities. Tags are arbitrary — define whatever makes sense for your game.
+** Create a prefab file and instantiate from it
-** Creating Entities with Tags
+When several entities share fields, lift them into mixins and let
+prefabs stamp them out:
#+begin_src scheme
-(list #:type 'player
- #:tags '(player solid)
- ...)
-
-(list #:type 'enemy
- #:tags '(enemy dangerous)
- ...)
+;; assets/actors.scm
+((mixins
+ (enemy-defaults #:solid? #t #:tags (enemy) #:hp 3))
+ (prefabs
+ (grunt physics-body has-facing enemy-defaults #:type grunt
+ #:tile-id 50 #:width 16 #:height 16)
+ (brute physics-body has-facing enemy-defaults #:type brute
+ #:tile-id 51 #:width 32 #:height 32 #:hp 8)))
+#+end_src
-(list #:type 'coin
- #:tags '(collectible)
- ...)
+#+begin_src scheme
+(define reg (load-prefabs "assets/actors.scm" (engine-mixins) '()))
+(define g (instantiate-prefab reg 'grunt 100 100 16 16))
+(define b (instantiate-prefab reg 'brute 200 100 32 32))
#+end_src
-** Querying by Tag
+=physics-body=, =has-facing=, =animated= are engine mixins — see
+=(engine-mixins)= above. Inline fields (e.g. =#:hp 8= on =brute=)
+override values from mixins.
-Use the scene tag lookup functions:
+*Animation demo* (prefab + load-prefabs) — run with =bin/demo-animation=,
+source in =demo/animation.scm=.
+*Platformer demo* (hand-built player via =plist->alist=) — run with
+=bin/demo-platformer=, source in =demo/platformer.scm=.
-#+begin_src scheme
-;; Find the first entity tagged 'player:
-(define player (scene-find-tagged scene 'player))
+** Add a user-defined mixin
-;; Find all enemies:
-(define enemies (scene-find-all-tagged scene 'enemy))
+User mixins live in the same data file's =(mixins ...)= section; if the
+name collides with an engine mixin, the user version wins:
-;; Find all collectibles and remove them:
-(scene-filter-entities scene
- (lambda (e) (not (member 'collectible (entity-ref e #:tags '())))))
+#+begin_src scheme
+((mixins
+ ;; Overrides the engine's physics-body: no gravity for this game.
+ (physics-body #:vx 0 #:vy 0 #:gravity? #f #:solid? #t)
+ ;; A brand-new mixin:
+ (stompable #:stompable? #t #:stomp-hp 1))
+ (prefabs
+ (slime physics-body stompable #:type slime #:tile-id 70)))
#+end_src
-** Tag Conventions
-
-While tags are free-form, consider using these conventions in your game:
+No engine change is needed — mixin names are resolved at
+=load-prefabs= time.
-- ~player~: the player character
-- ~enemy~: hostile entities
-- ~solid~: entities that participate in collision
-- ~collectible~: items to pick up
-- ~projectile~: bullets, arrows, etc.
-- ~hazard~: spikes, lava, etc.
+** Write your own pipeline step
-* Example: Complete Entity Setup
-
-Here is a full example showing entity creation, initialization in the scene, and update logic (assumes =downstroke-physics= is imported for =*jump-force*=):
+When you have per-entity logic that should honor =#:skip-pipelines=,
+reach for =define-pipeline= instead of writing a plain function. A
+minimal example:
#+begin_src scheme
-(define (create-player-entity)
- (list #:type 'player
- #:x 100 #:y 200
- #:width 16 #:height 16
- #:vx 0 #:vy 0
- #:ay 0
- #:gravity? #t
- #:on-ground? #f
- #:tile-id 1
- #:facing 1
- #:tags '(player solid)
- #:animations
- '((idle #:frames (1) #:duration 10)
- (walk #:frames (1 2) #:duration 6)
- (jump #:frames (3) #:duration 10))
- #:anim-name 'idle
- #:anim-frame 0
- #:anim-tick 0))
-
-(define (create-hook game)
- (let* ((scene (game-load-scene! game "assets/level.tmx"))
- (player (create-player-entity)))
- (scene-add-entity scene player)))
-
-(define (update-hook game dt)
- (let* ((input (game-input game))
- (scene (game-scene game))
- (player (scene-find-tagged scene 'player)))
- ;; Intent + presentation — default engine-update already ran physics this frame
- (let* ((player (entity-set player #:vx
- (cond
- ((input-held? input 'left) -3)
- ((input-held? input 'right) 3)
- (else 0))))
- (player (if (and (input-pressed? input 'a)
- (entity-ref player #:on-ground? #f))
- (entity-set player #:ay (- *jump-force*))
- player))
- (player (set-animation player
- (cond
- ((not (entity-ref player #:on-ground? #f)) 'jump)
- ((not (zero? (entity-ref player #:vx 0))) 'walk)
- (else 'idle))))
- (player (animate-entity player player-animations))
- (player (if (< (entity-ref player #:vx 0) 0)
- (entity-set player #:facing -1)
- (entity-set player #:facing 1))))
- (game-scene-set! game
- (update-scene scene entities: (list player))))))
+(import downstroke-entity)
+
+;; A decay step. Users can skip it with #:skip-pipelines '(decay).
+(define-pipeline (apply-decay decay) (scene entity dt)
+ guard: (entity-ref entity #:decays? #f)
+ (entity-update entity #:hp (lambda (hp) (max 0 (- hp 1))) 0))
#+end_src
-Import =*jump-force*= from =downstroke-physics= (or use a literal jump impulse for =#:ay=). The single ~game-scene-set!~ stores the scene after game logic; motion and =#:on-ground?= come from =default-engine-update=.
+Call it like any other step: =(apply-decay scene entity dt)=. Wiring
+it into the frame is the engine's job; see [[file:physics.org][physics.org]] for how
+built-in steps are composed and how to provide a custom
+=engine-update= if you need a different order.
+
+* See also
+
+- [[file:guide.org][guide.org]] — getting started; the 20-line game that uses entities.
+- [[file:physics.org][physics.org]] — full list of pipeline step symbols, =guard:= clauses,
+ and per-step behavior.
+- [[file:tweens.org][tweens.org]] — using =#:tween= and the =tweens= pipeline step on
+ entities.
+- [[file:animation.org][animation.org]] — =#:animations=, =#:anim-name=, the =animated= mixin.
+- [[file:input.org][input.org]] — reading the input system to drive entity updates.
+- [[file:scenes.org][scenes.org]] — scene-level queries (=scene-find-tagged=,
+ =scene-add-entity=, =update-scene=).
+- [[file:rendering.org][rendering.org]] — how =#:tile-id=, =#:color=, =#:facing=, and
+ =#:skip-render= affect drawing.
+- [[file:audio.org][audio.org]] — triggering sounds from entity update code.
diff --git a/docs/guide.org b/docs/guide.org
index ede9180..1b16d92 100644
--- a/docs/guide.org
+++ b/docs/guide.org
@@ -1,302 +1,281 @@
-#+TITLE: Downstroke — Getting Started
+#+TITLE: Getting Started with Downstroke
#+AUTHOR: Downstroke Project
-* Introduction
+* Welcome
-Downstroke is a 2D tile-driven game engine for Chicken Scheme, built on SDL2. It is inspired by Phaser 2: a minimal game is about 20 lines of Scheme.
+Downstroke is a 2D tile-driven game engine for CHICKEN Scheme, built on SDL2. Its API is inspired by Phaser 2: a minimal game is about twenty lines of Scheme, with the engine taking care of window creation, the main loop, input, and rendering. This guide walks you through building your very first Downstroke game — a blue square you can push around the screen with the arrow keys. By the end you will have the complete source of the =getting-started= demo and a clear map of where to go next.
-The engine handles SDL2 initialization, the event loop, input, rendering, and — by default — a full physics pipeline each frame (~default-engine-update~). You provide lifecycle hooks to customize behavior; ~update:~ is where you usually set movement *intent* (~#:vx~, ~#:vy~, ~#:ay~) and game rules. This guide walks you through building your first game with Downstroke.
+You write a Downstroke game by calling ~make-game~ with a few keyword arguments (a title, a size, and one or two lifecycle hooks), and then handing the result to ~game-run!~. Everything else — opening the window, polling input, clearing the framebuffer, presenting the frame — is the engine's job. You only describe /what/ the game is: what the scene looks like, what the entities are, and how they change.
-* Installation
+* The minimum you need
-** System Dependencies
+The smallest Downstroke program you can write opens a window, runs the main loop, and quits when you press =Escape=. No scene, no entities, no update logic — just the lifecycle shell:
-Downstroke requires the following system libraries:
-
-- =SDL2=
-- =SDL2_mixer=
-- =SDL2_ttf=
-- =SDL2_image=
-- =expat=
+#+begin_src scheme
+(import scheme
+ (chicken base)
+ downstroke-engine)
-On Debian/Ubuntu:
-#+begin_src bash
-sudo apt-get install libsdl2-dev libsdl2-mixer-dev libsdl2-ttf-dev libsdl2-image-dev libexpat1-dev
+(game-run!
+ (make-game
+ title: "Hello Downstroke"
+ width: 320
+ height: 240))
#+end_src
-On macOS with Homebrew:
-#+begin_src bash
-brew install sdl2 sdl2_mixer sdl2_ttf sdl2_image expat
-#+end_src
+Save that as =hello.scm=, compile, and run. You should get a 320×240 window with a black background. Press =Escape= to quit.
-** Chicken Eggs
+Two things are worth noticing:
-Install the Downstroke egg along with its dependencies:
+- ~make-game~ takes /keyword arguments/ (note the trailing colon: ~title:~, ~width:~, ~height:~). They all have defaults, so you can leave any of them off. The window defaults to 640×480 and titled "Downstroke Game".
+- ~game-run!~ is what actually starts SDL2 and enters the main loop. It blocks until the player quits.
-#+begin_src bash
-chicken-install downstroke
-#+end_src
+There are four lifecycle hooks you can attach to ~make-game~:
-This pulls in the eggs declared by the Downstroke package. If your game uses the =states= egg for AI or other state machines, install it separately (=chicken-install states=).
+| Keyword | When it runs | Signature |
+|------------+-------------------------------+--------------------------|
+| ~preload:~ | once, before ~create:~ | ~(lambda (game) ...)~ |
+| ~create:~ | once, after ~preload:~ | ~(lambda (game) ...)~ |
+| ~update:~ | every frame | ~(lambda (game dt) ...)~ |
+| ~render:~ | every frame, after scene draw | ~(lambda (game) ...)~ |
-* Hello World — Your First Game
-Create a file called =mygame.scm=:
-
-#+begin_src scheme
-(import downstroke-engine)
-
-(define *game*
- (make-game title: "Hello World" width: 640 height: 480))
-
-(game-run! *game*)
-#+end_src
+In the rest of this guide you will fill in ~create:~ (to build the scene) and ~update:~ (to move the player).
-Build and run:
-#+begin_src bash
-csc mygame.scm -o mygame
-./mygame
-#+end_src
+* Core concepts
-You should see a black window titled "Hello World". Press =Escape= or close the window to quit. The =game-run!= function handles SDL2 initialization, window creation, the event loop, and cleanup automatically. The engine also provides a default =quit= action (Escape key and window close button).
+** Creating a scene
-* Moving Square — First Entity
+A /scene/ is the container for everything the engine draws and simulates: a list of entities, an optional tilemap, a camera, and a background color. You build one with ~make-scene~ or — for sprite-only games like ours — with the simpler ~make-sprite-scene~, which skips the tilemap fields.
-Now let's add an entity you can move with the keyboard. Create =square.scm=:
+In the ~create:~ hook you typically build a scene and hand it to the game with ~game-scene-set!~:
#+begin_src scheme
(import scheme
(chicken base)
- (prefix sdl2 "sdl2:")
downstroke-engine
downstroke-world
- downstroke-entity
- downstroke-input)
-
-(define *game*
- (make-game
- title: "Moving Square" width: 640 height: 480
-
- create: (lambda (game)
- (game-scene-set! game
- (make-scene
- entities: (list (list #:type 'box #:x 300 #:y 200
- #:width 32 #:height 32
- #:vx 0 #:vy 0))
- 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))
- (box (car (scene-entities scene)))
- ;; Intent only: default engine-update integrates #:vx / #:vy each frame
- (box (entity-set box #:vx (cond ((input-held? input 'left) -3)
- ((input-held? input 'right) 3)
- (else 0))))
- (box (entity-set box #:vy (cond ((input-held? input 'up) -3)
- ((input-held? input 'down) 3)
- (else 0)))))
- (game-scene-set! game
- (update-scene scene entities: (list box)))))
-
- render: (lambda (game)
- (let* ((scene (game-scene game))
- (box (car (scene-entities scene)))
- (x (inexact->exact (floor (entity-ref box #:x 0))))
- (y (inexact->exact (floor (entity-ref box #:y 0))))
- (w (entity-ref box #:width 32))
- (h (entity-ref box #:height 32)))
- (sdl2:set-render-draw-color! (game-renderer game) 255 200 0 255)
- (sdl2:render-fill-rect! (game-renderer game)
- (sdl2:make-rect x y w h))))))
-
-(game-run! *game*)
+ downstroke-scene-loader)
+
+(game-run!
+ (make-game
+ title: "Blue Window"
+ width: 320 height: 240
+ create: (lambda (game)
+ (game-scene-set! game
+ (make-sprite-scene
+ entities: '()
+ background: '(20 22 30))))))
#+end_src
-Run it:
-#+begin_src bash
-csc square.scm -o square
-./square
-#+end_src
+The ~background:~ argument is a list of three or four integers ~(r g b)~ or ~(r g b a)~ in the 0–255 range. The engine clears the framebuffer with this color at the top of every frame. If you omit it, the background is plain black.
-Press arrow keys to move the yellow square around. Here are the key ideas:
+~make-sprite-scene~ also accepts ~camera:~, ~camera-target:~, ~tileset:~, ~tileset-texture:~, and ~engine-update:~, but you do not need any of them for the guide.
-- **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**: Each frame, after input, the built-in =engine-update= integrates =#:vx= / =#:vy= into position (gravity and tile steps no-op without =#:gravity?= or a tilemap). Your =update:= hook runs *after* that and sets velocities for the following frame. The =render:= hook runs after the default scene render and is used for custom drawing like this colored rectangle.
-- **Rendering**: Since our tilemap is =#f=, the default renderer draws nothing; all drawing happens in the =render:= hook using SDL2 functions.
+** Adding an entity
-* Adding a Tilemap and Physics
+In Downstroke an /entity/ is just an alist — a list of ~(key . value)~ pairs — with a small set of conventional keyword keys. There are no classes and no inheritance; an entity is data you can read with ~entity-ref~ and transform with ~entity-set~.
-For a real game, you probably want tilemaps, gravity, and collision detection. Downstroke includes a full physics pipeline. Here's the pattern:
+It is common to write entities in plist form (alternating keys and values) for readability and then convert them with ~plist->alist~ from the =list-utils= egg:
#+begin_src scheme
-(import scheme
- (chicken base)
- downstroke-engine
- downstroke-world
- downstroke-entity
- downstroke-input
- downstroke-physics
- downstroke-scene-loader
- downstroke-sound)
-
-(define *game*
- (make-game
- title: "Platformer Demo" width: 600 height: 400
-
- preload: (lambda (game)
- ;; Initialize audio and load sounds (optional)
- (init-audio!)
- (load-sounds! '((jump . "assets/jump.wav"))))
-
- create: (lambda (game)
- ;; Load the tilemap from a TMX file (made with Tiled editor)
- (let* ((scene (game-load-scene! game "assets/level.tmx"))
- ;; Create a player entity
- (player (list #:type 'player
- #:x 100 #:y 50
- #:width 16 #:height 16
- #:vx 0 #:vy 0
- #:gravity? #t
- #:on-ground? #f
- #:tile-id 1)))
- ;; Add player to the scene
- (scene-add-entity scene player)))
-
- update: (lambda (game dt)
- ;; Game logic only — gravity, collisions, and #:on-ground? run in default-engine-update
- (let* ((input (game-input game))
- (scene (game-scene game))
- (player (car (scene-entities scene)))
- (jump? (and (input-pressed? input 'a)
- (entity-ref player #:on-ground? #f)))
- (player (entity-set player #:vx
- (cond
- ((input-held? input 'left) -3)
- ((input-held? input 'right) 3)
- (else 0)))))
- (when jump? (play-sound 'jump))
- (let ((player (if jump?
- (entity-set player #:ay (- *jump-force*))
- player)))
- (game-scene-set! game
- (update-scene scene entities: (list player)))))))
-
-(game-run! *game*)
+(import (only (list-utils alist) plist->alist))
+
+(define (make-player)
+ (plist->alist
+ (list #:type 'player
+ #:x 150 #:y 100
+ #:width 32 #:height 32
+ #:color '(100 160 255))))
+#+end_src
+
+The keys in use here are the conventional ones the engine understands:
+
+- ~#:type~ — a symbol you use to distinguish entities; purely for your own bookkeeping.
+- ~#:x~, ~#:y~ — pixel position of the entity's top-left corner.
+- ~#:width~, ~#:height~ — pixel size of the entity's bounding box.
+- ~#:color~ — an ~(r g b)~ or ~(r g b a)~ list. When an entity has no ~#:tile-id~ (or the scene has no tileset texture), the renderer fills the entity's rectangle with this color. That is exactly what we want for our guide: no sprite sheet, just a colored box.
+
+Once you have a player factory, drop one into the scene's ~entities:~ list:
+
+#+begin_src scheme
+(make-sprite-scene
+ entities: (list (make-player))
+ background: '(20 22 30))
#+end_src
-Key points:
+Compile and run, and you should see a light-blue 32×32 square on a dark background. It does not move yet — that is the next step.
+
+** Reading input
+
+Input in Downstroke is organised around /actions/ rather than raw keys. The engine ships with a default input config (~*default-input-config*~ in ~downstroke-input~) that maps the arrow keys, WASD, a couple of action buttons, and game-controller buttons to a small set of action symbols:
-- **=game-load-scene!=** loads a TMX tilemap file (created with the Tiled editor), creates the tileset texture, and builds the scene. It returns the scene so you can add more entities.
-- **=init-audio!=** and **=load-sounds!=** initialize the audio subsystem and load sound files. Call these in the =preload:= hook.
-- **=play-sound=** plays a loaded sound effect.
-- **Automatic physics**: By default, =make-game= uses =default-engine-update=, which runs the full pipeline (tweens, acceleration, gravity, velocity, tile and entity collisions, ground detection, group sync) *before* your =update:= hook. Your =update:= sets =#:vx= and a one-frame =#:ay= for jumps; =*jump-force*= (from =downstroke-physics=, default 15) is a conventional jump impulse.
-- **=engine-update: #f=** disables that pipeline — use this for games that move entities entirely in =update:= (see =demo/shmup.scm=, =demo/scaling.scm=) or supply your own =engine-update:= procedure to replace =default-engine-update=.
-- **=detect-on-solid=** (inside the default pipeline) sets =#:on-ground?= from tiles below the feet and from other solids in the scene entity list. When your =update:= runs, that flag already reflects the current frame.
+| Action | Default keys |
+|----------+-------------------|
+| ~up~ | =Up=, =W= |
+| ~down~ | =Down=, =S= |
+| ~left~ | =Left=, =A= |
+| ~right~ | =Right=, =D= |
+| ~a~ | =J=, =Z= |
+| ~b~ | =K=, =X= |
+| ~start~ | =Return= |
+| ~select~ | (controller only) |
+| ~quit~ | =Escape= |
-See =demo/platformer.scm= in the engine source for a complete working example.
-* Development Tips
+Inside ~update:~ you reach the input state with ~(game-input game)~, and then query it with three predicates from ~downstroke-input~:
-** Pixel Scaling
+- ~(input-held? input 'left)~ — ~#t~ while the player holds the action down.
+- ~(input-pressed? input 'a)~ — ~#t~ only on the first frame the action goes down (edge).
+- ~(input-released? input 'a)~ — ~#t~ only on the frame the action goes up.
-Retro-style games often use a small logical resolution (e.g. 320×240) but need a larger window so players can actually see things. Use ~scale:~ to scale everything uniformly:
+~input-held?~ is what we want for continuous movement:
#+begin_src scheme
-(make-game title: "Pixel Art" width: 320 height: 240 scale: 2)
-;; Creates a 640×480 window; all coordinates remain in 320×240 space
+(define +speed+ 2)
+
+(define (input-dx input)
+ (cond ((input-held? input 'left) (- +speed+))
+ ((input-held? input 'right) +speed+)
+ (else 0)))
+
+(define (input-dy input)
+ (cond ((input-held? input 'up) (- +speed+))
+ ((input-held? input 'down) +speed+)
+ (else 0)))
#+end_src
-This is integer-only scaling. All rendering — tiles, sprites, text, debug overlays — is scaled automatically by SDL2. Game logic and coordinates are unaffected.
+~+speed+~ is pixels per frame. At the engine's default 16 ms frame delay that works out to roughly 120 pixels per second; feel free to adjust.
-See =demo/scaling.scm= for a complete example.
+The =quit= action is already wired into the main loop: pressing =Escape= ends the game, so you do not need to handle it yourself.
-** Fullscreen
+** Updating entity state
-Use SDL2 window flags in the ~preload:~ hook to go fullscreen:
+Entities are immutable. ~entity-set~ does not mutate; it returns a /new/ alist with the key updated. To move the player, read its current position, compute the new position, and produce a new entity:
#+begin_src scheme
-(make-game
- title: "Fullscreen Game" width: 320 height: 240 scale: 2
- preload: (lambda (game)
- (sdl2:window-fullscreen-set! (game-window game) 'fullscreen-desktop)))
+(define (move-player player input)
+ (let* ((x (entity-ref player #:x 0))
+ (y (entity-ref player #:y 0))
+ (dx (input-dx input))
+ (dy (input-dy input)))
+ (entity-set (entity-set player #:x (+ x dx)) #:y (+ y dy))))
#+end_src
-~'fullscreen-desktop~ fills the screen without changing display resolution; SDL2 handles letterboxing and scaling. ~'fullscreen~ uses exclusive fullscreen at the window size.
-
-** Debug Mode
+~entity-ref~ takes an optional default (~0~ above), returned if the key is absent.
-During development, enable ~debug?: #t~ on ~make-game~ to visualize collision boxes and the tile grid. This helps you understand what the physics engine "sees" and debug collision problems:
+Once you have a new entity, you also need a new scene that contains it, because scenes are immutable too. ~update-scene~ is the copier: pass it an existing scene and any fields you want to change.
#+begin_src scheme
-(define my-game
- (make-game
- title: "My Game" width: 600 height: 400
- debug?: #t)) ;; Enable collision visualization
-
-(game-run! my-game)
+(update-scene scene
+ entities: (list (move-player player input)))
#+end_src
-With debug mode enabled, you'll see:
+Finally, install the new scene on the game with ~game-scene-set!~. Putting it all together in the ~update:~ hook:
+
+#+begin_src scheme
+update: (lambda (game dt)
+ (let* ((scene (game-scene game))
+ (input (game-input game))
+ (player (car (scene-entities scene))))
+ (game-scene-set! game
+ (update-scene scene
+ entities: (list (move-player player input))))))
+#+end_src
-- **Purple outlines** around all non-zero tiles
-- **Blue boxes** around player entities
-- **Red boxes** around enemy entities
-- **Green boxes** around active attack hitboxes
+A note about ~dt~: the update hook is called with the number of milliseconds elapsed since the previous frame. We ignore it here because we move by a fixed number of pixels per frame, but most real games use ~dt~ to make motion frame-rate independent — see [[file:physics.org][physics.org]] for the engine's built-in pipeline which does this for you.
-This is invaluable for tuning collision geometry and understanding why entities are clipping or not colliding as expected.
+* Putting it together
-* Demo Overview
+Here is the full =demo/getting-started.scm= source. Read it top to bottom — each piece should now look familiar.
-Downstroke includes several demo games that showcase different features:
+#+begin_src scheme
+(import scheme
+ (chicken base)
+ (only (list-utils alist) plist->alist)
+ downstroke-engine
+ downstroke-world
+ downstroke-entity
+ downstroke-input
+ downstroke-scene-loader)
+
+(define +speed+ 2)
+
+(define (make-player)
+ (plist->alist
+ (list #:type 'player
+ #:x 150 #:y 100
+ #:width 32 #:height 32
+ #:color '(100 160 255))))
+
+(define (move-player player input)
+ (let* ((x (entity-ref player #:x 0))
+ (y (entity-ref player #:y 0))
+ (dx (cond ((input-held? input 'left) (- +speed+))
+ ((input-held? input 'right) +speed+)
+ (else 0)))
+ (dy (cond ((input-held? input 'up) (- +speed+))
+ ((input-held? input 'down) +speed+)
+ (else 0))))
+ (entity-set (entity-set player #:x (+ x dx)) #:y (+ y dy))))
+
+(game-run!
+ (make-game
+ title: "Getting Started"
+ width: 320 height: 240
+
+ create: (lambda (game)
+ (game-scene-set! game
+ (make-sprite-scene
+ entities: (list (make-player))
+ background: '(20 22 30))))
+
+ update: (lambda (game dt)
+ (let* ((scene (game-scene game))
+ (input (game-input game))
+ (player (car (scene-entities scene))))
+ (game-scene-set! game
+ (update-scene scene
+ entities: (list (move-player player input))))))))
+#+end_src
-| Demo | File | What it shows |
-|------|------|--------------|
-| Platformer | =demo/platformer.scm= | Gravity, jump, tile collision, camera follow, sound effects |
-| Top-down | =demo/topdown.scm= | 8-directional movement, no gravity, tilemap, camera follow |
-| Physics Sandbox | =demo/sandbox.scm= | Entity-entity collision, multi-entity physics, auto-respawn |
-| Shoot-em-up | =demo/shmup.scm= | No tilemap, entity spawning/removal, manual AABB collision |
-| Audio | =demo/audio.scm= | Sound effects, music toggle, text rendering |
-| Sprite Font | =demo/spritefont.scm= | Bitmap text rendering using non-contiguous tileset ranges |
-| Menu | =demo/menu.scm= | State machine menus, keyboard navigation, TTF text rendering |
-| Tweens | =demo/tweens.scm= | Easing curves, =tween-step=, =#:skip-pipelines= with tile collision |
-| Scaling | =demo/scaling.scm= | Integer pixel scaling with =scale: 2=, 2× upscaled rendering |
+This file ships with the engine. To run it, build the project and launch the demo binary:
-Each demo is self-contained and serves as a working reference for a particular game mechanic.
+*Getting started demo* — run with =bin/demo-getting-started=, source in =demo/getting-started.scm=.
-* Build & Run
+From the Downstroke source tree, ~make~ will compile the engine and ~make demos~ will build every demo executable under =bin/=. When the binary starts you should see a 320×240 window with a blue square you can push around with the arrow keys (or =WASD=). =Escape= quits.
-If you cloned the downstroke source, you can build everything:
+A few things to notice in the final code:
-#+begin_src bash
-cd /path/to/downstroke
-make # Compile all engine modules
-make demos # Build all demo executables
-./bin/demo-platformer
-./bin/demo-topdown
-# etc.
-#+end_src
+- We never mutate anything. ~move-player~ returns a new entity; ~update-scene~ returns a new scene; ~game-scene-set!~ swaps the scene on the ~game~ struct for the next frame.
+- Every frame, ~update:~ rebuilds the entire entities list from scratch. That is fine for a one-entity demo and scales happily into the dozens; for larger games the engine's built-in physics pipeline (see [[file:physics.org][physics.org]]) does the same work for you using keyword conventions like ~#:vx~ and ~#:vy~.
+- The engine's /default engine update/ — the physics pipeline — still runs before your ~update:~ hook. It is a no-op for our player because we never set velocity keys like ~#:vx~ or ~#:vy~, so the only thing moving the square is your own ~move-player~. The moment you set ~#:vx~ to a non-zero number, the engine will start applying it for you.
-* Next Steps
+* Where to next
-You now know how to:
+Once =getting-started= runs, the rest of Downstroke is a menu of subsystems you can opt into piece by piece. Every topic below has its own doc file and at least one matching demo:
-- Create a game with =make-game= and =game-run!=
-- Add entities to scenes
-- Read input with =input-held?= and =input-pressed?=
-- Rely on the default physics pipeline (or turn it off with =engine-update: #f=)
-- Load tilemaps with =game-load-scene!=
-- Play sounds with =load-sounds!= and =play-sound=
+- [[file:entities.org][entities.org]] — the entity model, the list of conventional keys, and how to build reusable prefabs.
+- [[file:physics.org][physics.org]] — the built-in frame pipeline: gravity, velocity, tile collisions, ground detection, and entity-vs-entity collisions.
+- [[file:scenes.org][scenes.org]] — scenes, cameras, camera follow, and switching between named game states.
+- [[file:rendering.org][rendering.org]] — how the renderer draws sprites, solid-color rects, and bitmap sprite fonts.
+- [[file:animation.org][animation.org]] — frame-based sprite animation driven by ~#:animations~ and ~#:anim-name~.
+- [[file:input.org][input.org]] — customising the action list, keyboard map, and game-controller bindings.
+- [[file:tweens.org][tweens.org]] — declarative easing for position, scale, color, and other numeric keys.
+- [[file:audio.org][audio.org]] — loading and playing sound effects and music.
-For more details:
+And the full set of demos that ship with the repository, each built by ~make demos~:
-- **Full API reference**: See =docs/api.org= for all functions and keyword arguments.
-- **Entity model**: See =docs/entities.org= to learn about plist keys, tags, prefabs, and mixins.
-- **Physics pipeline**: See =docs/physics.org= for the full physics specification and collision model.
-- **Tweens**: See =docs/tweens.org= for time-based property interpolation and combining tweens with physics.
+- [[file:../demo/getting-started.scm][Getting started]] (=bin/demo-getting-started=) — arrow keys move a blue square; the starting point for this guide.
+- [[file:../demo/platformer.scm][Platformer]] (=bin/demo-platformer=) — gravity, jumping, tile collisions, frame animation.
+- [[file:../demo/shmup.scm][Shmup]] (=bin/demo-shmup=) — top-down shooter-style movement and firing.
+- [[file:../demo/topdown.scm][Topdown]] (=bin/demo-topdown=) — four-direction movement without gravity.
+- [[file:../demo/audio.scm][Audio]] (=bin/demo-audio=) — background music and sound effects.
+- [[file:../demo/sandbox.scm][Sandbox]] (=bin/demo-sandbox=) — group prefabs composed from mixins.
+- [[file:../demo/spritefont.scm][Sprite font]] (=bin/demo-spritefont=) — bitmap font rendering.
+- [[file:../demo/menu.scm][Menu]] (=bin/demo-menu=) — a simple UI menu.
+- [[file:../demo/tweens.scm][Tweens]] (=bin/demo-tweens=) — side-by-side comparison of easing functions.
+- [[file:../demo/scaling.scm][Scaling]] (=bin/demo-scaling=) — logical-resolution scaling via ~scale:~.
+- [[file:../demo/animation.scm][Animation]] (=bin/demo-animation=) — frame-based sprite animation.
-Happy coding!
+Pick the demo closest to the game you want to build, open its source next to the matching topic doc, and you will have a concrete example of every API call in context.
diff --git a/docs/input.org b/docs/input.org
new file mode 100644
index 0000000..81ff65e
--- /dev/null
+++ b/docs/input.org
@@ -0,0 +1,345 @@
+#+TITLE: Input
+
+Downstroke's input system is *action-based*. Your game never asks "is
+the =W= key held?" — it asks "is the =up= action held?". A single
+=input-config= record decides which raw SDL events (keyboard keys,
+joystick buttons, controller buttons, analog axes) map to which
+abstract action symbols, so the same update loop works on keyboard,
+joystick, and game controller without changes.
+
+Each frame, the engine collects pending SDL events, folds them into a
+fresh =input-state= record (current + previous action alists), and
+stores it on the =game= struct. Your =update:= hook reads that state
+through =game-input=.
+
+* The minimum you need
+
+#+begin_src scheme
+(import downstroke-engine
+ downstroke-input
+ downstroke-world
+ downstroke-entity)
+
+(game-run!
+ (make-game
+ title: "Move a Square" width: 320 height: 240
+
+ create: (lambda (game)
+ (game-scene-set! game
+ (make-sprite-scene
+ entities: (list (plist->alist
+ (list #:type 'player
+ #:x 150 #:y 100
+ #:width 32 #:height 32
+ #:color '(100 160 255)))))))
+
+ update: (lambda (game dt)
+ (let* ((scene (game-scene game))
+ (input (game-input game))
+ (player (car (scene-entities scene)))
+ (dx (cond ((input-held? input 'left) -2)
+ ((input-held? input 'right) 2)
+ (else 0))))
+ (game-scene-set! game
+ (update-scene scene
+ entities: (list (entity-set player #:x
+ (+ (entity-ref player #:x 0) dx))))))) ))
+#+end_src
+
+No =input-config:= keyword is passed, so =*default-input-config*= is
+used: arrow keys, WASD, =j=/=z= for =a=, =k=/=x= for =b=, =return= for
+=start=, =escape= for =quit=, plus a standard game-controller mapping.
+
+* Core concepts
+
+** Actions vs raw keys
+
+An *action* is a symbol that names something the game cares about
+(=up=, =a=, =start=). A *key binding* (or button/axis binding) maps a
+raw hardware event to an action. Game code only ever reads actions —
+raw SDL keysyms never leak into your =update:= hook.
+
+The default action list, defined in =*default-input-config*=, is:
+
+#+begin_src scheme
+'(up down left right a b start select quit)
+#+end_src
+
+These map loosely to a generic two-button gamepad: a D-pad (=up=,
+=down=, =left=, =right=), two face buttons (=a=, =b=), two system
+buttons (=start=, =select=), and a synthetic =quit= action the engine
+uses to terminate =game-run!= (the default loop exits when =quit= is
+held).
+
+You are free to add or rename actions when you build a custom
+=input-config= — the action list is just the set of symbols your game
+agrees to recognise.
+
+** The default input config
+
+=*default-input-config*= is a =input-config= record bound at module
+load time in =input.scm=. Its contents:
+
+*Keyboard map* (SDL keysym → action):
+
+| Keys | Action |
+|------------------+---------|
+| =w=, =up= | =up= |
+| =s=, =down= | =down= |
+| =a=, =left= | =left= |
+| =d=, =right= | =right= |
+| =j=, =z= | =a= |
+| =k=, =x= | =b= |
+| =return= | =start= |
+| =escape= | =quit= |
+
+*Joystick button map* (SDL joy button id → action). These ids suit a
+generic USB pad in the "SNES-ish" layout:
+
+| Button id | Action |
+|-----------+----------|
+| =0= | =a= |
+| =1= | =b= |
+| =7= | =start= |
+| =6= | =select= |
+
+*Game-controller button map* (SDL =SDL_GameController= symbol →
+action). This is the "Xbox-style" API SDL2 exposes for known
+controllers:
+
+| Button | Action |
+|--------------+----------|
+| =a= | =a= |
+| =b= | =b= |
+| =start= | =start= |
+| =back= | =select= |
+| =dpad-up= | =up= |
+| =dpad-down= | =down= |
+| =dpad-left= | =left= |
+| =dpad-right= | =right= |
+
+*Analog axis bindings.* Each binding is =(axis positive-action
+negative-action)= — when the axis value exceeds the deadzone, the
+positive action is held; when it drops below the negated deadzone, the
+negative action is held.
+
+- Joystick: =(0 right left)= and =(1 down up)= (X and Y axes).
+- Controller: =(left-x right left)= and =(left-y down up)= (left
+ analog stick).
+
+*Deadzone* is =8000= (SDL axis values range −32768 to 32767).
+
+All of these are accessible via =input-config-keyboard-map=,
+=input-config-joy-button-map=, and so on, but you generally won't
+touch them directly — you pass a whole replacement record via
+=make-input-config= (see below).
+
+** Querying input each frame
+
+The engine calls =input-state-update= once per frame with the SDL
+events it has just collected. That produces a new =input-state= record
+and stores it on the game via =game-input-set!=. Your =update:= hook
+reads it with =(game-input game)=:
+
+#+begin_src scheme
+(update: (lambda (game dt)
+ (let ((input (game-input game)))
+ ...)))
+#+end_src
+
+Three predicates live on an =input-state=:
+
+- =(input-held? state action)= — ~#t~ while the action is currently
+ active (key/button down, axis past deadzone).
+- =(input-pressed? state action)= — ~#t~ for exactly one frame: the
+ frame on which the action transitioned from not-held to held.
+- =(input-released? state action)= — ~#t~ for exactly one frame: the
+ frame on which the action transitioned from held to not-held.
+
+Press / release are derived from the record itself: =input-state=
+carries both a =current= and =previous= alist of =(action . bool)=
+pairs, and =input-state-update= rolls the previous snapshot forward
+each frame. You do not need to maintain any input history yourself.
+
+A fourth convenience, =(input-any-pressed? state config)=, returns
+~#t~ if /any/ action in the config's action list transitioned to
+pressed this frame — useful for "press any key to continue" screens.
+
+The call shape throughout the demos is:
+
+#+begin_src scheme
+(input-held? (game-input game) 'left)
+(input-pressed? (game-input game) 'a)
+(input-released? (game-input game) 'start)
+#+end_src
+
+** Customising the input config
+
+Build a replacement config with =make-input-config= and pass it to
+=make-game= via the =input-config:= keyword:
+
+#+begin_src scheme
+(define my-input
+ (make-input-config
+ actions: '(up down left right fire pause quit)
+ keyboard-map: '((up . up) (w . up)
+ (down . down) (s . down)
+ (left . left) (a . left)
+ (right . right) (d . right)
+ (space . fire)
+ (p . pause)
+ (escape . quit))
+ joy-button-map: '()
+ controller-button-map: '()
+ joy-axis-bindings: '()
+ controller-axis-bindings: '()
+ deadzone: 8000))
+
+(make-game
+ title: "Custom Controls"
+ input-config: my-input
+ ...)
+#+end_src
+
+All seven slots are required, but any of the map/binding slots can be
+empty (~'()~) if you don't want that input type. The =actions= list
+determines which symbols =input-any-pressed?= sweeps, and seeds the
+initial =input-state= with an entry per action.
+
+If you want to *extend* the defaults instead of replacing them, pull
+each slot off =*default-input-config*= and cons your extra entries:
+
+#+begin_src scheme
+(define my-input
+ (make-input-config
+ actions: (input-config-actions *default-input-config*)
+ keyboard-map: (cons '(space . a)
+ (input-config-keyboard-map *default-input-config*))
+ joy-button-map: (input-config-joy-button-map *default-input-config*)
+ controller-button-map: (input-config-controller-button-map *default-input-config*)
+ joy-axis-bindings: (input-config-joy-axis-bindings *default-input-config*)
+ controller-axis-bindings: (input-config-controller-axis-bindings *default-input-config*)
+ deadzone: (input-config-deadzone *default-input-config*)))
+#+end_src
+
+* Common patterns
+
+** Arrow-key movement
+
+Straight from =demo/getting-started.scm=: test each horizontal and
+vertical direction independently, pick a signed step, and write back
+the updated position.
+
+#+begin_src scheme
+(define +speed+ 2)
+
+(define (move-player player input)
+ (let* ((x (entity-ref player #:x 0))
+ (y (entity-ref player #:y 0))
+ (dx (cond ((input-held? input 'left) (- +speed+))
+ ((input-held? input 'right) +speed+)
+ (else 0)))
+ (dy (cond ((input-held? input 'up) (- +speed+))
+ ((input-held? input 'down) +speed+)
+ (else 0))))
+ (entity-set (entity-set player #:x (+ x dx)) #:y (+ y dy))))
+#+end_src
+
+** Jump on press vs move while held
+
+The platformer distinguishes a /continuous/ action (running left/right
+while =left=/=right= are held) from an /edge-triggered/ action
+(jumping exactly once when =a= is first pressed):
+
+#+begin_src scheme
+(define (player-vx input)
+ (cond ((input-held? input 'left) -3)
+ ((input-held? input 'right) 3)
+ (else 0)))
+
+(define (update-player player input)
+ (let* ((jump? (and (input-pressed? input 'a)
+ (entity-ref player #:on-ground? #f)))
+ (player (entity-set player #:vx (player-vx input))))
+ (if jump?
+ (entity-set player #:ay (- *jump-force*))
+ player)))
+#+end_src
+
+Using =input-pressed?= instead of =input-held?= prevents the player
+from "spamming" the jump simply by keeping =a= depressed — the action
+must be released and re-pressed to fire again.
+
+The same pattern shows up in =demo/shmup.scm= for firing bullets:
+
+#+begin_src scheme
+(if (input-pressed? input 'a)
+ (values updated (list (make-bullet ...)))
+ (values updated '()))
+#+end_src
+
+** Controller + keyboard simultaneously
+
+The default config registers bindings for keyboard, joystick, /and/
+game controller at the same time — no configuration switch, no "input
+device" concept. Whichever device fires an event first on a given
+frame sets the corresponding action, and the next frame reflects it.
+You get controller support for free as long as you keep the default
+config (or carry its =joy-*= / =controller-*= slots forward into your
+custom config).
+
+=game-run!= opens every connected game controller at startup, and
+=handle-controller-device= opens any controller hot-plugged during the
+session, so no extra wiring is needed.
+
+** Remapping a single action
+
+To let players press =space= for =start= instead of =return=, override
+just the keyboard map (keeping the rest of the defaults):
+
+#+begin_src scheme
+(define my-input
+ (make-input-config
+ actions: (input-config-actions *default-input-config*)
+ keyboard-map:
+ '((w . up) (up . up)
+ (s . down) (down . down)
+ (a . left) (left . left)
+ (d . right) (right . right)
+ (j . a) (z . a)
+ (k . b) (x . b)
+ (space . start) ;; was (return . start)
+ (escape . quit))
+ joy-button-map: (input-config-joy-button-map *default-input-config*)
+ controller-button-map: (input-config-controller-button-map *default-input-config*)
+ joy-axis-bindings: (input-config-joy-axis-bindings *default-input-config*)
+ controller-axis-bindings: (input-config-controller-axis-bindings *default-input-config*)
+ deadzone: (input-config-deadzone *default-input-config*)))
+
+(make-game
+ title: "Remapped Start"
+ input-config: my-input
+ ...)
+#+end_src
+
+Multiple keys can map to the same action (the defaults already do
+this: both =w= and =up= trigger =up=), so another approach is to add
+=(space . start)= /alongside/ the existing =(return . start)= entry
+instead of replacing it.
+
+* Demos
+
+- [[file:../demo/getting-started.scm][Getting started]] (=bin/demo-getting-started=) — arrow-key movement with =input-held?=.
+- [[file:../demo/platformer.scm][Platformer]] (=bin/demo-platformer=) — mixes =input-held?= for running with =input-pressed?= for jumping.
+- [[file:../demo/shmup.scm][Shmup]] (=bin/demo-shmup=) — uses =input-pressed?= on =a= to fire bullets exactly once per button press.
+
+* See also
+
+- [[file:guide.org][Getting-started guide]] — the full walkthrough that
+ builds the minimal input example above.
+- [[file:entities.org][Entities]] — the =#:input-map= entity key and
+ =apply-input-to-entity= hook for data-driven movement.
+- [[file:physics.org][Physics]] — how velocities set by input feed into
+ the built-in physics pipeline.
+- [[file:scenes.org][Scenes]] — how =game-input= fits alongside
+ =game-scene= in the =update:= hook.
diff --git a/docs/physics.org b/docs/physics.org
index 81c57b8..9cf9b96 100644
--- a/docs/physics.org
+++ b/docs/physics.org
@@ -1,493 +1,655 @@
-#+title: Physics System
-#+author: Downstroke Contributors
-
-* Overview
-
-With the default =engine-update:= hook (=default-engine-update= from =downstroke-engine=), the physics pipeline runs **automatically** every frame *before* your =update:= hook. Input is processed first, then =engine-update= integrates motion and collisions, then =update:= runs so your game logic sees resolved positions and =#:on-ground?=.
-
-Set =engine-update: #f= on =make-game= to disable the built-in pass, or pass a custom =(lambda (game dt) ...)= to drive your own order — useful for shmups, menus, or experiments.
-
-All physics functions are **functional and immutable**: per-entity steps take an entity (plist) plus the current scene and frame =dt=, and return a NEW plist. Bulk steps take the entity list only. No in-place mutation of entities.
-
-Tile collision reads solid flags from the tilemap embedded in the scene (=scene-tilemap=).
-
-** Key Physics Functions
-
-- =step-tweens= — advance =#:tween= (see =docs/tweens.org=)
-- =apply-acceleration= — consume one-shot #:ay into velocity
-- =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-on-solid= — set =#:on-ground?= from tiles below feet and/or solid entities in the scene list
-- =resolve-entity-collisions= — all-pairs AABB push-apart for solid entities (=entities= only)
-- =sync-groups= — group follow origins (=entities= only)
-- =aabb-overlap?= — pure boolean collision test (for queries, not resolution)
-
-Jumping is **game logic**: set =#:ay= in =update:= (e.g. =(- *jump-force*)=) when the player jumps; there is no =apply-jump=.
-
-* Physics Pipeline
-
-The canonical order inside =default-engine-update= (and the recommended order when composing steps yourself) is:
+#+TITLE: Physics
+#+AUTHOR: Downstroke Contributors
+
+Downstroke ships with a built-in *physics pipeline* that runs every frame
+before your =update:= hook. It advances tweens, integrates acceleration
+and gravity into velocity, moves entities, resolves AABB collisions
+against the tilemap and against each other, detects whether an entity is
+standing on solid ground, and finally advances per-entity animation. The
+pipeline is purely *functional*: every step takes an entity (alist) and
+returns a new entity — no in-place mutation. You can opt specific
+entities out of specific steps (=#:skip-pipelines=), replace the whole
+pipeline with a custom procedure, or turn it off entirely
+(=engine-update: 'none=) for shmups and menus that want full manual
+control. Velocities are in *pixels per frame*, gravity is a constant
+=+1= pixel/frame² added to =#:vy= every frame for entities with
+=#:gravity? #t=, and tile collisions snap entities cleanly to tile edges
+on both axes. The canonical reference example is the *Platformer demo*
+— run with =bin/demo-platformer=, source in =demo/platformer.scm=.
+
+* The minimum you need
+
+To get physics behavior in your game, do three things:
+
+1. Give your player entity dimensions, velocity keys, and
+ =#:gravity? #t=:
+
+ #+begin_src scheme
+ (list #:type 'player
+ #:x 100 #:y 50
+ #:width 16 #:height 16
+ #:vx 0 #:vy 0
+ #:gravity? #t
+ #:on-ground? #f
+ #:tags '(player))
+ #+end_src
+
+2. Let the default engine update run. When you call =make-game=
+ *without* overriding =engine-update:= on the scene, the built-in
+ pipeline runs automatically each frame. You do not need to import
+ or compose any physics procedure yourself.
+
+3. In your =update:= hook, set =#:vx= from input and set =#:ay= to
+ =(- *jump-force*)= on the frame the player jumps. The pipeline
+ integrates velocity, handles tile collisions, and refreshes
+ =#:on-ground?= *before* your next =update:= tick so you can gate
+ jumps on it:
+
+ #+begin_src scheme
+ update: (lambda (game dt)
+ (let* ((input (game-input game))
+ (scene (game-scene game))
+ (player (car (scene-entities scene)))
+ (jump? (and (input-pressed? input 'a)
+ (entity-ref player #:on-ground? #f)))
+ (player (entity-set player #:vx
+ (cond ((input-held? input 'left) -3)
+ ((input-held? input 'right) 3)
+ (else 0)))))
+ (let ((player (if jump?
+ (entity-set player #:ay (- *jump-force*))
+ player)))
+ (game-scene-set! game
+ (update-scene scene entities: (list player))))))
+ #+end_src
+
+That is the entire contract: set intent (velocity / one-shot
+acceleration) in =update:=, the pipeline resolves motion and collisions
+before your next tick sees the entity again. The *Platformer demo* —
+run with =bin/demo-platformer=, source in =demo/platformer.scm= — is
+exactly this shape.
+
+Everything else in this document is either a refinement (skipping
+specific steps, custom pipelines, entity–entity collisions) or a
+diagnostic aid.
+
+* Core concepts
+
+** The engine pipeline, step by step
+
+=default-engine-update= (defined in =engine.scm=) is the procedure the
+engine runs each frame when a scene's =engine-update= field is =#f=
+(the default). It applies ten steps, in a fixed order, across the
+scene's entity list. The first eight steps are *per-entity* (each
+entity is processed independently via =scene-map-entities=). The last
+two operate on the *whole entity list* (via
+=scene-transform-entities=):
#+begin_src
-Frame loop (see =docs/api.org= =game-run!=):
+Frame:
input
- engine-update (=default-engine-update= by default)
+ engine-update (default-engine-update unless overridden)
- update (your game logic: set #:vx, #:vy, #:ay, …)
+ update: (your game logic — set #:vx, #:vy, #:ay, ...)
- camera follow → render
-
-Inside =default-engine-update= (per entity, then bulk):
-step-tweens (advance #:tween)
- ↓
-apply-acceleration (consume #:ay into #:vy)
- ↓
-apply-gravity (add gravity constant to #:vy)
- ↓
-apply-velocity-x (add #:vx to #:x)
- ↓
-resolve-tile-collisions-x (snap off horizontal tiles, zero #:vx)
- ↓
-apply-velocity-y (add #:vy to #:y)
- ↓
-resolve-tile-collisions-y (snap off vertical tiles, zero #:vy)
- ↓
-detect-on-solid (tiles and/or scene entities underfoot → #:on-ground?)
- ↓
-resolve-entity-collisions (whole entity list)
- ↓
-sync-groups (whole entity list)
-#+end_src
-
-Per-entity steps use signature =(entity scene dt)=. Bulk steps =(resolve-entity-collisions entities)= and =(sync-groups entities)= apply to the full list; use =scene-transform-entities=.
-
-**Not all steps apply to every entity** (=#:skip-pipelines=, guards like =#:gravity?=, missing tilemap). See the examples section for patterns:
-
-- **Platformer**: default =engine-update=; =update:= sets =#:vx= and =#:ay= for jumps
-- **Top-down**: same auto pipeline; entities without =#:gravity?= skip gravity/acceleration/ground
-- **Physics Sandbox**: default or custom =scene-map-entities= with the same step order
-- **Custom / shmup**: =engine-update: #f= and move entities in =update:=
-
-* Skipping steps (~#:skip-pipelines~)
-
-An entity may include ~#:skip-pipelines=, a list of **symbols** naming steps to **omit** for that entity only. Absent or empty means no steps are skipped.
-
-| Symbol | Skipped call |
-|--------+----------------|
-| ~acceleration~ | ~apply-acceleration~ |
-| ~gravity~ | ~apply-gravity~ |
-| ~velocity-x~ | ~apply-velocity-x~ |
-| ~velocity-y~ | ~apply-velocity-y~ |
-| ~tile-collisions-x~ | ~resolve-tile-collisions-x~ |
-| ~tile-collisions-y~ | ~resolve-tile-collisions-y~ |
-| ~on-solid~ | ~detect-on-solid~ |
-| ~entity-collisions~ | participation in ~resolve-entity-collisions~ / ~resolve-pair~ |
-| ~tweens~ | ~step-tweens~ (see ~downstroke-tween~) |
-
-**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.
-
-**Legacy ~apply-velocity~:** skips each axis independently if ~velocity-x~ or ~velocity-y~ is listed.
-
-Helper: ~(entity-skips-pipeline? entity step-symbol)~ (from ~downstroke-entity~) returns ~#t~ if ~step-symbol~ is in the entity’s skip list.
-
-** ~define-pipeline~ (~downstroke-entity~)
-
-Physics steps are defined with ~(define-pipeline (procedure-name skip-symbol) (entity scene dt) body ...)~ from the entity module, optionally with ~guard: expr~ before ~body ...~: when the guard is false, the entity is returned unchanged before the body runs. The first formal must be the entity; ~scene~ and ~dt~ are standard for pipeline uniformity. 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.
-
-Use cases:
-
-- **Tweens / knockback:** skip ~acceleration~, ~gravity~, ~velocity-x~, ~velocity-y~ (and often ~tweens~ is *not* skipped so ~step-tweens~ still runs) while a tween drives ~#:x~ / ~#:y~, but keep tile resolution so the body does not rest inside walls.
-- **Top-down:** entities without ~#:gravity?~ skip gravity, acceleration, and ground detection automatically; you usually do not need ~#:skip-pipelines= unless some entities differ from others.
-
-* Pipeline Steps
-
-** apply-acceleration
-
-#+begin_src scheme
-(apply-acceleration entity scene dt)
-#+end_src
-
-**Reads**: =#:ay= (default 0), =#:vy= (default 0), =#:gravity?= to decide whether to consume
-
-**Writes**: =#:vy= (adds #:ay to it), =#:ay= (reset to 0)
-
-**Description**: Consumes the one-shot acceleration into velocity. Used for jumps (set =#:ay= in =update:=) and other instant bursts. Only works if =#:gravity?= is true (gravity-enabled entities). =scene= and =dt= are unused but required for the pipeline signature.
+ camera follow
+ ↓
+ render
-** apply-gravity
+Inside default-engine-update, per entity:
+ step-tweens (advance #:tween)
+ ↓
+ apply-acceleration (consume #:ay into #:vy, clear #:ay)
+ ↓
+ apply-gravity (add *gravity* to #:vy)
+ ↓
+ apply-velocity-x (add #:vx to #:x)
+ ↓
+ resolve-tile-collisions-x (snap off horizontal tiles, zero #:vx)
+ ↓
+ apply-velocity-y (add #:vy to #:y)
+ ↓
+ resolve-tile-collisions-y (snap off vertical tiles, zero #:vy)
+ ↓
+ detect-on-solid (set #:on-ground? from tiles / solids)
-#+begin_src scheme
-(apply-gravity entity scene dt)
+Then, across the whole entity list:
+ resolve-entity-collisions (AABB push-apart of solid entities)
+ ↓
+ sync-groups (snap group members to their origin)
+ ↓
+ apply-animation (advance #:anim-tick / #:anim-frame)
#+end_src
-**Reads**: =#:gravity?= (boolean), =#:vy= (default 0)
-
-**Writes**: =#:vy= (adds 1 pixel/frame²)
-
-**Description**: Applies a constant gravitational acceleration. The gravity constant is =*gravity* = 1= pixel per frame per frame. Only applies if =#:gravity?= is true. Safe to call every frame.
-
-** apply-velocity-x
+Here is what each step does, with the exact keys it reads and writes.
+All per-entity steps have the signature =(step scene entity dt)=.
+
+*** step-tweens
+
+- *Reads*: =#:tween=
+- *Writes*: =#:tween= (advanced, or removed when finished), plus any
+ entity keys the tween is targeting (typically =#:x=, =#:y=, a color
+ component, etc. — see =docs/tweens.org=)
+- *Guard*: does nothing if =#:tween= is =#f=
+- Defined in =tween.scm=.
+
+*** apply-acceleration
+
+- *Reads*: =#:ay= (default 0), =#:vy= (default 0)
+- *Writes*: =#:vy= (set to =(+ vy ay)=), =#:ay= (reset to 0)
+- *Guard*: =(entity-ref entity #:gravity? #f)= — only runs on entities
+ with =#:gravity? #t=
+- One-shot: =#:ay= is *consumed* every frame. This is the jump
+ mechanism — set =#:ay= to =(- *jump-force*)= on the frame you want
+ to jump and this step folds it into =#:vy= exactly once.
+
+*** apply-gravity
+
+- *Reads*: =#:gravity?=, =#:vy= (default 0)
+- *Writes*: =#:vy= (set to =(+ vy *gravity*)=)
+- *Guard*: only runs when =#:gravity? #t=
+- =*gravity*= is exported from =downstroke-physics=; its value is
+ =1= pixel/frame². Gravity accumulates until =resolve-tile-collisions-y=
+ zeroes =#:vy= on contact with the floor.
+
+*** apply-velocity-x
+
+- *Reads*: =#:x= (default 0), =#:vx= (default 0)
+- *Writes*: =#:x= (set to =(+ x vx)=)
+- No guard. Every entity moves by =#:vx=, regardless of =#:gravity?=.
+ Top-down games therefore "just work" — set =#:vx= and =#:vy= from
+ input, the pipeline moves the entity and resolves tile collisions,
+ and the absence of =#:gravity?= makes gravity/acceleration/ground
+ detection no-op.
+
+*** resolve-tile-collisions-x
+
+- *Reads*: scene's tilemap via =scene-tilemap=, then =#:x=, =#:y=,
+ =#:width=, =#:height=, =#:vx=
+- *Writes*: =#:x= (snapped), =#:vx= (set to 0 if a collision occurred)
+- *Guard*: only runs when =(scene-tilemap scene)= is truthy. If the
+ scene has no tilemap this step is a no-op.
+- Behavior: computes every tile cell overlapping the entity's AABB
+ (=entity-tile-cells=), walks them, and if any cell's tile id is
+ non-zero it snaps the entity to that tile's edge on the X axis.
+ Moving right (=#:vx > 0=) snaps the right edge to the *near* side of
+ the first solid tile found (shallowest penetration). Moving left
+ (=#:vx < 0=) snaps the left edge to the *far* side of the last solid
+ tile found. In both cases =#:vx= is zeroed.
+
+*** apply-velocity-y
+
+- *Reads*: =#:y= (default 0), =#:vy= (default 0)
+- *Writes*: =#:y= (set to =(+ y vy)=)
+- No guard. Same as =apply-velocity-x= on the Y axis.
+
+*** resolve-tile-collisions-y
+
+- *Reads*: scene's tilemap, then =#:x=, =#:y=, =#:width=, =#:height=,
+ =#:vy=
+- *Writes*: =#:y= (snapped), =#:vy= (zeroed on collision)
+- *Guard*: only runs when =(scene-tilemap scene)= is truthy.
+- Behavior: same as the X-axis version, but on Y. Moving down
+ (=#:vy > 0=) snaps the feet to a floor tile's top; moving up
+ (=#:vy < 0=) snaps the head to a ceiling tile's bottom. X and Y are
+ resolved in *separate passes* (move X → resolve X → move Y → resolve
+ Y) to avoid corner-clipping.
+
+*** detect-on-solid
+
+- *Reads*: =#:gravity?=, =#:x=, =#:y=, =#:width=, =#:height=, =#:vy=;
+ and =(scene-entities scene)= for entity-supported ground
+- *Writes*: =#:on-ground?= (=#t= or =#f=)
+- *Guard*: only runs on gravity entities. The =?= in the name is
+ slightly misleading: the step returns an updated *entity* (with
+ =#:on-ground?= set), not a boolean.
+- Two sources of "ground":
+ 1. *Tile ground.* Probes one pixel below the feet (=(+ y h 1)=) at
+ both lower corners; if either column's tile id at that row is
+ non-zero, the entity is grounded. If =scene-tilemap= is =#f= this
+ probe is skipped.
+ 2. *Entity ground.* Another entity in the scene with =#:solid? #t=
+ counts as ground if it horizontally overlaps the mover, its top
+ is within =*entity-ground-contact-tolerance*= (5 pixels) of the
+ mover's bottom, and the mover's =|#:vy|= is at most
+ =*entity-ground-vy-max*= (12) — so a body falling too fast is
+ *not* treated as supported mid-frame.
+
+*** resolve-entity-collisions
+
+This is a *bulk* step: it takes the entity list and returns a new
+entity list.
+
+- *Reads*: for each entity, =#:solid?=, =#:immovable?=, =#:x=, =#:y=,
+ =#:width=, =#:height=
+- *Writes*: for overlapping pairs, =#:x=, =#:y=, and whichever axis
+ velocity (=#:vx= or =#:vy=) the separation was applied on
+- Behavior: all-pairs AABB overlap check (O(n²)). For each pair where
+ *both* entities have =#:solid? #t=:
+ - both =#:immovable? #t= → pair skipped
+ - one =#:immovable? #t= → the movable one is pushed out along the
+ shallow penetration axis, its velocity on that axis is zeroed. If
+ the mover's center is still *above* the immovable's center (a
+ landing contact), separation is *forced vertical* so the mover
+ doesn't get shoved sideways off the edge of a platform.
+ - neither immovable → both are pushed apart by half the overlap
+ along the smaller-penetration axis, and their velocities on that
+ axis are set to ±1 (to prevent sticking / drift back into each
+ other).
+ - If either entity lists =entity-collisions= in =#:skip-pipelines=,
+ the pair is skipped entirely.
+
+*** sync-groups
+
+- Bulk step. Takes the entity list and returns a new entity list.
+- For each entity with =#:group-origin? #t= and a =#:group-id=, marks
+ it as the origin of its group (first-wins).
+- For each entity with a =#:group-id= that is *not* the origin, snaps
+ its =#:x= / =#:y= to =(+ origin-x #:group-local-x)= /
+ =(+ origin-y #:group-local-y)=.
+- Intended use: multi-part entities (a platform made of several tiles,
+ a boss with attached hitboxes) that should move as one body. Move
+ the origin only; sync-groups rigidly follows the members.
+
+*** apply-animation
+
+- *Reads*: =#:animations=, =#:anim-name=, =#:anim-frame=, =#:anim-tick=
+- *Writes*: =#:anim-tick=, =#:anim-frame=, =#:tile-id=, =#:duration=
+- *Guard*: only runs when =#:animations= is present.
+- Advances the per-entity animation state machine and updates
+ =#:tile-id= so the renderer draws the correct sprite. Fully
+ documented in =docs/animation.org=.
+
+** Opting in/out of the pipeline
+
+Most per-entity steps have one or two ways to opt out. Use the one
+that matches your intent:
+
+*** Per-step guard keys
+
+Some pipeline steps only run when a specific entity key is set. These
+are *guards* declared with the =guard:= clause of =define-pipeline=:
+
+| Step | Guard |
+|-----------------------------+-------------------------|
+| =step-tweens= | =#:tween= |
+| =apply-acceleration= | =#:gravity?= |
+| =apply-gravity= | =#:gravity?= |
+| =detect-on-solid= | =#:gravity?= |
+| =apply-animation= | =#:animations= |
+| =resolve-tile-collisions-x= | =(scene-tilemap scene)= |
+| =resolve-tile-collisions-y= | =(scene-tilemap scene)= |
+
+A top-down entity with =#:gravity?= absent (or =#f=) therefore
+automatically skips acceleration, gravity, and ground detection — the
+rest of the pipeline (velocity, tile collisions, entity collisions,
+animation) still runs.
+
+Entity–entity collisions have their own opt-*in* gate instead of a
+guard: only entities with =#:solid? #t= participate in
+=resolve-entity-collisions=. Non-solid entities are ignored by the
+pair walk.
+
+*** =#:skip-pipelines= per-entity override
+
+Every per-entity step is also gated by =entity-skips-pipeline?= (from
+=downstroke-entity=). If the entity's =#:skip-pipelines= list contains
+the step's *symbol*, the step returns the entity unchanged. The
+symbols match the second name in each =define-pipeline= form:
+
+| Skip symbol | Skips |
+|---------------------+----------------------------------------------|
+| =tweens= | =step-tweens= |
+| =acceleration= | =apply-acceleration= |
+| =gravity= | =apply-gravity= |
+| =velocity-x= | =apply-velocity-x= |
+| =velocity-y= | =apply-velocity-y= |
+| =tile-collisions-x= | =resolve-tile-collisions-x= |
+| =tile-collisions-y= | =resolve-tile-collisions-y= |
+| =on-solid= | =detect-on-solid= |
+| =entity-collisions= | participation in =resolve-entity-collisions= |
+| =animation= | =apply-animation= |
+
+For =resolve-entity-collisions=, if *either* entity in a pair lists
+=entity-collisions=, the whole pair is skipped. This is the clean way
+to mark "ghosts" or script-driven actors that should not be pushed
+apart from others.
+
+Example: an entity driven by a tween that shouldn't be integrated by
+velocity or affected by gravity, but should still resolve against
+walls and run animation:
#+begin_src scheme
-(apply-velocity-x entity scene dt)
+(list #:type 'moving-platform
+ #:x 100 #:y 300 #:width 48 #:height 16
+ #:vx 0 #:vy 0
+ #:tween ...
+ #:skip-pipelines '(acceleration gravity velocity-x velocity-y))
#+end_src
-**Reads**: =#:vx= (default 0), =#:x= (default 0)
-
-**Writes**: =#:x= (adds #:vx to it)
+The pipeline will advance =#:tween= (which can overwrite =#:x= / =#:y=
+directly), skip the four listed integrators, still resolve tile
+collisions, and still detect ground under it.
-**Description**: Moves the entity horizontally by its velocity. Call once per frame, before =resolve-tile-collisions-x=.
+*** =define-pipeline= (how steps are declared)
-** apply-velocity-y
+All per-entity pipeline steps are declared with =define-pipeline= from
+=downstroke-entity= (see =docs/entities.org=). The shape is:
#+begin_src scheme
-(apply-velocity-y entity scene dt)
+(define-pipeline (procedure-name skip-symbol) (scene entity dt)
+ guard: (some-expression-over entity)
+ (body ...))
#+end_src
-**Reads**: =#:vy= (default 0), =#:y= (default 0)
-
-**Writes**: =#:y= (adds #:vy to it)
+The macro expands to a procedure =(procedure-name scene entity dt)=
+that returns =entity= unchanged if either the guard is =#f= *or* the
+entity's =#:skip-pipelines= list contains =skip-symbol=. Otherwise it
+runs =body=. The =guard:= clause is optional; when absent, only
+=#:skip-pipelines= is consulted.
-**Description**: Moves the entity vertically by its velocity. Call once per frame, before =resolve-tile-collisions-y=.
+** Overriding or disabling the pipeline
-** resolve-tile-collisions-x
+A scene has an =engine-update= field. The game's frame loop dispatches
+on it every tick:
#+begin_src scheme
-(resolve-tile-collisions-x entity scene dt)
+;; In game-run!, for the current scene:
+(cond
+ ((eq? eu 'none)) ; do nothing
+ ((procedure? eu) (eu game dt)) ; run user procedure
+ ((not eu) (default-engine-update game dt))) ; run default pipeline
#+end_src
-**Reads**: =#:x=, =#:y=, =#:width=, =#:height=, =#:vx=
-
-**Writes**: =#:x= (snapped to tile edge), =#:vx= (zeroed if collision occurs)
-
-**Description**: Detects all solid tiles overlapping the entity's AABB and snaps the entity to the nearest tile edge on the X-axis. If a collision is found:
-
-- **Moving right** (vx > 0): entity's right edge is snapped to the left edge of the tile (position = tile-x - width)
-- **Moving left** (vx < 0): entity's left edge is snapped to the right edge of the tile (position = tile-x + tile-width)
-
-Velocity is zeroed to stop the entity from sliding. Call this immediately after =apply-velocity-x=.
-
-** resolve-tile-collisions-y
+This is three distinct modes:
+
+| =engine-update= | Behavior |
+|-----------------+----------------------------------------------------------------------|
+| =#f= (default) | Run =default-engine-update= — the full built-in pipeline. |
+| A procedure | Call =(proc game dt)= each frame *instead of* the default. |
+| ='none= | Run *no* engine update. Only your =update:= hook advances the scene. |
+
+=#f= is the normal case. =default-engine-update= is the recommended
+starting point even for customized games — use =#:skip-pipelines= per
+entity if you need selective opt-outs. Drop to a custom procedure when
+you need a *different order* of steps (for instance, running
+=detect-on-solid= *after* =resolve-entity-collisions= so that standing
+on an entity that was pushed apart this frame is seen correctly). Drop
+to ='none= when the pipeline has no value for your game at all — the
+*shmup demo* is this case: bullets, enemies, and the player all move
+in =update:= with per-type rules that don't map to gravity+tiles.
+
+See the *Common patterns* section below for worked examples of all
+three modes.
+
+** Tile collisions & entity collisions
+
+Both resolution systems are *AABB-only*. Every entity and every tile
+is treated as an axis-aligned rectangle; slopes, rotations, and
+per-pixel collision are not supported.
+
+*** Tile collision algorithm
+
+Tiles come from a =downstroke-tilemap= parsed from a TMX file (Tiled
+editor). The tilemap stores a grid of tile ids across one or more
+layers; =tilemap-tile-at= returns =0= for an empty cell and a positive
+id for a filled cell. Only *empty vs non-empty* is checked — there is
+no per-tile "solid" flag in the core engine; any non-zero tile counts
+as solid for collision purposes.
+
+For each axis:
+
+1. Compute the set of tile cells the entity's AABB overlaps
+ (=entity-tile-cells=), in tile coordinates. The AABB is computed
+ from =#:x=, =#:y=, =#:width=, =#:height=; the lower edges use
+ =(- (+ coord size) 1)= so an entity exactly flush with a tile edge
+ is *not* considered overlapping that tile.
+2. For each overlapping cell, if its tile id is non-zero, snap the
+ entity to the tile edge on that axis. The snap function
+ (=tile-push-pos=) is:
+
+ #+begin_src scheme
+ ;; Moving forward (v>0): snap leading edge to tile's near edge.
+ ;; Moving backward (v<0): snap trailing edge to tile's far edge.
+ (if (> v 0)
+ (- (* coord tile-size) entity-size)
+ (* (+ coord 1) tile-size))
+ #+end_src
+
+3. Zero the axis velocity so the entity doesn't slide through.
+
+X and Y are resolved in *separate passes* (see the pipeline order
+above). Resolving them together tends to produce corner-clip bugs
+where a player moving diagonally gets stuck at the corner of a floor
+and a wall; the two-pass approach avoids this entirely.
+
+*** Entity collision algorithm
+
+=resolve-entity-collisions= walks all unique pairs =(i, j)= with =i <
+j= of the entity list and calls =resolve-pair=. For each pair:
+
+- Both must have =#:solid? #t= and neither may list =entity-collisions=
+ in =#:skip-pipelines=.
+- AABB overlap test (=aabb-overlap?=): strictly *overlapping*;
+ edge-touching does *not* count.
+- On overlap, =push-apart= picks the minimum-penetration axis (X vs Y,
+ whichever overlap is smaller) and pushes each entity half the
+ overlap away from the other, setting velocity on that axis to ±1.
+- If exactly one entity has =#:immovable? #t= (static geometry like a
+ platform), only the *other* entity moves, by the full overlap, and
+ its velocity on the separation axis is zeroed.
+- Landing-on-top preference: when a movable body is falling onto a
+ static one and the movable's center is still above the static's
+ center, separation is forced *vertical* regardless of which axis has
+ the smaller overlap. This is what makes moving platforms usable — a
+ narrow horizontal overlap at the edge of a platform doesn't shove
+ the player off sideways.
+
+*** =#:on-ground?= as a query
+
+=#:on-ground?= is the single key you'll read most often. It is a
+*result* of the pipeline (written by =detect-on-solid=), not an input.
+It is re-computed every frame *after* both tile-collision passes but
+*before* entity collisions. If your game needs ground detection to
+reflect entity push-apart (rare — it matters for very thin platforms),
+install a custom =engine-update= that calls =detect-on-solid= after
+=resolve-entity-collisions=.
+
+*** =aabb-overlap?= for manual queries
+
+If you want to detect overlap *without* resolving it (bullets hitting
+enemies, damage zones, pickups), call =aabb-overlap?= directly:
#+begin_src scheme
-(resolve-tile-collisions-y entity scene dt)
+(aabb-overlap? x1 y1 w1 h1 x2 y2 w2 h2) ;; → #t or #f
#+end_src
-**Reads**: =#:x=, =#:y=, =#:width=, =#:height=, =#:vy=
-
-**Writes**: =#:y= (snapped to tile edge), =#:vy= (zeroed if collision occurs)
+This is pure — it does not touch either entity. The *shmup demo*
+(=demo/shmup.scm=) uses exactly this pattern to find bullet/enemy
+overlaps and remove them from the scene.
-**Description**: Detects all solid tiles overlapping the entity's AABB and snaps the entity to the nearest tile edge on the Y-axis. If a collision is found:
+* Common patterns
-- **Moving down** (vy > 0): entity's bottom edge is snapped to the top edge of the tile (position = tile-y - height)
-- **Moving up** (vy < 0): entity's top edge is snapped to the bottom edge of the tile (position = tile-y + tile-height)
+** Moving platform (=vx= + =immovable?=)
-Velocity is zeroed. Call this immediately after =apply-velocity-y=.
-
-** detect-on-solid
+A platform that slides horizontally and carries the player:
#+begin_src scheme
-(detect-on-solid entity scene dt)
+(list #:type 'platform
+ #:x 200 #:y 300
+ #:width 48 #:height 8
+ #:vx 1 #:vy 0
+ #:solid? #t #:immovable? #t
+ #:tags '(platform))
#+end_src
-**Reads**: =#:gravity?=, =#:x=, =#:y=, =#:width=, =#:height=, =#:vy=; other entities from =(scene-entities scene)= for solid support
-
-**Writes**: =#:on-ground?= (set to =#t= if supported by a solid tile probe and/or by another solid's top surface, else =#f=)
+- =#:solid? #t= makes the platform participate in
+ =resolve-entity-collisions=.
+- =#:immovable? #t= means only *other* entities are pushed; the
+ platform's =#:x= / =#:y= are never touched by entity collisions.
+- =#:vx 1= makes =apply-velocity-x= move the platform 1 px/frame.
+- The platform has no =#:gravity?= so it doesn't fall.
+- When the player lands on it, =detect-on-solid= sees the solid entity
+ directly below (via =entity-solid-support-below?=) and sets the
+ player's =#:on-ground?= to =#t=.
+- Turn the platform around at the ends by flipping =#:vx= in your
+ =update:= hook when =#:x= reaches a limit.
-**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), **or** (2) resting on another solid entity's **top** from the scene list (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). If =scene-tilemap= is =#f=, the tile probe is skipped; entity–entity support still applies when other solids exist in the list. Only runs when =#:gravity?= is true.
+For a platform that should *not* move horizontally on its own (a
+purely static crate), leave =#:vx= / =#:vy= at 0.
-Use =#:on-ground?= in =update:= to gate jump input; in =default-engine-update= this step runs **after** tile collision and **before** =resolve-entity-collisions= (see engine order). If you need ground detection to account for entity push-apart first, use a custom =engine-update= and call =detect-on-solid= after =resolve-entity-collisions=.
+** Disabling gravity on a bullet
-** resolve-entity-collisions
+A bullet wants straight-line motion, no gravity, no ground detection,
+no entity push-apart (it destroys enemies on contact, it doesn't shove
+them):
#+begin_src scheme
-(resolve-entity-collisions entities)
+(list #:type 'bullet
+ #:x 100 #:y 200
+ #:width 4 #:height 8
+ #:vx 0 #:vy -6
+ ;; Omit #:gravity?, so apply-acceleration, apply-gravity,
+ ;; and detect-on-solid are all no-ops for this entity.
+ ;; Omit #:solid?, so it doesn't enter resolve-entity-collisions.
+ #:tags '(bullet))
#+end_src
-**Input**: A list of entity plists
-
-**Reads**: For each entity: =#:solid?=, =#:x=, =#:y=, =#:width=, =#:height=, =#:vx=, =#:vy=
-
-**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: if one has =#:immovable? #t=, only the other entity is displaced (its velocity on that axis is zeroed). For movable vs immovable, separation prefers **vertical** resolution when the movable’s center is still **above** the immovable’s center (typical landing on a platform), so narrow horizontal overlap does not shove the mover sideways through the edge. Otherwise the shallow overlap axis is used. If both are immovable, the pair is skipped. If neither is immovable, the two bodies are pushed apart along the smaller overlap axis and their velocities on that axis are set to ±1.
+=apply-velocity-x= and =apply-velocity-y= still move the bullet
+(-6 px/frame upward). Tile collisions still run, which is usually what
+you want — bullets can hit walls. For a bullet that passes through
+walls, add =#:skip-pipelines '(tile-collisions-x tile-collisions-y)=.
-Entities without =#:solid?= or with =#:solid? #f= are skipped. Returns a new entity list with collisions resolved.
+Use =aabb-overlap?= in your =update:= hook to detect hits against
+enemies.
-This is relatively expensive: O(n²) for n entities. Use only when entity count is low (< 100) or for game objects where push-apart is desired.
+** Skipping just tile collisions on one entity
-** Using =resolve-entity-collisions= on a scene
-
-Apply the pure list function via =scene-transform-entities= (from =downstroke-world=):
+Sometimes you have an entity (pickup, decorative particle, ghost) that
+should move via =#:vx= / =#:vy= but *not* snap against tiles:
#+begin_src scheme
-(scene-transform-entities scene resolve-entity-collisions)
+(list #:type 'ghost
+ #:x 100 #:y 100 #:width 16 #:height 16
+ #:vx 2 #:vy 0
+ #:skip-pipelines '(tile-collisions-x tile-collisions-y))
#+end_src
-Returns a new scene with the updated entity list; the original scene is not modified.
-
-** aabb-overlap?
-
-#+begin_src scheme
-(aabb-overlap? x1 y1 w1 h1 x2 y2 w2 h2)
-#+end_src
+Velocity integration still runs. Tile-collision snapping and velocity
+zeroing are both bypassed for this entity only. The rest of the scene
+collides with tiles as normal.
-**Returns**: Boolean (#t if overlapping, #f if separated or touching)
+** Replacing the pipeline with a custom =engine-update:=
-**Description**: Pure collision test for two axis-aligned bounding boxes. Does not modify any state. Useful for manual collision queries (e.g., "did bullet hit enemy?") where you want to decide the response manually rather than using push-apart.
-
-#+begin_src scheme
-;; Example: check if player overlaps enemy
-(if (aabb-overlap? (entity-ref player #:x 0) (entity-ref player #:y 0)
- (entity-ref player #:width 0) (entity-ref player #:height 0)
- (entity-ref enemy #:x 0) (entity-ref enemy #:y 0)
- (entity-ref enemy #:width 0) (entity-ref enemy #:height 0))
- (do-damage!))
-#+end_src
-
-* Tile Collision Model
-
-Tiles come from **TMX maps** loaded by the tilemap module. The tilemap parses the XML and builds a 2D tile grid. Each cell references a tile ID from the tileset.
-
-**Solid tiles** are identified by **tile metadata** in the TSX tileset (defined in Tiled editor). The physics module checks each tile to see if it has collision metadata. Tiles with no metadata are treated as non-solid background tiles.
-
-** How Collision Works
-
-1. Entity's AABB (x, y, width, height) is used to compute all overlapping tile cells
-2. For each cell, the physics module queries the tilemap: "does this cell have a solid tile?"
-3. If yes, the entity is snapped to the edge of that tile in the axis being resolved
-4. Velocity is zeroed in that axis to prevent sliding
-
-** Why X and Y are Separate
-
-Tile collisions are resolved in two passes:
-
-#+begin_src
-apply-velocity-x → resolve-tile-collisions-x → apply-velocity-y → resolve-tile-collisions-y
-#+end_src
-
-This prevents a common platformer bug: if you moved and collided on both axes at once, you could get "corner clipped" (stuck in a diagonal corner). By separating X and Y, you guarantee the entity snaps cleanly.
-
-* Entity-Entity Collision
-
-Entity-entity collisions are for when two **game objects** overlap: a player and an enemy, two boxes stacked, etc.
-
-=resolve-entity-collisions= does all-pairs AABB checks. Any entity with =#:solid? #t= that overlaps another solid entity is pushed apart.
-
-The push-apart is along the **minimum penetration axis** (X or Y, whichever overlap is smaller). Entities are pushed in opposite directions by half the overlap distance, and their velocities are set to ±1 to prevent stacking.
-
-** When to Use
-
-- **Physics sandbox**: multiple boxes/balls that should bounce apart
-- **Solid obstacles**: pushable crates that block the player
-- **Knockback**: enemies that push back when hit
-
-** When NOT to Use
-
-- **Trigger zones**: enemies that should detect the player without colliding — use =aabb-overlap?= manually
-- **Bullets**: should pass through enemies — don't mark bullet as solid, use =aabb-overlap?= to check hits
-- **High entity count**: 100+ entities = O(n²) is too slow
-
-* Platformer Example
-
-A minimal platformer with a player, gravity, jumping, and tile collisions. Physics runs in =default-engine-update=; =update:= is input → intent only:
-
-#+begin_src scheme
-;; Omit engine-update: or use default — physics runs automatically before this hook.
-update: (lambda (game dt)
- (let* ((input (game-input game))
- (scene (game-scene game))
- (player (car (scene-entities scene)))
- (jump? (and (input-pressed? input 'a)
- (entity-ref player #:on-ground? #f)))
- (player (entity-set player #:vx
- (cond
- ((input-held? input 'left) -3)
- ((input-held? input 'right) 3)
- (else 0)))))
- (when jump? (play-sound 'jump))
- (let ((player (if jump?
- (entity-set player #:ay (- *jump-force*))
- player)))
- (game-scene-set! game
- (update-scene scene entities: (list player))))))
-#+end_src
-
-Import =*jump-force*= from =downstroke-physics= (or use a literal such as =-15= for =#:ay=).
-
-** Step-by-Step
-
-1. **Set #:vx** from input (left = -3, right = +3, idle = 0)
-2. **Jump**: if A pressed and =#:on-ground?= (already updated for this frame — your =update:= runs after =engine-update=), play sound and set =#:ay= to =(- *jump-force*)=
-3. **Next frame’s =engine-update=** runs =apply-acceleration= through =sync-groups= as documented above
-
-* Top-Down Example
-
-A top-down game with no gravity and free 4-way movement. Give the player =#:gravity? #f= so acceleration, gravity, and ground detection no-op inside =default-engine-update=:
-
-#+begin_src scheme
-update: (lambda (game dt)
- (let* ((input (game-input game))
- (scene (game-scene game))
- (player (car (scene-entities scene)))
- (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)))
- ;; Camera follow still runs after update: in game-run!
- (camera-x-set! (scene-camera scene) (max 0 (- (entity-ref player #:x 0) 300)))
- (game-scene-set! game
- (update-scene scene entities: (list player)))))
-#+end_src
-
-** Step-by-Step
-
-1. **Read input**: combine left/right into dx, up/down into dy
-2. **Set velocity**: both #:vx and #:vy based on input; the next =engine-update= applies velocity and tile collision
-3. **No gravity**: =#:gravity? #f= skips =apply-acceleration=, =apply-gravity=, =detect-on-solid=
-
-The built-in pipeline still runs; most steps no-op or pass through for non-gravity entities.
-
-* Physics Sandbox Example
-
-The real =demo/sandbox.scm= relies on =default-engine-update= for boxes, rafts, and tile collision; =update:= only runs per-entity AI (e.g. bots) via =scene-map-entities=.
-
-If you **replace** =engine-update= with your own procedure, a full multi-entity pass looks like this (same step order as =default-engine-update=, minus tweens/sync if you omit them):
+If you need a different step order — the canonical reason is "check
+=#:on-ground?= *after* entity collisions so an entity standing on a
+just-pushed platform is seen as grounded in this tick" — you can pass
+your own procedure on the scene:
#+begin_src scheme
(define (my-engine-update game dt)
(let ((scene (game-scene game)))
(when scene
(game-scene-set! game
- (scene-transform-entities
- (scene-map-entities scene
- (lambda (e) (apply-gravity e scene dt))
- (lambda (e) (apply-velocity-x e scene dt))
- (lambda (e) (resolve-tile-collisions-x e scene dt))
- (lambda (e) (apply-velocity-y e scene dt))
- (lambda (e) (resolve-tile-collisions-y e scene dt))
- (lambda (e) (detect-on-solid e scene dt)))
- resolve-entity-collisions)))))
-#+end_src
-
-Prefer reusing =default-engine-update= unless you need a different order (e.g. =detect-on-solid= after entity collisions). A complete custom hook should also run =step-tweens= first and =sync-groups= last, matching =engine.scm=.
-
-** step-by-step
-
-1. **scene-map-entities**: applies each step to all entities in order (snippet above starts at gravity for brevity)
- - =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-on-solid= (set #:on-ground?)
-
-2. **scene-transform-entities** with **resolve-entity-collisions**: after all entities are moved and collided with tiles, resolve entity-entity overlaps (boxes pushing apart)
-
-This pattern matches how multiple movers interact in the sandbox demo, except the demo uses the built-in =default-engine-update= instead of a hand-rolled hook.
-
-** Capturing =scene= and =dt=
-
-=scene-map-entities= passes only each entity into the step. Per-entity physics steps take =(entity scene dt)=, so wrap them:
-
-#+begin_src scheme
-(lambda (e) (resolve-tile-collisions-x e scene dt))
+ (chain scene
+ (scene-map-entities _ (cut step-tweens <> <> dt))
+ (scene-map-entities _ (cut apply-acceleration <> <> dt))
+ (scene-map-entities _ (cut apply-gravity <> <> dt))
+ (scene-map-entities _ (cut apply-velocity-x <> <> dt))
+ (scene-map-entities _ (cut resolve-tile-collisions-x <> <> dt))
+ (scene-map-entities _ (cut apply-velocity-y <> <> dt))
+ (scene-map-entities _ (cut resolve-tile-collisions-y <> <> dt))
+ (scene-transform-entities _ resolve-entity-collisions)
+ ;; Re-order: detect-on-solid AFTER entity collisions.
+ (scene-map-entities _ (cut detect-on-solid <> <> dt))
+ (scene-transform-entities _ sync-groups)
+ (scene-map-entities _ (cut apply-animation <> <> dt)))))))
+
+;; Install it on the scene:
+(make-scene
+ entities: ...
+ tilemap: tm
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: tex
+ engine-update: my-engine-update)
#+end_src
-Use the same =dt= (milliseconds) that =game-run!= passes to your hooks.
+Prefer starting from =default-engine-update= and tweaking one thing,
+rather than writing a pipeline from scratch. Forgetting =step-tweens=,
+=sync-groups=, or =apply-animation= is the usual mistake — tweens stop
+advancing, multi-part entities fall apart, animations freeze.
-* Common Patterns
+** Turning the pipeline off entirely (='none=)
-** Jumping
+For a shmup or any game where motion rules are entirely
+per-entity-type, set =engine-update: 'none= on the scene. The
+*Shmup demo* — run with =bin/demo-shmup=, source in =demo/shmup.scm= —
+does exactly this: player moves via =apply-velocity-x= called
+inline, bullets and enemies move via a bespoke =move-projectile=
+helper, collisions are checked with =aabb-overlap?= and dead entities
+are filtered out. No gravity, no ground detection, no pairwise
+push-apart:
#+begin_src scheme
-;; In update: (after physics, #:on-ground? is current)
-(let ((jump? (and (input-pressed? input 'a)
- (entity-ref player #:on-ground? #f))))
- (if jump?
- (entity-set player #:ay (- *jump-force*))
- player))
+(make-scene
+ entities: (list (make-player))
+ tilemap: #f
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: #f
+ camera-target: #f
+ engine-update: 'none)
#+end_src
-=*jump-force*= (default 15) is exported from =downstroke-physics=. =apply-acceleration= and gravity run inside =default-engine-update= on the following frame.
+With ='none=, nothing in =physics.scm= runs unless you call it
+yourself. You still have access to every physics procedure as a
+library: =apply-velocity-x=, =aabb-overlap?=, =resolve-tile-collisions-y=,
+and so on are all exported from =downstroke-physics= and usable
+individually. ='none= just disables the *automatic* orchestration.
-** No Gravity
+** Reading =#:on-ground?= to gate jumps
-For top-down games, use =#:gravity? #f= on movers so acceleration, gravity, and ground detection no-op. For shmups or fully custom motion, use =engine-update: #f= and move entities in =update:=.
-
-** Knockback
-
-Set #:vx or #:vy directly to push an entity:
+The jump check in the *Platformer demo*:
#+begin_src scheme
-(entity-set enemy #:vx -5) ;; Push left
-#+end_src
-
-Gravity will resume on the next frame if =#:gravity?= is #t.
-
-** Trigger Zones
-
-For areas that don't collide but detect presence (spawn zones, damage zones):
-
-#+begin_src scheme
-;; In update:
-(if (aabb-overlap? (entity-ref trigger #:x 0) (entity-ref trigger #:y 0)
- (entity-ref trigger #:width 0) (entity-ref trigger #:height 0)
- (entity-ref player #:x 0) (entity-ref player #:y 0)
- (entity-ref player #:width 0) (entity-ref player #:height 0))
- (on-trigger-enter! player))
+(define (update-player player input)
+ (let* ((jump? (and (input-pressed? input 'a)
+ (entity-ref player #:on-ground? #f)))
+ (player (entity-set player #:vx (player-vx input))))
+ (when jump? (play-sound 'jump))
+ (if jump?
+ (entity-set player #:ay (- *jump-force*))
+ player)))
#+end_src
-Don't mark the trigger as solid; just use =aabb-overlap?= to query.
-
-** Slopes and Ramps
-
-The current tile collision system is AABB-based and does not support slopes. All tiles are axis-aligned rectangles. To simulate slopes, you can:
-
-1. Use small tiles to approximate a slope (many tiny tiles at angles)
-2. Post-process the player's position after collision (rotate velocity vector)
-3. Use a custom collision function instead of =resolve-tile-collisions-y=
-
-This is beyond the scope of the basic physics system.
-
-* Performance Notes
-
-- **Tile collision**: O(overlapping tiles) per entity. Usually 1–4 tiles per frame.
-- **Entity collision**: O(n²) where n = entity count. Avoid for > 100 solid entities.
-- **Ground detection**: O(2) tile lookups per entity (corners below feet).
-
-For large games, consider spatial partitioning (grid, quadtree) to cull entity pairs. The basic physics system is designed for small to medium games (< 100 entities).
-
-* Troubleshooting
-
-** Entity Sinks Into Floor
-
-- Make sure =#:height= is set correctly (not 0 or too large)
-- Verify tileset metadata marks floor tiles as solid in Tiled editor
-- Check that =resolve-tile-collisions-y= is called after =apply-velocity-y=
-
-** Double-Jump / Can't Jump
-
-- Ensure =#:on-ground?= is set: with =default-engine-update=, =detect-on-solid= runs each frame; for standing on other solids, note it runs *before* =resolve-entity-collisions= in the default order (see *detect-on-solid* above)
-- Gate jump in =update:= with =#:on-ground?= and =input-pressed?= (not =input-held?=)
-- Set =#:ay= only on the jump frame; =apply-acceleration= clears =#:ay= when it runs
-
-** Entity Slides Through Walls
-
-- Check that #:width and #:height are set correctly
-- Verify tileset marks wall tiles as solid
-- Ensure =resolve-tile-collisions-x= is called after =apply-velocity-x=
-
-** Entities Get Stuck Overlapping
-
-- Use =(scene-transform-entities scene resolve-entity-collisions)= after all physics steps
-- Verify both entities have =#:solid? #t=
-- Reduce =*gravity*= or max velocity if entities are moving too fast (can cause multi-frame overlap)
-
-** Performance Drops
-
-- Profile entity count (should be < 100 for full physics pipeline)
-- Disable =resolve-entity-collisions= if not needed
-- Use =aabb-overlap?= for queries instead of marking many entities solid
+- =input-pressed?= (not =input-held?=) ensures one jump per keypress.
+- =#:on-ground?= was updated by =detect-on-solid= in the pipeline
+ *this frame*, before your =update:= ran, so it reflects the current
+ position after tile / entity collisions.
+- =#:ay= is cleared by =apply-acceleration= on the next frame, so the
+ impulse applies exactly once.
+
+* See also
+
+- [[file:guide.org][Getting started guide]] — overall game structure and the minimal
+ example that calls into the physics pipeline.
+- [[file:entities.org][Entities]] — the keys the pipeline reads and writes
+ (=#:x=, =#:vy=, =#:gravity?=, =#:solid?=, =#:skip-pipelines=, etc.),
+ =entity-ref= / =entity-set=, and the =define-pipeline= macro.
+- [[file:tweens.org][Tweens]] — =step-tweens= as the first step of the pipeline, and how
+ to combine tweens with =#:skip-pipelines= for knockback-style effects
+ that bypass velocity integration.
+- [[file:animation.org][Animation]] — =apply-animation= as the last step of the pipeline;
+ =#:animations=, =#:anim-name=, =#:anim-frame=.
+- [[file:input.org][Input]] — =input-held?= / =input-pressed?= for reading movement and
+ jump intent in =update:=.
+- [[file:scenes.org][Scenes]] — the =engine-update= field of =make-scene=, group entities
+ (=#:group-id=, =#:group-origin?=) consumed by =sync-groups=, and
+ =scene-map-entities= / =scene-transform-entities= used throughout
+ the pipeline.
+- [[file:rendering.org][Rendering]] — how =#:tile-id= (written by =apply-animation=) is drawn,
+ and where the camera transform is applied.
+- [[file:../demo/platformer.scm][Platformer]] (=bin/demo-platformer=) — canonical gravity + jump + tile-collide example.
+- [[file:../demo/shmup.scm][Shmup]] (=bin/demo-shmup=) — canonical =engine-update: 'none= example with manual collision checks via =aabb-overlap?=.
+- [[file:../demo/sandbox.scm][Sandbox]] (=bin/demo-sandbox=) — multiple movers, entity–entity push-apart, the default pipeline in a busier scene.
+- [[file:../demo/tweens.scm][Tweens]] (=bin/demo-tweens=) — =step-tweens= in action; useful when combined with =#:skip-pipelines '(velocity-x velocity-y)=.
diff --git a/docs/rendering.org b/docs/rendering.org
new file mode 100644
index 0000000..35d69bb
--- /dev/null
+++ b/docs/rendering.org
@@ -0,0 +1,440 @@
+#+TITLE: Rendering
+
+Downstroke draws every frame in two steps: the engine calls
+=render-scene!= on the current scene, then (if you supplied one) it
+calls your =render:= hook for HUDs, text, and overlays. =render-scene!=
+is deliberately conservative — it draws the tilemap if there is one,
+then each entity in turn. An entity gets a tileset sprite when it
+carries =#:tile-id= /and/ a tileset texture is available, a filled
+rect when it carries =#:color=, and is silently skipped otherwise.
+The renderer never crashes on a missing tilemap or tileset texture;
+it simply does less drawing.
+
+This document covers the default rendering pipeline, the scaling /
+logical-resolution system, sprite fonts (bitmap text), and the hooks
+you can use to layer custom rendering on top — including the built-in
+debug overlay.
+
+See also: [[file:guide.org][guide]], [[file:entities.org][entities]],
+[[file:scenes.org][scenes]], [[file:animation.org][animation]],
+[[file:input.org][input]].
+
+* The minimum you need
+
+The simplest possible renderable entity is a colored rectangle with a
+position and size:
+
+#+begin_src scheme
+(plist->alist
+ (list #:type 'player
+ #:x 150 #:y 100
+ #:width 32 #:height 32
+ #:color '(100 160 255))) ;; RGB; alpha defaults to 255
+#+end_src
+
+Put it in a scene (=make-sprite-scene= needs no TMX map and no
+tileset), and you're done:
+
+#+begin_src scheme
+(game-run!
+ (make-game
+ title: "Getting Started"
+ width: 320 height: 240
+ create: (lambda (game)
+ (game-scene-set! game
+ (make-sprite-scene
+ entities: (list (make-player))
+ background: '(20 22 30))))
+ update: ...))
+#+end_src
+
+The engine clears the frame with =scene-background= (black if =#f=),
+draws every entity in =scene-entities=, and presents. No =render:=
+hook is required for this to show something on screen.
+
+When you want sprites instead of colored rects, an entity looks like:
+
+#+begin_src scheme
+(list #:type 'player
+ #:x 100 #:y 50
+ #:width 16 #:height 16
+ #:tile-id 29 ;; tileset frame index
+ #:facing 1 ;; -1 flips the sprite horizontally
+ #:tags '(player))
+#+end_src
+
+…and the scene must carry a tileset /and/ a tileset texture. The
+simplest way is to load a TMX map with
+=game-load-scene!=, which wires both up for you.
+
+/Full example: the *getting-started demo* — run with
+=bin/demo-getting-started=, source in =demo/getting-started.scm=./
+
+* Core concepts
+
+** What =render-scene!= does
+
+=render-scene!= is the one function =game-run!= calls each frame to
+draw the current scene. It does three things, in order:
+
+1. Clear the framebuffer using =scene-background= (via
+ =renderer-set-clear-color!=). A missing or non-list background
+ falls back to opaque black.
+2. Draw the tilemap, if /both/ a =tilemap= and a =tileset-texture=
+ are present on the scene. A missing tilemap or missing texture
+ simply skips this step — no error.
+3. Draw each entity in =scene-entities= via =draw-entity=.
+
+=draw-entity= applies a strict priority when deciding /how/ to draw
+one entity:
+
+1. If =#:skip-render= is truthy, the entity is skipped entirely.
+2. Otherwise, if the entity has a =#:tile-id= /and/ the scene
+ provides both a tileset and a tileset texture, it is drawn as a
+ tileset sprite.
+3. Otherwise, if the entity has a =#:color= list of three or four
+ integers =(r g b)= / =(r g b a)=, it is drawn as a filled rect.
+4. Otherwise, nothing is drawn for that entity.
+
+The =#:facing= key controls horizontal flip on sprite-drawn entities:
+=#:facing -1= renders the tile mirrored, any other value renders it
+unflipped. =#:color= entities ignore =#:facing=.
+
+*** Key invariant: both =tileset= /and/ =tileset-texture=
+
+=draw-entity= needs /both/ a =tileset= struct (so it can look up the
+source rectangle for a given =tile-id=) /and/ an SDL texture (so it
+can blit pixels) before it will draw a sprite. If either is missing,
+the entity falls back to =#:color= — or is skipped if that's also
+absent. It never crashes.
+
+Because of this, =render-scene!= resolves the tileset from /either/
+source:
+
+#+begin_src scheme
+(or (scene-tileset scene)
+ (and (scene-tilemap scene)
+ (tilemap-tileset (scene-tilemap scene))))
+#+end_src
+
+That means a sprite-only scene (one with =tilemap: #f=) must set
+=tileset:= explicitly on the scene — not on the tilemap, since there
+isn't one. =make-sprite-scene= exposes that as a keyword. See the
+sprite-only pattern below.
+
+Entity order in =scene-entities= is the draw order — later entries
+render on top of earlier ones. There is no z-buffer and no layer
+system for entities; if you need one, sort the entity list yourself
+before drawing (typically in your =update:= hook).
+
+** Backgrounds, logical resolution, scaling
+
+*** Background color
+
+=scene-background= is either =#f= or a list of three or four
+integers. The engine reads it every frame and uses it as the
+framebuffer clear color. Missing or malformed backgrounds fall back
+to opaque black. This is the simplest way to paint a flat backdrop
+without adding an entity.
+
+#+begin_src scheme
+(make-sprite-scene
+ entities: (list (make-player))
+ background: '(20 22 30)) ;; dark navy
+#+end_src
+
+*** Scale factor
+
+=make-game= accepts a =scale:= keyword argument: a /positive integer/
+that multiplies the output window size without changing the game's
+logical coordinates. =scale: 2= means a =320×240= logical game runs
+in a =640×480= window with every pixel drawn twice as large.
+
+Internally the engine does two things when =scale: > 1=:
+
+1. =create-window!= is called with =width × scale= by =height × scale=.
+2. =render-logical-size-set!= is called with the /logical/ size so
+ that all subsequent drawing is measured in logical pixels.
+
+This means your entity coordinates, camera offsets, and
+=render:=-hook drawing all use logical pixels regardless of the
+scale factor. You don't have to multiply anything yourself.
+
+=scale: 1= (the default) skips the logical-size call entirely, and
+behaves as if scaling were off.
+
+Non-integer and non-positive values raise an error from =make-game=.
+
+/Full example: the *scaling demo* — run with =bin/demo-scaling=,
+source in =demo/scaling.scm=./
+
+*** =renderer-set-clear-color!=
+
+This is the function the engine calls before =render-clear!= each
+frame. You can call it yourself if you ever need to re-clear to a
+specific color mid-frame (for letterboxing effects, say) — it takes
+the SDL2 renderer and a scene, and reads the scene's background.
+
+** Sprite fonts
+
+A *sprite font* is a bitmap font encoded as glyphs inside a tileset.
+Downstroke's =sprite-font= record captures the tile size, spacing,
+and a hash table mapping characters to =tile-id= values. Construct
+one with =make-sprite-font*=:
+
+#+begin_src scheme
+(make-sprite-font*
+ #:tile-size 16
+ #:spacing 1
+ #:ranges '((#\A #\M 918) ;; A-M start at tile 918
+ (#\N #\Z 967)
+ (#\0 #\9 869)))
+#+end_src
+
+Arguments:
+
+- =tile-size= (keyword, required): pixel width and height of each
+ glyph tile.
+- =spacing= (keyword, default =1=): horizontal pixels between
+ glyphs.
+- =ranges= (keyword, required): list of triples =(start-char
+ end-char first-tile-id)=. The first character in the range maps to
+ =first-tile-id=, and each subsequent character maps to the next
+ tile id.
+
+Ranges are always stored uppercase; lookups also uppercase, so a
+font defined with =#\A…#\Z= will also render lowercase input
+correctly. Overlapping ranges raise an error at construction time.
+
+To draw text, call =draw-sprite-text=:
+
+#+begin_src scheme
+(draw-sprite-text renderer tileset-texture tileset font text x y)
+#+end_src
+
+- =renderer=, =tileset-texture=, =tileset= come from the scene
+ (typically =(game-renderer game)=, =(scene-tileset-texture
+ scene)=, and =(tilemap-tileset (scene-tilemap scene))=).
+- =font= is the =sprite-font= returned by =make-sprite-font*=.
+- =text= is a string — characters not in the font's char map are
+ silently skipped (the cursor still advances).
+- =x=, =y= are pixel coordinates on the screen (/not/ camera-local).
+
+Two helpers are useful for layout:
+
+- =sprite-font-char->tile-id font ch= returns the tile id for one
+ character, or =#f= if it isn't mapped. Upcases =ch= first.
+- =sprite-text-width font text= returns the total pixel width of
+ =text= drawn with this font, accounting for spacing.
+
+Sprite fonts and tile frames share the same tileset in the example
+demo, so the same texture that draws the level tiles also draws the
+HUD text. See the *spritefont demo* — run with =bin/demo-spritefont=,
+source in =demo/spritefont.scm=.
+
+*** Tileset spacing
+
+=tileset-tile= (in =tilemap.scm=) computes the source rectangle for a
+given =tile-id=. It uses =(tileset-spacing tileset)= when walking
+across columns and down rows, so tilesets exported from Tiled with a
+non-zero =spacing= attribute (margins between tile cells) render
+correctly. If =tileset-spacing= is =#f= or missing, zero spacing is
+assumed.
+
+The same spacing logic governs sprite-font rendering: each glyph is
+sourced through =tileset-tile=, so a tileset with =spacing="1"= in
+its TSX produces correctly-aligned glyphs without any extra tuning.
+
+** Custom rendering and overlays
+
+The =render:= keyword on =make-game= is an optional hook called
+every frame /after/ =render-scene!= (and after the debug overlay, if
+enabled) but /before/ =render-present!=. Its signature is
+=(lambda (game) …)= — no delta-time, since rendering should be a
+pure read of game state.
+
+This is the right place for:
+
+- HUDs (score, health bars, timers).
+- Menu text and button overlays.
+- Debug visualizations beyond the built-in overlay.
+- Any SDL2 drawing that needs to go /on top of/ the scene.
+
+#+begin_src scheme
+(make-game
+ title: "..." width: 600 height: 400
+
+ render: (lambda (game)
+ (let ((renderer (game-renderer game)))
+ ;; draw a HUD bar across the top
+ (set! (sdl2:render-draw-color renderer)
+ (sdl2:make-color 0 0 0 200))
+ (sdl2:render-fill-rect! renderer
+ (sdl2:make-rect 0 0 600 24))
+ (draw-ui-text renderer *hud-font* "SCORE 00" color 8 4))))
+#+end_src
+
+=draw-ui-text= is a thin convenience that wraps =ttf:render-text-solid=
+and =create-texture-from-surface=. Pass it a renderer, a TTF font
+(from =ttf:open-font=), a string, an SDL color, and the top-left
+pixel coordinates. For menus, =draw-menu-items= takes a list of items
+and a cursor index and stacks them vertically with a selected prefix
+=" > "= in front of the current one.
+
+/Full example: the *menu demo* — run with =bin/demo-menu=, source in
+=demo/menu.scm=./ The menu demo uses =draw-ui-text= and
+=draw-menu-items= from a =render:= hook that is resolved via
+=make-game-state= (per-state render hooks override the top-level
+=render:= hook)./
+
+*** Debug overlay
+
+Passing =debug?: #t= to =make-game= flips on an overlay that
+=game-render!= draws after =render-scene!= and before your own
+=render:= hook. =render-debug-scene!= draws:
+
+- Colored filled rectangles over the AABB of every entity whose
+ =#:type= is =player= (blue) or =enemy= (red). Other entity types
+ are ignored by the overlay.
+- Attack hitboxes — a tile-wide green rect offset from the entity in
+ its =#:facing= direction — whenever =#:attack-timer= is greater
+ than zero.
+- Purple filled rectangles over every non-zero tile in the tilemap
+ (when the scene has one), marking the tilemap's footprint for
+ collision debugging.
+
+The overlay is tilemap-optional: on a sprite-only scene the tilemap
+tiles are skipped and attack-hitbox thickness falls back to the
+entity's own =#:width=. There is no crash path through the overlay
+code.
+
+The overlay covers up sprites because it draws filled rects, so
+enable it only for debugging.
+
+* Common patterns
+
+** Render a solid color entity (no tileset needed)
+
+Any entity with =#:color '(r g b)= draws as a filled rect, even when
+the scene has no tileset or tilemap at all. This is the fastest way
+to get something on screen.
+
+#+begin_src scheme
+(plist->alist
+ (list #:type 'player
+ #:x 150 #:y 100
+ #:width 32 #:height 32
+ #:color '(100 160 255)))
+#+end_src
+
+See the *getting-started demo* — run with =bin/demo-getting-started=,
+source in =demo/getting-started.scm=.
+
+** Scale the whole game 2×
+
+Define your game in its /logical/ resolution and pass =scale:=:
+
+#+begin_src scheme
+(make-game
+ title: "Demo: Scaling (2×)"
+ width: 320 height: 240 ;; logical pixels
+ scale: 2 ;; window is 640×480
+ ...)
+#+end_src
+
+All subsequent drawing — entities, camera, =render:= hook — uses the
+logical =320×240= coordinate space. See the *scaling demo* — run with
+=bin/demo-scaling=, source in =demo/scaling.scm=.
+
+** Draw text with a sprite font
+
+Build the font once in =preload:= or =create:=, then call
+=draw-sprite-text= from the =render:= hook:
+
+#+begin_src scheme
+(render: (lambda (game)
+ (let* ((scene (game-scene game))
+ (renderer (game-renderer game))
+ (tileset (tilemap-tileset (scene-tilemap scene)))
+ (texture (scene-tileset-texture scene)))
+ (draw-sprite-text renderer texture tileset *sprite-font*
+ "HELLO WORLD" 50 50))))
+#+end_src
+
+See the *spritefont demo* — run with =bin/demo-spritefont=, source in
+=demo/spritefont.scm=.
+
+** Draw a HUD in the =render:= hook
+
+Use =draw-ui-text= (TTF) or =draw-sprite-text= (bitmap) from a
+=render:= hook. The hook runs every frame, /after/ scene rendering,
+so anything drawn here sits on top of the game world:
+
+#+begin_src scheme
+(render: (lambda (game)
+ (let ((renderer (game-renderer game)))
+ (draw-ui-text renderer *hud-font*
+ (format #f "SCORE ~a" *score*)
+ (sdl2:make-color 255 255 255) 8 4))))
+#+end_src
+
+See the *menu demo* — run with =bin/demo-menu=, source in
+=demo/menu.scm= — which uses =draw-ui-text= and =draw-menu-items=
+from per-state render hooks attached via =make-game-state=.
+
+** Enable the debug overlay
+
+Flip on colored AABBs for players, enemies, attacks, and tile cells:
+
+#+begin_src scheme
+(make-game
+ title: "..." width: 600 height: 400
+ debug?: #t ;; or (command-line-arguments)-driven
+ ...)
+#+end_src
+
+The *platformer demo* wires this to a =--debug= command-line flag —
+run with =bin/demo-platformer --debug=, source in
+=demo/platformer.scm=.
+
+** Sprite-only scene (no TMX tilemap)
+
+When you want sprite drawing but have no Tiled map, build the scene
+with =make-sprite-scene= and pass the =tileset=, =tileset-texture=,
+and any =background=:
+
+#+begin_src scheme
+(create: (lambda (game)
+ (let* ((ts (game-load-tileset! game 'tiles "assets/tiles.tsx"))
+ (tex (create-texture-from-tileset
+ (game-renderer game) ts)))
+ (game-scene-set! game
+ (make-sprite-scene
+ entities: (list (make-player))
+ tileset: ts
+ tileset-texture: tex
+ background: '(20 22 30))))))
+#+end_src
+
+Because there is no tilemap, =render-scene!= skips the tilemap draw
+and goes straight to =draw-entities=. The sprite tileset resolves
+from =scene-tileset= as documented above.
+
+* See also
+
+- [[file:guide.org][guide]] — minimal game walkthrough that ends at the
+ getting-started demo.
+- [[file:entities.org][entities]] — the =#:tile-id=, =#:color=,
+ =#:facing=, =#:skip-render= keys in context, plus the entity plist
+ model used by every drawable.
+- [[file:scenes.org][scenes]] — =make-scene=, =make-sprite-scene=,
+ =scene-background=, =scene-tileset=, and the scene-loader helpers
+ that wire up TMX maps.
+- [[file:animation.org][animation]] — how =#:tile-id= changes over
+ time for animated sprites. The renderer reads whatever =#:tile-id=
+ is current; animation updates it.
+- [[file:input.org][input]] — reading key state inside =update:= and
+ =render:= hooks for interactive overlays like menus.
+- [[file:physics.org][physics]] — the pipeline that precedes
+ rendering, and the =#:x=, =#:y=, =#:width=, =#:height= fields that
+ =draw-entity= uses to size sprites.
diff --git a/docs/scenes.org b/docs/scenes.org
new file mode 100644
index 0000000..da62291
--- /dev/null
+++ b/docs/scenes.org
@@ -0,0 +1,432 @@
+#+TITLE: Scenes, Cameras, and Game States
+#+AUTHOR: Downstroke Project
+
+* Overview
+
+A /scene/ in Downstroke is the immutable record that answers the question "what should the engine simulate and draw right now?". It bundles the entity list, an optional tilemap (for TMX-backed levels), an optional tileset (for sprite-only games that still want tile-sheet rendering), a camera, a background clear color, and a choice of which physics pipeline to run. Each frame the engine reads the current scene from the ~game~ struct, runs an engine-update pass on its entities, lets your ~update:~ hook produce a /new/ scene, snaps the camera to a tagged target if asked, and then hands the new scene to the renderer.
+
+Everything about scenes is built around swapping one immutable value for another: constructors like ~make-scene~ and ~make-sprite-scene~ build them; accessors like ~scene-entities~ and ~scene-camera~ read them; functional transformers like ~scene-map-entities~ and ~scene-add-entity~ return new scenes; and ~game-scene-set!~ is the single point where the engine sees the new state. If you also want more than one "mode" — a title screen and a play screen, say, or a paused state and a gameplay state — Downstroke layers a lightweight /game state/ system on top, so each state gets its own update and render hooks while sharing the same scene or building its own on demand.
+
+* The minimum you need
+
+The smallest scene is a sprite-only one with a single entity, built inside the ~create:~ hook:
+
+#+begin_src scheme
+(import scheme
+ (chicken base)
+ (only (list-utils alist) plist->alist)
+ downstroke-engine
+ downstroke-world
+ downstroke-entity
+ downstroke-scene-loader)
+
+(define (make-player)
+ (plist->alist
+ (list #:type 'player #:x 100 #:y 100
+ #:width 32 #:height 32
+ #:color '(100 160 255))))
+
+(game-run!
+ (make-game
+ title: "Minimal Scene"
+ width: 320 height: 240
+ create: (lambda (game)
+ (game-scene-set! game
+ (make-sprite-scene
+ entities: (list (make-player))
+ background: '(20 22 30))))))
+#+end_src
+
+That is all that is required to put a blue square on a dark background. ~make-sprite-scene~ fills in sensible defaults for everything you have not specified — no tilemap, a camera at ~(0, 0)~, no camera target, the default (physics) engine update — and ~game-scene-set!~ installs the result on the game. From here on, every section in this document describes a field you can fill in or a function you can compose to reshape what is on screen.
+
+* Core concepts
+
+** The scene record
+
+~scene~ is a record type defined with ~defstruct~ in ~downstroke-world~. Its fields map one-to-one onto the things the engine needs to know about the current frame:
+
+| Field | Type | Purpose |
+|-------------------+--------------------------------+--------------------------------------------------------------------------------------------------|
+| ~entities~ | list of entity alists | Every entity the engine simulates and draws this frame. |
+| ~tilemap~ | ~tilemap~ struct or ~#f~ | Optional TMX-loaded tilemap (layers + embedded tileset). ~#f~ for sprite-only scenes. |
+| ~tileset~ | ~tileset~ struct or ~#f~ | Optional tileset used for sprite rect math when ~tilemap~ is ~#f~. |
+| ~camera~ | ~camera~ struct | The viewport offset (see /Cameras/ below). |
+| ~tileset-texture~ | SDL2 texture or ~#f~ | GPU texture used to draw tiles and any entity with a ~#:tile-id~. ~#f~ is allowed (see note). |
+| ~camera-target~ | symbol or ~#f~ | A tag the engine will auto-follow with the camera. ~#f~ disables auto-follow. |
+| ~background~ | ~#f~ or ~(r g b)~ / ~(r g b a)~ | Framebuffer clear color. ~#f~ means plain black. |
+| ~engine-update~ | ~#f~, procedure, or ~'none~ | Per-scene physics pipeline choice (see below). |
+
+The raw ~make-scene~ constructor is auto-generated by ~defstruct~ and accepts every field as a keyword argument. It does /not/ provide defaults — if you call ~make-scene~ directly you must pass every field, or wrap it with ~make-sprite-scene~ (below) which does the defaulting for you. The paired functional copier ~update-scene~ takes an existing scene and returns a new one with only the named fields replaced:
+
+#+begin_src scheme
+(update-scene scene
+ entities: (list new-player)
+ camera-target: 'player)
+#+end_src
+
+*** What each "optional" field actually means
+
+- ~tilemap: #f~ — sprite-only scenes. No tilemap rendering, no tile collisions (the physics pipeline tile-collision steps no-op when there is no tilemap). Used by all the shmup, topdown, tween, scaling, and sandbox demos when they want full control over layout.
+- ~tileset: #f~ — fine when the scene has a ~tilemap~ (the renderer falls back to ~(tilemap-tileset tilemap)~ for rect math) /or/ when no entity has a ~#:tile-id~. Set it explicitly only when you have sprites but no tilemap and still want tile-sheet rendering (the ~animation~ and ~sandbox~ demos do this).
+- ~tileset-texture: #f~ — allowed. The renderer's ~draw-entity~ guards on the texture being present and falls back to ~#:color~ rendering when it is missing, so sprite entities are /not/ silently dropped on ~#f~ — they draw as solid colored rectangles. Tilemap layers are simply skipped if either ~tilemap~ or ~tileset-texture~ is ~#f~.
+- ~background: #f~ — the renderer clears the framebuffer with opaque black every frame. Any non-~#f~ value must be a list of at least three 0–255 integers; a four-element list includes alpha.
+- ~engine-update:~ — three forms are accepted:
+ - ~#f~ (the default) means "inherit": the engine runs ~default-engine-update~, which is the built-in physics pipeline (tweens → acceleration → gravity → velocity → tile collisions → ground detection → entity collisions → group sync → animation).
+ - A procedure ~(lambda (game dt) ...)~ replaces the pipeline entirely for this scene. You are fully responsible for whatever transformations of ~(game-scene game)~ should happen each frame.
+ - The symbol ~'none~ disables engine-update altogether — nothing runs before your ~update:~ hook. Used by the shmup and scaling demos which hand-roll all motion.
+- ~camera-target:~ — a symbol that will be looked up via ~scene-find-tagged~ in each entity's ~#:tags~ list. When set, the engine centers the camera on that entity on every frame and clamps the result to ~x ≥ 0~ and ~y ≥ 0~. ~#f~ disables auto-follow (you can still move ~scene-camera~ yourself).
+
+All eight fields are plain slots: read them with ~scene-entities~, ~scene-tilemap~, ~scene-camera~, ~scene-camera-target~, ~scene-background~, ~scene-engine-update~, and so on.
+
+** Constructing a scene
+
+Downstroke offers three escalating ways to build a scene, from full manual control to "load a whole level off disk in one call".
+
+*** ~make-scene~ — full control
+
+The auto-generated constructor accepts every slot as a keyword and does no defaulting. You will use it when you want to build a scene by hand with some exotic combination of fields, most often because you are bypassing the physics pipeline or embedding a procedurally built tilemap:
+
+#+begin_src scheme
+(make-scene
+ entities: (list (make-player))
+ tilemap: #f
+ tileset: #f
+ camera: (make-camera x: 0 y: 0)
+ tileset-texture: #f
+ camera-target: #f
+ engine-update: 'none)
+#+end_src
+
+This is the form the ~shmup~ and ~scaling~ demos use to turn off the physics pipeline and drive everything from their own ~update:~ hook. If you omit a field it will be unbound on the struct; prefer ~make-sprite-scene~ below unless you actually need that degree of control.
+
+*** ~make-sprite-scene~ — convenient sprite-only scenes
+
+~make-sprite-scene~ lives in ~downstroke-scene-loader~ and wraps ~make-scene~ with sensible defaults for sprite-only games:
+
+#+begin_src scheme
+(make-sprite-scene
+ entities: (list (make-player))
+ background: '(20 22 30))
+#+end_src
+
+It always passes ~tilemap: #f~, and supplies these defaults for anything you leave out:
+
+| Keyword | Default |
+|--------------------+-----------------------------|
+| ~entities:~ | ~'()~ |
+| ~tileset:~ | ~#f~ |
+| ~tileset-texture:~ | ~#f~ |
+| ~camera:~ | ~(make-camera x: 0 y: 0)~ |
+| ~camera-target:~ | ~#f~ |
+| ~background:~ | ~#f~ |
+| ~engine-update:~ | ~#f~ (inherit = run physics) |
+
+Use this whenever your game has no TMX map — see ~demo/getting-started.scm~ for the canonical example.
+
+*** ~game-load-scene!~ — load a TMX level
+
+When you have a Tiled (TMX) map on disk, ~game-load-scene!~ wires up the entire pipeline in one call:
+
+#+begin_src scheme
+(game-load-scene! game "demo/assets/level-0.tmx")
+#+end_src
+
+Internally it performs four steps:
+
+1. Calls ~game-load-tilemap!~ which invokes ~load-tilemap~ to parse the TMX (via expat) and the linked TSX tileset, also loading the tileset's PNG image. The resulting ~tilemap~ struct is stored in the game's asset registry under the key ~'tilemap~.
+2. Calls ~create-tileset-texture~ (a thin wrapper around ~create-texture-from-tileset~) to upload the tileset image as an SDL2 texture via the game's renderer.
+3. Builds a ~scene~ with ~entities: '()~, the parsed ~tilemap~, the fresh tileset texture, a camera at the origin, and no camera target.
+4. Installs the scene on the game with ~game-scene-set!~ and returns it.
+
+Because it returns the new scene, you can chain additional transformations before the frame begins (see the ~topdown~ and ~platformer~ demos):
+
+#+begin_src scheme
+(let* ((s0 (game-load-scene! game "demo/assets/level-0.tmx"))
+ (s1 (scene-add-entity s0 (make-player)))
+ (s2 (update-scene s1 camera-target: 'player)))
+ (game-scene-set! game s2))
+#+end_src
+
+Note that ~game-load-scene!~ does /not/ automatically populate ~entities~ from the TMX object layer. That conversion is available as a separate function:
+
+#+begin_src scheme
+(tilemap-objects->entities tilemap registry)
+#+end_src
+
+~tilemap-objects->entities~ walks the parsed ~tilemap-objects~ and, for each TMX ~<object>~ whose ~type~ string matches a prefab in your registry, calls ~instantiate-prefab~ with the object's ~(x, y, width, height)~. Objects with no matching prefab are filtered out. You can call it yourself to build the initial entity list from the TMX objects and then feed the result to ~scene-add-entity~ or a fresh ~update-scene~.
+
+*** Related asset loaders
+
+Three smaller helpers register assets into ~(game-assets game)~ keyed by a symbol, so other code can fetch them with ~game-asset~:
+
+- ~(game-load-tilemap! game key filename)~ — parse a ~.tmx~ and store the ~tilemap~ struct.
+- ~(game-load-tileset! game key filename)~ — parse a ~.tsx~ and store the ~tileset~ struct.
+- ~(game-load-font! game key filename size)~ — open a TTF font at a given point size and store it.
+
+Each returns the loaded value so you can bind it directly:
+
+#+begin_src scheme
+(let* ((ts (game-load-tileset! game 'tileset "demo/assets/monochrome_transparent.tsx"))
+ (tex (create-texture-from-tileset (game-renderer game) ts)))
+ ...)
+#+end_src
+
+This pattern — load once in ~preload:~ (or early in ~create:~), retrieve many times via ~game-asset~ — is how the ~animation~ and ~sandbox~ demos share one tileset across multiple prefabs and scenes.
+
+** Manipulating scene entities
+
+All scene transformers in ~downstroke-world~ are /functional/: they take a scene and return a new one. Nothing is mutated; the idiomatic pattern inside an ~update:~ hook is always
+
+#+begin_src scheme
+(game-scene-set! game (<transformer> (game-scene game) ...))
+#+end_src
+
+or, when composing multiple transformations, a ~chain~ form from ~srfi-197~.
+
+*** ~scene-add-entity~
+
+~(scene-add-entity scene entity)~ appends one entity to the entity list. Use it after ~game-load-scene!~ to drop the player into a freshly loaded TMX level:
+
+#+begin_src scheme
+(chain (game-load-scene! game "demo/assets/level-0.tmx")
+ (scene-add-entity _ (make-player))
+ (update-scene _ camera-target: 'player))
+#+end_src
+
+*** ~scene-map-entities~
+
+~(scene-map-entities scene proc ...)~ applies each ~proc~ in sequence to every entity in the scene. Each ~proc~ has the signature ~(lambda (scene entity) ...) → entity~ — it receives the /current/ scene (read-only, snapshot at the start of the call) and one entity, and must return a replacement entity. Multiple procs are applied like successive ~map~ passes, in argument order.
+
+This is the workhorse of the physics pipeline; see ~default-engine-update~ in ~downstroke-engine~ for how it is chained to apply acceleration, gravity, velocity, and so on. In game code you use it for per-entity updates that do not need to see each other:
+
+#+begin_src scheme
+(scene-map-entities scene
+ (lambda (scene_ e)
+ (if (eq? (entity-type e) 'demo-bot)
+ (update-demo-bot e dt)
+ e)))
+#+end_src
+
+*** ~scene-filter-entities~
+
+~(scene-filter-entities scene pred)~ keeps only entities for which ~pred~ returns truthy. Use it to remove dead/expired/off-screen entities each frame:
+
+#+begin_src scheme
+(scene-filter-entities scene
+ (lambda (e) (or (eq? (entity-type e) 'player) (in-bounds? e))))
+#+end_src
+
+*** ~scene-transform-entities~
+
+~(scene-transform-entities scene proc)~ hands /the whole entity list/ to ~proc~ and expects a replacement list back. Use this when an operation needs to see all entities at once — resolving pairwise entity collisions, for instance, or synchronising grouped entities to their origin:
+
+#+begin_src scheme
+(scene-transform-entities scene resolve-entity-collisions)
+(scene-transform-entities scene sync-groups)
+#+end_src
+
+The engine's default pipeline uses ~scene-transform-entities~ for exactly these two steps, because they are inherently N-to-N.
+
+*** Tagged lookups
+
+Two helpers let you find entities by tag without iterating yourself:
+
+- ~(scene-find-tagged scene tag)~ returns the /first/ entity whose ~#:tags~ list contains ~tag~, or ~#f~.
+- ~(scene-find-all-tagged scene tag)~ returns every such entity as a list.
+
+Both are O(n) scans and intended for coarse queries — finding the player, all enemies of a type, the camera target — not for every-frame loops over hundreds of entities. The engine itself uses ~scene-find-tagged~ internally to resolve ~camera-target~.
+
+*** The update idiom
+
+Putting it together, the typical shape of an ~update:~ hook is either a single transformer:
+
+#+begin_src scheme
+update: (lambda (game dt)
+ (let* ((input (game-input game))
+ (scene (game-scene game))
+ (player (update-player (car (scene-entities scene)) input)))
+ (game-scene-set! game
+ (update-scene scene entities: (list player)))))
+#+end_src
+
+or a chain of them, when multiple pipelines compose:
+
+#+begin_src scheme
+(game-scene-set! game
+ (chain (update-scene scene entities: all)
+ (scene-map-entities _
+ (lambda (scene_ e) (if (eq? (entity-type e) 'player) e (move-projectile e))))
+ (scene-remove-dead _)
+ (scene-filter-entities _ in-bounds?)))
+#+end_src
+
+In both shapes, ~game-scene-set!~ is called exactly once per frame with the final scene. That single write is what the next frame's engine-update and renderer read from.
+
+** Cameras
+
+A /camera/ is a tiny record with two integer slots, ~x~ and ~y~, holding the top-left pixel coordinate of the viewport in world space. ~make-camera~ is the constructor:
+
+#+begin_src scheme
+(make-camera x: 0 y: 0)
+#+end_src
+
+The renderer subtracts ~camera-x~ and ~camera-y~ from every sprite and tile's world position before drawing, so moving the camera east visually scrolls everything west.
+
+*** ~camera-follow~
+
+~(camera-follow camera entity viewport-w viewport-h)~ returns a /new/ camera struct centered on the entity, clamped so neither ~x~ nor ~y~ goes below zero. It floors both results to integers (SDL2 wants integer rects). You can call it directly from an ~update:~ hook — for example to manually follow an entity without tagging it — but the engine provides an auto-follow mechanism built on top of it.
+
+*** Auto-follow via ~camera-target:~
+
+If a scene has ~camera-target:~ set to a symbol, the engine runs ~update-camera-follow~ /after/ your ~update:~ hook and /before/ rendering. That function looks up the first entity whose ~#:tags~ list contains the target symbol via ~scene-find-tagged~. If found, it replaces the scene's camera with one centered on that entity (using the game's ~width~ and ~height~ as the viewport size); if no tagged entity is found, the camera is left alone. The clamp to ~x ≥ 0~ and ~y ≥ 0~ is inherited from ~camera-follow~, so the camera never reveals negative world coordinates.
+
+To opt in, just tag the entity you want followed and set ~camera-target:~ to that tag:
+
+#+begin_src scheme
+(define (make-player)
+ (plist->alist
+ (list #:type 'player
+ #:x 100 #:y 50
+ #:tags '(player)
+ ...)))
+
+(update-scene scene camera-target: 'player)
+#+end_src
+
+Subsequent frames will keep the camera locked to the player. To stop following, set ~camera-target:~ back to ~#f~ (you keep whatever camera position was most recent).
+
+If you need to animate the camera yourself (shake, parallax, cutscenes), leave ~camera-target:~ at ~#f~ and edit ~scene-camera~ via ~update-scene~ from inside your own update. The engine will not override what you write so long as no target is set.
+
+** Named game states
+
+Many games have more than one "mode": a title menu, a playing state, a game-over screen, maybe a pause overlay. Downstroke's game-state system is a lightweight way to switch update and render hooks between modes without having to build a single mega-update.
+
+A game state is just an alist of lifecycle hooks, built with ~make-game-state~:
+
+#+begin_src scheme
+(make-game-state #:create main-menu-create
+ #:update main-menu-update
+ #:render main-menu-render)
+#+end_src
+
+Each of ~#:create~, ~#:update~, and ~#:render~ is optional (~#f~ by default). The engine uses them as follows:
+
+- ~#:create~ is called by ~game-start-state!~ when the state becomes active. Use it to lazily build the scene for that state (e.g. build the level the first time the player picks "Play").
+- ~#:update~, if present, /replaces/ the game's top-level ~update-hook~ while this state is active. The engine's ~resolve-hooks~ prefers the state hook and falls back to the game-level hook when the state has no ~#:update~.
+- ~#:render~ is handled the same way: state-level wins when present, otherwise the game-level ~render-hook~ runs.
+
+Registration is done by name with ~game-add-state!~:
+
+#+begin_src scheme
+(game-add-state! game 'main-menu
+ (make-game-state #:update main-menu-update
+ #:render main-menu-render))
+(game-add-state! game 'playing
+ (make-game-state #:update playing-update
+ #:render playing-render))
+#+end_src
+
+And transitions happen through ~game-start-state!~, which sets the active state and runs the new state's ~#:create~ hook if present:
+
+#+begin_src scheme
+(game-start-state! game 'main-menu)
+...
+(when (input-pressed? input 'a)
+ (game-start-state! game 'playing))
+#+end_src
+
+Only ~update~ and ~render~ are state-scoped; ~preload~ and ~create~ at the ~make-game~ level still fire once each at boot. The engine's built-in engine-update (the physics pipeline) still runs whenever a scene exists, regardless of which state is active — states only swap which user hook drives the frame.
+
+* Common patterns
+
+** Sprite-only scene with no tilemap
+
+The default choice for small demos and menus: one ~make-sprite-scene~ call, no tilemap, a plain background color.
+
+*Getting started demo* — run with ~bin/demo-getting-started~, source in ~demo/getting-started.scm~. A blue square you push around the screen with the arrow keys; canonical minimal sprite scene.
+
+*Tweens demo* — run with ~bin/demo-tweens~, source in ~demo/tweens.scm~. A grid of easing curves applied to moving boxes; also sprite-only.
+
+** TMX-loaded scene with a player prefab
+
+Load the level off disk, append the player, set up camera follow. One chain per create hook.
+
+*Platformer demo* — run with ~bin/demo-platformer~, source in ~demo/platformer.scm~. Uses ~game-load-scene!~ for the TMX map and then ~scene-add-entity~ and ~update-scene~ to attach the player and enable camera follow.
+
+*Topdown demo* — run with ~bin/demo-topdown~, source in ~demo/topdown.scm~. Same pattern, with ~#:gravity? #f~ on the player so the default engine-update gives four-direction motion.
+
+** Camera following a tagged entity
+
+Tag the entity you want the viewport to track, set ~camera-target:~ to the tag, let the engine take care of the rest:
+
+#+begin_src scheme
+(define (make-player)
+ (plist->alist
+ (list #:type 'player
+ #:x 100 #:y 50
+ #:width 16 #:height 16
+ #:tags '(player)
+ ...)))
+
+;; In create:
+(chain (game-load-scene! game "demo/assets/level-0.tmx")
+ (scene-add-entity _ (make-player))
+ (update-scene _ camera-target: 'player)
+ (game-scene-set! game _))
+#+end_src
+
+Any entity carrying the right tag will be followed, and the camera clamps to non-negative world coordinates every frame. The ~platformer~ and ~topdown~ demos both use ~camera-target: 'player~ exactly this way.
+
+** Switching between a title screen and a play state
+
+Register two named states in ~create:~, each with its own update and render hooks, and kick off the first one. Transitions happen anywhere you have a ~game~ handle, including from inside another state's update.
+
+*Menu demo* — run with ~bin/demo-menu~, source in ~demo/menu.scm~. Registers ~main-menu~ and ~playing~ states, switches to ~playing~ when the player selects the menu entry, and switches back on ~Escape~.
+
+#+begin_src scheme
+create: (lambda (game)
+ (game-add-state! game 'main-menu
+ (make-game-state #:update main-menu-update
+ #:render main-menu-render))
+ (game-add-state! game 'playing
+ (make-game-state #:update playing-update
+ #:render playing-render))
+ (game-start-state! game 'main-menu))
+#+end_src
+
+Each state owns its render surface — in the menu demo both states clear the screen themselves, since there is no scene installed on the game — but nothing stops a state from sharing a scene: the menu state's ~#:create~ could build it, and the play state's ~#:update~ could simply read and transform ~(game-scene game)~.
+
+** Loading multiple assets up front
+
+The ~preload:~ hook is the one place guaranteed to run before the first scene is built, after SDL2 has opened a renderer. Use it to warm up the asset registry so ~create:~ and ~update:~ never block on I/O:
+
+#+begin_src scheme
+preload: (lambda (game)
+ (game-load-tileset! game 'tiles "demo/assets/monochrome_transparent.tsx")
+ (game-load-font! game 'title "demo/assets/DejaVuSans.ttf" 24)
+ (game-load-font! game 'body "demo/assets/DejaVuSans.ttf" 14)
+ (init-audio!)
+ (load-sounds! '((jump . "demo/assets/jump.wav")
+ (coin . "demo/assets/coin.wav"))))
+#+end_src
+
+Inside ~create:~ or ~update:~ you read them back with ~game-asset~:
+
+#+begin_src scheme
+(let ((ts (game-asset game 'tiles))
+ (fnt (game-asset game 'title)))
+ ...)
+#+end_src
+
+Because the registry is a plain hash table, you can store anything — parsed JSON, precomputed tables, your own structs — under any symbol key you like. The three ~game-load-*!~ helpers are there for the file formats the engine itself understands; everything else is ~game-asset-set!~ + ~game-asset~.
+
+* See also
+
+- [[file:guide.org][guide.org]] — step-by-step construction of the getting-started demo, including the first ~make-sprite-scene~ call.
+- [[file:entities.org][entities.org]] — the entity alist, conventional keys like ~#:tags~ and ~#:type~, and how prefabs build entities that the scene-loader instantiates.
+- [[file:physics.org][physics.org]] — what the default engine-update actually does, and how to skip or replace individual pipeline steps per entity.
+- [[file:rendering.org][rendering.org]] — how ~render-scene!~ walks the scene to draw tiles, sprites, and fallback colored rects.
+- [[file:animation.org][animation.org]] — the animation pipeline step, run by ~default-engine-update~ on every entity.
+- [[file:tweens.org][tweens.org]] — per-entity declarative tweens, stepped as the first stage of the default pipeline.
+- [[file:input.org][input.org]] — action mapping and polling, used inside ~update:~ hooks to drive entity changes.
+- [[file:audio.org][audio.org]] — loading and playing sound effects alongside your scene-loading hooks.
diff --git a/docs/tweens.org b/docs/tweens.org
index 11bf510..d1ee966 100644
--- a/docs/tweens.org
+++ b/docs/tweens.org
@@ -1,132 +1,314 @@
-#+title: Tweens
-#+author: Downstroke Contributors
+#+TITLE: Tweens
-* Overview
+Tweens smoothly interpolate numeric entity properties over time: an
+=#:x= that slides, a =#:y= that bobs, a custom numeric field that ramps
+up for use inside your =update:= hook. You build a tween with
+=make-tween=, attach it to an entity under the =#:tween= key, and the
+engine advances it for you every frame. When it finishes, it removes
+itself.
-The =downstroke-tween= module interpolates **numeric** entity properties over wall-clock time. It is **decoupled** from the engine: you create tween values, call =tween-step= each frame from your =update:= hook, and store the returned entity back into the scene.
+This doc covers the =downstroke-tween= module: =make-tween=, the
+=step-tweens= pipeline step, and every easing curve shipped in the
+=*ease-table*=.
-Durations and delays are in **milliseconds**, matching the =dt= argument to =update:=.
+* The minimum you need
-* Import
+A one-shot slide to the right, 500 ms, linear ease:
-#+begin_src scheme
+#+BEGIN_SRC scheme
(import downstroke-tween)
-#+end_src
-* Core API
-
-** ~make-tween~
-
-#+begin_src scheme
-(make-tween entity #!key props duration (delay 0) ease
- (on-complete #f) (repeat 0) (yoyo? #f))
-#+end_src
-
-| Keyword | Meaning |
-|---------+---------|
-| ~props~ | Alist =((#:x . 200) (#:y . 40))= — keyword keys, numeric targets |
-| ~duration~ | Positive integer, milliseconds of interpolation (after ~delay~) |
-| ~delay~ | Non-negative integer ms before interpolation starts (first cycle only) |
-| ~ease~ | Easing symbol (see table below) or ~(lambda (t) ...)= with ~t~ in $[0,1]$ |
-| ~on-complete~ | Optional ~(lambda (entity) ...)=, called **once** when the tween fully ends (not called with ~repeat: -1~) |
-| ~repeat~ | ~0~ = play once (default), ~N~ = replay N additional times, ~-1~ = loop forever |
-| ~yoyo?~ | ~#f~ (default) = replay same direction, ~#t~ = reverse direction each cycle |
-
-Start values are captured from ~entity~ at construction time. While the tween runs, intermediate values may be **inexact** (flonums) even if starts and ends are integers.
-
-*** Repeat and yoyo
-
-~repeat:~ controls how many extra times the tween replays after the initial play. ~yoyo?: #t~ swaps start and end values on each cycle, creating a ping-pong effect.
-
-| ~repeat~ | ~yoyo?~ | Behavior |
-|------+-------+----------|
-| ~0~ | either | Play once and finish (default) |
-| ~1~ | ~#f~ | Play forward twice, then finish |
-| ~1~ | ~#t~ | Play forward, then backward, then finish |
-| ~-1~ | ~#f~ | Play forward forever |
-| ~-1~ | ~#t~ | Ping-pong forever |
-
-~delay:~ only applies before the first cycle. ~on-complete~ fires once when the last repeat finishes; it never fires with ~repeat: -1~. Overflow time from a completed cycle carries into the next cycle for smooth transitions.
-
-** ~tween-step~
-
-#+begin_src scheme
-(tween-step tween entity dt)
-#+end_src
-
-Returns ~(values new-tween new-entity)~. Advance time by ~dt~ (ms). Before ~delay~ elapses, ~entity~ is unchanged. After completion, further steps return the same values (idempotent). When the tween completes, ~on-complete~ runs with the **final** entity (targets applied), then the callback slot is cleared. With ~repeat:~ / ~yoyo?:~, the tween automatically resets for the next cycle.
-
-** ~tween-finished?~ / ~tween-active?~
-
-Predicates on the tween struct.
-
-** ~step-tweens~ (pipeline step)
-
-#+begin_src scheme
-(step-tweens entity scene dt)
-#+end_src
-
-Auto-advances ~#:tween~ on an entity. If the entity has no ~#:tween~ key (guard fails), returns the entity unchanged. When the tween finishes (no more repeats), ~#:tween~ is removed from the entity. Respects ~#:skip-pipelines~: skipped when ~'tweens~ is in the list.
-
-~step-tweens~ is part of ~default-engine-update~ and runs automatically each frame. No manual wiring is needed — just attach a ~#:tween~ to an entity and the engine handles the rest.
-
-** Entity key: ~#:tween~
-
-Attach a tween directly to an entity for automatic advancement by ~step-tweens~:
-
-#+begin_src scheme
-(list #:type 'platform
- #:x 100 #:y 300 #:width 48 #:height 16
- #:solid? #t #:immovable? #t #:gravity? #f
- #:tween (make-tween (list #:x 100)
- props: '((#:x . 300))
- duration: 3000 ease: 'sine-in-out
- repeat: -1 yoyo?: #t))
-#+end_src
-
-The platform ping-pongs between x=100 and x=300 forever. No manual tween management needed.
-
-* Easing
-
-Each ease maps normalized time ~t ∈ [0,1]~ to an interpolation factor (usually in ~[0,1]~; ~back-out~ may exceed ~1~ briefly).
-
-| Symbol | Procedure |
-|--------|-----------|
-| ~linear~ | ~ease-linear~ |
-| ~quad-in~, ~quad-out~, ~quad-in-out~ | quadratic |
-| ~cubic-in~, ~cubic-out~, ~cubic-in-out~ | cubic |
-| ~sine-in-out~ | smooth sine |
-| ~expo-in~, ~expo-out~, ~expo-in-out~ | exponential |
-| ~back-out~ | overshoot then settle (Robert Penner–style) |
-
-** ~ease-named~ / ~ease-resolve~
-
-~ease-named~ turns a symbol into a procedure. ~ease-resolve~ accepts a symbol or procedure (identity for procedures) for use in custom tooling.
-
-All easing procedures are exported if you want to compose curves manually.
-
-* Order of operations with physics
-
-Tweens usually **fight** velocity and gravity if both update ~#:x~ / ~#:y~. Typical pattern:
-
-1. Set the entity’s ~#:skip-pipelines~ to skip integration steps you do not want (see [[physics.org][Physics]]).
-2. Run ~tween-step~ for that entity.
-3. Run your normal physics pipeline (collisions can still run).
-
-Clear ~#:skip-pipelines~ in ~on-complete~ when the tween ends.
-
-Example skip list for “kinematic shove” while keeping tile collisions:
-
-#+begin_src scheme
-(entity-set player #:skip-pipelines
- '(jump acceleration gravity velocity-x velocity-y))
-#+end_src
-
-* Demo
-
-=bin/demo-tweens= (source =demo/tweens.scm=) shows one row per easing and a crate that tweens horizontally while integration is skipped and tile resolution still runs.
-
-* Limitations (current version)
-
-- Single segment per tween (no built-in chains or sequences).
-- Numeric properties only.
+(let ((e (entity-set (make-entity 0 100 16 16) #:type 'slider)))
+ (entity-set e #:tween
+ (make-tween e props: '((#:x . 300)))))
+#+END_SRC
+
+=make-tween= reads current values off the entity you pass in (starts),
+stores your =props= as the targets (ends), and returns an opaque tween
+struct. Put that struct on the entity under =#:tween= and the engine's
+default update pipeline will advance it — no further wiring.
+
+No entity key? No problem. =make-tween= treats any missing source key
+as =0= (see =entity-ref= in the source), so tweening a custom numeric
+key that isn't on the entity yet starts from zero.
+
+* Core concepts
+
+** Creating a tween
+
+=make-tween= signature (from [[file:../tween.scm][tween.scm]]):
+
+#+BEGIN_SRC scheme
+(make-tween entity #!key
+ props ; alist of (#:key . target-number) — REQUIRED
+ (duration 500) ; ms, must be a positive integer
+ (delay 0) ; ms before interpolation starts
+ (ease 'linear) ; symbol (see ease table) or a procedure
+ (on-complete #f) ; (lambda (entity) ...) called once at end
+ (repeat 0) ; 0 = no repeats, -1 = infinite, N = N more cycles
+ (yoyo? #f)) ; swap starts/ends on every repeat
+#+END_SRC
+
+- =entity= is the entity the tween is about to animate. Its current
+ values for every key in =props= are captured as the starting
+ values — so call =make-tween= /after/ you've built the entity, not
+ before.
+- =props= must be a non-empty alist whose keys are keywords:
+ =((#:x . 200) (#:y . 40))=. Any numeric entity key is valid — the
+ standard =#:x= / =#:y= / =#:width= / =#:height=, a physics field like
+ =#:vx=, or a custom key you inspect inside your own hooks.
+- =ease= accepts either a symbol from the ease table or a raw
+ procedure of signature =(number -> number)= mapping
+ \([0,1] \to [0,1]\).
+- =repeat= counts /additional/ cycles after the first. =repeat: 2= plays
+ three times total.
+- =yoyo?= only matters when =repeat= is non-zero. It flips starts and
+ ends on each cycle so the tween plays forward, backward, forward, etc.
+
+A full call:
+
+#+BEGIN_SRC scheme
+(make-tween player
+ props: '((#:x . 200) (#:y . 40))
+ duration: 800
+ delay: 100
+ ease: 'cubic-in-out
+ on-complete: (lambda (e) (print "arrived"))
+ repeat: 0
+ yoyo?: #f)
+#+END_SRC
+
+Violating the contracts — duration not a positive integer, props not a
+non-empty alist, a non-keyword key, a bad =repeat= value — raises an
+error immediately from =make-tween=.
+
+** The tween lifecycle
+
+Attach a tween by storing it under =#:tween= on an entity. The engine's
+default update pipeline runs =step-tweens= as its first per-entity
+step (see [[file:../engine.scm][engine.scm]], =default-engine-update=):
+
+#+BEGIN_SRC scheme
+(chain scene
+ (scene-map-entities _ (cut step-tweens <> <> dt))
+ (scene-map-entities _ (cut apply-acceleration <> <> dt))
+ ;; ...
+)
+#+END_SRC
+
+=step-tweens= is a =define-pipeline= step with the pipeline name
+=tweens=. On each frame it:
+
+1. Looks at =#:tween= on the entity. If it's =#f= (or absent), it
+ returns the entity untouched.
+2. Advances the tween by =dt= milliseconds. If the tween is still in
+ its =delay= window, nothing interpolates yet.
+3. For each =(key . target)= pair in =props=, linearly interpolates
+ between the start and target using the eased progress factor, and
+ writes the result back with =entity-set=.
+4. When the tween completes (progress reaches 1.0):
+ - If =repeat= is 0, invokes the =on-complete= callback (once) with
+ the final entity, and returns an entity with =#:tween= cleared
+ to =#f=.
+ - Otherwise, it decrements =repeat= and starts a new cycle. If
+ =yoyo?= is true, it swaps starts and ends so the next cycle plays
+ backward.
+
+The "clear to =#f=" behaviour is hard-coded in =step-tweens= (see
+[[file:../tween.scm][tween.scm]]):
+
+#+BEGIN_SRC scheme
+(if (tween-finished? tw2)
+ (entity-set ent2 #:tween #f)
+ (entity-set ent2 #:tween tw2))
+#+END_SRC
+
+So you never have to clean up finished tweens — just check whether
+=#:tween= is =#f= if you need to know whether one's running.
+
+Note: =on-complete= fires only when the tween truly ends (=repeat=
+exhausted), not on every cycle of a repeating tween. For an infinite
+tween (=repeat: -1=), =on-complete= never fires.
+
+** Easing functions
+
+The ease symbol you pass to =make-tween= is looked up in =*ease-table*=
+and resolved to a procedure. The full table, copied from
+[[file:../tween.scm][tween.scm]]:
+
+| symbol | shape |
+|---------------+---------------------------------------------|
+| =linear= | straight line, no easing |
+| =quad-in= | slow start, quadratic |
+| =quad-out= | slow end, quadratic |
+| =quad-in-out= | slow start and end, quadratic |
+| =cubic-in= | slow start, cubic (sharper than quad) |
+| =cubic-out= | slow end, cubic |
+| =cubic-in-out= | slow start and end, cubic |
+| =sine-in-out= | smooth half-cosine |
+| =expo-in= | slow start, exponential |
+| =expo-out= | slow end, exponential |
+| =expo-in-out= | slow start and end, exponential |
+| =back-out= | overshoots past the target then settles |
+
+Passing an unknown symbol raises =ease-named: unknown ease symbol=
+immediately from =make-tween=.
+
+You can also pass a /procedure/ directly as =ease:=. =ease-resolve=
+accepts any procedure of signature =(number -> number)= mapping the
+normalised time \(t \in [0,1]\) to an eased factor. A trivial example:
+
+#+BEGIN_SRC scheme
+;; A "step" ease: snap halfway through.
+(make-tween e
+ props: '((#:x . 100))
+ duration: 400
+ ease: (lambda (t) (if (< t 0.5) 0.0 1.0)))
+#+END_SRC
+
+The returned factor is used for a straight linear interpolation
+between the start and target, so values outside =[0,1]= are legal and
+will overshoot or undershoot (that's exactly how =back-out= works).
+
+** Interaction with the pipeline
+
+=step-tweens= runs as a =define-pipeline= step named =tweens=. You can
+opt out per-entity by adding the step name to =#:skip-pipelines=:
+
+#+BEGIN_SRC scheme
+(entity-set entity #:skip-pipelines '(tweens))
+#+END_SRC
+
+While that's set, any =#:tween= on the entity is frozen. Remove the
+symbol from the list to resume.
+
+Because =step-tweens= is the /first/ step in =default-engine-update=,
+a tween that writes to =#:x= or =#:y= runs /before/ physics. That
+matters in practice:
+
+- If you tween an entity's =#:x= and the physics tile-collision step
+ decides to snap the entity back out of a wall that same frame, the
+ tween's write is overwritten for the frame. This is a real
+ scenario — see [[file:physics.org][physics.org]] for the full
+ pipeline order.
+- For purely-visual tweens (decorative entities, menu widgets, the demo
+ boxes in =bin/demo-tweens=) you usually also want =#:gravity?= and
+ =#:solid?= off so physics leaves the entity alone.
+
+If you need a tween to "win" against physics, either disable the
+physics steps you don't want on that entity via =#:skip-pipelines=, or
+set =#:gravity?= and =#:solid?= to =#f= so the physics steps are no-ops.
+
+* Common patterns
+
+** One-shot move-to-position
+
+Slide the camera-locked player to a checkpoint, then print a message:
+
+#+BEGIN_SRC scheme
+(entity-set player #:tween
+ (make-tween player
+ props: '((#:x . 640) (#:y . 200))
+ duration: 600
+ ease: 'cubic-in-out
+ on-complete: (lambda (e)
+ (print "checkpoint reached"))))
+#+END_SRC
+
+=on-complete= receives the /final/ entity (with =props= written to
+their targets). The engine takes care of clearing =#:tween= to =#f=
+afterwards.
+
+** Yoyoing bob animation
+
+An enemy that hovers up and down forever:
+
+#+BEGIN_SRC scheme
+(entity-set bat #:tween
+ (make-tween bat
+ props: `((#:y . ,(+ (entity-ref bat #:y 0) 8)))
+ duration: 500
+ ease: 'sine-in-out
+ repeat: -1
+ yoyo?: #t))
+#+END_SRC
+
+This is exactly the pattern the tweens demo uses
+(=+ease-duration+= = 2600 ms, =repeat: -1=, =yoyo?: #t= — one entity
+per ease, all bouncing in parallel). Because =repeat= is =-1=, the
+tween never finishes and =#:tween= stays set for the lifetime of the
+entity.
+
+** Chaining actions with =on-complete=
+
+The callback is invoked with the entity in its /final/ interpolated
+state. The signature is just =(lambda (entity) ...)= and — importantly —
+its return value is /discarded/. Look at =tween-complete= in
+[[file:../tween.scm][tween.scm]]: the callback runs for side-effects
+only, and the entity returned to the pipeline is the unmodified
+=final=. So =on-complete= is for triggering world-level actions
+(playing a sound, logging, enqueueing a command, mutating a global
+flag), not for returning a new version of the entity.
+
+A "slide off screen, then log" example:
+
+#+BEGIN_SRC scheme
+(entity-set popup #:tween
+ (make-tween popup
+ props: '((#:y . -40))
+ duration: 400
+ ease: 'quad-out
+ on-complete: (lambda (final)
+ (print "popup dismissed: " (entity-ref final #:id #f)))))
+#+END_SRC
+
+=on-complete= fires once, only when =repeat= is exhausted — for a
+=repeat: -1= tween it never fires.
+
+To chain a /new/ tween after this one, start it from the user =update:=
+hook by inspecting whether =#:tween= is =#f= on the entity (remember,
+=step-tweens= clears it automatically on completion). That keeps the
+chain inside the normal pipeline data flow instead of trying to
+smuggle a new tween through the discarded-callback-return.
+
+** Easing preview recipe
+
+The easing curves are easiest to compare side-by-side. =bin/demo-tweens=
+renders one horizontal-sliding box per ease symbol, labelled with its
+name. If you want to eyeball a single curve, copy the per-entity
+recipe from the demo:
+
+#+BEGIN_SRC scheme
+;; One entity, one ease. Ping-pongs forever between x=20 and x=140.
+(define (make-ease-entity ease-sym y rgb)
+ (let* ((left 20)
+ (right (+ left 120))
+ (base (plist->alist (list #:x left #:y y))))
+ (plist->alist
+ (list #:type 'tween-demo #:x left #:y y
+ #:width 14 #:height 14
+ #:vx 0 #:vy 0 #:gravity? #f #:solid? #f
+ #:color rgb
+ #:ease-name ease-sym
+ #:tween (make-tween base props: `((#:x . ,right))
+ duration: 2600
+ ease: ease-sym
+ repeat: -1 yoyo?: #t)))))
+#+END_SRC
+
+See the full source — including the rendering loop and the label
+layout — in =demo/tweens.scm=. The demo is the canonical visual
+reference for every ease name.
+
+*Tweens demo* — run with =bin/demo-tweens=, source in =demo/tweens.scm=.
+
+* See also
+
+- [[file:guide.org][guide.org]] — getting started with =make-game= and
+ the main loop.
+- [[file:entities.org][entities.org]] — the entity alist model,
+ =entity-ref=, =entity-set=, and =#:skip-pipelines=.
+- [[file:physics.org][physics.org]] — the rest of the default update
+ pipeline that runs right after =step-tweens=.
+- [[file:animation.org][animation.org]] — the complementary sprite-frame
+ animation system; frequently paired with tweens on the same entity.