data/ships.ts
Ship roster — the v4 player-ship data table. One ShipDef row per (hull_class, star) pair, generated from a single BASELINE_STATS template plus per-hull-and-per-star overrides. Hull keys are the EXACT v4 sprite filenames (case + spaces preserved) so the case-sensitive ships-v4-loader and getShipDef share one identifier. Stat scaling between stars is interpolated, not stored row-by-row. Engine consumers reach this module through getShipDef(hull, star) → toShipCombatStats / toShipMetaStats.
λ — Charge across the river
| Export | Kind | Purpose |
|---|---|---|
Faction | type | 'angel_corp' | 'crystal_casino' | 'solaris' | 'wrongsiders' |
ShipRarity | type | 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary' |
HeatCurve | type | 'linear' | 'front_loaded' | 'back_loaded' | 'explosive_tail' | 'flat_plateau' | 'surge_pulse' |
DragCurve | type | 'linear' | 'exponential' | 'front_loaded' | 'back_loaded' | 'overshoot' |
AccelCurve | type | 'linear' | 'ease_in' | 'ease_out' | 'instant' |
ShipDef | interface | The contract for a single (hull, star) row — see schema below |
RARITY_COLORS | Record<ShipRarity, {color, accent}> | Frame + accent hex per rarity (UI) |
RARITY_NAMES | Record<ShipRarity, string> | Title-cased label per rarity |
RARITY_GRADES | Record<ShipRarity, string> | Single-letter grade (C / B / A / S / L) |
HULL_CLASSES | string[] | All hull keys, sourced from SHIPS_V4_RARITY |
SHIPS | ShipDef[] | The 300-row pre-baseline roster (every hull × ★1-★5) |
SHIP_STATS_S1..S5 | Record<string, Partial<ShipDef>> | Per-hull per-star overrides keyed by hull |
displayHullName(hull) | (string) => string | Strips faction prefix + replaces _ with spaces |
getShipDef(hull, star) | (string, number) => ShipDef | Resolves baseline → S1/S5 lerp → exact-star override |
toShipCombatStats(ship) | (ShipDef) => ShipCombatStats | Engine adapter — combat / movement / VFX subset |
toShipMetaStats(ship) | (ShipDef) => ShipMetaStats | Engine adapter — metagame subset (luck, currencyBonus, derived objectiveSpeed) |
BASELINE_STATS, LERP_FIELDS, STAR_STATS, SHIPS_BY_ID, lerpStat are module-private.
Κ — The registry shape
ShipDef schema
A ShipDef is a flat record. Fields group into seven bands:
| Band | Fields |
|---|---|
| Identity | id ({hull}_s{star}), name, hull_class, faction, rarity, star, passiveId, shipClass, desc, color, accent |
| Combat stats | hp, shield, armor, speed, acceleration, turnRate, weaponSlots, upgradeSlots, weaponDamagePct, fireRatePct, meleeMult, startingWeapons |
| Movement feel | drag, heatBuildup, heatCooldown, heatCurve, burnoutSeverity |
| Regen | shieldRegenRate, shieldRegenDelay, hpRegen |
| Vehicle feel | rotates, fixedAngleDeg, accelCurve, dragCurve, stopShakeIntensity, heatShakeIntensity, heatShakeThreshold, stopSpringAmt |
| Meta | luck, magnetRange, currencyBonus |
| Physics / collision | shipScale, ramSpeedBleed, contactSpeedBleed, terrainRestitution, terrainFriction, ramThreshold, ramDamageLo, ramDamageHi, pushRatio, enemySolidity, contactDecel, contactCooldown |
| Sprite filter | spriteHue, spriteSaturation, spriteContrast, spriteBrightness |
id carries a literal-field comment (Track-B regex requires literal id: field) — do not rename or reformat.
The 300-row generator
Module load builds the entire roster at import time:
HULL_CLASSES = Object.keys(SHIPS_V4_RARITY)— hulls come from the rarity table.- For each hull and
star ∈ [1, 5], push aShipDefpopulated fromBASELINE_STATSwith three deviations:id = ${hull}_s${star},name = ${hull} ★${star},rarity = SHIPS_V4_RARITY[hull], andcolor / accent = RARITY_COLORS[rarity]. SHIPS_BY_ID[s.id] = sindexes the 300 rows forgetShipDef.
Everything in step 2 lives at module scope — SHIPS is final once the file loads. Star-level differences are NOT baked into SHIPS; they only appear through getShipDef’s lerp pass.
Rarity / hull / faction sourcing
| Concept | Where it lives | Notes |
|---|---|---|
| Hull rarity | SHIPS_V4_RARITY in data/ships-v4-rarity.ts | Vision-tagged outline color on each sprite — single source of truth |
| Hull list | HULL_CLASSES = Object.keys(SHIPS_V4_RARITY) | Order matches insertion order in the rarity map |
| Hull naming | <Family>_<Hull> (e.g. Backwater_Lizard, Prism_Crystal, 'Backwater_Killer Croc') | Family prefix is the human-readable family group; displayHullName strips it for UI |
| Faction (mechanical) | ShipDef.faction | Default 'solaris' from BASELINE_STATS.faction; per-hull override allowed via SHIP_STATS_S1 |
| Visual frame color | RARITY_COLORS[rarity] | Bound at roster build, NOT recomputed by getShipDef |
Family prefix is a UI/grouping convention only — it has no type. Faction is the union type for mechanical alignment and is independent of the family prefix.
μ — Star interpolation
getShipDef(hull, star) is the only sanctioned read path. The lookup runs three passes:
- Exact-star override. If
SHIP_STATS_S{star}[hull]has the field, use it verbatim. - Endpoint lerp. Otherwise interpolate between
SHIP_STATS_S1[hull][field]andSHIP_STATS_S5[hull][field]usingt = (star - 1) / 4(★1=0, ★2=0.25, ★3=0.5, ★4=0.75, ★5=1). - Baseline fallback. If S1 or S5 doesn’t override the field, that endpoint defaults to
BASELINE_STATS[field](and S5 defaults to S1’s value when S5 isn’t overridden, collapsing to a flat curve).
Only fields enumerated in LERP_FIELDS participate in the lerp. Non-numeric / discrete fields (heatCurve, passiveId, shipClass, rotates, fixedAngleDeg, accelCurve, dragCurve, startingWeapons) read from the S1 override when present, otherwise fall through to the baseline row stored in SHIPS_BY_ID.
star=1 and star=5 are NOT special-cased — they pass through the same lerp with t=0 / t=1. The S1 and S5 records are therefore the “endpoint anchors”; S2/S3/S4 records exist only to pin off-curve mid-star values (the design note in the source calls this “pinning a non-linear curve without touching the endpoints”).
getShipDef throws on an unknown hull — bad data is a bug.
LERP_FIELDS
The interpolation whitelist is explicit. Every numeric ShipDef field is in this list except slot-count integers weaponSlots / upgradeSlots (these are listed too — they lerp and then become floats; callers that need integers must round). startingWeapons, passiveId, color, accent, desc, id, name, hull_class, faction, rarity, star, heatCurve, rotates, fixedAngleDeg, accelCurve, dragCurve, shipClass are deliberately excluded — they are categorical or already-resolved-at-build.
STAR_STATS index quirk
const STAR_STATS = [{}, SHIP_STATS_S1, SHIP_STATS_S2, SHIP_STATS_S3, SHIP_STATS_S4, SHIP_STATS_S5];Index 0 is an empty sentinel so STAR_STATS[star] reads naturally for star ∈ [1, 5]. The array is Array<Record<string, Partial<ShipDef>>> (non-const) on purpose — the dev playground’s /__dev/push-stats upserts into these records, and getShipDef reads through the references on the next call.
Ι — Engine adapters
toShipCombatStats
Projects ShipDef onto ShipCombatStats (defined in data/run-config.ts). The adapter renames a handful of fields:
ShipDef | ShipCombatStats |
|---|---|
hp | hpMax |
shield | shieldMax |
armor | damageReduction |
acceleration | thrust |
speed | maxSpeed |
heatBuildup | overheatBurn |
heatCooldown | overheatCool |
turnRate | turnSpeed |
It also injects three constants the data table does NOT carry: shieldRegenFillTime = 2, heatSpeedTarget = 80, heatBoostMult = 2.5. These are engine-side constants, not ship-tunable — anything per-ship tuning must touch ShipDef.
toShipMetaStats
Trivial projection — luck, currencyBonus, plus a derived objectiveSpeed = Math.round(luck * 0.5). The 0.5 multiplier is hard-coded here, not in BASELINE_STATS.
Θ — HMR contract
The file ends with a import.meta.hot.accept block. The dev playground edits this file (via /__dev/push-stats) every time the designer hits SAVE, so the module needs to swap in place — otherwise Vite walks the dependency graph to the next non-accepting parent and full-reloads the page, killing the running mission. Because every consumer reads through getShipDef() at call time, the accept callback has no state to migrate; it logs a confirmation and returns. The playground separately calls patchShipStats on the live mission for the visual update.
EXTRACT-CANDIDATE
- Header comment says “60 hull classes × 5 stars = 300 entries” but
SHIPS_V4_RARITYcurrently exposes ~44 hulls. The 300-row count drifts with the rarity map; either update the comment or treat it as a target and add a hull-count invariant test. BASELINE_STATSis module-private but every field shows up in everyPartial<ShipDef>override anyway (the S1 records repeat all 35+ fields per hull). ExportingBASELINE_STATSand letting the playground emit minimalPartials would shrink the file dramatically; the current full-record overrides defeat the lerp design.LERP_FIELDSincludesweaponSlotsandupgradeSlots, which are conceptually integers — interpolation produces floats. Either round at thegetShipDefboundary or move slot counts out of the lerp pool and into the discrete-field branch.getShipDefre-walksLERP_FIELDSand rebuilds a new object on every call — 35+ field reads per spawn. A per-(hull, star)memo keyed onidwould amortize the cost; cache invalidation hooks into the existing HMR accept callback.toShipMetaStatshard-codesluck * 0.5forobjectiveSpeed. Move the constant out (e.g.OBJECTIVE_SPEED_PER_LUCK) so the conversion ratio is greppable and traces to a named constant.Factionunion is declared but no UI/engine code currently distinguishes between'crystal_casino' / 'angel_corp' / 'wrongsiders' / 'solaris'— every generated row gets'solaris'from baseline. Either prune the union to what’s actually used or fan it through the per-hull S1 overrides.- The family prefix convention (
Backwater_Lizard,Prism_Crystal, etc.) is informal — noFamilytype, no lookup helper to enumerate “all Prism ships”. AgetFamily(hull): string+Familyunion derived fromSHIPS_V4_RARITYkeys would make grouping queries cheap. SHIPS_BY_IDis module-private butgetShipDefis the only caller. Either inline the map intogetShipDef’s closure or exposeSHIPS_BY_IDfor read-only consumers (saves an O(n).findcall when tools need to validate ids).