How to Design a New Weapon

Before you start

A new weapon usually needs only a data file — one .ts export under src/starship-survivors/data/weapons/, plus one line in the barrel. The engine already wires every standard projectile, beam, chain, mortar, and AoE shape; picking from those primitives is the common case.

Custom behavior is a separate, rarer task. It means registering a new BulletBehavior (or, less often, a new WeaponArchetype) in the engine. See step 6. Decide which case you are in before writing anything.

CaseWhat you touch
Reskin / re-tune an existing flight patternone data file + barrel
New flight pattern or fire dispatchone data file + barrel + bullets.ts + (sometimes) weapons.ts
New family-level capabilityabove, plus archetypes.ts

Step 1: Pick the weapon’s identity

The designer locks seven decisions upfront. Each maps to a WeaponCoreSpec field with a fixed enum domain.

DecisionFieldAllowed valuesNotes
Rarityraritycommon, uncommon, rare, epic, legendaryDrop-rate display is flattened at runtime: any non-legendary spec resolves to common; legendaries (those with isLegendary: true) resolve to legendary. See weapons.
Familyfamilyprojectile, explosive, sniper, beam, chain, homing, sweepFamily drives juice defaults and family-level filters in artifacts.
Damage tagdamageTagbullet, energy, bomb, fireRequired to participate in legendary fusion. See damage tags. Legendaries also set secondaryDamageTag.
Damage typedamage_typenormal, trueOmit for normal (default). true bypasses shield absorption.
Target modetargetModeclosest, furthest, flanking, low_hpSee target modes. Conventional: rifles/SMGs use closest; mortars and railguns use furthest; cannon/revolver use flanking; missile uses low_hp.
Collision modecollisionModefirst_hit, pierce_all, target_only, beam_trace, chain_arcSee collision modes. Conventional: bullets first_hit, lasers/snipers beam_trace, chain weapons chain_arc, swarm/AoE pierce_all.
Scaling curvescalingCurvelinear, exponential, steep_exp, front_loaded, s_curve, linear_fastSee scaling curves. Default is linear. The curve remaps the L1→L20 progress and feeds every per-stat formula.
Area modeareaModeblast, blast_strong, chain_radius, beam_splash, beam_width, bonus_explosion, cone_width, blade_size, splash, line_spread, arc_thicknessSee area modes. Drives how the player’s horizontal Area upgrade scales this weapon.

Unusual combinations (e.g. beam family with first_hit collision, or projectile with beam_trace) are not wired in the dispatch chain — they will fall through to the default branch and behave like a generic projectile. Pick a conventional pairing or plan on adding a dispatch case in step 6.

Step 2: Write the data file

Create one new file: src/starship-survivors/data/weapons/<weapon-id>.ts. Export a single const typed as WeaponCoreSpec. Use the existing weapons as the template: rifle.ts for a simple projectile, mortar.ts for a lobbed explosive, legendaries.ts for any merge-result weapon.

The full field list, with required/optional status and the default to use when unsure:

FieldTypeRequiredDefaultPurpose
weaponIdstringyesUnique runtime key. Lowercase snake/kebab. Legendaries use lgd_ prefix.
namestringyesDisplay name in picker, ship screens, reward cards.
familyWeaponFamilyyesSee step 1.
damageTagDamageTagyes (de facto)One of bullet/energy/bomb/fire.
secondaryDamageTagDamageTaglegendaries onlySecond tag for dual-tag artifact triggers.
damage_typeDamageTypeno'normal''true' = bypass shields.
isLegendarybooleannofalseSet on the 10 merge-result weapons; excludes them from chests.
mergeParents[DamageTag, DamageTag]legendaries onlyCanonical (alphabetical) parent pair.
color1 / color2 / color3string (hex)yesVFX core / glow / accent.
targetModeTargetModeyesSee step 1.
collisionModeCollisionModeyesSee step 1.
canHitDestructiblesbooleanyestrueWhether the weapon damages destructible props.
acquireRangeWeaponStatyesAuto-aim search radius. { base, scaling }base is L1 value, scaling is added per effective level.
travelRangeMultnumberyes1.0Projectile lifetime as a multiple of acquireRange. Higher = longer-lived shots.
warmupSecnumberyes0.10Charge time before first shot when weapon enters fire window.
damageWeaponStatyesPer-hit damage at L1. Multiplied by the global getWeaponDamageMult(level) curve at fire time.
fireRateWeaponStatyesShots per second at L1.
fireRateJitternumberno0Per-shot RNG on cooldown (0.20 = ±20%). Omit for deterministic cadence.
projectileCountWeaponStat | SteppedStatfamily-specificShots per fire event.
projectileSpeedWeaponStatfamily-specificWorld units per second.
projectileSizeWeaponStatfamily-specificVisual + hit radius.
spreadDegnumberno0Cone half-angle in degrees.
randomSpreadbooleannofalseIf true, spread is randomized per shot instead of evenly fanned.
perpendicularLayoutbooleannofalseLay shots out perpendicular to fire vector (cannon/revolver).
perpSpacingnumbernoSpacing between perpendicular shots in world units.
randomOffsetnumberno0Spawn-position jitter in world units.
shellSpeedVariancebooleannofalseAdds per-shell speed RNG (mortar shells).
doubleShotChancenumberno00–1 chance to fire twice per trigger.
detonateAtTargetbooleannofalseAttaches cannon_track behavior; round explodes at aim point regardless of collision.
scaleSpreadWithCountbooleannofalseWidens spread as projectileCount grows.
staggerDelaySecnumberno0Delay between simultaneous shots inside one fire event.
blastRadiusWeaponStatexplosivesExplosion radius in world units.
beamWidthWeaponStatbeamsBeam half-thickness in world units.
beamLengthMultnumberbeams1.0Beam length relative to acquireRange.
hideBeamLinebooleannofalseSuppress the visible beam render (hitscan-style flash only).
chainCountSteppedStatchainNumber of jumps per chain.
chainRadiusWeaponStatchainJump search radius.
jumpDamageFalloffnumberchain1.0Multiplicative damage per jump (0.8 = 20% per hop).
homingStrengthWeaponStathomingTurn rate. Use 999 for hard-locked homing.
canRetargetbooleanhomingfalseWhether a homing shot picks a new target if its first dies.
behaviorWeaponBehaviornoOverride flight pattern. Must match a registered BulletBehavior id. See step 5.
returnSpeedWeaponStatboomerang_returnReturn speed multiplier.
lingerSecWeaponStatlingerPersistence time for stationary zones.
orbitRadiusWeaponStatorbitOrbital radius around ship.
triggerRadiusWeaponStatminesDetonation proximity radius.
armSecnumberminesArming delay.
burstPatternSteppedStatburst_fireRound-count steps per level.
maxActiveSteppedStatlinger / orbitCap on simultaneous active instances.
ringBurstCountnumberringStars per ring (legacy).
ringBurstDelaynumberringDelay between ring spawns.
starCountSteppedStatorbit_ringStars per cast at each level bracket.
ringRadiusWeaponStatorbit_ringFormation radius (capped to 50% portrait width).
ringSpinsnumberorbit_ring3Number of full revolutions before burst-out.
ringSpinDurationnumberorbit_ringSpin-phase duration in seconds.
starStreamSpacingSecnumberorbit_ringDelay between sequential star spawns.
starStreamSpeednumberorbit_ringSpeed (px/s) of streamed stars toward formation slot.
ringBurstOutDistWeaponStatorbit_ringRadial distance the ring expands before detonation.
beamDecaySecnumberbeam_decay2.0Seconds the decaying beam persists.
beamDecayExplosionsSteppedStatbeam_decayCascade explosion count per level.
beamDecayBlastRadiusWeaponStatbeam_decayPer-cascade-explosion radius.
artilleryTelegraphSecnumberartillery_rain0.6Telegraph circle visible time before impact.
artilleryStrikeRadiusWeaponStatartillery_rainRadial distance from ship the bombs land within.
arcTimenumbermortar/arcTime of flight for lobbed shots in seconds.
arcHeightMultnumbermortar/arc1.0Arc peak height multiplier.
scatterRadiusWeaponStatmortar/arcRandom scatter from aim point.
shellCountSteppedStatmortar/arcShells per fire event.
pullRadiusWeaponStatgravity_mineGravity pull radius.
pullForceWeaponStatgravity_minePull force magnitude.
rampBonusnumberlaser0Damage ramp-up while sustaining a beam.
splashDmgPctnumberlaser0Splash damage as fraction of primary damage.
emberCountSteppedStatflameEmber count per fire event.
coneWidthnumberflame / cone_beam_dotCone half-angle in degrees.
contactCooldownnumberswordsPer-enemy hit cooldown for orbiting blades.
bladeCountSteppedStatswordsActive blade count per level.
bladeSizeWeaponStatswordsBlade hitbox radius.
orbitSpeedWeaponStatswordsAngular velocity of blade orbit.
seekerCountSteppedStatrevolverSeeker count per fire event.
splashRadiusWeaponStatrevolverSplash radius on seeker impact.
pelletCountSteppedStatshotgunPellets per shell.
dmgPerHnumberyes0.04Damage gain per horizontal Damage upgrade level.
ratePerHnumberyes0.10Fire-rate gain per horizontal Rate upgrade level.
areaModeAreaModeyesSee step 1.
cadencePatternnumber[]yes[1]Cadence mask used for audio + per-shot rhythm; 1 = play, 0 = skip.
cadenceStepSecnumberyes0.0Step interval for cadence pattern.
shipPulseStrengthnumberyes1.0Ship-body pulse on fire.
sonarPulseScalenumberyes1.0Sonar overlay pulse scale on fire.
postFxSecnumberyes0.08Post-FX flash duration.
chimeTimbrestringyesAudio timbre key (glass, bronze, crystal, …).
recoilProfileRecoilProfilenoarchetype default{ sx, sy, dur, kick }.
fireShakeShakeProfilenoarchetype default{ amp, dur }. Subtle: amp 0.5–4 px, dur 0.05–0.15 s.
hitShakeShakeProfilenoarchetype default{ amp, dur }. Throttled engine-side.
bulletArchetypestringnoinferredVisual archetype id (e.g. pulse_ball, plasma_ball, mega_bullet).
firingGroupbooleannotrueIf false, weapon fires solo (Phoenix, Plasma Mortar).
_alwaysForwardbooleannofalseForce fire direction to ship facing regardless of target.
raritystringyes'common'Stored value is ignored at runtime — resolveWeaponRarity() flattens. Still required to typecheck.
disabledbooleannofalseDisabled weapons stay typed but never appear in pool.
defaultAnglenumbernoOverride initial fire angle.
earlyLevelDamageBuff{ maxBuff, endLevel }noPer-weapon override on top of the global low-level damage buff.

