Skip to content

Physics: collision shapes follow the renderable, and segments collide as segments - #1693

Merged
obiot merged 6 commits into
masterfrom
fix/anchor-collision-frame
Sep 22, 2026
Merged

obiot merged 6 commits into
masterfrom
fix/anchor-collision-frame

Conversation

@obiot

@obiot obiot commented Sep 22, 2026

Copy link
Copy Markdown
Member

Five fixes across the engine and both adapters, each found by measurement and each pinned by a mutation-tested spec.

Collision shapes follow the anchor

anchorPoint shifts a renderable's bounds and its rendering by -size * anchorPoint, but collision shapes were measured from pos regardless. Anything not anchored at its top-left corner collided where it was not drawn — half a body off at the default centred anchor, a full body height at the bottom anchor a platformer actor uses.

Entity and Tiled objects set their own anchor to (0,0), which is why only the newer bodyDef path was exposed.

A game that compensated for this by offsetting its shapes by hand should remove those offsets.

Verified against the reporter's own build: with their generator's compensation removed, the reported geometry matches where the sprite draws to 0.00px at 0°, 40° and 90°.

A Line collides as the segment it is

Line is what Tiled emits for every polyline. Both adapters silently replaced it:

before after
matter the line's axis-aligned bounding box (a diagonal became a solid block) thin quad along the segment
planck a one-metre square wherever the body was (SetAsBox(1,1), Box2D's fallback for <3 vertices) thin quad along the segment

Built from explicit vertices, not a rectangle with an angle: an angle belongs to the body, and syncFromPhysics mirrors it onto the renderable, which would turn a sprite by the slope of its own ground. That one has its own regression test.

Every shape of a compound body is separated

The narrowphase returns at the first overlapping shape pair, and the extra separation pass for compound bodies re-ran that same first-hit-wins scan — re-resolving the pair it had already resolved, always at overlap zero, while a sibling was penetrated unmeasured.

A crate on a Tiled polyline junction sank 0.9px/frame with no vertical velocity (depth 3 → 5 → 7 → 10), then was ejected through the ground once the neighbour was finally reached at 18px deep.

collides() keeps its exact semantics — it is public, and making a zero overlap stop counting there would break onCollisionActive and the falling flags for every resting body. Measured cost on 20 crates over a 60-segment ground: 2070 SAT tests/step vs 2760 before.

RoundRect, and shapes a 2D solver cannot express

RoundRect extends Polygon and carries 36 points. Box2D caps vertices and truncates silently — an 80×40 rounded rect was simulated as a 10×34 blob. The cap was measured against planck (it is 12, not the documented 8); outlines over it are sampled down, worst deviation 0.7px on a 10px radius.

Point, Box3d and Sphere now throw on planck as they already did on matter. Returning null left the body with no collision geometry at all, silently, on one backend only.

Also

  • matter placed polygons by the vertex mean where Bodies.fromVertices puts the area centroid; a traced outline drifted 39px on a 200px blob. The two coincide for rectangles and triangles, which is why no simple fixture caught it.
  • detector.js private members drop their underscore prefix. _contactId keeps its own: it is a marker Body#addShape stamps on shape objects, not a Detector field.

Examples

A new Line Collision example draws the shape as authored against the geometry each adapter reports as colliding. That comparison is the point — an overlay drawn from the adapter alone always looks self-consistent, because the outline is the visual, which is how every one of these placement bugs stayed hidden. The physics shapes example gains generated artwork for the same reason.

Verification

suite result
melonjs 7143 passing (+ the pre-existing trigger_level_change flake)
@melonjs/matter-adapter 223
@melonjs/planck-adapter 218
@melonjs/debug-plugin 23

~20 new specs; six behaviours mutation-tested (reverting each fix fails its test). Adapters bumped to matter 1.5.0 and planck 1.6.0, both minors per the precedent for behaviour-changing fixes, and both independent of the engine change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t

obiot and others added 5 commits September 22, 2026 10:42
`anchorPoint` says where in its own bounds a renderable sits on its
`pos`: `updateBounds()` shifts its bounds by `-size * anchorPoint` and
`preDraw` shifts its pixels by the same amount. Collision shapes were
measured from `pos` regardless, so anything not anchored at its top-left
corner collided where it was not drawn, by half a body at the default
centred anchor and by a full body height at the bottom anchor a
platformer actor uses.

`Entity` sets its own anchor to (0, 0), as Tiled objects do, which is
why the legacy routes never saw this and only the `bodyDef` path is
affected. The physics was never wrong in itself, which is what made it
slippery: a body rested exactly on the floor at either anchor, and
collisions fired normally. Only the hitbox sat somewhere the artwork
was not, and anything drawn from `draw()` shifted with the sprite and
hid the gap.

Applied at the one place local shape coordinates become world
positions: the SAT for the builtin solver, and the body origin in each
adapter. Authored shapes keep their own coordinates, so the rotation
pivot, `applyForce`'s lever arm and the shape pools all still read what
the caller wrote. The readback follows the same frame, or the debug
overlay would draw a body half its size away from where it collides.

The planck offset that maps the body back onto `renderable.pos` keeps
referencing the real `pos` rather than the shifted origin; feeding the
anchor back in twice made a static body appear to jump on its first
step.

Also fixes matter placing a polygon by the arithmetic mean of its
vertices where `Bodies.fromVertices` puts the area centroid, which
shifted every polygon by the difference. The two coincide for a
rectangle and for any triangle, so no simple fixture could see it; a
traced outline from a shape editor drifted 39px on a 200px blob.

Tests pin the contract as behaviour rather than as coordinates: a body
comes to rest with its drawn bottom edge on the floor whatever the
anchor, across the builtin, matter and planck adapters, with anchor 0
as a passing control. Fixtures that did their arithmetic in `pos + size`
terms now state the corner anchor they always assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
The scene drew only the collision outlines each adapter reports, which
looks correct by construction: the outline IS the visual, so a body
placed wrong draws an outline that is wrong in exactly the same way and
nothing seems amiss. That is how the matter polygon placement bug
survived in a scene built to show off shape loading.

The sprites are generated FROM the shape file by
`scripts/generate-physics-shape-sprites.mjs` (`npm run sprites`), in the
same coordinate space, so artwork and collision geometry share one
source of truth and any gap between them on screen is a real bug. The
generator rasterises the outlines itself and encodes the PNGs through
`node:zlib`, so it pulls in no new dependency and is byte-for-byte
reproducible.

`autoTransform` is on now. It was off because the reported shapes come
back already posed and `preDraw` would have turned them twice, but with
a sprite in the scene that flag also left `updateBounds()` reporting the
UNROTATED frame, so the debug panel's green box sat square around a
tilted body. The sprite rides `preDraw` like any ordinary renderable and
the shapes have the transform undone instead.

On the builtin solver the example calls `body.rotate()` rather than
`setAngle`, since the SAT never reads `body.angle`: `setAngle` would
have turned the artwork alone and left every hitbox upright under it.

Also registers the debug panel (S), which is what makes all of this
checkable, and matches gravity across the three backends: measured in
this scene the defaults spread thirteenfold (builtin ~4450 px/s2, matter
~900, planck ~330), so the piles were never comparable. Matter is the
reference.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
…irst

The narrowphase reports ONE contact per body pair: it returns at the first
overlapping shape pair it finds. The extra separation pass for compound
bodies re-ran that same first-hit-wins scan, so it kept re-resolving the
pair it had already resolved, always with an overlap of zero, and never
looked at the siblings.

A crate resting across the junction of a Tiled polyline, which arrives as
one body carrying one shape per segment, therefore rode the segment it was
found against while sinking into its neighbour unmeasured: 0.9px per frame
with no vertical velocity, depth growing 3, 5, 7, 10. When the first pair
finally separated the neighbour was reached at 18px deep, and a zero-area
segment's shortest exit from that far below points through the line, so the
crate was ejected downward and fell out of the world.

The pass now enumerates the shape pairs itself and corrects each overlapping
solid pair, measuring every pair against the position the previous
corrections left, and exits on the first clean sweep. `collides()` keeps its
semantics exactly: it is public, the adapters and the quadtree spec use it,
and making an overlap of zero stop counting as a contact there would have
broken `onCollisionActive` and the falling flags for every resting body.

Counted on a scene of 20 crates over a 60-segment ground, this costs 2070
SAT tests per step against the old 2760: the early exit more than pays for
enumerating, because each of the three old re-scans walked most of the shape
list before reaching the resting contact.

The detector's private members lose their underscore prefix to match the
rest of the API. `_contactId` keeps its own, deliberately: it is not a
Detector field but a marker `Body#addShape` stamps onto shape objects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Three shapes a game can author were being silently replaced by something
else, each in its own way.

A `Line` is two points and no area, and it is what Tiled emits for every
polyline, so it is the natural way to draw a slope or a strip of ground.
matter refuses a zero-area outline and the adapter fell back to the line's
AXIS-ALIGNED BOUNDING BOX, turning a diagonal into a solid block. Box2D does
not refuse it at all: `b2PolygonShape` substitutes `SetAsBox(1, 1)` for a
polygon with fewer than three vertices, so the segment became a one-metre
square, 64px at the default scale, sitting wherever the body was. Both now
build a thin quad along the segment.

It is built from explicit vertices rather than a rectangle with an angle,
because an angle belongs to the BODY: `syncFromPhysics` mirrors it onto the
renderable's transform, and a sprite would have been turned by the slope of
its own ground, with the already-posed shapes drawn turned a second time.

A `RoundRect` extends `Polygon`, not `Rect`, and carries 36 points for its
corner arcs. Box2D caps a polygon's vertex count and truncates past it
without a word: an 80x40 rounded rect was simulated as a 10x34 blob. The cap
was measured against planck rather than taken from the header, and it is 12.
An outline over it is now sampled down evenly, which keeps the extent and
the silhouette, with the worst deviation from the authored outline at 0.7px
on a 10px corner radius. One fixture rather than a decomposition on purpose:
splitting a convex outline introduces interior seams for a body to catch on.

`Point`, `Box3d` and `Sphere` now throw on planck as they already did on
matter. Returning null left the body with no collision geometry at all and
said nothing, so it simply never collided, and it did so on only one of the
two backends.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
…to date

The example draws two layers on every piece of ground: the shape as
AUTHORED, and the geometry the adapter reports as colliding. Four cases, a
Rect control, a horizontal and a diagonal `Line`, and a polyline of three
segments, on a backend switcher with the debug panel registered.

The two layers are the point. An overlay drawn from the adapter alone always
looks self-consistent, because the outline IS the visual, so a body placed
wrong draws an outline that is wrong in exactly the same way. Every
placement bug fixed in this line of work hid behind that.

The skill gains a shape-support matrix across the three backends, the
vertex cap, and a warning not to diagnose a physics failure by reading a
body's final `y`: a body that slid off the end of a ramp and one that fell
straight through it both finish far below and are indistinguishable in that
number. Tracking against the surface at the body's current x is what
separates them, and it is what localised two of these bugs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI lite review requested due to automatic review settings September 22, 2026 07:09

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

matter-adapter 1.5.0 and planck-adapter 1.6.0 are being released. melonjs
stays `_unreleased_`: the adapter fixes read only `anchorPoint`, `width` and
`height`, API that has always existed, so neither needs a newer engine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGvtaUNATVCVxD2qcbiY4t
Copilot AI review requested due to automatic review settings September 22, 2026 07:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@obiot
obiot merged commit 676314b into master Sep 22, 2026
6 checks passed
@obiot
obiot deleted the fix/anchor-collision-frame branch September 22, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants