PURPOSE

Maintains a Rapier2D kinematic sensor body for every alive enemy in the world so the physics engine can emit intersection events when an enemy overlaps the ship collider. Bodies are pooled by enemy object identity: created on first sync, position-updated each frame, removed when the enemy stops being collidable. Sensors do not produce a physics response; they only detect overlap. The actual damage and push-apart response runs elsewhere in the collision resolver.

OWNS

  • enemyBodies: Map<any, EnemyPhysics> — primary pool, keyed by the live enemy object reference, holding the RAPIER.RigidBody and RAPIER.Collider pair for that enemy.
  • handleToEnemy: Map<number, any> — reverse lookup from collider handle back to the enemy object, used when processing intersection events from the world step.
  • The EnemyPhysics shape { body, collider }.
  • The public module object RapierEnemies with methods sync, getEnemyByHandle, clear, getCount, getPhysics.

READS FROM

  • RapierWorld — calls isReady() and getWorld() to access the active Rapier world; bails out silently if the world is not ready.
  • COLLISION_GROUP_ENEMY from ./rapier-ship — applied via setCollisionGroups so enemy sensors filter into the correct interaction layer with the ship body.
  • @dimforge/rapier2d-compat — the Rapier API surface (RigidBodyDesc.kinematicPositionBased, ColliderDesc.ball, ActiveEvents.COLLISION_EVENTS).
  • Each enemy object: e.alive, e.x, e.y, and the soft-skip flags e._spawnT, e._frozenForLag, e._dying.
  • A caller-supplied getRadius(e) callback to size each ball collider at creation time.

PUSHES TO

  • The Rapier world: creates kinematic rigid bodies (world.createRigidBody), creates sensor ball colliders (world.createCollider), drives positions via body.setNextKinematicTranslation, and removes bodies via world.removeRigidBody on death or clear().
  • The two internal maps (enemyBodies, handleToEnemy) — inserted on body creation, deleted on per-enemy removal and on full clear().

The module does not push to enemy objects, the ship, the bridge, or any rendering pipeline. Damage propagation happens downstream when the collision resolver reads intersection events and uses getEnemyByHandle to map handles back to enemies.

DOES NOT

  • Does not perform damage, knockback, or any gameplay response — sensors are non-physical and only emit events.
  • Does not push or steer enemies; positions are driven by AI and copied in. The Rapier body is kinematic-position-based, so it never reacts to forces.
  • Does not handle the ship body, projectiles, destructibles, or arena walls.
  • Does not resize an existing collider when an enemy’s radius changes; radius is sampled once at body creation.
  • Does not process intersection events itself or read world.intersectionPairsWith — that is the collision resolver’s job.
  • Does not persist anything; all state is in-process and is wiped on clear().
  • Does not log, render, or telemetry-report.

Signals

  • Body creation: triggered when sync sees an alive, collidable enemy that has no entry in enemyBodies.
  • Body removal: triggered when an enemy previously in enemyBodies is absent from the per-frame alive set — happens when e.alive flips false, _dying flips true, _frozenForLag flips true, or _spawnT exceeds 0.15.
  • Position update: each frame for every alive enemy with an existing body, via setNextKinematicTranslation. Rapier interpolates this on the next step.
  • Full pool drop: clear() is called externally on level transitions and arena resets (sealed arena, bridge teardown points).
  • Intersection events: the sensor collider has ActiveEvents.COLLISION_EVENTS set, so Rapier will emit start/stop intersection pairs for it; the resolver consumes those via getEnemyByHandle.

Entry points

  • RapierEnemies.sync(enemies, getRadius) — primary tick entry called from bridge.ts once per physics step with world.enemies and getEnemyCollisionRadius.
  • RapierEnemies.getEnemyByHandle(handle) — used by collision-event consumers to turn a Rapier collider handle into the enemy object.
  • RapierEnemies.clear() — called from sealed-arena teardown and from bridge reset paths (level change, full reset).
  • RapierEnemies.getCount() — used by the Rapier debug render overlay to display the active sensor count.
  • RapierEnemies.getPhysics(enemy) — direct accessor for the body/collider pair for advanced queries.

Pattern notes

  • Pool-by-identity: the enemy object reference itself is the map key, so the module does not need an explicit id field on enemies. Identity equality is the contract — re-using or re-pooling enemy objects elsewhere will collide with this map.
  • Two-pass sync: first pass walks enemies and either updates an existing body or creates a new one while building an alive set; second pass walks the enemyBodies map and removes any entry not in alive. This keeps the pool exactly equal to the set of collidable enemies each frame.
  • Soft-skip predicates (!e.alive, _spawnT > 0.15, _frozenForLag, _dying) intentionally treat “not currently collidable” the same as “dead” for the purposes of the body pool — a frozen or dying enemy is removed from Rapier and re-created if it becomes collidable again.
  • Kinematic-position-based bodies are driven via setNextKinematicTranslation, which is the Rapier-recommended way to feed externally simulated motion through the solver without losing intersection events.
  • Sensor colliders (setSensor(true)) generate intersection events but no contact force — the existing push-apart logic in the collision resolver provides the response, so the Rapier integration adds detection only.
  • Reverse-lookup map is kept in lockstep with the primary pool: both entries are added on creation and both are deleted on removal, including in clear().
  • The module guards every public method against RapierWorld.isReady() so it is safe to call before the Rapier world is initialized.