Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ npm run dev # http://127.0.0.1:5173
| `↑` `↓` `A` `D` | Steer without a mouse |
| `Q` / `E` | Roll left / right |
| `Space` or left click | Fire |
| `F` or right click | Hold to spool the BFG, release to abort |
| `Shift` | Phase dash (Hornet only) |
| `Tab` / `T` | Switch target lock |
| `Esc` / `P` | Pause |
Expand Down Expand Up @@ -65,6 +66,38 @@ lock out for 2.9 seconds, which costs a third of your output. The card bars in t
*derived* from these numbers rather than typed in, so they cannot drift away from the flight
model — see `sustainedDps` in `src/ships/specs.ts`.

## The BFG

Officially a Bulk Fusion Generator. Nobody calls it that.

**Two rounds a run, no refills.** Hold `F` for 1.3 seconds and a slow green ball
leaves the nose; anything within 340 units of where it stops takes up to 260
damage, falling off sharply toward the edge. That kills any airframe in the game
outright at the centre and is a hard shove and a scare at the rim.

Three things make it a decision rather than a button:

**Charging costs you everything else.** While it spools the guns are cold, the
dash is locked and the throttle is capped at 55%. You are committing to a
heading for a second and a half in an arena where everyone else is still
manoeuvring. A launch consumes the trigger — holding through the shot does not
wind the second round; you have to let go and mean it.

**The blast does not care who fired it.** The pilot takes 60% — 156 damage at
point-blank, which kills a Wasp outright and takes a bite out of a Drone. A
held Shield does not change this: the Shield is for the gunfight, not for
standing in a fusion blast. The distance you keep is the price of the damage
you get.

**Hostiles run from a live round.** Each one registers as a steering hazard the
size of its own blast, so the AI scatters as it crosses the arena. A round that
hits nothing still breaks a formation off your tail, which makes it a zoning
tool as much as a killing one: fire it where you want people to *not be*. It
also chain-detonates any mines it goes off near.

Aborting a charge keeps the round, so a mispress costs you a moment rather than
a third of your firepower. The numbers all live at the top of `src/game/bfg.ts`.

## Hazards

**Stations** are solid on their core only — rings, trusses and solar panels are fly-through, so
Expand Down Expand Up @@ -396,9 +429,11 @@ silently freezes the loop and makes every behavioural observation meaningless. I
combat contract (hits land, kills register, friendly fire is off), the hull quirks, the
boundary, the power-up pods (placement, collection, respawn, that a boosted bolt still does
exactly its spec damage, that a Shield refuses damage without crediting the shooter, and that
both timed buffs stack, expire and do not survive a respawn), that clearing the roster reports
a win, and that a fatal hit plays its death animation out in full before the debrief takes the
screen.
both timed buffs stack, expire and do not survive a respawn), the BFG (spool, abort, ammo,
falloff, self-damage, mine chaining, that charging really does silence the guns on the first
frame after warp-in, that a long hold cannot spend the second round, and that a Shield does
not make the blast free), that clearing the roster reports a win, and that a fatal hit plays
its death animation out in full before the debrief takes the screen.

