Physics
Table of Contents
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.
1. The minimum you need
To get physics behavior in your game, do three things:
Give your player entity dimensions, velocity keys, and
#:gravity? #t:(list #:type 'player #:x 100 #:y 50 #:width 16 #:height 16 #:vx 0 #:vy 0 #:gravity? #t #:on-ground? #f #:tags '(player))- Let the default engine update run. When you call
make-gamewithout overridingengine-update:on the scene, the built-in pipeline runs automatically each frame. You do not need to import or compose any physics procedure yourself. In your
update:hook, set#:vxfrom input and set#:ayto(- *jump-force*)on the frame the player jumps. The pipeline integrates velocity, handles tile collisions, and refreshes#:on-ground?before your nextupdate:tick so you can gate jumps on it: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))))))
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.
2. Core concepts
2.1. 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):
Frame:
input
↓
engine-update (default-engine-update unless overridden)
↓
update: (your game logic — set #:vx, #:vy, #:ay, ...)
↓
camera follow
↓
render
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)
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)
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).
2.1.1. 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. — seedocs/tweens.org) - Guard: does nothing if
#:tweenis#f - Defined in
tween.scm.
2.1.2. 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:
#:ayis consumed every frame. This is the jump mechanism — set#:ayto(- *jump-force*)on the frame you want to jump and this step folds it into#:vyexactly once.
2.1.3. 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 is1pixel/frame². Gravity accumulates untilresolve-tile-collisions-yzeroes#:vyon contact with the floor.
2.1.4. 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#:vxand#:vyfrom input, the pipeline moves the entity and resolves tile collisions, and the absence of#:gravity?makes gravity/acceleration/ground detection no-op.
2.1.5. 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#:vxis zeroed.
2.1.6. apply-velocity-y
- Reads:
#:y(default 0),#:vy(default 0) - Writes:
#:y(set to(+ y vy)) - No guard. Same as
apply-velocity-xon the Y axis.
2.1.7. 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.
2.1.8. detect-on-solid
- Reads:
#:gravity?,#:x,#:y,#:width,#:height,#:vy; and(scene-entities scene)for entity-supported ground - Writes:
#:on-ground?(#tor#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":
- 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. Ifscene-tilemapis#fthis probe is skipped. - Entity ground. Another entity in the scene with
#:solid? #tcounts 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.
- Tile ground. Probes one pixel below the feet (
2.1.9. 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 (#:vxor#: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-collisionsin#:skip-pipelines, the pair is skipped entirely.
- both
2.1.10. sync-groups
- Bulk step. Takes the entity list and returns a new entity list.
- For each entity with
#:group-origin? #tand a#:group-id, marks it as the origin of its group (first-wins). - For each entity with a
#:group-idthat is not the origin, snaps its#:x/#:yto(+ 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.
2.1.11. apply-animation
- Reads:
#:animations,#:anim-name,#:anim-frame,#:anim-tick(the tick budget each step isanimation-frame-durationon the current animation + frame index — seedocs/animation.org). - Writes:
#:anim-tick,#:anim-frame,#:tile-id,#:anim-duration(#:anim-durationmirrors the resolved budget for the current frame). - Guard: only runs when
#:animationsis present. - Advances the per-entity animation state machine and updates
#:tile-idso the renderer draws the correct sprite. Fully documented indocs/animation.org.
2.2. 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:
2.2.1. 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.
2.2.2. #: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:
(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))
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.
2.2.3. define-pipeline (how steps are declared)
All per-entity pipeline steps are declared with define-pipeline from
(downstroke entity) (see docs/entities.org). The shape is:
(define-pipeline (procedure-name skip-symbol) (scene entity dt) guard: (some-expression-over entity) (body ...))
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.
2.3. Overriding or disabling the pipeline
A scene has an engine-update field. The game's frame loop dispatches
on it every tick:
;; 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
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.
2.4. 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.
2.4.1. 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:
- 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. 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:;; 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))- 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.
2.4.2. 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? #tand neither may listentity-collisionsin#:skip-pipelines. - AABB overlap test (
aabb-overlap?): strictly overlapping; edge-touching does not count. - On overlap,
push-apartpicks 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.
2.4.3. #: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.
2.4.4. aabb-overlap? for manual queries
If you want to detect overlap without resolving it (bullets hitting
enemies, damage zones, pickups), call aabb-overlap? directly:
(aabb-overlap? x1 y1 w1 h1 x2 y2 w2 h2) ;; → #t or #f
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.
3. Common patterns
3.1. Moving platform (vx + immovable?)
A platform that slides horizontally and carries the player:
(list #:type 'platform
#:x 200 #:y 300
#:width 48 #:height 8
#:vx 1 #:vy 0
#:solid? #t #:immovable? #t
#:tags '(platform))
#:solid? #tmakes the platform participate inresolve-entity-collisions.#:immovable? #tmeans only other entities are pushed; the platform's#:x/#:yare never touched by entity collisions.#:vx 1makesapply-velocity-xmove the platform 1 px/frame.- The platform has no
#:gravity?so it doesn't fall. - When the player lands on it,
detect-on-solidsees the solid entity directly below (viaentity-solid-support-below?) and sets the player's#:on-ground?to#t. - Turn the platform around at the ends by flipping
#:vxin yourupdate:hook when#:xreaches a limit.
For a platform that should not move horizontally on its own (a
purely static crate), leave #:vx / #:vy at 0.
3.2. Disabling gravity on a bullet
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):
(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))
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).
Use aabb-overlap? in your update: hook to detect hits against
enemies.
3.3. Skipping just tile collisions on one entity
Sometimes you have an entity (pickup, decorative particle, ghost) that
should move via #:vx / #:vy but not snap against tiles:
(list #:type 'ghost
#:x 100 #:y 100 #:width 16 #:height 16
#:vx 2 #:vy 0
#:skip-pipelines '(tile-collisions-x tile-collisions-y))
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.
3.4. Replacing the pipeline with a custom engine-update:
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:
(define (my-engine-update game dt)
(let ((scene (game-scene game)))
(when scene
(game-scene-set! game
(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)
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.
3.5. Turning the pipeline off entirely ('none)
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:
(make-scene entities: (list (make-player)) tilemap: #f camera: (make-camera x: 0 y: 0) tileset-texture: #f camera-target: #f engine-update: 'none)
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.
3.6. Reading #:on-ground? to gate jumps
The jump check in the Platformer demo:
(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)))
input-pressed?(notinput-held?) ensures one jump per keypress.#:on-ground?was updated bydetect-on-solidin the pipeline this frame, before yourupdate:ran, so it reflects the current position after tile / entity collisions.#:ayis cleared byapply-accelerationon the next frame, so the impulse applies exactly once.
4. See also
- Getting started guide — overall game structure and the minimal example that calls into the physics pipeline.
- Entities — the keys the pipeline reads and writes
(
#:x,#:vy,#:gravity?,#:solid?,#:skip-pipelines, etc.),entity-ref/entity-set, and thedefine-pipelinemacro. - Tweens —
step-tweensas the first step of the pipeline, and how to combine tweens with#:skip-pipelinesfor knockback-style effects that bypass velocity integration. - Animation —
apply-animationas the last step of the pipeline;#:animations,#:anim-name,#:anim-frame. - Input —
input-held?/input-pressed?for reading movement and jump intent inupdate:. - Scenes — the
engine-updatefield ofmake-scene, group entities (#:group-id,#:group-origin?) consumed bysync-groups, andscene-map-entities/scene-transform-entitiesused throughout the pipeline. - Rendering — how
#:tile-id(written byapply-animation) is drawn, and where the camera transform is applied. - Platformer (
bin/demo-platformer) — canonical gravity + jump + tile-collide example. - Shmup (
bin/demo-shmup) — canonicalengine-update: 'noneexample with manual collision checks viaaabb-overlap?. - Sandbox (
bin/demo-sandbox) — multiple movers, entity–entity push-apart, the default pipeline in a busier scene. - Tweens (
bin/demo-tweens) —step-tweensin action; useful when combined with#:skip-pipelines '(velocity-x velocity-y).