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
EnemyBehaviorsregistry singleton —register,get,can(capability gate),list, internal_registrymap keyed by behavior id.EnemyBehaviorHandlerinterface —movement,combat,onSpawn,onDamage,onDeath,onStun,renderhooks plus capability flags (smartRange,useSharedAttackFSM,useSharedSeparation,useSharedAggro,stunnable,knockbackable).- Friction constants and
applyFriction(e, fricRate, dt)— precomputed-ln(k)valuesFRIC_IDLE,FRIC_HARD,FRIC_STOP,FRIC_SOFTto replaceMath.pow(k, dt)in the hot path. - Movement helpers —
seekPoint(exported as the dumb-mode fallback),steerToward(car-like, capped turn rateCAR_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_packLeaderIdlinear scan;triggerPackAggro(e, ship)flipse.aggrowhen leader gets within 250 px. - Terrain push-out —
terrainBounce(e, world)using theworld.chunksspatial index (TERRAIN_CHUNK_SIZE = 1024) with brute-force fallback; legacy aliasterrainPathfind; private_bounceOneTerrainPieceapplies 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 constantsDEFAULT_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)(usesPERSONALITY_PROFILESkeyed 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 functionsorbit,mosquito,horde_charge,bomber,sniper,guardian,kamikaze(each delegating into one of the registered handlers’movement).
READS FROM
../core/types—WorldStateshape.../core/state—game(aliasedgameState) for_attackSpeedMult,missionElapsed,worldKnobs.rarityScale,timeDilation,_hitFreezeTimer.../../data/enemies—PERSONALITY_PROFILES(forrollPersonality) andENEMY_TYPE_MAP(imported; archetype defs are also read offe._typeDef).../../data/weapons—ENEMY_WEAPON_STATSindexed bye.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)._lastMortarTimelock.
PUSHES TO
world.enemyBullets— burst-shot projectiles (fireBurstShot, machine-gun flag,_machineGun,_maxDistrandomized 1.0–1.4× ofENEMY_PROJ_TARGET_DISTANCE) and sniper bullets (_sniperBullet,_noTerrainCollide).world.timedStrikes— mortar AoE entries withfuse,maxFuse,radius,dmg,srcX/srcY.world.forecasts— visual telegraphs:circle(orb dash target, gunner charge ring, mortar AoE),shooter_arrow(_followEid/_followRefso 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(_followShipso it tracks the player during charge).(world as any)._lastMortarTime— reset to 0 on every mortar fire; ticks up toGLOBAL_MORTAR_COOLDOWNto 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 writesship.vx/ship.vyknockback andship._electricHit = 0.4), charger lunge (also writesgameState.timeDilation = 0andgameState._hitFreezeTimer = CHARGER_HIT_FREEZE), gunner hitscan (withshieldMult = 0.2plus an internal 1.5× shield boost), field active-tick damage (also scalesship.vx/ship.vyby 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,_mortarRingActiveare flags; rendering reads them. - Doesn’t resolve enemy↔enemy collisions or apply contact damage from chargers’ phase-2 hit beyond the one
_lungeHitwrite — the generic contact-damage path is incollision-resolverand explicitly skipped for charger phases 2/3. - Doesn’t decide pack composition or set
_packLeaderId— set at spawn inspawner.ts. - Doesn’t perform pathfinding —
terrainPathfindis a back-compat alias that just callsterrainBounce. - Doesn’t apply stuns or knockback impulses — only reads
e._stunTimerand exposesstunnable/knockbackablecapability flags. - Doesn’t iterate
world.enemiesinupdateEnemyAIto callonSpawn/onDamage/onDeath— those are invoked by the bridge/lifecycle.
Signals
- Forecast lifecycle —
world.forecasts[]entries carrysourceEid; behaviors setf.done = trueon resolution;onDeathhandlers for orb / charger / shooter / gunner / field / sniper splice any of their own forecasts to prevent lingering telegraphs. - Pack aggro cascade —
e.aggrois the broadcast flag; leaders flip it viatriggerPackAggro, followers read it via_packLeaderRef/_packLeaderId. - Global mortar throttle —
world._lastMortarTimeticks across all mortars; only the first to expireGLOBAL_MORTAR_COOLDOWNfires. - 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 = 0andgameState._hitFreezeTimer = CHARGER_HIT_FREEZEfor 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 = 0when waypoints exhaust; lifecycle handles the rest.
Entry points
updateEnemyAI(enemies, dt, ship, world)— single per-frame call from the bridge; loops live enemies, dispatchesmovementthencombat.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 plusorbit/stationaryaliases).seekPoint(re-exported asdumbSeekcallsite in bridge),steerToward,dumbFace,isMidAttack,terrainBounce,terrainPathfind,ENEMY_PROJ_ARM_DISTANCE,rollPersonality,resolveArchetype,initAttackState,BEHAVIOR_MAP, plus the legacy type shimsorbit/mosquito/horde_charge/bomber/sniper/guardian/kamikaze.
Pattern notes
- Archetype-only switch —
resolveArchetypedeclares the eight live archetypes (orb,charger,shooter,mortar,gunner,field,sniper,racer); any other id falls back toorbit(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 untilARROW_TRACK_FRACof 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 recoil8, thenSHOOTER_POST_FIRE_PAUSE) or push onetimedStrikesentry with player-velocity lead (ship.vx * fuseT * 0.5). - Forecast types —
circle(AoE landing),shooter_arrow(_followEid/_followRefso it tracks the live shooter),charger_bar(locked endpoints, fixed lengthCHARGER_BAR_LENGTH),field_zone(preview pass with_preview: true, then real zone with_fadeInDur/_vertical),sniper_dot(_followShipso it tracks the player during charge). - Two steering styles —
seekPoint(instant velocity blending, used by orb idle, mortar approach/flee) vssteerToward(heading rotates atCAR_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
damagePlayerdirectly inside the behavior to avoid the one-frame forecast-detonation delay; only the projectile-archetype path usesworld.enemyBullets. - Capability flags —
useSharedAttackFSM,useSharedSeparation,useSharedAggroopt in/out of bridge-level systems (e.g. orb / gunner / field / sniper / racer setuseSharedAttackFSM: false; racer also opts out of separation, aggro, stun, knockback and hassmartRange: 9999so it always runs full behavior). - Continuous orbit math —
chargerphase-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;shooteruses fish-swim (radial + tangential acceleration with drag0.97, max speedspd * 1.5orspd * 0.6during firing). - Performance —
applyFrictionreplacesMath.pow(k, dt)with a precomputed-ln(k)linearization;terrainBounceandraycastTerraingo throughworld.chunkswithTERRAIN_CHUNK_SIZE = 1024; pack aggro caches_packLeaderRefon first lookup to drop to O(1). - Defensive cleanup — every archetype’s
onDeathwalksworld.forecaststo splice anything withsourceEid === e.eid; gunner additionally nulls_hitscanLines.