#+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-on-solid= — set =#:on-ground?= from tiles below feet and/or solid entities underfoot - =resolve-entity-collisions= — all-pairs AABB push-apart for solid entities - =aabb-overlap?= — pure boolean collision test (for queries, not resolution) * Physics Pipeline The canonical **per-entity** physics pipeline (what you typically call from =update:=) is: #+begin_src 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-on-solid (tiles and/or other solids underfoot, set #:on-ground?) ↓ resolve-entity-collisions (push apart overlapping solid entities; whole list) #+end_src Input and rendering live **outside** this list — you read input first, then run the steps you need, then render. **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 * 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 | |--------+----------------| | ~jump~ | ~apply-jump~ | | ~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~ | **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) (formals ...) body ...)~ from the entity module. The first formal must be the entity. The procedure name and skip symbol are separate (e.g. ~detect-on-solid~ vs ~on-solid~). ~apply-velocity~ is still written by hand because it consults ~velocity-x~ and ~velocity-y~ independently. The renderer and other subsystems do **not** use ~#:skip-pipelines~ today; they run after your ~update:~ hook. If you add render-phase or animation-phase skips later, reuse the same plist key and helpers from ~downstroke-entity~ and document the new symbols alongside physics. Use cases: - **Tweens / knockback:** skip ~jump~, ~acceleration~, ~gravity~, ~velocity-x~, ~velocity-y~ while a tween drives ~#:x~ / ~#:y~, but keep tile resolution so the body does not rest inside walls. - **Top-down:** omit gravity, jump, acceleration, ground detection from your *call order*; you usually do not need ~#:skip-pipelines= unless some entities differ from others. * 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-on-solid #+begin_src scheme (detect-on-solid entity tilemap #!optional other-entities) #+end_src **Reads**: =#:gravity?=, =#:x=, =#:y=, =#:width=, =#:height=, =#:vy=; when =other-entities= is passed, each other entity's =#:solid?=, position, and size **Writes**: =#:on-ground?= (set to =#t= if supported by a solid tile probe and/or by another solid's top surface, else =#f=) **Description**: Despite the =?=-suffix, this returns an **updated entity** (it sets =#:on-ground?=), not a boolean. Ground is **either** (1) a solid tile one pixel below the feet (probe at both lower corners of the AABB, same as before), **or** (2) when =other-entities= is a non-=#f= list, resting on another solid entity's **top** (horizontal overlap, feet within a few pixels of that entity's top, and vertical speed small enough that we treat the body as supported—so moving platforms and crates count). Omit =other-entities= (or pass =#f=) to keep tile-only behavior. Only runs when =#:gravity?= is true. If =tilemap= is =#f=, the tile probe is skipped and only entity support applies when a list is provided. Used by =apply-jump= (via =#:on-ground?= on the **next** frame). Call **after** tile collision and **after** =resolve-entity-collisions= when using entity support, so positions and velocities are settled. ** resolve-entity-collisions #+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: if one has =#:immovable? #t=, only the other entity is displaced along the shallow overlap axis (and its velocity on that axis is zeroed); if both are immovable, the pair is skipped. Otherwise the two bodies are pushed apart along the smaller overlap axis and their velocities on that axis are set to ±1. Entities without =#:solid?= or with =#:solid? #f= are skipped. Returns a new entity list with collisions resolved. 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-on-solid player tm))) ;; Update camera to follow player (let ((cam-x (max 0 (- (entity-ref player #:x 0) 300)))) (camera-x-set! (scene-camera scene) cam-x)) ;; 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-on-solid**: set #:on-ground? from tiles and/or other solids (optional entity list), 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-on-solid 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-on-solid= (set #:on-ground? for next frame) 2. **scene-resolve-collisions**: after all entities are moved and collided with tiles, resolve entity-entity overlaps (boxes pushing apart) 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-on-solid= is needed to prevent double-jumping. ** No Gravity For top-down or shmup games, skip =apply-jump=, =apply-acceleration=, =apply-gravity=, and =detect-on-solid=. 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-on-solid= is called after tile collision and, when using the optional entity list, after =resolve-entity-collisions= - Verify =#:on-ground?= is checked in =apply-jump= (not hardcoded) - Make sure you're checking =input-pressed?= (not =input-held?=) for jump ** 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