PURPOSE

Per-frame enemy AI: registers movement + combat handlers for every enemy archetype, drives their internal state machines, runs the shared charge/burst/cooldown fire pipeline, and emits forecast (telegraph) entities so the player can read incoming attacks. Imports archetype defaults from data/enemies, weapon stats from data/weapons, and writes damage to the player through engine/combat/damage. Consumed by the per-frame bridge and by tests via the BEHAVIOR_MAP / updateEnemyAI exports.

OWNS

  • EnemyBehaviors registry singleton — register, get, can (capability gate), list, internal _registry map keyed by behavior id.
  • EnemyBehaviorHandler interface — movement, combat, onSpawn, onDamage, onDeath, onStun, render hooks plus capability flags (smartRange, useSharedAttackFSM, useSharedSeparation, useSharedAggro, stunnable, knockbackable).
  • Friction constants and applyFriction(e, fricRate, dt) — precomputed -ln(k) values FRIC_IDLE, FRIC_HARD, FRIC_STOP, FRIC_SOFT to replace Math.pow(k, dt) in the hot path.
  • Movement helpers — seekPoint (exported as the dumb-mode fallback), steerToward (car-like, capped turn rate CAR_TURN_RATE = 3.0), steerAway, dumbFace, faceShip (spring-damper with angular velocity, stiffness 12 / damping 0.85).
  • State predicate — isMidAttack(e) covering orb phases, charger phases 1/2, shooter/mortar charge/burst, gunner firing, field planting/active, sniper aiming, and racer (always full).
  • Pack aggro — isAggroed(e, world) resolves leader via _packLeaderRef (cached) or _packLeaderId linear scan; triggerPackAggro(e, ship) flips e.aggro when leader gets within 250 px.
  • Terrain push-out — terrainBounce(e, world) using the world.chunks spatial index (TERRAIN_CHUNK_SIZE = 1024) with brute-force fallback; legacy alias terrainPathfind; private _bounceOneTerrainPiece applies position correction (0.8) and velocity deflection (1.2).
  • Shared fire pipeline — enemyFire(e, dt, ship, world) (cooldown → charge → burst/timed-strike), fireBurstShot, spawnForecastForEnemy, plus constants DEFAULT_CHARGE_TIME = 1.4, ENEMY_PROJ_SPEED_MULT = 0.7, ENEMY_PROJ_ARM_DISTANCE = 18 (exported), ENEMY_PROJ_ACCEL_TIME = 0.4, ENEMY_PROJ_START_SPEED_FRAC = 0.06, ENEMY_PROJ_TARGET_DISTANCE = 208, ENEMY_PROJ_RANGE_VARIANCE = 0.4, ENEMY_BURST_COUNT = 12, ENEMY_BURST_DELAY = 0.06, SHOOTER_POST_FIRE_PAUSE = 2.5, ARROW_TRACK_FRAC = 0.8, GLOBAL_MORTAR_COOLDOWN = 2.0.
  • Fish-swim tuning — FISH_STEER_FORCE = 3.0, FISH_TANGENT_FORCE = 1.8, FISH_DRAG = 0.97, FISH_TURN_SPEED = 8.0.
  • Raycast — raycastTerrain(x1, y1, x2, y2, world) marching at 20 px steps through the chunk index.
  • Eight registered archetype handlers — orb, charger, shooter, mortar, gunner, field, sniper, racer.
  • Two legacy aliases — orbit (delegates to shooter), stationary (friction-stop + enemyFire).
  • Per-archetype constants — ORB_WINDUP_DUR = 1.12, ORB_DASH_DUR = 0.25, ORB_WINDUP_RETREAT = 15, ORB_DASH_DIST = 50, ORB_ATTACK_RANGE = 120; CHARGER_WINDUP_RANGE = 250, CHARGER_WINDUP_DURATION = 3.2, CHARGER_LUNGE_SPEED = 6.3, CHARGER_RECOVERY_DURATION = 1.5, CHARGER_BAR_LENGTH = 300, CHARGER_COOLDOWN = 10.0, CHARGER_KNOCKBACK = 220, CHARGER_LUNGE_DMG = 50, CHARGER_HIT_FREEZE = 0.12; GUNNER_ENGAGE_RANGE = 100, GUNNER_BURST_INTERVAL = 0.15, GUNNER_CHARGE_DUR = 1.2, GUNNER_MARCH_MOVE_DUR = 0.6, GUNNER_MARCH_PAUSE_DUR = 0.25; FIELD_PLANT_RANGE = 120, FIELD_PLANT_DUR = 0.3, FIELD_CYCLE_PAUSE = 0.5, FIELD_SEEK_SPEED = 1.3, FIELD_RETARGET_DIST = 150; SNIPER_COOLDOWN = 3.0, SNIPER_PROJ_SPEED = 800, SNIPER_MAX_RANGE = 600, SNIPER_PROJ_MAX_DIST = 700, SNIPER_PROJ_RAD = 5.
  • Helper exports — rollPersonality(typeId) (uses PERSONALITY_PROFILES keyed by archetype prefix, ±0.15 jitter, clamped 0–1), resolveArchetype(behavior, typeId), attack-state constants (ATTACK_CHARGE_TIME = 0.8, ATTACK_FIRE_PAUSE = 0.08, ATTACK_RECOIL_TIME = 0.25, ATTACK_RECOIL_FORCE = 60, ATTACK_WIGGLE_BASE = 0.3, ATTACK_WIGGLE_MAX = 1.8, ATTACK_WIGGLE_FREQ = 18, MELEE_CHARGE_TIME = 0.5, MELEE_LUNGE_SPEED_MULT = 3.0), initAttackState(e).
  • Compatibility exports — BEHAVIOR_MAP, updateEnemyAI(enemies, dt, ship, world), and shimmed type-name functions orbit, mosquito, horde_charge, bomber, sniper, guardian, kamikaze (each delegating into one of the registered handlers’ movement).

