aboutsummaryrefslogtreecommitdiff
path: root/docs/physics.org
diff options
context:
space:
mode:
authorGene Pasquet <dev@etenil.net>2026-04-08 07:08:54 +0100
committerGene Pasquet <dev@etenil.net>2026-04-08 07:08:54 +0100
commitafc30a12e25215ff5e9226c3b4f8fd127d9a4d68 (patch)
treef736393fb8ebfd8982a4b79310a08c57ee430ff0 /docs/physics.org
parent9e8b75f9949259ef01942cd3717b79b044efddf7 (diff)
Move the engine-update to the scene
Diffstat (limited to 'docs/physics.org')
-rw-r--r--docs/physics.org250
1 files changed, 108 insertions, 142 deletions
diff --git a/docs/physics.org b/docs/physics.org
index e5b3749..81c57b8 100644
--- a/docs/physics.org
+++ b/docs/physics.org
@@ -3,54 +3,72 @@
* 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.
+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?=.
-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.
+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.
-The =tilemap= argument is required by collision functions, since collision data lives in the tilemap metadata (which tiles are solid, which are not).
+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
-- =apply-jump= — trigger jump acceleration if on ground
+- =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 underfoot
-- =resolve-entity-collisions= — all-pairs AABB push-apart for solid entities
+- =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 **per-entity** physics pipeline (what you typically call from =update:=) is:
+The canonical order inside =default-engine-update= (and the recommended order when composing steps yourself) is:
#+begin_src
-apply-jump (set #:ay if jump pressed and on-ground)
+Frame loop (see =docs/api.org= =game-run!=):
+ input
+ ↓
+ engine-update (=default-engine-update= by default)
+ ↓
+ 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)
+apply-velocity-x (add #:vx to #:x)
resolve-tile-collisions-x (snap off horizontal tiles, zero #:vx)
-apply-velocity-y (add #:vy to #:y)
+apply-velocity-y (add #:vy to #:y)
+ ↓
+resolve-tile-collisions-y (snap off vertical tiles, zero #:vy)
-resolve-tile-collisions-y (snap off vertical tiles, zero #:vy)
+detect-on-solid (tiles and/or scene entities underfoot → #:on-ground?)
-detect-on-solid (tiles and/or other solids underfoot, set #:on-ground?)
+resolve-entity-collisions (whole entity list)
-resolve-entity-collisions (push apart overlapping solid entities; whole list)
+sync-groups (whole entity list)
#+end_src
-Input and rendering live **outside** this list — you read input first, then run the steps you need, then render.
+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 are needed for all game types.** See the examples section for three different patterns:
+**Not all steps apply to every entity** (=#:skip-pipelines=, guards like =#:gravity?=, missing tilemap). See the examples section for patterns:
-- **Platformer**: uses all 9 steps
-- **Top-down**: skips gravity, acceleration, jump, ground detection
-- **Physics Sandbox**: uses all steps, applies them to multiple entities
+- **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~)
@@ -58,7 +76,6 @@ An entity may include ~#:skip-pipelines=, a list of **symbols** naming steps to
| Symbol | Skipped call |
|--------+----------------|
-| ~jump~ | ~apply-jump~ |
| ~acceleration~ | ~apply-acceleration~ |
| ~gravity~ | ~apply-gravity~ |
| ~velocity-x~ | ~apply-velocity-x~ |
@@ -67,6 +84,7 @@ An entity may include ~#:skip-pipelines=, a list of **symbols** naming steps to
| ~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.
@@ -76,57 +94,33 @@ Helper: ~(entity-skips-pipeline? entity step-symbol)~ (from ~downstroke-entity~)
** ~define-pipeline~ (~downstroke-entity~)
-Physics steps are defined with ~(define-pipeline (procedure-name skip-symbol) (formals ...) body ...)~ from the entity module, 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. 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.
+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 ~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.
+- **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-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)
+(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 and other instant bursts. Only works if =#:gravity?= is true (gravity-enabled entities). Call once per frame, after =apply-jump=.
+**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.
** apply-gravity
#+begin_src scheme
-(apply-gravity entity)
+(apply-gravity entity scene dt)
#+end_src
**Reads**: =#:gravity?= (boolean), =#:vy= (default 0)
@@ -138,7 +132,7 @@ Use cases:
** apply-velocity-x
#+begin_src scheme
-(apply-velocity-x entity)
+(apply-velocity-x entity scene dt)
#+end_src
**Reads**: =#:vx= (default 0), =#:x= (default 0)
@@ -150,7 +144,7 @@ Use cases:
** apply-velocity-y
#+begin_src scheme
-(apply-velocity-y entity)
+(apply-velocity-y entity scene dt)
#+end_src
**Reads**: =#:vy= (default 0), =#:y= (default 0)
@@ -162,7 +156,7 @@ Use cases:
** resolve-tile-collisions-x
#+begin_src scheme
-(resolve-tile-collisions-x entity tilemap)
+(resolve-tile-collisions-x entity scene dt)
#+end_src
**Reads**: =#:x=, =#:y=, =#:width=, =#:height=, =#:vx=
@@ -179,7 +173,7 @@ Velocity is zeroed to stop the entity from sliding. Call this immediately after
** resolve-tile-collisions-y
#+begin_src scheme
-(resolve-tile-collisions-y entity tilemap)
+(resolve-tile-collisions-y entity scene dt)
#+end_src
**Reads**: =#:x=, =#:y=, =#:width=, =#:height=, =#:vy=
@@ -196,16 +190,16 @@ Velocity is zeroed. Call this immediately after =apply-velocity-y=.
** detect-on-solid
#+begin_src scheme
-(detect-on-solid entity tilemap #!optional other-entities)
+(detect-on-solid entity scene dt)
#+end_src
-**Reads**: =#:gravity?=, =#:x=, =#:y=, =#:width=, =#:height=, =#:vy=; when =other-entities= is passed, each other entity's =#:solid?=, position, and size
+**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=)
-**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.
+**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.
-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.
+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=.
** resolve-entity-collisions
@@ -299,83 +293,52 @@ The push-apart is along the **minimum penetration axis** (X or Y, whichever over
* Platformer Example
-A minimal platformer with a player, gravity, jumping, and tile collisions:
+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))
- (tm (scene-tilemap scene))
(player (car (scene-entities scene)))
- ;; Set horizontal velocity based on input
+ (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))))
- ;; 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)))
- (game-scene-set! game
- (update-scene scene entities: (list player)))))
+ (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. **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
+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, no jumping, and free 4-way movement:
+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))
- (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
+ (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)))))
@@ -384,72 +347,75 @@ update: (lambda (game dt)
** 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
+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 pipeline is much simpler because there's no vertical acceleration to manage.
+The built-in pipeline still runs; most steps no-op or pass through for non-gravity entities.
* Physics Sandbox Example
-Multiple entities falling and colliding with each other:
+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):
#+begin_src scheme
-update: (lambda (game dt)
- (let* ((scene (game-scene game))
- (tm (scene-tilemap scene)))
- ;; Apply physics to all entities in one pass, then resolve entity-entity collisions
- (scene-transform-entities
- (scene-map-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)))
- resolve-entity-collisions)))
+(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
+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? for next frame)
+ - =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 is efficient for sandbox simulations: apply the same pipeline to all entities, then resolve inter-entity collisions once.
+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.
-** Lambdas with Tilemap
+** Capturing =scene= and =dt=
-Notice that =resolve-tile-collisions-x= needs the tilemap argument, so it's wrapped in a lambda:
+=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 tm))
+(lambda (e) (resolve-tile-collisions-x e scene dt))
#+end_src
-Same for other functions that take tilemap. The =scene-map-entities= macro applies each function to all entities, so you wrap single-argument functions in a lambda to capture the tilemap.
+Use the same =dt= (milliseconds) that =game-run!= passes to your hooks.
* 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
+;; 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))
#+end_src
-You only need =apply-jump=, =apply-acceleration=, and =apply-gravity=. =detect-on-solid= is needed to prevent double-jumping.
+=*jump-force*= (default 15) is exported from =downstroke-physics=. =apply-acceleration= and gravity run inside =default-engine-update= on the following frame.
** No Gravity
-For top-down or shmup games, skip =apply-jump=, =apply-acceleration=, =apply-gravity=, and =detect-on-solid=. Just do velocity + tile collision.
+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
@@ -504,9 +470,9 @@ For large games, consider spatial partitioning (grid, quadtree) to cull entity p
** 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
+- 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