PURPOSE

Terrain collision module. Resolves overlap between moving entities (ship, enemies, projectiles) and terrain pieces (asteroids, floaters) using Separating Axis Theorem (SAT). Collision polygons are pixel-traced from the same offscreen canvas used for rendering, so the collision boundary matches the visual boundary exactly. Also exposes a spatial query helper for collecting nearby terrain via the world chunk index, and a line-segment vs polygon intersection used by beam weapons for line-of-sight checks.

OWNS

  • The Terrain interface (collision-relevant fields: x, y, radius, boundingRadius, worldVerts, normals, optional chunkKey).
  • The CollisionResult shape (overlap, nx, ny) — minimum translation vector returned by every overlap test.
  • SAT circle-vs-polygon narrowphase (_satCirclePoly): projects circle and polygon onto each edge normal plus the closest-vertex axis to catch corner cases.
  • SAT polygon-vs-polygon narrowphase (_satPolyPoly): tests both polygons’ edge normals; consumes pre-computed terrain normals and computes ship-hull normals on the fly.
  • Circle-vs-circle fallback (_circleCircleOverlap) used when a terrain piece’s pixel-traced polygon has not been computed yet.
  • Bounce physics application (_applyBounce): pushes the entity out by the MTV overlap, then reflects the inward normal velocity component with restitution and applies tangential friction.
  • The shared _nearbyResult buffer reused across nearbyTerrain calls to avoid per-frame allocation.
  • The shared _projLo / _projHi scratch variables for SAT projection in polygon-vs-polygon tests.
  • _terrainOverlap / _terrainOverlapPoly glue that runs the broadphase circle reject and dispatches to SAT or circle fallback.
  • The exported overlap entry points: nearbyTerrain, terrainCollide, floaterCollide, segmentTerrainIntersect.
  • Line-segment vs polygon intersection (_segmentPolyIntersect): per-edge segment-segment test plus a ray-casting inside-polygon test for the segment start.

READS FROM

  • world.terrain — flat array of all terrain pieces in the active world.
  • world.chunks — spatial index mapping chunk keys ("px,py") to chunk objects whose terrain field holds indices into world.terrain.
  • CHUNK_SIZE — chunk grid pitch in world units, used to convert world coordinates into chunk coordinates.
  • ship — singleton reference used to identify when the colliding entity is the ship so its tunable terrainRestitution and terrainFriction overrides apply.
  • Each terrain piece’s x, y, radius, boundingRadius, worldVerts (local-space vertex offsets), and normals (outward-facing edge normals).
  • Each entity’s x, y, and optionally vx, vy for bounce reflection.

PUSHES TO

  • The colliding entity itself: mutates entity.x, entity.y to push it out by the MTV overlap, then mutates entity.vx, entity.vy to apply the restitution-scaled normal reflection and tangential friction.
  • The shared _nearbyResult array — cleared and refilled on each nearbyTerrain call; callers must copy if they need to retain the result across frames.
  • The shared _projLo / _projHi scratch slots during SAT projection.
  • Returns true from terrainCollide / floaterCollide when any overlap was resolved this frame, letting callers tag the entity as having just bounced.
  • Returns true from segmentTerrainIntersect to short-circuit beam line-of-sight checks at the first hit.

DOES NOT

  • Does not spawn, despawn, or modify terrain pieces — collision is read-only against terrain geometry.
  • Does not trace pixel polygons or compute edge normals — it consumes worldVerts and normals that the terrain bake pipeline populated.
  • Does not rebuild the chunk spatial index; stale chunk entries (indices past the current terrain length) are skipped defensively without touching world.chunks.
  • Does not apply damage, score, or any game-side consequence of contact — pure positional and velocity response only.
  • Does not sort, prioritise, or coalesce multiple simultaneous collisions; each nearby terrain piece is resolved independently in iteration order.
  • Does not allocate new arrays in the hot path (nearbyTerrain reuses _nearbyResult, SAT polygon-vs-polygon reuses _projLo / _projHi).
  • Does not handle entity-vs-entity collision; it only handles terrain and floater obstacles.
  • Does not perform swept (continuous) collision — overlap is resolved against current positions only.