`scripts/balance.ts` is the same idea pointed at fairness instead of correctness. It flies pinned
duels — every airframe against every other, every bolt on target — and prints alpha strike,
Expand Down
59 changes: 59 additions & 0 deletions scripts/balance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ import * as THREE from 'three'
import type { Audio } from '../src/core/audio'
import { createBolts, FACTION_AI, FACTION_PLAYER } from '../src/game/bolts'
import { Ship, type Controls, type ShipContext } from '../src/game/ship'
import {
blastFraction,
BFG_CHARGES,
BLAST_DAMAGE,
BLAST_RADIUS,
SELF_DAMAGE,
SPOOL_TIME,
} from '../src/game/bfg'
import {
SHIPS,
SHIP_ORDER,
Expand Down Expand Up @@ -93,6 +101,9 @@ function silentAudio(): Audio {
pickup() {},
overheat() {},
alarm() {},
charge() {},
siege() {},
detonation() {},
uiSelect() {},
uiLaunch() {},
fanfare() {},
Expand All @@ -109,6 +120,7 @@ function controls(overrides: Partial<Controls> = {}): Controls {
throttle: 0,
fire: false,
dash: false,
secondary: false,
aim: null,
spread: 0,
...overrides,
Expand Down Expand Up @@ -388,6 +400,29 @@ for (const attacker of SHIP_ORDER) {
console.log(` ${pad(SHIPS[attacker].name, 8)}${cells.join('')}`)
}

section('BFG')
console.log(` ${pad('', 14)}${padLeft('damage', 9)}${padLeft('vs Wasp', 10)}${padLeft('vs Hornet', 12)}${padLeft('vs Drone', 11)}`)
for (const [label, distance] of [
['centre', 0],
['quarter radius', BLAST_RADIUS * 0.25],
['half radius', BLAST_RADIUS * 0.5],
['three quarters', BLAST_RADIUS * 0.75],
] as [string, number][]) {
const damage = BLAST_DAMAGE * blastFraction(distance)
const share = (id: ShipId): string => {
const pct = Math.min(100, (damage / SHIPS[id].maxHull) * 100)
return pct >= 100 ? 'KILL' : `${pct.toFixed(0)}%`
}
console.log(
` ${pad(label, 14)}${padLeft(damage.toFixed(0), 9)}${padLeft(share('wasp'), 10)}` +
`${padLeft(share('hornet'), 12)}${padLeft(share('drone'), 11)}`,
)
}
const selfBlast = BLAST_DAMAGE * SELF_DAMAGE
console.log(
` ${BFG_CHARGES} rounds a run · ${SPOOL_TIME}s spool · point-blank self-damage ${selfBlast.toFixed(0)}`,
)

section('Hangar cards')
for (const id of SHIP_ORDER) {
const b = SHIPS[id].bars
Expand Down Expand Up @@ -490,6 +525,30 @@ for (const [matchup, t] of ttk) {
)
}

/**
* The BFG is a moment, not a build. Two rounds spread over even a short
* engagement have to come out below the *weakest* gun in the fleet, or the
* right way to play becomes opening with both and mopping up — and every
* balance number above stops describing the game.
*/
const ENGAGEMENT = 30
const bfgDpsOverAFight = (BFG_CHARGES * BLAST_DAMAGE) / ENGAGEMENT
check(
'the BFG cannot replace the guns',
bfgDpsOverAFight < Math.min(...SHIP_ORDER.map(best)),
`${bfgDpsOverAFight.toFixed(1)} DPS across a ${ENGAGEMENT}s fight vs ${Math.min(...SHIP_ORDER.map(best)).toFixed(1)} from the weakest gun`,
)

/**
* And it has to be able to kill you. A blast the pilot can safely stand inside
* is a free button, and a free button gets pressed on cooldown.
*/
check(
'a point-blank BFG kills the pilot who fired it',
selfBlast > SHIPS.wasp.maxHull,
`${selfBlast.toFixed(0)} self-damage vs a ${SHIPS.wasp.maxHull} hull`,
)

/** A hazard that one-shots an airframe stops being a hazard and becomes a wall. */
for (const id of SHIP_ORDER) {
const spec = SHIPS[id]
Expand Down
61 changes: 47 additions & 14 deletions scripts/mutate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,22 +33,23 @@ const MUTATIONS = [
{
name: 'every seat flies intents[0]',
file: 'src/game/game.ts',
from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n seat.ship.step(seat.lastControls, STEP, ctx)',
to: ' recordControls(seat, intents[0])\n seat.ship.step(seat.lastControls, STEP, ctx)',
from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)',
to: ' recordControls(seat, intents[0])\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)',
},
{
name: "every seat flies the drawn seat's intent",
file: 'src/game/game.ts',
from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n seat.ship.step(seat.lastControls, STEP, ctx)',
to: ' recordControls(seat, intents[localIndex])\n seat.ship.step(seat.lastControls, STEP, ctx)',
from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)',
to: ' recordControls(seat, intents[localIndex])\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)',
},
{
name: 'seat i flies intents[i+1], wrapped',
file: 'src/game/game.ts',
from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n seat.ship.step(seat.lastControls, STEP, ctx)',
from: ' recordControls(seat, intents[i])\n // The hull flies the *record*, not the caller\'s struct: admission — `aim`\n // dropped, `spread` zeroed — happens in `recordControls`, and flying its\n // output is what makes the record the truth rather than a copy of it.\n applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)',
to:
' const j = (i + 1) % seats.length\n' +
' recordControls(seat, intents[j])\n' +
' applyBfgInterlock(seat, seat.lastControls)\n' +
' seat.ship.step(seat.lastControls, STEP, ctx)',
},
{
Expand Down Expand Up @@ -127,8 +128,8 @@ const MUTATIONS = [
{
name: 'a late packet keeps firing',
file: 'src/game/intent.ts',
from: ' out.fire = false\n out.dash = false\n out.aim = null',
to: ' out.fire = held.fire\n out.dash = held.dash\n out.aim = null',
from: ' out.fire = false\n out.dash = false\n out.secondary = false\n out.aim = null',
to: ' out.fire = held.fire\n out.dash = held.dash\n out.secondary = false\n out.aim = null',
},
{
name: 'a late packet stalls the throttle',
Expand All @@ -139,8 +140,8 @@ const MUTATIONS = [
{
name: 'an admitted intent keeps the aim override',
file: 'src/game/intent.ts',
from: ' out.dash = claim.dash === true\n out.aim = null',
to: ' out.dash = claim.dash === true\n out.aim = claim.aim as THREE.Vector3 | null',
from: ' out.dash = claim.dash === true\n out.secondary = claim.secondary === true\n out.aim = null',
to: ' out.dash = claim.dash === true\n out.secondary = claim.secondary === true\n out.aim = claim.aim as THREE.Vector3 | null',
},

/* ---- Snapshots: the world on the wire ----------------------------------- */
Expand Down Expand Up @@ -308,7 +309,7 @@ const MUTATIONS = [
{
name: 'predict records the intent but never flies it',
file: 'src/game/game.ts',
from: ' recordControls(s, controls)\n s.ship.step(s.lastControls, STEP, dryCtx)',
from: ' recordControls(s, controls)\n applyBfgInterlock(s, s.lastControls)\n s.ship.step(s.lastControls, STEP, dryCtx)',
to: ' recordControls(s, controls)',
},
{
Expand All @@ -334,8 +335,8 @@ const MUTATIONS = [
{
name: 'the last hitter is never remembered',
file: 'src/game/game.ts',
from: ' if (direct) {\n lastHitter.set(self, direct)\n creditHit(direct, amount)\n return\n }',
to: ' if (direct) {\n creditHit(direct, amount)\n return\n }',
from: ' if (direct) {\n lastHitter.set(self, direct)\n // A BFG blast that catches three hulls is one shot, not three. Points\n // still land per hull; the accuracy numerator is bumped once in\n // `resolveBfg` after the sphere has finished.\n if (resolvingBlast) creditDamage(direct, amount)\n else creditHit(direct, amount)\n return\n }',
to: ' if (direct) {\n if (resolvingBlast) creditDamage(direct, amount)\n else creditHit(direct, amount)\n return\n }',
},
{
name: "the arena's damage is nobody's, even in a match of one",
Expand All @@ -346,7 +347,7 @@ const MUTATIONS = [
{
name: 'a hit on a participant pays nothing',
file: 'src/game/game.ts',
from: ' lastHitter.set(self, direct)\n creditHit(direct, amount)',
from: ' lastHitter.set(self, direct)\n if (resolvingBlast) creditDamage(direct, amount)\n else creditHit(direct, amount)',
to: ' lastHitter.set(self, direct)',
},
{
Expand Down Expand Up @@ -830,6 +831,38 @@ const MUTATIONS = [
' return out.applyAxisAngle(_launchAxis, (index / count) * Math.PI * 2)',
to: ' void count\n void index\n return out.copy(PLAYER_SPAWN)',
},

/* ---- BFG --------------------------------------------------------------- */
{
name: 'holding through a BFG launch winds the next round',
file: 'src/game/bfg.ts',
from: ' recovery = ABORT_RECOVERY\n needsRelease = true',
to: ' recovery = ABORT_RECOVERY\n needsRelease = false',
},
{
name: 'the BFG interlock waits for last tick\'s spooling flag',
file: 'src/game/bfg.ts',
from: ' return spoolTimer > 0 || canBegin(hold, owner)',
to: ' return spoolTimer > 0',
},
{
name: 'a Shield eats a BFG blast',
file: 'src/game/ship.ts',
from: ' if (this.shieldTimer > 0 && !pierceShield) {',
to: ' if (this.shieldTimer > 0) {',
},
{
name: 'the BFG does not hurt the pilot who fired it',
file: 'src/game/bfg.ts',
from: 'export const SELF_DAMAGE = 0.6',
to: 'export const SELF_DAMAGE = 0',
},
{
name: 'charging the BFG does not silence the guns',
file: 'src/game/game.ts',
from: ' applyBfgInterlock(seat, seat.lastControls)\n seat.ship.step(seat.lastControls, STEP, ctx)',
to: ' seat.ship.step(seat.lastControls, STEP, ctx)',
},
]

function dirty() {
Expand Down Expand Up @@ -864,7 +897,7 @@ function runSuite() {
* If you added or removed checks on purpose, bump this in the same commit. If you did not,
* something stopped running.
*/
const EXPECTED_ASSERTIONS = 629
const EXPECTED_ASSERTIONS = 675
const PASS_SUMMARY = 'All checks passed.'
const SUMMARY = /check\(s\) failed\.$|All checks passed\.$/

Expand Down
Loading