READS FROM

  • ../core/typesWorldState shape.
  • ../core/stategame (aliased gameState) for _attackSpeedMult, missionElapsed, worldKnobs.rarityScale, timeDilation, _hitFreezeTimer.
  • ../../data/enemiesPERSONALITY_PROFILES (for rollPersonality) and ENEMY_TYPE_MAP (imported; archetype defs are also read off e._typeDef).
  • ../../data/weaponsENEMY_WEAPON_STATS indexed by e.weaponId.
  • Per-enemy fields touched on every tick — x/y/vx/vy/angle, spd/speed, radius, eid, behavior, weaponId, damageMult, orbitRadius, _typeDef, alive, aggro, _isPackLeader, _packLeaderId, _packLeaderRef, _stunTimer, fireTimer.
  • World fields — world.enemies, world.enemyBullets, world.timedStrikes, world.forecasts, world.terrain, world.chunks, plus the (world as any)._lastMortarTime lock.

PUSHES TO

  • world.enemyBullets — burst-shot projectiles (fireBurstShot, machine-gun flag, _machineGun, _maxDist randomized 1.0–1.4× of ENEMY_PROJ_TARGET_DISTANCE) and sniper bullets (_sniperBullet, _noTerrainCollide).
  • world.timedStrikes — mortar AoE entries with fuse, maxFuse, radius, dmg, srcX/srcY.
  • world.forecasts — visual telegraphs: circle (orb dash target, gunner charge ring, mortar AoE), shooter_arrow (_followEid / _followRef so the arrow trails the live shooter), charger_bar (locked endpoint), field_zone (preview pass with _preview: true, then real zone with _fadeInDur, _vertical), sniper_dot (_followShip so it tracks the player during charge).
  • (world as any)._lastMortarTime — reset to 0 on every mortar fire; ticks up to GLOBAL_MORTAR_COOLDOWN to gate every mortar in the level.
  • Per-enemy state writes — _orbPhase (idle/windup/dash/sploot), _orbPhaseT, _orbSquash, _dashTargetX/Y, _aoeCooldown, _aoeChargeDur; _chargerPhase (0/1/2/3), _chargerWindupT, _chargerAimAngle, _chargerLungeDist, _chargerLungeFromX/Y, _chargerCooldown, _chargerRecoveryT, _chargerRecovering, _lungeHit; _fishDir; _fireCharging, _fireChargeT, _fireChargeDur, _fireChargeAim, _burstRemaining, _burstTimer, _burstAim, _postFirePause; _gunnerFiring, _gunnerCharging, _gunnerChargeT, _gunnerBurstRemaining, _gunnerBurstTimer, _gunnerCooldown, _gunnerMarching, _gunnerMarchTimer, _gunnerRecoil, _hitscanLines; _fieldPhase (moving/planting/active/cooldown), _fieldTargetX/Y, _fieldVertical, _fieldPlantTimer, _fieldActiveTimer, _fieldTickTimer, _fieldCooldownTimer; _sniperPhase (positioning/aiming/cooldown), _sniperChargeT, _sniperChargeDur, _sniperCooldown, _sniperCooldownTimer, _sniperFireFlash, _sniperFireAngle, _sniperBeam; _racerLane, _racerWaypointIdx; flash/VFX flags _dmgFlash, _dmgActive, _firePlop, _mortarRingActive, _angVel, _prevAngle.
  • damagePlayer(ship, dmg, gameState, angle, [shieldMult]) — orb sploot (also writes ship.vx/ship.vy knockback and ship._electricHit = 0.4), charger lunge (also writes gameState.timeDilation = 0 and gameState._hitFreezeTimer = CHARGER_HIT_FREEZE), gunner hitscan (with shieldMult = 0.2 plus an internal 1.5× shield boost), field active-tick damage (also scales ship.vx/ship.vy by 0.1 for an electric freeze), sniper projectile damage applied later via the projectile system.

