How to design a new ship

A sequential recipe for adding a new ship hull to Starship Survivors. Hulls are the v4 primary key: one hull × five stars = five ShipDef entries, generated automatically from a per-hull stat block. Stars unlock backpack grid space, not stat scaling — power comes from installed mods, not from leveling the hull.

Before you start

Read this first; it decides whether the work is data-only or needs an engine change.

QuestionIf yesIf no
Does the hull use one of the existing drive curves (linear, ease_in, ease_out, instant for accel; linear, exponential, front_loaded, back_loaded, overshoot for drag)?Data-only — write a SHIP_STATS_S1 block and optionally SHIP_STATS_S5, register in SHIPS_V4_RARITY, drop the sprite in the v4 atlas.Plan a curve addition in engine/physics/movement.ts first. New AccelCurve / DragCurve values must be added to both the type union in ships.ts and the if/else branches in movement.ts before any hull can reference them.
Is the silhouette a normal convex blob (one connected body, no thin antennae)?Let scripts/generate-hull-hitboxes.ts auto-trace it from the sprite alpha.A custom polygon must be hand-authored into HULL_HITBOXES after the auto-trace runs. The auto-traced hull will be a too-coarse convex envelope.
Does the hull need a brand-new starting weapon?Author the weapon first using the weapon authoring guide, then reference its id in startingWeapons.Reuse an existing id from data/weapons.ts (rifle, cannon, mortar, disc, flame, revolver, barrier, sweep, burst, lightning, line, railgun, shotgun, missile, fire_ring).
Does the hull need a new passive?Author the passive first using the passives authoring guide, then put its id in passiveId.Reuse jack_of_all (baseline default) or any existing passive id.

Data-only is the default path. The roster is built by a single loop over HULL_CLASSES × stars 1-5, so adding a hull is mostly bookkeeping: fill the rarity manifest, fill the ★1 block, decide if ★5 deviates, drop the sprite.

Step 1: Pick the hull’s identity

Fill this table before writing any code. Every cell maps to a field downstream.

SlotOptionsWhere it lands
Hull keyFaction_Name string, case-sensitive, spaces allowed (e.g. Backwater_Killer Croc)Both SHIPS_V4_RARITY and SHIP_STATS_S1 keys; must equal the v5 sprite filename without extension
Factionone of the seven existing factions (Ancients, Backwater, Industria, Junkrats, Prism, Solaris) or a new oneprefix of the hull key; baseline default is solaris in the type union (angel_corp, crystal_casino, solaris, wrongsiders) — extend Faction type if adding a new one
Raritycommon, uncommon, rare, epic, legendarySHIPS_V4_RARITY value; drives outline color via RARITY_COLORS
Ship classheavy, medium, lightshipClass field; baseline default is medium
Roletank / glass-cannon / brawler / sniper / hovercraft / etc.informs stat balance; not a typed field
Starting weaponweapon id from data/weapons.tsstartingWeapons array (typically [{ id, level: 1 }])
Passivepassive id from the passive registrypassiveId field
Sprite scaletypically 1.0-2.0; bigger sprites = bigger collision boxshipScale field
Movement archetyperotating spaceship vs. fixed-angle hovercraftrotates boolean + fixedAngleDeg

Hull keys are the v4 primary key — they are case-sensitive, spaces are preserved, and the same string is used as the sprite filename, the rarity manifest key, and the stat-override dictionary key. Get the key right once and the whole loop wires up.

Step 2: Write the ★1 stat block

This is the only required block. Anything not specified falls back to BASELINE_STATS (currently the Starpod baseline shared by all hulls).

The full set of fields on ShipDef is below. Bold rows are the high-leverage stats most new hulls override. Italic rows almost always inherit the baseline.

