Bullet Archetype Renderer

The bullet archetype is a string tag a weapon attaches to every projectile it spawns. It is the renderer’s branch key: when drawBullet runs each frame it switches on this string to pick a distinct silhouette, glow color, scale, and per-frame animation. Two weapons that share the same BulletBehavior (e.g. both pierce) can still look completely different because their archetypes route to different visual branches.

Where it lives in the data

The authored field is bulletArchetype on a weapon definition (src/starship-survivors/data/weapons/_types.ts):

bulletArchetype?: string;

Each weapon data file sets one literal string. Sampled values in the live registry:

Weapon filebulletArchetype
cannon.tscannonball
rifle.tspulse_ball
coilgun.tspulse_ball
disc.tsdisc_ring
line.tstesla_line
magnetar.tstesla_line
barrier.tsshield_arc
sweep.tssweep_laser
fire-ring.tssweep_laser
legendaries.tsmega_bullet, star_orbit, quad_round, plasma_arc, pulse_ball, plasma_ball

If a weapon omits bulletArchetype, the spawner falls back to the weapon family name (e.g. projectile, explosive, chain, homing, blade, ember). The renderer has dedicated branches for both archetype strings and family fallbacks.

How it reaches the bullet

fireWeapon in engine/weapons/weapons.ts builds a barrel config per shot:

archetype: def.bulletArchetype || def.family,

A few specialized spawn paths bypass the field entirely and hard-code an archetype string into the barrel — 'mortar_shell', 'tesla_line', 'shield_arc', 'blade', 'ember', 'cone_beam', 'star_halo_root', 'beam_decay', 'artillery_strike', 'fire_patch', 'phoenix_pulse', 'carpet_bomber' (see lines 1521, 1603, 1724, 1805, 1895, 2030, 2500, 2574, 2644, 2724, 2751, 2808). Those weapons either don’t author a bulletArchetype or have it explicitly overridden because the visual is tied to a special spawn pathway, not the standard projectile barrel.

The shared bullet-construction step on line 2918 stores the string on the live bullet as the short field arch:

arch: barrel.archetype || 'projectile',

Bullets also accumulate one-off archetype tags when behaviors spawn child projectiles inside engine/weapons/bullets.ts — examples include arch: 'nova_ring' from chain explosions, arch: 'emp_ring', arch: 'plasma_fire_zone', arch: 'plasma_droplet', and arch: 'fire_patch'. These never flow through bulletArchetype on the data side; they are minted at spawn time inside the behavior handler.

How the renderer reads it

The per-frame draw call lives in engine/bridge.ts (around line 6076):

const bArch = b.arch || 'projectile';
drawBullet(ctx, b.x, b.y, b.rad, b.c1, 1, bArch, b.c2 || '#ffffff', b.vx, b.vy, wid, b);

drawBullet (engine/rendering/draw.ts from line 746) is one giant if/else if chain keyed on the arch parameter. Each branch draws a fully bespoke silhouette using ctx.save() / translate / rotate / layered fills. As of the verified commit the active branches are:

pulse_ball, cannonball, projectile, explosive, sniper, beam, chain, homing, mortar_shell, blade (also matches sweep_laser), ember, disc_ring, shield_arc, tesla_line, mega_bullet, plasma_arc, quad_round, phoenix_pulse, plasma_ball, plasma_fire_zone, fire_patch, carpet_bomber, star_halo_root, beam_decay, artillery_strike.

If arch doesn’t match any branch, no body draws — there is no generic fallback inside drawBullet’s dispatch (it relies on the spawner always defaulting to 'projectile').

After the per-archetype body, drawBullet runs a shared additive-glow pass at the bottom of the function — but the pass is skipped for the archetypes that own their own glow logic: sniper, star_halo_root, beam_decay, artillery_strike, phoenix_pulse, plasma_fire_zone, fire_patch, carpet_bomber, mega_bullet. cannonball gets a 1.8× glow-radius multiplier because the iron-ball silhouette is dark and needs a bigger hot halo to read against most backgrounds.

The 'cone_beam' archetype is the one true exception — bridge.ts short-circuits before drawBullet and routes those bullets to drawConeBeam, which uses the dynamic_cone_fire GLSL shader instead of Canvas 2D.

Distinct from BulletBehavior

Behaviors (the strings stored on b._behaviors and registered in engine/weapons/bullets.ts) drive what the bullet does — homing, chaining, exploding on death, spawning fire patches, applying status, etc. Archetypes drive what the bullet looks like. They are independent dimensions:

  • A weapon with bulletArchetype: 'pulse_ball' and a homing behavior renders as a glowing orb but flies like a missile.
  • Two weapons sharing the chain behavior can have completely different archetypes and look nothing alike.
  • The cannonball archetype renders a dark iron ball regardless of whether the underlying behavior is bouncing, piercing, or exploding.

The renderer never inspects _behaviors. The behavior layer never inspects arch. They meet only at the bullet object.

Color and scale inputs the archetype reads

Each branch combines the archetype-fixed silhouette with three per-bullet values:

  • b.c1 — primary tint (passed as color to drawBullet). Set per-weapon via def.color1.
  • b.c2 — secondary tint (passed as color2). Set per-weapon via def.color2.
  • b.rad — collision radius. The renderer derives visual size as (radius || 3) * camera.zoom * 1.04, then applies a legendary-only shrink curve (0.15× at L1 → 0.50× at L20) for any weapon whose weaponId starts with lgd_.

That separation is why every weapon needs two color stops in its data file: the archetype defines the shape and how the colors are mixed, but the data file picks which colors get mixed.

Authoring rules

  1. Pick an existing archetype string if your weapon looks like one of them. The branches in drawBullet are hand-tuned; reusing one is much cheaper than adding a new branch.
  2. Add a new archetype only when the silhouette genuinely needs to differ. A new branch means new code in engine/rendering/draw.ts plus a decision about whether the universal glow pass should skip it.
  3. Set both color1 and color2. Most branches use the secondary color for outlines, nose cones, or highlights — a missing color2 defaults to white, which looks wrong on most archetypes.
  4. Don’t reuse family as an archetype unless the family already has a branch. The fallback def.bulletArchetype || def.family only produces a visible bullet when the family string matches an existing arch === '...' clause.

Source files

  • src/starship-survivors/data/weapons/_types.ts:306 — declares the optional bulletArchetype?: string field.
  • src/starship-survivors/engine/weapons/weapons.ts:890, 1003, 1521, 1895, 2030, 2918 — propagates def.bulletArchetype onto the barrel/bullet as archetype and then arch.
  • src/starship-survivors/engine/weapons/bullets.ts — bullet behavior handlers; spawns secondary projectiles with hard-coded arch tags (nova_ring, emp_ring, plasma_fire_zone, plasma_droplet, fire_patch).
  • src/starship-survivors/engine/bridge.ts:6049-6076 — per-frame dispatch: reads b.arch, short-circuits for cone_beam, calls drawBullet for everything else.
  • src/starship-survivors/engine/rendering/draw.ts:746drawBullet switch keyed on arch; lines 810-2400 are the per-archetype branches; lines 2400-2410 are the shared additive glow pass with the skip-list.
  • src/starship-survivors/data/weapons/cannon.ts, rifle.ts, coilgun.ts, disc.ts, line.ts, magnetar.ts, barrier.ts, sweep.ts, fire-ring.ts, legendaries.ts — concrete bulletArchetype assignments.