DOES NOT

  • Doesn’t spawn enemies — that’s engine/enemies/spawner.ts / director.ts / zone-spawn-adapter.ts.
  • Doesn’t integrate projectile motion or detonate timedStrikes — owners are the projectile and forecast systems in the bridge.
  • Doesn’t render anything — _dmgFlash, _dmgActive, _orbSquash, _hitscanLines, _sniperBeam, _firePlop, _mortarRingActive are flags; rendering reads them.
  • Doesn’t resolve enemy↔enemy collisions or apply contact damage from chargers’ phase-2 hit beyond the one _lungeHit write — the generic contact-damage path is in collision-resolver and explicitly skipped for charger phases 2/3.
  • Doesn’t decide pack composition or set _packLeaderId — set at spawn in spawner.ts.
  • Doesn’t perform pathfinding — terrainPathfind is a back-compat alias that just calls terrainBounce.
  • Doesn’t apply stuns or knockback impulses — only reads e._stunTimer and exposes stunnable/knockbackable capability flags.
  • Doesn’t iterate world.enemies in updateEnemyAI to call onSpawn/onDamage/onDeath — those are invoked by the bridge/lifecycle.

Signals

  • Forecast lifecycle — world.forecasts[] entries carry sourceEid; behaviors set f.done = true on resolution; onDeath handlers for orb / charger / shooter / gunner / field / sniper splice any of their own forecasts to prevent lingering telegraphs.
  • Pack aggro cascade — e.aggro is the broadcast flag; leaders flip it via triggerPackAggro, followers read it via _packLeaderRef/_packLeaderId.
  • Global mortar throttle — world._lastMortarTime ticks across all mortars; only the first to expire GLOBAL_MORTAR_COOLDOWN fires.
  • Mid-attack inhibitor — isMidAttack(e) is the contract the bridge uses to skip dumb-mode downgrades; behaviors must keep their phase fields truthy for the duration of any committed attack.
  • Hit-freeze handshake — charger lunge writes gameState.timeDilation = 0 and gameState._hitFreezeTimer = CHARGER_HIT_FREEZE for the global frame freeze.
  • Early-game throttle — orb and gunner cooldowns tick at 0.2× while gameState.missionElapsed < 60 (gunner also rolls a skip chance each engage).
  • Racer despawn — sets e.hp = 0, e._dying = true, e._deathTimer = 0 when waypoints exhaust; lifecycle handles the rest.

