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:

  1. 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))
    
  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:

    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. — see docs/tweens.org)
  • Guard: does nothing if #:tween is #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: #: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.

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 is 1 pixel/frame². Gravity accumulates until resolve-tile-collisions-y zeroes #:vy on 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 #: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.

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 #:vx is 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-x on 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? (#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.

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 (#: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.

2.1.10. 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.

2.1.11. apply-animation

  • Reads: #:animations, #:anim-name, #:anim-frame, #:anim-tick (the tick budget each step is animation-frame-duration on the current animation + frame index — see docs/animation.org).
  • Writes: #:anim-tick, #:anim-frame, #:tile-id, #:anim-duration (#:anim-duration mirrors the resolved budget for the current frame).
  • 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.

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:

  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:

    ;; 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))
    
  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.

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? #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.

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? #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.

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? (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.

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 the define-pipeline macro.
  • Tweensstep-tweens as the first step of the pipeline, and how to combine tweens with #:skip-pipelines for knockback-style effects that bypass velocity integration.
  • Animationapply-animation as the last step of the pipeline; #:animations, #:anim-name, #:anim-frame.
  • Inputinput-held? / input-pressed? for reading movement and jump intent in update:.
  • 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.
  • Rendering — how #:tile-id (written by apply-animation) is drawn, and where the camera transform is applied.
  • Platformer (bin/demo-platformer) — canonical gravity + jump + tile-collide example.
  • Shmup (bin/demo-shmup) — canonical engine-update: 'none example with manual collision checks via aabb-overlap?.
  • Sandbox (bin/demo-sandbox) — multiple movers, entity–entity push-apart, the default pipeline in a busier scene.
  • Tweens (bin/demo-tweens) — step-tweens in action; useful when combined with #:skip-pipelines '(velocity-x velocity-y).

Author: Downstroke Contributors

Created: 2026-04-20 Mon 15:22

Validate