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
Terraininterface (collision-relevant fields:x,y,radius,boundingRadius,worldVerts,normals, optionalchunkKey). - The
CollisionResultshape (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
_nearbyResultbuffer reused acrossnearbyTerraincalls to avoid per-frame allocation. - The shared
_projLo/_projHiscratch variables for SAT projection in polygon-vs-polygon tests. _terrainOverlap/_terrainOverlapPolyglue 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 whoseterrainfield holds indices intoworld.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 tunableterrainRestitutionandterrainFrictionoverrides apply.- Each terrain piece’s
x,y,radius,boundingRadius,worldVerts(local-space vertex offsets), andnormals(outward-facing edge normals). - Each entity’s
x,y, and optionallyvx,vyfor bounce reflection.
PUSHES TO
- The colliding entity itself: mutates
entity.x,entity.yto push it out by the MTV overlap, then mutatesentity.vx,entity.vyto apply the restitution-scaled normal reflection and tangential friction. - The shared
_nearbyResultarray — cleared and refilled on eachnearbyTerraincall; callers must copy if they need to retain the result across frames. - The shared
_projLo/_projHiscratch slots during SAT projection. - Returns
truefromterrainCollide/floaterCollidewhen any overlap was resolved this frame, letting callers tag the entity as having just bounced. - Returns
truefromsegmentTerrainIntersectto 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
worldVertsandnormalsthat 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 (
nearbyTerrainreuses_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?)returnstruewhen at least one push-out occurred this call.floaterCollide(entity, eRadius, floaters, hullPoly?)returnstruewhen at least one push-out occurred this call.segmentTerrainIntersect(ax, ay, bx, by)returnstrueif the segment crosses any terrain polygon edge or if its start point lies inside a terrain polygon.nearbyTerrain(cx, cy, radius)returns the sharedTerrain[]buffer; an empty buffer indicates no candidates in range or no terrain in the world.- Internal
_satCirclePoly,_satPolyPoly,_terrainOverlap,_terrainOverlapPoly,_circleCircleOverlapreturnCollisionResult | null—nullmeans 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_nearbyResultbuffer; 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 whenhullPolyis provided (ship), circle-vs-polygon SAT otherwise (enemies, generic entities).floaterCollide(entity, eRadius, floaters, hullPoly?)— same physics asterrainCollideagainst 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 whenworldVertsare present, falls back to a circle vs segment closest-point test otherwise.TerrainandCollisionResultinterfaces 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/_projHivia_projectA/_projectBhelpers instead of returning tuples; this avoids per-axis allocation in the hot path. - Restitution and friction default to
1.09and0.01respectively (nearly inelastic), but the ship can override viaship.terrainRestitutionandship.terrainFrictionfor 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.
nearbyTerraindefends against stale chunk entries left behind by terrain splices (e.g. boss-room cleanup) by skipping indices past the current terrain length and skippingundefinedslots — a fix specifically for callers like push-off logic that would otherwise dereferencet.xonundefined.- The chunk search radius is
ceil(radius / CHUNK_SIZE) + 1to ensure pieces straddling chunk boundaries are not missed. - The shared
_nearbyResultbuffer is documented as non-retainable across calls; callers must iterate immediately or copy. - Polygon and circle fallbacks are layered: missing or degenerate (
length < 3)worldVertsornormalsfall 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
CollisionResultshape returned by reference from a singlereturn { ... }site or write to module-level scratch slots.