Entry points

  • updateEnemyAI(enemies, dt, ship, world) — single per-frame call from the bridge; loops live enemies, dispatches movement then combat.
  • EnemyBehaviors.get(id) / EnemyBehaviors.can(id, capability) — bridge gates for separation, attack FSM, aggro, stun, knockback.
  • EnemyBehaviors.register(id, handler) — registration point (used internally to install the eight archetypes plus orbit / stationary aliases).
  • seekPoint (re-exported as dumbSeek callsite in bridge), steerToward, dumbFace, isMidAttack, terrainBounce, terrainPathfind, ENEMY_PROJ_ARM_DISTANCE, rollPersonality, resolveArchetype, initAttackState, BEHAVIOR_MAP, plus the legacy type shims orbit / mosquito / horde_charge / bomber / sniper / guardian / kamikaze.

Pattern notes

  • Archetype-only switch — resolveArchetype declares the eight live archetypes (orb, charger, shooter, mortar, gunner, field, sniper, racer); any other id falls back to orbit (which delegates to shooter).
  • Phase-strings vs phase-numbers — orb / field / sniper use named string phases ('idle'/'windup'/'dash'/'sploot', 'moving'/'planting'/'active'/'cooldown', 'positioning'/'aiming'/'cooldown'); charger uses numeric phases (0/1/2/3) because the lunge hit-test cares about identity comparison cost in the hot path.
  • Shared fire pipeline — every projectile/mortar archetype passes through enemyFire: cooldown → spawn forecast + start charge → during charge re-aim until ARROW_TRACK_FRAC of the duration → either spawn 12-shot burst (ENEMY_BURST_COUNT / ENEMY_BURST_DELAY, ±4° scatter, ±10 px origin jitter, 1/12 split damage, per-shot recoil 8, then SHOOTER_POST_FIRE_PAUSE) or push one timedStrikes entry with player-velocity lead (ship.vx * fuseT * 0.5).
  • Forecast types — circle (AoE landing), shooter_arrow (_followEid/_followRef so it tracks the live shooter), charger_bar (locked endpoints, fixed length CHARGER_BAR_LENGTH), field_zone (preview pass with _preview: true, then real zone with _fadeInDur/_vertical), sniper_dot (_followShip so it tracks the player during charge).
  • Two steering styles — seekPoint (instant velocity blending, used by orb idle, mortar approach/flee) vs steerToward (heading rotates at CAR_TURN_RATE, velocity is always forward along heading; used by charger orbit, gunner march, field move, sniper repositioning).
  • Damage shortcuts — orb sploot, charger lunge, gunner hitscan, and field tick all call damagePlayer directly inside the behavior to avoid the one-frame forecast-detonation delay; only the projectile-archetype path uses world.enemyBullets.
  • Capability flags — useSharedAttackFSM, useSharedSeparation, useSharedAggro opt in/out of bridge-level systems (e.g. orb / gunner / field / sniper / racer set useSharedAttackFSM: false; racer also opts out of separation, aggro, stun, knockback and has smartRange: 9999 so it always runs full behavior).
  • Continuous orbit math — charger phase-0 uses a single tangent + radial-pull formula (RADIAL_BAND = 100, TANGENT_LEN = 80, RADIAL_LEN = 80, orbitDist = 180) to avoid the snap stalls of hard distance zones; shooter uses fish-swim (radial + tangential acceleration with drag 0.97, max speed spd * 1.5 or spd * 0.6 during firing).
  • Performance — applyFriction replaces Math.pow(k, dt) with a precomputed -ln(k) linearization; terrainBounce and raycastTerrain go through world.chunks with TERRAIN_CHUNK_SIZE = 1024; pack aggro caches _packLeaderRef on first lookup to drop to O(1).
  • Defensive cleanup — every archetype’s onDeath walks world.forecasts to splice anything with sourceEid === e.eid; gunner additionally nulls _hitscanLines.