FieldTypeBaselineWhat it controls
hpnumber90Hull integrity; damage past shield drains here
shieldnumber90Regenerating energy buffer; tanks damage before HP
armornumber2Flat damage reduction per hit
speednumber95Top speed cap (u/s) — orthogonal from acceleration
accelerationnumber130Thrust force; how quickly the ship reaches top speed
turnRatenumber0.14Turn lerp rate per frame (higher = snappier)
dragnumber0.26Drag coefficient; combined with dragCurve for decel feel
weaponSlotsnumber3Max equipped weapons
upgradeSlotsnumber3Max equipped upgrades
weaponDamagePctnumber10Flat % damage bonus to all weapons
fireRatePctnumber5Flat % fire-rate bonus to all weapons
heatBuildupnumber12How fast heat rises when thrusting (heatBurnRate)
heatCooldownnumber40How fast heat decays when not thrusting (heatCoolRate)
heatCurveHeatCurvelinearReserved — currently only linear is read by movement.ts
burnoutSeveritynumber0.35HP damage multiplier on overheat stall
rotatesbooleantruetrue = spaceship (rotates to face input); false = hovercraft (fixed angle, omnidirectional thrust)
fixedAngleDegnumber0Hovercraft facing angle in degrees (0=up, 90=right, 180=down, 270=left); ignored when rotates=true
accelCurveAccelCurvelinearHow thrust ramps with current speed (see Step 4)
dragCurveDragCurveexponentialHow drag scales with current speed (see Step 4)
stopShakeIntensitynumber0Camera shake when crossing down through 15 u/s while not thrusting
heatShakeIntensitynumber0Continuous camera shake while heat fraction is above threshold
heatShakeThresholdnumber0.85Heat fraction at which heat-shake activates
stopSpringAmtnumber0Spring-back glide on stop; weakens drag below 15 u/s for hovercraft drift
shieldRegenRatenumber6Shield restored per second during partial regen
shieldRegenDelaynumber4Seconds after damage before shield begins regenerating
hpRegennumber0HP restored per second (passive)
lucknumber8Drop-quality bias
magnetRangenumber130Pickup magnet radius (u)
currencyBonusnumber6% credit/scrap bonus
shipScalenumber1.0Sprite render scale; also scales the Rapier hull collider
ramSpeedBleednumber0.50Velocity loss when ramming an enemy
contactSpeedBleednumber0.60Velocity loss on light contact
terrainRestitutionnumber1.09Bounce coefficient against world geometry
terrainFrictionnumber0.01Surface friction against world geometry
ramThresholdnumber150Speed (u/s) at which contact counts as a ram (damage-dealing collision)
ramDamageLonumber40Ram damage at ramThreshold
ramDamageHinumber1000Ram damage at the 5000 u/s absolute speed cap
pushRationumber0.1How much the ship is pushed back by enemy collision
enemySoliditynumber0.8How much enemies block the ship vs. squish through
contactDecelnumber0.6Velocity scaling on contact frame
contactCooldownnumber0.25Seconds before contact damage can trigger again
spriteHuenumber0Hue rotation in degrees, -180 to 180
spriteSaturationnumber10 = grayscale, 1 = normal, 3 = vivid
spriteContrastnumber10 = flat, 1 = normal, 3 = punchy
spriteBrightnessnumber10 = black, 1 = normal, 3 = blown out
passiveIdstringjack_of_allHull’s signature passive (granted at ★1)
shipClassheavy/medium/lightmediumInfluences enemy AI weighting and some upgrade conditionals
meleeMultnumber1.0Melee damage multiplier
startingWeaponsStartingWeapon[][{ id: 'rifle', level: 1 }]Equipped at run start
colorstringfrom rarityOutline color (set automatically from RARITY_COLORS[rarity])
accentstringfrom rarityOutline accent (set automatically)
descstring''UI flavor text

Required fields for a new hull’s ★1 block: at minimum weaponSlots, hp, shield, armor, acceleration, speed, drag, turnRate, shipScale, rotates, fixedAngleDeg, dragCurve, startingWeapons, plus the long tail of physics/sprite/regen knobs that the existing entries spell out. Field omissions fall back to BASELINE_STATS, but the existing ★1 entries spell every field explicitly so a hot reload of the file picks up changes cleanly — match that convention.

Heat-curve specifics: heatBuildup and heatCooldown are read directly. A hull with heatBuildup: 0 does not accumulate heat at all and is effectively burnout-immune.

Step 3: Pick a drive feel

Two independent knobs — accelCurve controls how thrust ramps with speed, dragCurve controls how the ship decelerates when thrust is released. Mix and match freely.

AccelCurve options

CurveFeelUsed by
linearConstant thrust force — predictable, racy. Default.Baseline; almost every hull
ease_inSlow off the line (0.4× at rest), faster as speed builds (1.6× at top). Heavy/diesel feel.(no current hull, available)
ease_outPunchy launch (1.6× at rest), plateaus near top speed (0.4× at top). Sports car / boost feel.(no current hull, available)
instantVelocity snaps to maxSpeed in input direction the moment thrust starts. Arcade twin-stick feel.(no current hull, available)

DragCurve options