The designer never computes L10 or L20 values by hand. Each WeaponStat is { base, scaling }; _helpers.ts (getWeaponStatAtLevel, getEffectiveLevel, getSteppedStatAtLevel, getProbabilisticSteppedStat, getVfxTier, getWeaponDamageMult) resolves the value at runtime using the chosen scaling curve and the global damage anchors. The global damage curve anchors are:

LevelMultiplier
11.00
41.25
82.00
123.00
165.00
207.50

A low-level buff stacks on top: +30% at L1, decaying 5% per level, 0% at L7+. Legendaries skip the curve and ride at the L20 baseline (LEGENDARY_DAMAGE_BASELINE_MULT = 7.50) from L1.

Step 3: Register the weapon

Open src/starship-survivors/data/weapons/index.ts. Two edits:

  1. Add the import alongside the other imports at the top: import { my_weapon } from './my-weapon';
  2. Add the symbol to the WEAPONS array. Non-legendaries go in the main block; legendaries are spread in via ...LEGENDARY_WEAPONS (so legendary registration happens inside legendaries.ts instead — see step 4).

WEAPON_MAP and WEAPON_ORDER are built from WEAPONS at module load. No other registration is required for the weapon to appear in chests, in the picker, in getExtraProjectiles (only if you also add an entry to EXTRA_PROJ_TABLE), and in rarity color resolution.

