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.
| Question | If yes | If 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.
| Slot | Options | Where it lands |
|---|---|---|
| Hull key | Faction_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 |
| Faction | one of the seven existing factions (Ancients, Backwater, Industria, Junkrats, Prism, Solaris) or a new one | prefix 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 |
| Rarity | common, uncommon, rare, epic, legendary | SHIPS_V4_RARITY value; drives outline color via RARITY_COLORS |
| Ship class | heavy, medium, light | shipClass field; baseline default is medium |
| Role | tank / glass-cannon / brawler / sniper / hovercraft / etc. | informs stat balance; not a typed field |
| Starting weapon | weapon id from data/weapons.ts | startingWeapons array (typically [{ id, level: 1 }]) |
| Passive | passive id from the passive registry | passiveId field |
| Sprite scale | typically 1.0-2.0; bigger sprites = bigger collision box | shipScale field |
| Movement archetype | rotating spaceship vs. fixed-angle hovercraft | rotates 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.
| Field | Type | Baseline | What it controls |
|---|---|---|---|
| hp | number | 90 | Hull integrity; damage past shield drains here |
| shield | number | 90 | Regenerating energy buffer; tanks damage before HP |
| armor | number | 2 | Flat damage reduction per hit |
| speed | number | 95 | Top speed cap (u/s) — orthogonal from acceleration |
| acceleration | number | 130 | Thrust force; how quickly the ship reaches top speed |
| turnRate | number | 0.14 | Turn lerp rate per frame (higher = snappier) |
| drag | number | 0.26 | Drag coefficient; combined with dragCurve for decel feel |
| weaponSlots | number | 3 | Max equipped weapons |
| upgradeSlots | number | 3 | Max equipped upgrades |
| weaponDamagePct | number | 10 | Flat % damage bonus to all weapons |
| fireRatePct | number | 5 | Flat % fire-rate bonus to all weapons |
| heatBuildup | number | 12 | How fast heat rises when thrusting (heatBurnRate) |
| heatCooldown | number | 40 | How fast heat decays when not thrusting (heatCoolRate) |
| heatCurve | HeatCurve | linear | Reserved — currently only linear is read by movement.ts |
| burnoutSeverity | number | 0.35 | HP damage multiplier on overheat stall |
| rotates | boolean | true | true = spaceship (rotates to face input); false = hovercraft (fixed angle, omnidirectional thrust) |
| fixedAngleDeg | number | 0 | Hovercraft facing angle in degrees (0=up, 90=right, 180=down, 270=left); ignored when rotates=true |
| accelCurve | AccelCurve | linear | How thrust ramps with current speed (see Step 4) |
| dragCurve | DragCurve | exponential | How drag scales with current speed (see Step 4) |
| stopShakeIntensity | number | 0 | Camera shake when crossing down through 15 u/s while not thrusting |
| heatShakeIntensity | number | 0 | Continuous camera shake while heat fraction is above threshold |
| heatShakeThreshold | number | 0.85 | Heat fraction at which heat-shake activates |
| stopSpringAmt | number | 0 | Spring-back glide on stop; weakens drag below 15 u/s for hovercraft drift |
| shieldRegenRate | number | 6 | Shield restored per second during partial regen |
| shieldRegenDelay | number | 4 | Seconds after damage before shield begins regenerating |
| hpRegen | number | 0 | HP restored per second (passive) |
| luck | number | 8 | Drop-quality bias |
| magnetRange | number | 130 | Pickup magnet radius (u) |
| currencyBonus | number | 6 | % credit/scrap bonus |
| shipScale | number | 1.0 | Sprite render scale; also scales the Rapier hull collider |
| ramSpeedBleed | number | 0.50 | Velocity loss when ramming an enemy |
| contactSpeedBleed | number | 0.60 | Velocity loss on light contact |
| terrainRestitution | number | 1.09 | Bounce coefficient against world geometry |
| terrainFriction | number | 0.01 | Surface friction against world geometry |
| ramThreshold | number | 150 | Speed (u/s) at which contact counts as a ram (damage-dealing collision) |
| ramDamageLo | number | 40 | Ram damage at ramThreshold |
| ramDamageHi | number | 1000 | Ram damage at the 5000 u/s absolute speed cap |
| pushRatio | number | 0.1 | How much the ship is pushed back by enemy collision |
| enemySolidity | number | 0.8 | How much enemies block the ship vs. squish through |
| contactDecel | number | 0.6 | Velocity scaling on contact frame |
| contactCooldown | number | 0.25 | Seconds before contact damage can trigger again |
| spriteHue | number | 0 | Hue rotation in degrees, -180 to 180 |
| spriteSaturation | number | 1 | 0 = grayscale, 1 = normal, 3 = vivid |
| spriteContrast | number | 1 | 0 = flat, 1 = normal, 3 = punchy |
| spriteBrightness | number | 1 | 0 = black, 1 = normal, 3 = blown out |
| passiveId | string | jack_of_all | Hull’s signature passive (granted at ★1) |
| shipClass | heavy/medium/light | medium | Influences enemy AI weighting and some upgrade conditionals |
| meleeMult | number | 1.0 | Melee damage multiplier |
| startingWeapons | StartingWeapon[] | [{ id: 'rifle', level: 1 }] | Equipped at run start |
| color | string | from rarity | Outline color (set automatically from RARITY_COLORS[rarity]) |
| accent | string | from rarity | Outline accent (set automatically) |
| desc | string | '' | 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
| Curve | Feel | Used by |
|---|---|---|
linear | Constant thrust force — predictable, racy. Default. | Baseline; almost every hull |
ease_in | Slow off the line (0.4× at rest), faster as speed builds (1.6× at top). Heavy/diesel feel. | (no current hull, available) |
ease_out | Punchy launch (1.6× at rest), plateaus near top speed (0.4× at top). Sports car / boost feel. | (no current hull, available) |
instant | Velocity snaps to maxSpeed in input direction the moment thrust starts. Arcade twin-stick feel. | (no current hull, available) |
DragCurve options
| Curve | Feel | Used by (typical) |
|---|---|---|
linear | Constant brake rate regardless of speed — boat-coast | (rare) |
exponential | v *= factor per step — feels like space drag. Baseline default. | Ancients, Backwater, Junkrats |
front_loaded | Strong brake at high speed, eases at low. Arcade car. | Prism hulls (Citrine, Crystal, Jade, Pearl, Ruby, Shard) |
back_loaded | Weak 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 |
overshoot | Near 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, orSHIP_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.
data/ships-v4-rarity.ts— add'Faction_Name': 'rarity'under the right faction comment block.HULL_CLASSESderives fromObject.keys(SHIPS_V4_RARITY)so this is what makes the hull exist in the roster loop.data/ships.ts— add the ★1 entry inSHIP_STATS_S1. Optional: ★2-★5 overrides in their respective dictionaries.- 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.pngis a valid filename and must match the hull key exactly. shipScalein 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 * burnoutSeveritydamage. A hull withheatBuildup: 0never 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:
src/starship-survivors/data/ships.ts— extend theAccelCurveorDragCurvetype 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.src/starship-survivors/engine/physics/movement.ts— add anelse if (aCurve === 'pulsed')branch in the accel-shaping block (inside theplayerInput.isThrustingbody), or anelse if (curve === 'pulsed')branch in the drag-shaping block (after thebaseDragArgis computed). The branch setsaccelMult(accel) ordragArg(drag) based onspeedNormor 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.