Proposal: 2D collision / physics-lite foundation — Body + Collider + esys_move (Phase 0 of #57) #65

Closed
opened 2026-09-01 11:37:33 +02:00 by orkun · 1 comment
Owner

Context

This is the Phase 0 foundation prerequisite that #57 (builtin gameplay controllers) explicitly defers to a dedicated issue:

2D collision / physics-lite (Body + Collider + esys_move). AABB sweep + tile-grid resolution, one-way platforms, triggers/sensors. Referenced by all four families. […] this is the biggest shared prerequisite and likely a dedicated issue.

All four controller families (platformer / shooter / NPC AI / RPG) depend on it; none can be implemented until movement-with-collision exists. Sprites and animation are already handled by the engine (esys_spriteanim #43, esys_motion, Tween.*), so this issue is only about the missing world-simulation layer.

Current state

Collision.* today is a static-overlap stub only — selfhost/backend/game/emit_collide.ludic exposes just:

  • Collision.rects (AABB vs AABB overlap → bool)
  • Collision.circles, Collision.rect_circle, Collision.point_rect

These are boolean tests. There is no velocity integration, no swept/continuous collision, no resolution (push-out), no tile-grid broadphase, no triggers, and no per-entity Body/Collider components an engine system ticks. Value types Rect/Vector/IVec2 and Grid.* (spatial queries + pathfinding) already exist to build on.

Proposed scope

Follow the existing engine-owned-system (esys_*) pattern (runtime/native/systems.ludic, resolved by name through the reflection ABI, no-op when a field is absent) so it composes with the six-lever contract in #57.

Components (POD property bundles, defaulted, override-able):

  • Body { vx, vy, gravity, max_fall, on_ground, ... } — Q16.16 fixed-point velocity/accel state.
  • Collider { w, h, offx, offy, is_trigger, layer, mask } — AABB shape + layer/mask filtering.

Engine system esys_move (auto-ticked each frame, disable-able via lever 5):

  • Integrate velocity (+ optional gravity) → tentative position.
  • Swept AABB vs solids to avoid tunneling at speed.
  • Tile-grid resolution (reuse Grid.*) — the platformer/topdown broadphase.
  • One-way platforms (collide only when moving down / from above).
  • Triggers/sensors — overlap that reports but doesn't resolve.
  • Set derived flags (on_ground, hit_wall, hit_ceiling) consumed by controllers.

Events (lever 4 — EV0–EV7 bus, observable + cancellable):

  • CollisionResolved (contact normal + other entity), TriggerEntered / TriggerExited, and a cancellable pre-resolve hook so a game can veto/alter a resolution.

Policy (lever 6):

  • policy: int on Body for the common integration variants (e.g. platformer gravity vs top-down no-gravity vs floaty), with the cancellable event for the fully-custom case. No fn values (Ludic has no closures) — enum-field-plus-event, deterministic + replayable.

Constraints (inherited from #57 / Ludic invariants)

  • No float. Q16.16 fixed / integer only, so lockstep + replay + world_save snapshots hold.
  • No hidden singletons. All state in per-entity components.
  • POD components + composition, no inheritance.

Deliverable

A shared Body/Collider + esys_move that the #57 controller families build directly on top of — turning today's static overlap tests into a real (physics-lite) moving-body-with-collision layer. Blocks #PLATFORMER / #SHOOTER / #NPCAI / #RPG.

Refs: #57.

## Context This is the **Phase 0 foundation** prerequisite that [#57](https://git.workshopsoft.io/workshopsoft/ludic/issues/57) (builtin gameplay controllers) explicitly defers to a dedicated issue: > **2D collision / physics-lite (`Body` + `Collider` + `esys_move`).** AABB sweep + tile-grid resolution, one-way platforms, triggers/sensors. Referenced by all four families. […] this is the biggest shared prerequisite and likely a dedicated issue. All four controller families (platformer / shooter / NPC AI / RPG) depend on it; none can be implemented until movement-with-collision exists. Sprites and animation are **already handled** by the engine (`esys_spriteanim` #43, `esys_motion`, `Tween.*`), so this issue is **only** about the missing world-simulation layer. ## Current state `Collision.*` today is a **static-overlap stub only** — `selfhost/backend/game/emit_collide.ludic` exposes just: - `Collision.rects` (AABB vs AABB overlap → bool) - `Collision.circles`, `Collision.rect_circle`, `Collision.point_rect` These are boolean *tests*. There is **no** velocity integration, no swept/continuous collision, no resolution (push-out), no tile-grid broadphase, no triggers, and no per-entity `Body`/`Collider` components an engine system ticks. Value types `Rect`/`Vector`/`IVec2` and `Grid.*` (spatial queries + pathfinding) already exist to build on. ## Proposed scope Follow the existing engine-owned-system (`esys_*`) pattern (`runtime/native/systems.ludic`, resolved by name through the reflection ABI, no-op when a field is absent) so it composes with the six-lever contract in #57. **Components (POD `property` bundles, defaulted, override-able):** - `Body { vx, vy, gravity, max_fall, on_ground, ... }` — Q16.16 fixed-point velocity/accel state. - `Collider { w, h, offx, offy, is_trigger, layer, mask }` — AABB shape + layer/mask filtering. **Engine system `esys_move` (auto-ticked each frame, disable-able via lever 5):** - Integrate velocity (+ optional gravity) → tentative position. - **Swept AABB** vs solids to avoid tunneling at speed. - **Tile-grid resolution** (reuse `Grid.*`) — the platformer/topdown broadphase. - **One-way platforms** (collide only when moving down / from above). - **Triggers/sensors** — overlap that reports but doesn't resolve. - Set derived flags (`on_ground`, `hit_wall`, `hit_ceiling`) consumed by controllers. **Events (lever 4 — EV0–EV7 bus, observable + cancellable):** - `CollisionResolved` (contact normal + other entity), `TriggerEntered` / `TriggerExited`, and a cancellable pre-resolve hook so a game can veto/alter a resolution. **Policy (lever 6):** - `policy: int` on `Body` for the common integration variants (e.g. platformer gravity vs top-down no-gravity vs floaty), with the cancellable event for the fully-custom case. No `fn` values (Ludic has no closures) — enum-field-plus-event, deterministic + replayable. ## Constraints (inherited from #57 / Ludic invariants) - **No float.** Q16.16 fixed / integer only, so lockstep + replay + `world_save` snapshots hold. - **No hidden singletons.** All state in per-entity components. - POD components + composition, no inheritance. ## Deliverable A shared `Body`/`Collider` + `esys_move` that the #57 controller families build directly on top of — turning today's static overlap tests into a real (physics-lite) moving-body-with-collision layer. Blocks #PLATFORMER / #SHOOTER / #NPCAI / #RPG. Refs: #57.
Author
Owner

Shipped in 0b149a6 (pushed to main).

What landed

The static-overlap stub is now a real physics-lite moving-body-with-collision layer, delivered as an engine-owned system in the exact shape of esys_spriteanim / esys_motion / esys_light2d — resolved by name through the reflection ABI, spliced only when the game declares the component, no-op when a field is absent, and byte-for-byte unchanged for any build that declares no Body (bootstrap fixpoint + every golden render still match).

Components (POD property bundles, all defaulted, override-able):

  • Body { vx, vy, gravity, max_fall, rx, ry, policy, on_ground, hit_wall, hit_ceiling } — Q16.16 fixed-point velocity/accel + engine-owned sub-pixel accumulators (rx/ry), an integration policy (0 = platformer w/ gravity, 1 = top-down), and derived contact flags. Optional hit_nx/hit_ny expose the last contact normal.
  • Collider { w, h, offx, offy, is_trigger, one_way, layer, mask, hit, entered, exited } — AABB shape offset from Position { x, y }, layer/mask filtering, one-way + trigger flags, and per-frame trigger outputs.
  • Solids { tile, wall, oneway } — optional single config entity that turns on the tile-grid broadphase over the Map.* tilemap.

esys_move (Update phase, after the game's handlers):

  • Integrates velocity (+ optional gravity, clamped to max_fall) into a tentative move, carried in Q16.16 sub-pixels so slow speeds accumulate exactly.
  • Swept AABB, resolved per axis, against both solid Collider entities and the tile grid (Grid.*/rt_tile) — a fast body never tunnels.
  • One-way platforms (entity one_way flag and one-way tile glyph) — block only a downward landing, pass through from below / horizontally.
  • Triggers/sensors — overlap that reports (hit + entered/exited edges) but never resolves.
  • Sets on_ground / hit_wall / hit_ceiling for controllers to consume.

Constraints held (from #57 / Ludic invariants)

  • No float — Q16.16 fixed / integer only, so lockstep + replay + world_save snapshots reproduce movement exactly.
  • No hidden singletons — all state in per-entity components (the only module state is the trigger edge-table + per-frame reflected ids).
  • POD components + composition, no inheritance.

Events (lever 4)

Contact is surfaced as polled flags (the SpriteAnim.event_fired pattern from #48), not engine-emitted events: the engine detects the boundary, the game raises its own CollisionResolved / TriggerEntered / TriggerExited from its handler. This keeps the engine from coupling to a user-declared event and stays inside the no-runtime-dispatch, deterministic/replayable model. The cancellable-pre-resolve case is covered by the lever-6 policy enum + making a collider a trigger to veto resolution; a richer event object can layer on later without changing the foundation.

Tests / docs

  • examples/library/physics.ludic — entity broadphase: gravity+land, wall stop, sub-pixel accumulation, entity platform, one-way (land-from-above and rise-through), trigger enter/exit edges. Asserts 184 1 1 284 1 1 50 51 104 1 134 1 120 0 1 1 1 0 1 1 1.
  • examples/library/physics_tiles.ludic — tile-grid broadphase: tile floor, tile wall, one-way tile land-from-above / rise-through. Asserts 112 1 80 1 48 1 60.
  • Both wired into x test (now 89 passed, 0 failed); self-host suite 32/0 with goldens byte-identical.
  • Documented in the Collision section (docs/language/collision/_section.md).

This is the shared Body/Collider + esys_move foundation the #57 controller families (#PLATFORMER / #SHOOTER / #NPCAI / #RPG) build directly on. Closing.

Shipped in `0b149a6` (pushed to `main`). ## What landed The static-overlap stub is now a real physics-lite **moving-body-with-collision** layer, delivered as an engine-owned system in the exact shape of `esys_spriteanim` / `esys_motion` / `esys_light2d` — resolved by name through the reflection ABI, spliced only when the game declares the component, no-op when a field is absent, and **byte-for-byte unchanged** for any build that declares no `Body` (bootstrap fixpoint + every golden render still match). **Components** (POD `property` bundles, all defaulted, override-able): - `Body { vx, vy, gravity, max_fall, rx, ry, policy, on_ground, hit_wall, hit_ceiling }` — Q16.16 fixed-point velocity/accel + engine-owned sub-pixel accumulators (`rx`/`ry`), an integration `policy` (0 = platformer w/ gravity, 1 = top-down), and derived contact flags. Optional `hit_nx`/`hit_ny` expose the last contact normal. - `Collider { w, h, offx, offy, is_trigger, one_way, layer, mask, hit, entered, exited }` — AABB shape offset from `Position { x, y }`, layer/mask filtering, one-way + trigger flags, and per-frame trigger outputs. - `Solids { tile, wall, oneway }` — optional single config entity that turns on the tile-grid broadphase over the `Map.*` tilemap. **`esys_move`** (Update phase, after the game's handlers): - Integrates velocity (+ optional gravity, clamped to `max_fall`) into a tentative move, carried in Q16.16 sub-pixels so slow speeds accumulate exactly. - **Swept AABB**, resolved per axis, against **both** solid `Collider` entities *and* the tile grid (`Grid.*`/`rt_tile`) — a fast body never tunnels. - **One-way platforms** (entity `one_way` flag and one-way tile glyph) — block only a downward landing, pass through from below / horizontally. - **Triggers/sensors** — overlap that reports (`hit` + `entered`/`exited` edges) but never resolves. - Sets `on_ground` / `hit_wall` / `hit_ceiling` for controllers to consume. ## Constraints held (from #57 / Ludic invariants) - **No float** — Q16.16 fixed / integer only, so lockstep + replay + `world_save` snapshots reproduce movement exactly. - **No hidden singletons** — all state in per-entity components (the only module state is the trigger edge-table + per-frame reflected ids). - POD components + composition, no inheritance. ## Events (lever 4) Contact is surfaced as **polled flags** (the `SpriteAnim.event_fired` pattern from #48), not engine-emitted events: the engine detects the boundary, the game raises its own `CollisionResolved` / `TriggerEntered` / `TriggerExited` from its handler. This keeps the engine from coupling to a user-declared event and stays inside the no-runtime-dispatch, deterministic/replayable model. The cancellable-pre-resolve case is covered by the lever-6 `policy` enum + making a collider a trigger to veto resolution; a richer event object can layer on later without changing the foundation. ## Tests / docs - `examples/library/physics.ludic` — entity broadphase: gravity+land, wall stop, sub-pixel accumulation, entity platform, one-way (land-from-above **and** rise-through), trigger enter/exit edges. Asserts `184 1 1 284 1 1 50 51 104 1 134 1 120 0 1 1 1 0 1 1 1`. - `examples/library/physics_tiles.ludic` — tile-grid broadphase: tile floor, tile wall, one-way tile land-from-above / rise-through. Asserts `112 1 80 1 48 1 60`. - Both wired into `x test` (now **89 passed, 0 failed**); self-host suite **32/0** with goldens byte-identical. - Documented in the `Collision` section (`docs/language/collision/_section.md`). This is the shared `Body`/`Collider` + `esys_move` foundation the #57 controller families (#PLATFORMER / #SHOOTER / #NPCAI / #RPG) build directly on. Closing.
orkun closed this issue 2026-09-01 12:18:19 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#65
No description provided.