If the weapon should receive bonus projectiles from the player’s horizontal more_projectiles upgrade, add a 21-entry array to EXTRA_PROJ_TABLE keyed on weaponId. The array is indexed by horizontal level 0–20.

Step 4: Add a legendary fusion (optional)

If the new weapon is the merge result of a level-20 pair, it goes in src/starship-survivors/data/weapons/legendaries.ts rather than its own file.

Required additions on the spec:

FieldValue
isLegendarytrue
mergeParents[tagA, tagB] in canonical (alphabetical) order via sortTagPairbomb < bullet < energy < fire
damageTagfirst parent tag
secondaryDamageTagsecond parent tag
rarity'legendary'

Then append the symbol to the LEGENDARY_WEAPONS array at the bottom of the file. LEGENDARY_PAIR_MAP is rebuilt from this array, so getLegendaryForPair will resolve the new pair automatically.

The 10 pairings are fully populated. To replace an existing legendary, swap the export; to add a brand-new dimension you must first add the new tag to the DamageTag and WeaponTag unions, which is out of scope of a routine weapon add.

Dual-tag inheritance: damage events fire signals for BOTH damageTag and secondaryDamageTag, so artifacts and effects keyed on either base tag trigger on legendary hits. See damage tags.

Step 5: Pick existing primitives first

The engine has the following BulletBehavior ids already wired in engine/weapons/bullets.ts. Pick one before considering custom code.

Behavior idOne-line summary
blinkVisual strobe; no gameplay change.
buzzsawSpinning blade, grows over time.
periodicRingEmits a periodic AoE ring from the bullet’s position.
phaseAuraDamaging aura attached to the bullet.
toxicZoneDrops a damage-over-time ground zone.
mineStationary mine with proximity trigger.
homingTracks the closest target until expiry.
boomerang_returnFlies out then returns to ship.
linger_outOutbound flight, then persistent linger at end position.
deploy_mineSpawns a mine on death/expiry.
bounceReflects off bounds.
burst_fireMulti-round burst from a single trigger.
empWaveExpanding EMP ring.
aoe_finishAoE detonation on death.
cannon_trackRound detonates at aim point regardless of collision.
arc_mortarLobbed parabolic trajectory.
gravity_minePull-radius + pull-force gravity well.
orbitOrbits around the ship.
chain_sequentialChains to N nearest enemies sequentially.
tesla_lineConnects ship to target with a damage beam.
cone_beam_dotPersistent cone visual + damage hitbox (shader-driven).
shield_arcDefensive arc in front of ship.
beam_lingerBeam that persists after the trace.
orbit_ringStreamed stars form a ring, spin, then burst out.
beam_decayBeam flash plus cascading explosions along its path.
artillery_rainTelegraphed off-screen bombardment.
mega_bullet_trailBrass-trail particles + slipstream behind slow rounds.
plasma_arcCurved blue plasma arc projectile.
quad_burstFour hitscan beams ≥30° apart per cast.
fire_trailDrops fire damage zones along flight path.
phoenix_pulsePeriodic 360° fire pulse from ship.
plasma_mortar_trailTrail particles for the plasma mortar shell.
plasma_mortar_landPersistent damage zone where a plasma mortar lands.
plasma_fire_zoneBlue-fire damage zone variant.
carpet_bomberScreen-pinned bomber sprites that drop firebomb lines.
fire_patch_zoneGeneric fire-damage patch zone.

The archetype registry (engine/weapons/archetypes.ts) currently defines: projectile, beam, sniper, leech, field, melee, nova, vortex, chain, arc_wave, flamethrower, singularity, orbital, buzzsaw, void_tear, whip, bass_cannon, bubble, bug_gun, emp, missile. Each archetype carries capability flags (canExplode, canPierce, canBounce, canLeech, canSlow, canBurn) that gate modifier eligibility for the weapon family.

If one of these matches your weapon’s flight pattern, set behavior (and optionally bulletArchetype) on the spec and stop here.

Step 6: When you DO need custom behavior