Signals

  • terrainCollide(entity, eRadius, hullPoly?) returns true when at least one push-out occurred this call.
  • floaterCollide(entity, eRadius, floaters, hullPoly?) returns true when at least one push-out occurred this call.
  • segmentTerrainIntersect(ax, ay, bx, by) returns true if the segment crosses any terrain polygon edge or if its start point lies inside a terrain polygon.
  • nearbyTerrain(cx, cy, radius) returns the shared Terrain[] buffer; an empty buffer indicates no candidates in range or no terrain in the world.
  • Internal _satCirclePoly, _satPolyPoly, _terrainOverlap, _terrainOverlapPoly, _circleCircleOverlap return CollisionResult | nullnull means a separating axis was found or the entities are coincident within the epsilon guard.

Entry points

  • nearbyTerrain(cx, cy, radius) — chunk-indexed spatial query that returns the shared _nearbyResult buffer; falls back to a linear scan when the chunk index is empty.
  • terrainCollide(entity, eRadius, hullPoly?) — main per-frame terrain push-out; uses polygon-vs-polygon SAT when hullPoly is provided (ship), circle-vs-polygon SAT otherwise (enemies, generic entities).
  • floaterCollide(entity, eRadius, floaters, hullPoly?) — same physics as terrainCollide against a caller-supplied floater array instead of the global terrain list.
  • segmentTerrainIntersect(ax, ay, bx, by) — line-of-sight test used by beam-style weapons; uses polygon-edge intersection when worldVerts are present, falls back to a circle vs segment closest-point test otherwise.
  • Terrain and CollisionResult interfaces are exported for use by terrain producers and physics consumers.

Pattern notes

  • Two-phase collision: broadphase circle-circle reject using boundingRadius + eRadius, then narrowphase SAT only on candidates that pass.
  • Worldverts are stored in local space relative to the piece centre; SAT routines add the piece position when projecting polygon B in polygon-vs-polygon, and subtract it when transforming circle centres in circle-vs-polygon. Segment tests transform the segment into local space before calling _segmentPolyIntersect.
  • MTV direction is chosen by comparing the circle (or polygon A) midpoint projection to the polygon (or polygon B) midpoint projection along the candidate axis; the axis is flipped when needed so the normal always points away from the obstacle.
  • Circle-vs-polygon adds an extra axis from the closest polygon vertex to the circle centre on top of the edge normals — this is the standard SAT extension that catches the case where the circle slips between two edges at a corner.
  • Polygon-vs-polygon scratches _projLo / _projHi via _projectA / _projectB helpers instead of returning tuples; this avoids per-axis allocation in the hot path.
  • Restitution and friction default to 1.09 and 0.01 respectively (nearly inelastic), but the ship can override via ship.terrainRestitution and ship.terrainFriction for tuning. Bounce only applies when the incoming normal-velocity dot product is negative (moving into the obstacle).
  • Push-out happens unconditionally when overlap > 0; only the velocity reflection is gated on the inward-motion check, so resting contact still separates positions without injecting energy.
  • nearbyTerrain defends against stale chunk entries left behind by terrain splices (e.g. boss-room cleanup) by skipping indices past the current terrain length and skipping undefined slots — a fix specifically for callers like push-off logic that would otherwise dereference t.x on undefined.
  • The chunk search radius is ceil(radius / CHUNK_SIZE) + 1 to ensure pieces straddling chunk boundaries are not missed.
  • The shared _nearbyResult buffer is documented as non-retainable across calls; callers must iterate immediately or copy.
  • Polygon and circle fallbacks are layered: missing or degenerate (length < 3) worldVerts or normals fall back to circle-circle in overlap tests and to circle-segment closest-point in line-segment tests, so the first frame before the pixel bake is still safe.
  • The line-segment intersection test combines a per-edge segment-segment crossing test with a ray-casting parity test on the segment start, so beams that originate inside an asteroid also register as blocked.
  • Coincident-position guards (dSq < 0.01) prevent divide-by-zero in normalisation when an entity and a piece share the same coordinates.
  • All allocation-sensitive hot paths (per-frame collision, per-axis SAT projection, per-frame nearby query) avoid object literals; results use the exported CollisionResult shape returned by reference from a single return { ... } site or write to module-level scratch slots.