stress-tests.ts
PURPOSE
Headless performance harness. Builds synthetic gameplay loads (enemies, bullets, particles, damage numbers, world events, XP orbs) and measures wall-clock cost per simulated frame at fixed dt. No rendering occurs — output is pure engine/update cost (AI movement, spatial grid, collision pipeline, weapons fire, VFX state, event update, orb collection). Used to gate phone-performance regressions and to characterize the worst realistic frame.
OWNS
StressResultinterface — per-scenario record (frame times, avg/min/max/p95/p99, peak entity counts).StressReportinterface —{ timestamp, results[] }.STRESS_SCENARIOStable — id-to-fn registry of all runnable scenarios.runAllStressTests()— executes every scenario in order, returns aStressReport.runStressTestsWithReport()— same plus a formattedconsole.logtable; stashes report onwindow.__stressReportwhen in browser.- Ten scenario functions, all exported:
stressEnemySpawnStorm(enemyCount)— ring of enemies chasing ship, spatial grid rebuild only.stressBulletHell()— 3 weapons firing into 60 respawning enemies, full bullet/collision pipeline.stressDamageNumberFlood()— fixed-rateDmgNumbers.add+DmgNumbers.update.stressParticleExplosionChain()— periodic burst clusters (Particles.burstx3 + dmg number).stressWorldEventDensity(eventCount)— concurrent magnet events + 40 enemies + particles.stressShieldHeat()— close-range enemies, shield regen/decay, heat oscillation, sampled dmg numbers.stressXpOrbFlood()— orb cap filled, realxpOrbs.update(magnet/merge/collect) + respawn.stressLateGameCompound()— composite worst-case (enemies + weapons + events + particles + orbs).stressCollisionGrid(enemyCount)— dense rings, broad-phase + narrow-phase stressor.stressParticlesCapped()— particle pool filled nearCFG.MAX_PARTICLES, sustained update + replenish.
- Internal helpers:
makeEnemy,makeWeapon,makeEvent,setupShip,setupGame,percentile,measureFrames,buildResult. - Module-local counter
_nextEidseeded at10000for synthetic entity IDs.
READS FROM
../engine/core/state—game,ship,world,camera,resetState. Mutates them directly; no isolation.../engine/core/config—CFG.MAX_XP_ORBS,CFG.MAX_PARTICLES.../data/enemies—ENEMY_TYPE_MAP['shooter_common']is the canonical base def for every synthetic enemy (radius, tags, typeId).../data/weapons—WEAPONS,WEAPON_MAP,getWeaponStatAtLevel,getEffectiveLevel. Weapons referenced by id:rifle,shotgun,railgun.../engine/weapons/weapons—WeaponManager.getAutoAimAngle,WeaponManager.updateCooldowns,WeaponManager.fire,getWeaponEffectiveRange,Weapontype.../engine/weapons/bullets—updateBullets.../engine/combat/collision-resolver—CollisionResolver.rebuildEnemyGrid,CollisionResolver.resolvePlayerBulletEnemyCollisions.../engine/vfx/particles—Particles.add,Particles.burst,Particles.update,DmgNumbers.add,DmgNumbers.update.../engine/world/events—EventManager.update,GameEventtype.../engine/world/xp-orbs(namespace import) —clear,spawn,update,count.performance.now()for timing;Math.randomfor placement jitter.
PUSHES TO
world.enemies,world.events,world.particles,world.dmgNumbers,world.playerBullets,world.enemyBullets— populated/read for peak counts via the live singletons. Bullets are produced indirectly byWeaponManager.fire.ship.*— overwritesx,y,vx,vy,hp,hpMax,shield,shieldMax,shieldRegenRate,shieldRegenTimer,radius,lifeSteal,heat,weapons,aliveviasetupShip; some scenarios further mutateheatandshieldeach frame and pinhpto a sentinel large value to keep the ship alive.game.phase(set to'playing'),game.time(incremented bydtper frame),game._dt(set toSIM_DT),game.timeDilation(set to1).camera.x/camera.y— pinned to ship in scenarios that exercise weapons (auto-aim uses camera-relative state).window.__stressReport— set byrunStressTestsWithReportwhenwindowexists.console.log— formatted table only viarunStressTestsWithReport.
DOES NOT
- Does not render. No canvas, no WebGL, no draw calls. Frame cost reflects update/sim only.
- Does not advance variable dt — every frame uses
SIM_DT = 1/60regardless of wall-clock duration. - Does not isolate state per scenario beyond calling
resetState()once insetupGame. Scenarios run sequentially against the same global singletons; ordering matters only if a scenario leaves residue (each scenario callssetupGamefirst, which callsresetState). - Does not assert thresholds. Pure measurement — callers (vitest, console) decide pass/fail.
- Does not exercise the real game loop (
engine/looporengine/core/game-step). Each scenario implements its own minimal per-frame sequence inline. - Does not feed input. Ship is stationary; aim is computed via
WeaponManager.getAutoAimAngleagainst live enemies. - Does not measure GC pauses, allocation rate, or memory. Frame time only.
- Does not write to disk or post telemetry. Report is in-memory; browser path stashes on
window.
Signals
StressResult.frameTimes[]is the raw series;avgMs,p95Ms,p99Ms,maxMs,minMsare derived inbuildResultfrom a sorted copy.percentileuses ceiling indexing:idx = ceil(N * p/100) - 1, clamped to>=0.- Peak counts (
peakEnemies,peakBullets,peakParticles,peakDmgNumbers,peakEvents) are sampled once per frame fromworldarray lengths insidemeasureFrames.peakBulletssums player and enemy bullet arrays. DEFAULT_FRAME_COUNT = 120(2s at 60fps) — every scenario inherits this unless it passes its own count tomeasureFrames(none currently do).
Entry points
- Vitest: imported by
tests/engine/perf/stress.test.ts(see source header). - Browser console:
After completion,import('/src/starship-survivors/testing/stress-tests.ts') .then(m => m.runStressTestsWithReport())window.__stressReportholds the structuredStressReport. - Programmatic:
runAllStressTests()for the report without console output; individualstress*functions can be called in isolation.
Pattern notes
- Modeled after
gauntlet-runner.ts(per source header). Same global-singleton mutation pattern, sameresetState()reset, same use ofshooter_commonas the canonical synthetic enemy. - Synthetic enemy shape is hand-rolled in
makeEnemyrather than constructed by the production spawn path — must stay in sync with the shape thatCollisionResolver,damage.ts, andxp-orbsexpect (fields:eid,typeId,_entityType,x/y/vx/vy,angle,speed,radius,hp/hpMax,alive,_dying,_deathTimer,_spawnT,_spawnPopT,_spawnDur,_hitImmune,flashAmount,shapeKey,isBoss,dmgCapped,hasShield,shieldHp,behavior,orbitRadius,weaponId,tags). If the live enemy contract grows, this helper drifts silently. - Synthetic event shape in
makeEventis locked totype: 'magnet'withphase: 'active'andinitiated: true. Other event types are not exercised. - Weapon construction uses
getEffectiveLevel(level, def.scalingCurve)thengetWeaponStatAtLevel(def.fireRate, effLvl)to derivecooldown = 1/fireRate(falls back to0.5if fireRate is<= 0). Other weapon stats (damage, range, projectiles) are resolved later insideWeaponManager.firefrom the same defs. - Per-scenario AI loop is inlined, not delegated to a real enemy-update module. Movement is straight-line
dist > 1chase at constantspeed, withdt * 0.3scaling in the collision-grid scenario to keep the cluster from collapsing into the ship. - Respawn pattern (used by bullet-hell and late-game): after
_deathTimer <= 0flipsalive = false, the slot is overwritten in place with a freshmakeEnemyrather than spliced — keeps array length stable and avoids reallocation noise in the measurement. - Ship is force-alive in scenarios where enemies are inside collision range (
ship.hp = 999999reset every frame). This isolates engine cost from death/game-over branches. stressXpOrbFloodcalls the realxpOrbs.update(SoA), so it does exercise the production magnet/merge/collection path — unlike the inline AI loops elsewhere.runStressTestsWithReportis the intended interactive entry;runAllStressTestsis the programmatic one. The console formatter pads withpadEnd(28)for scenarioId andpadStart(8)for numeric columns.
EXTRACT-CANDIDATE
- The inline straight-line chase block (compute
dx/dy/dist, setvx/vy, integratex/ybydt) is duplicated acrossstressEnemySpawnStorm,stressBulletHell,stressWorldEventDensity,stressShieldHeat,stressLateGameCompound, and (in scaled form)stressCollisionGrid. Could be a sharedchaseShip(enemies, ship, dt, scale = 1)helper local to this file. - The death-tick + in-place-respawn block (
_dyingcountdown,_hitImmunedecrement, slot overwrite) is duplicated betweenstressBulletHellandstressLateGameCompound. Could be a sharedrecycleDeadEnemies(world, ship, dt, makeFreshFn)helper. - The synthetic-entity shape in
makeEnemyduplicates fields owned by the production enemy module — if acreateEnemyShape(def, overrides)factory existed indata/enemiesor a test util, both this file andgauntlet-runner.tscould share it and stop drifting.