Three places to touch, in this order:

  1. New bullet behavior. Open engine/weapons/bullets.ts. Call BulletBehaviors.register(id, handler) next to the existing registrations. The handler implements BulletBehaviorHandler:

    FieldTypeRequiredPurpose
    prioritynumberno (default 0)Order among multiple behaviors on the same bullet.
    update(b, dt, world, ship)functionnoCalled each frame the bullet exists.
    onHit(b, enemy, world, ship)functionnoCalled when the bullet collides with an enemy.
    onDeath(b, world, ship)functionnoCalled once when the bullet despawns.

    Bullets carry a _behaviors: string[] array; behaviors are invoked in registration order, gated by priority. update/onHit/onDeath are all optional — register only what your behavior needs. Then add the new id string to the WeaponBehavior union in data/weapons/_types.ts so specs can reference it.

  2. New fire dispatch path. Open engine/weapons/weapons.ts and find WeaponManager.fire. The dispatch is a flat if (def.behavior === '...') { ... } else if (...) { ... } chain keyed on behavior (and sometimes collisionMode). Add a new branch in the same shape next to the existing ones. Do not introduce a new abstraction; the chain is intentionally flat so dispatch is grep-able.

  3. New archetype. Open engine/weapons/archetypes.ts and call WeaponArchetypes.register(id, meta) where meta matches WeaponArchetypeMeta:

    FieldTypePurpose
    damageTypestringDamage-type label (e.g. kinetic, energy, fire, gravity).
    entityTypestringbullet, hitscan, aoe, chain, cone.
    canExplodebooleanModifier eligibility for explosions.
    canPiercebooleanModifier eligibility for pierce.
    canBouncebooleanModifier eligibility for bounce.
    canLeechbooleanModifier eligibility for life-leech.
    canSlowbooleanModifier eligibility for slow.
    canBurnbooleanModifier eligibility for burn DoT.
    descstringOne-line description.

    New archetypes are needed only when a weapon introduces a family-level capability mix that no existing archetype expresses (e.g. a hitscan with canExplode: true but canPierce: false — that pattern is the sniper archetype). When in doubt, reuse projectile.

Step 7: VFX, audio, and feel

Standard juice routes automatically: color1/2/3, shipPulseStrength, sonarPulseScale, postFxSec, chimeTimbre, recoilProfile, fireShake, hitShake, cadencePattern, cadenceStepSec. Fill those fields and the weapon picks up muzzle flash, hull squash, camera shake, sonar pulse, post-FX flash, and audio cadence.

Custom recipes (a new particle effect, a new shake pattern, a new chime timbre) follow the same registry-then-reference pattern as bullet behaviors. Register the recipe in its system module (particles, shake, audio), then reference it by id from the weapon spec. See the VFX and audio system pages for those registries. Do not inline a custom recipe at the weapon-spec level.

Step 8: Validate

The engine has no test harness for new weapons. Verification is in-game.

Minimal checklist:

CheckExpected
Weapon appears in the in-run weapon pickerCard shows correct name, color, rarity border.
Auto-aim respects targetModeFires at the right target class.
Per-stat scaling tracks expectationsL1 / L10 / L20 stats match base + scaling * (effectiveLevel - 1) under the chosen curve.
Legendary merge (if added) produces the right childgetLegendaryForPair(tagA, tagB) returns the new weaponId.
secondaryDamageTag (if legendary) triggers both-tag artifactsArtifacts keyed on either parent tag activate on hit.
Horizontal modifiers scale the weaponDamage, Rate, more-projectiles, Area each visibly affect the weapon.
No console errors during fire / hit / deathClean run.

Custom-element structure rule

Any custom element MUST be registered through one of the engine registries — BulletBehaviors for flight/hit/death logic, WeaponArchetypes for family-level capability — rather than special-cased inline in weapons.ts. The only legitimate edit to weapons.ts for a new weapon is a new branch in the flat dispatch chain in WeaponManager.fire, and that branch should delegate to a registered behavior rather than do logic itself.

Custom elements live alongside their canonical siblings. A new bullet behavior goes in bullets.ts next to the existing BulletBehaviors.register('…', …) calls. A new archetype goes in archetypes.ts next to the existing WeaponArchetypes.register('…', …) calls. A new data spec goes in its own file under data/weapons/ next to rifle.ts. Do not create new directories, sub-modules, or abstraction layers for one-off weapons.