aboutsummaryrefslogtreecommitdiff
path: root/docs/physics.org
diff options
context:
space:
mode:
Diffstat (limited to 'docs/physics.org')
-rw-r--r--docs/physics.org502
1 files changed, 502 insertions, 0 deletions
diff --git a/docs/physics.org b/docs/physics.org
new file mode 100644
index 0000000..a267d6d
--- /dev/null
+++ b/docs/physics.org
@@ -0,0 +1,502 @@
+#+title: Physics System
+#+author: Downstroke Contributors
+
+* Overview
+
+The downstroke physics system is **explicit**: YOU call each physics step in your =update:= hook. This design gives you full control over the game's behavior—skip gravity for top-down games, skip tile collision for shmups, or skip entity collision for single-player platformers.
+
+All physics functions are **functional and immutable**: they take an entity (plist) and return a NEW plist with updated values. No side effects. This makes it easy to reason about physics state and compose multiple steps together.
+
+The =tilemap= argument is required by collision functions, since collision data lives in the tilemap metadata (which tiles are solid, which are not).
+
+** Key Physics Functions
+
+- =apply-jump= — trigger jump acceleration if on ground
+- =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-ground= — probe 1px below feet to set #:on-ground?
+- =resolve-entity-collisions= — all-pairs AABB push-apart for solid entities
+- =aabb-overlap?= — pure boolean collision test (for queries, not resolution)
+
+* Physics Pipeline
+
+The canonical 9-step physics pipeline is:
+
+#+begin_src
+input
+ ↓
+apply-jump (set #:ay if jump pressed and on-ground)
+ ↓
+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-ground (probe 1px below feet, set #:on-ground?)
+ ↓
+resolve-entity-collisions (push apart overlapping solid entities)
+ ↓
+render
+#+end_src
+
+**Not all steps are needed for all game types.** See the examples section for three different patterns:
+
+- **Platformer**: uses all 9 steps
+- **Top-down**: skips gravity, acceleration, jump, ground detection
+- **Physics Sandbox**: uses all steps, applies them to multiple entities
+
+* Pipeline Steps
+
+** apply-jump
+
+#+begin_src scheme
+(apply-jump entity jump-pressed?)
+#+end_src
+
+**Signature**: Takes an entity plist and a boolean (jump button state).
+
+**Reads**: =#:on-ground?= (must be #t), =#:jump-force= (default: 15 pixels/frame²)
+
+**Writes**: =#:ay= (acceleration-y), set to =(- jump-force)= if jump pressed and on ground, else #:ay is left untouched
+
+**Description**: Sets up a one-shot vertical acceleration if the jump button was just pressed AND the entity is standing on ground. The acceleration is consumed on the next frame by =apply-acceleration=. Do not call this repeatedly in a loop; call it once per frame.
+
+#+begin_src scheme
+;; Example:
+(define entity (list #:type 'player #:on-ground? #t #:jump-force 15))
+(define jumped (apply-jump entity #t))
+;; jumped has #:ay -15
+
+(define jumped2 (apply-jump entity #f))
+;; jumped2 is unchanged (not on ground)
+#+end_src
+
+** apply-acceleration
+
+#+begin_src scheme
+(apply-acceleration entity)
+#+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 and other instant bursts. Only works if =#:gravity?= is true (gravity-enabled entities). Call once per frame, after =apply-jump=.
+
+** apply-gravity
+
+#+begin_src scheme
+(apply-gravity entity)
+#+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
+
+#+begin_src scheme
+(apply-velocity-x entity)
+#+end_src
+
+**Reads**: =#:vx= (default 0), =#:x= (default 0)
+
+**Writes**: =#:x= (adds #:vx to it)
+
+**Description**: Moves the entity horizontally by its velocity. Call once per frame, before =resolve-tile-collisions-x=.
+
+** apply-velocity-y
+
+#+begin_src scheme
+(apply-velocity-y entity)
+#+end_src
+
+**Reads**: =#:vy= (default 0), =#:y= (default 0)
+
+**Writes**: =#:y= (adds #:vy to it)
+
+**Description**: Moves the entity vertically by its velocity. Call once per frame, before =resolve-tile-collisions-y=.
+
+** resolve-tile-collisions-x
+
+#+begin_src scheme
+(resolve-tile-collisions-x entity tilemap)
+#+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
+
+#+begin_src scheme
+(resolve-tile-collisions-y entity tilemap)
+#+end_src
+
+**Reads**: =#:x=, =#:y=, =#:width=, =#:height=, =#:vy=
+
+**Writes**: =#:y= (snapped to tile edge), =#:vy= (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 Y-axis. If a collision is found:
+
+- **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)
+
+Velocity is zeroed. Call this immediately after =apply-velocity-y=.
+
+** detect-ground
+
+#+begin_src scheme
+(detect-ground entity tilemap)
+#+end_src
+
+**Reads**: =#:gravity?=, =#:x=, =#:y=, =#:width=, =#:height=
+
+**Writes**: =#:on-ground?= (set to #t if tile found 1px below feet, else #f)
+
+**Description**: Probes 1 pixel directly below the entity's feet (bottom edge) to see if it's standing on a tile. The probe checks both corners of the entity's width to handle partial overlaps. Only works if =#:gravity?= is true.
+
+Used by =apply-jump= to decide whether jump is allowed. Call this at the end of each physics frame, after all collision resolution.
+
+** resolve-entity-collisions
+
+#+begin_src scheme
+(resolve-entity-collisions entities)
+#+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:
+
+1. Calculate overlap on both X and Y axes
+2. Push apart along the axis with smaller overlap (to avoid getting stuck in corners)
+3. Set each entity's velocity along that axis to push them in opposite directions
+
+Entities without =#:solid?= or with =#:solid? #f= are skipped. Returns a new entity list with collisions resolved.
+
+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.
+
+** scene-resolve-collisions
+
+#+begin_src scheme
+(scene-resolve-collisions scene)
+#+end_src
+
+**Description**: Convenience wrapper. Extracts all entities from the scene, passes them to =resolve-entity-collisions=, and updates the scene in place. Modifies the scene.
+
+** aabb-overlap?
+
+#+begin_src scheme
+(aabb-overlap? x1 y1 w1 h1 x2 y2 w2 h2)
+#+end_src
+
+**Returns**: Boolean (#t if overlapping, #f if separated or touching)
+
+**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:
+
+#+begin_src scheme
+update: (lambda (game dt)
+ (let* ((input (game-input game))
+ (scene (game-scene game))
+ (tm (scene-tilemap scene))
+ (player (car (scene-entities scene)))
+ ;; Set horizontal velocity based on input
+ (player (entity-set player #:vx
+ (cond
+ ((input-held? input 'left) -3)
+ ((input-held? input 'right) 3)
+ (else 0))))
+ ;; Play sound if jump pressed
+ (_ (when (and (input-pressed? input 'a)
+ (entity-ref player #:on-ground? #f))
+ (play-sound 'jump)))
+ ;; Apply jump acceleration if button pressed
+ (player (apply-jump player (input-pressed? input 'a)))
+ ;; Consume #:ay into #:vy
+ (player (apply-acceleration player))
+ ;; Apply gravity constant
+ (player (apply-gravity player))
+ ;; Move horizontally
+ (player (apply-velocity-x player))
+ ;; Resolve tile collisions on x-axis
+ (player (resolve-tile-collisions-x player tm))
+ ;; Move vertically
+ (player (apply-velocity-y player))
+ ;; Resolve tile collisions on y-axis
+ (player (resolve-tile-collisions-y player tm))
+ ;; Check if standing on ground
+ (player (detect-ground player tm)))
+ ;; Update camera to follow player
+ (let ((cam-x (max 0 (- (entity-ref player #:x 0) 300))))
+ (camera-x-set! (scene-camera scene) cam-x))
+ ;; Store updated player back in scene
+ (scene-entities-set! scene (list player))))
+#+end_src
+
+** Step-by-Step
+
+1. **Set #:vx** from input (left = -3, right = +3, idle = 0)
+2. **Check jump**: if A button pressed and on-ground, sound plays
+3. **apply-jump**: if A pressed and on-ground, set #:ay to -15 (jump force)
+4. **apply-acceleration**: consume #:ay into #:vy (so #:ay becomes 0)
+5. **apply-gravity**: add 1 to #:vy each frame (falling acceleration)
+6. **apply-velocity-x**: add #:vx to #:x (move left/right)
+7. **resolve-tile-collisions-x**: if hit a tile, snap #:x and zero #:vx
+8. **apply-velocity-y**: add #:vy to #:y (move up/down)
+9. **resolve-tile-collisions-y**: if hit a tile, snap #:y and zero #:vy
+10. **detect-ground**: probe 1px below feet, set #:on-ground? for next frame's jump check
+
+* Top-Down Example
+
+A top-down game with no gravity, no jumping, and free 4-way movement:
+
+#+begin_src scheme
+update: (lambda (game dt)
+ (let* ((input (game-input game))
+ (scene (game-scene game))
+ (tm (scene-tilemap scene))
+ (player (car (scene-entities scene)))
+ ;; Combine directional input
+ (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)))
+ ;; Set both velocities
+ (player (entity-set (entity-set player #:vx dx) #:vy dy))
+ ;; Move horizontally
+ (player (apply-velocity-x player))
+ ;; Resolve tile collisions on x-axis
+ (player (resolve-tile-collisions-x player tm))
+ ;; Move vertically
+ (player (apply-velocity-y player))
+ ;; Resolve tile collisions on y-axis
+ (player (resolve-tile-collisions-y player tm)))
+ ;; Update camera to follow player
+ (camera-x-set! (scene-camera scene) (max 0 (- (entity-ref player #:x 0) 300)))
+ (camera-y-set! (scene-camera scene) (max 0 (- (entity-ref player #:y 0) 200)))
+ ;; Store updated player back in scene
+ (scene-entities-set! scene (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
+3. **apply-velocity-x** and **resolve-tile-collisions-x**: move and collide horizontally
+4. **apply-velocity-y** and **resolve-tile-collisions-y**: move and collide vertically
+5. **No gravity, no jumping, no ground detection**: top-down games don't need these
+
+The pipeline is much simpler because there's no vertical acceleration to manage.
+
+* Physics Sandbox Example
+
+Multiple entities falling and colliding with each other:
+
+#+begin_src scheme
+update: (lambda (game dt)
+ (let* ((scene (game-scene game))
+ (tm (scene-tilemap scene)))
+ ;; Apply physics to all entities in one pass
+ (scene-update-entities scene
+ apply-gravity
+ apply-velocity-x
+ (lambda (e) (resolve-tile-collisions-x e tm))
+ apply-velocity-y
+ (lambda (e) (resolve-tile-collisions-y e tm))
+ (lambda (e) (detect-ground e tm)))
+ ;; Then resolve entity-entity collisions
+ (scene-resolve-collisions scene)))
+#+end_src
+
+** step-by-step
+
+1. **scene-update-entities**: applies each step to all entities in order
+ - =apply-gravity= (all entities fall)
+ - =apply-velocity-x=, =resolve-tile-collisions-x= (move and collide on x-axis)
+ - =apply-velocity-y=, =resolve-tile-collisions-y= (move and collide on y-axis)
+ - =detect-ground= (set #:on-ground? for next frame)
+
+2. **scene-resolve-collisions**: after all entities are moved and collided with tiles, resolve entity-entity overlaps (boxes pushing apart)
+
+This pattern is efficient for sandbox simulations: apply the same pipeline to all entities, then resolve inter-entity collisions once.
+
+** Lambdas with Tilemap
+
+Notice that =resolve-tile-collisions-x= needs the tilemap argument, so it's wrapped in a lambda:
+
+#+begin_src scheme
+(lambda (e) (resolve-tile-collisions-x e tm))
+#+end_src
+
+Same for other functions that take tilemap. The =scene-update-entities= macro applies each function to all entities, so you wrap single-argument functions in a lambda to capture the tilemap.
+
+* Common Patterns
+
+** Jumping
+
+#+begin_src scheme
+;; In update:
+(player (apply-jump player (input-pressed? input 'jump)))
+(player (apply-acceleration player))
+(player (apply-gravity player))
+;; ... rest of pipeline
+#+end_src
+
+You only need =apply-jump=, =apply-acceleration=, and =apply-gravity=. =detect-ground= is needed to prevent double-jumping.
+
+** No Gravity
+
+For top-down or shmup games, skip =apply-jump=, =apply-acceleration=, =apply-gravity=, and =detect-ground=. Just do velocity + tile collision.
+
+** Knockback
+
+Set #:vx or #:vy directly to push an entity:
+
+#+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))
+#+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 =detect-ground= is called after all collision resolution
+- Verify =#:on-ground?= is checked in =apply-jump= (not hardcoded)
+- Make sure you're checking =input-pressed?= (not =input-held?=) for jump
+
+** 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-resolve-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