CurveFeelUsed by (typical)
linearConstant brake rate regardless of speed — boat-coast(rare)
exponentialv *= factor per step — feels like space drag. Baseline default.Ancients, Backwater, Junkrats
front_loadedStrong brake at high speed, eases at low. Arcade car.Prism hulls (Citrine, Crystal, Jade, Pearl, Ruby, Shard)
back_loadedWeak brake at high speed, strong at low. Heavy cargo / industrial.Industria hulls (Bigbot, Caravan, Digbot, Drillbot, Eel, Loader, Milita, Towncar), Solaris hulls (Armada, Cargo, Cruiser, Flyer, Hauler, Oracle, Princess, Torque, Valet), Junkrats Orca, Junkrats Tank
overshootNear zero, drag weakens and ship glides further. Hovercraft drift.(no current hull, available; pairs with stopSpringAmt > 0)

Faction conventions to honor: Prism = front-loaded (sharp brake, twitchy crystalline feel), Industria/Solaris = back-loaded (heavy industrial inertia), Junkrats/Backwater/Ancients = exponential (classic space-drag).

Hovercraft pattern: set rotates: false, pick a fixedAngleDeg (typically 180 for facing-down sprites like the Prism + Ancients capital ships), and the ship will translate omnidirectionally without rotating. Thrust applies along the input direction, not the ship’s facing. Existing examples: Ancients_Glyph, Ancients_Rune, Prism_Crystal, Prism_Ruby.

Step 4: Define ★5 endpoints (optional)

SHIP_STATS_S1 is the ★1 endpoint. SHIP_STATS_S5 is the ★5 endpoint. Every field in LERP_FIELDS (the numeric stats — HP, shield, armor, speed, acceleration, turnRate, drag, slots, regen, damage %, magnet, scale, ram, sprite filters, etc.) lerps linearly across stars via t = (star - 1) / 4.

  • If you only fill ★1, ★2-★5 all read the same numbers (the lerp degenerates).
  • If you fill ★1 and ★5, ★2/★3/★4 are filled by linear interpolation automatically.
  • If you want a non-linear curve, pin a midpoint in SHIP_STATS_S2, SHIP_STATS_S3, or SHIP_STATS_S4. Mid-star overrides take priority over the lerped value.

Typical ★5 uplift pattern (look at the existing entries for shape): ~20-25% on HP/shield, +2-3 armor, +2.5 weaponDamagePct, +0-1 weaponSlots (legendary hulls often bump to 4 slots). Movement/scale numbers are usually held flat — those should already be tuned at ★1.

Non-numeric fields (startingWeapons, passiveId, shipClass, rotates, accelCurve, dragCurve, heatCurve, faction, color, accent, desc) do not interpolate. If you override them at ★5, the ship suddenly switches at ★5; usually you want these stable across stars and only specified in ★1.

Star progression is decoupled from stats: stars unlock backpack grid space via GRID_DIMS_BY_STAR (★1 = 3×3 slots, ★2 = 4×3, ★3 = 4×4, ★4 = 5×4, ★5 = 5×5). XP to reach each star is STAR_XP_THRESHOLDS = [0, 0, 1, 3, 7, 17] cumulative — ★5 reaches at 17 dupes (18 total pulls including the original).

Step 5: Hitbox

The Rapier collider is a convex hull built from a normalized 2D polygon in HULL_HITBOXES (range ±0.9 in each axis, scaled by shipScale at body-create time).

Auto-trace path (default): run npx tsx scripts/generate-hull-hitboxes.ts after dropping the new sprite. The script extracts the sprite alpha channel, takes the convex hull of the boundary pixels, simplifies to ~32 vertices via Visvalingam-Whyatt, normalizes against the content bounding box, shrinks 5% for a tight-but-fair feel, and rotates -90° CW from sprite-south to engine-right convention. The result is appended to HULL_HITBOXES keyed by hull class.

Fallback: any hull missing from HULL_HITBOXES gets a default octagon (a 0.9-radius eight-sided shape). This will warn in the dev console and emit a hull_hitbox_missing diag to Supabase + Sentry on first use, so missing hulls show up in production telemetry. The fallback keeps the Rapier body alive — without it, loop.ts would force-reset ship pos/vel to (0,0) every frame.

When to author a custom polygon by hand:

  • Sprite has thin antennae, wings, or appendages that the auto-trace’s convex envelope inflates the hitbox over.
  • Two-body silhouette (e.g. a ship with a detached side-pod) that should not be one big convex blob.
  • You want a deliberately smaller-than-silhouette hitbox for game feel (the auto-trace already shrinks 5% — go further only if the hull plays unfair otherwise).

To author by hand: edit HULL_HITBOXES[hullKey] directly with a manually-tuned polygon. Vertex order does not matter (Rapier takes the convex hull). Note the file header says “DO NOT EDIT” but that’s a soft warning aimed at protecting auto-generated entries from drift; intentional manual overrides are allowed if you keep them in sync when regenerating.

Step 6: Register the hull

Three places must agree on the hull key string. Skip any one and the ship never spawns.

  1. data/ships-v4-rarity.ts — add 'Faction_Name': 'rarity' under the right faction comment block. HULL_CLASSES derives from Object.keys(SHIPS_V4_RARITY) so this is what makes the hull exist in the roster loop.
  2. data/ships.ts — add the ★1 entry in SHIP_STATS_S1. Optional: ★2-★5 overrides in their respective dictionaries.
  3. Sprite at public/ships-v4/Faction_Name.png — 1024×1024 PNG with premultiplied alpha. Filename must match the hull key exactly, case-sensitive, spaces preserved.

The 300-entry SHIPS array, SHIPS_BY_ID index, and rarity coloring all build automatically from those three inputs. displayHullName() strips the Faction_ prefix for UI rendering and converts remaining underscores to spaces.

Step 7: Sprite + atlas

  • File location: public/ships-v4/<Hull_Key>.png.
  • Format: 1024×1024 PNG, premultiplied alpha, square canvas, ship roughly centered.
  • The sprite’s rarity is read visually from the outline color baked into the sprite by the sprite workbench, then re-asserted at runtime via SHIPS_V4_RARITY (the manifest is the source of truth — outline color is documentation).
  • The atlas loader is case-sensitive and preserves spaces in filenames. Backwater_Killer Croc.png is a valid filename and must match the hull key exactly.
  • shipScale in the stat block multiplies the rendered sprite size AND the Rapier collider size. Don’t use it as a sprite-only scale knob; if you want a bigger sprite without a bigger hitbox, scale the source PNG instead.

Step 8: Validate

Checklist after registering. Run through every item before considering the hull shipped.

  • Hull appears in the ship picker UI at the expected rarity color.
  • ★1 stats match design — verify in the playground or by reading the generated SHIPS_BY_ID['<Hull>_s1'] entry.
  • ★5 stats match design — same, with _s5.
  • ★2/★3/★4 lerp values look reasonable (no negative HP, no zero acceleration, no weaponSlots dropping below the floor).
  • Hitbox feels right in collision — ram an enemy, scrape terrain, check the collider matches the silhouette in playground hitbox-overlay mode.
  • Drive feel matches the faction expectation — front-loaded for Prism (twitchy stop), back-loaded for Industria/Solaris (heavy coast), exponential elsewhere.
  • Heat behavior matches intent — overheat triggers a stall at 100% heat, dealing CFG.HEAT_STALL_DMG * burnoutSeverity damage. A hull with heatBuildup: 0 never overheats.
  • No console warnings about hull_hitbox_missing — if you see one, the sprite was added but the hitbox regen step was skipped.
  • No Sentry diags emitted on hull load.

Custom-element structure rule

If the hull genuinely needs new movement physics (a curve none of the existing options match), the engine work happens before the data. Two files must change in lockstep:

  1. src/starship-survivors/data/ships.ts — extend the AccelCurve or DragCurve type union with the new string literal (e.g. 'pulsed'). All existing hulls compile-error until the new value is added to the union, so the type is the one place that knows the full set.
  2. src/starship-survivors/engine/physics/movement.ts — add an else if (aCurve === 'pulsed') branch in the accel-shaping block (inside the playerInput.isThrusting body), or an else if (curve === 'pulsed') branch in the drag-shaping block (after the baseDragArg is computed). The branch sets accelMult (accel) or dragArg (drag) based on speedNorm or other physics state.

The existing branches are the template — keep them stateless functions of speedNorm and baseDragArg so curves stay deterministic and frame-rate independent. The drag block reads speedNorm = clamp(currentSpeedForDrag / Math.max(1, ship.maxSpeed), 0, 1) once at the top; the accel block reads speedFrac = clamp(Math.hypot(ship.vx, ship.vy) / ship.maxSpeed, 0, 1) inside the curve check. Match those patterns.

Similarly, new HeatCurve values would need both a type-union extension and a branch in the heat-accumulation block of movement.ts (currently only linear is read — the field is reserved for future per-hull heat shaping). Adding a new heat curve is the same shape of change: type union, then implementation, then data uses it.

Brand-new physics fields on ShipDef (e.g. a new “boost charge” stat) require four changes: add to the ShipDef interface in ships.ts, add to BASELINE_STATS, add to LERP_FIELDS if it should interpolate across stars, and add to the roster-loop assignment that copies baseline into each generated entry. Skip any of those four and the field silently disappears at runtime.