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
13 changes: 10 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,14 +302,21 @@ None is configured by default, because a relay is exactly the infrastructure thi
trying not to run.

**A joined player's stick is attached to their ship.** A client flies its own hull the moment
the stick moves — `Game.predict` steps that one seat locally on the same flight model, guns into
nothing — and the host's snapshot carries, per seat, the client intent tick it last flew
the stick moves — `Game.predict` steps that one seat locally on the same flight model
and the host's snapshot carries, per seat, the client intent tick it last flew
(`ackTick`). On every snapshot the client resets to the host's truth and `Game.reconcile`s by
replaying its unacknowledged intents on top, keeping the previous pose where the hull was last
drawn so a correction slides over one frame rather than snapping. Flight is deterministic, so on a
clean wire there is nothing to correct: `simcheck` asserts the host's truth lands within 0.1 units
of what the client predicted for every acknowledged intent, and that a client with prediction off
trails by the wire's latency. Bolts and hits are never predicted; they arrive with the truth.
trails by the wire's latency. Fresh local shots produce cosmetic tracers at the predicted
muzzle and one local laser sound per volley. Reconciliation is silent; snapshots restore the
weapon cooldown along with the hull. A volley counter prevents corrections from presenting
the same shot twice. Delayed authoritative bolts from that seat remain in the snapshot but
are hidden while prediction is active. Other bolts are drawn from snapshots as before.
Cosmetic tracers can stop against visible geometry but cannot damage anything: hits, damage,
and scoring remain entirely authoritative. Protocol and snapshot version 3 carry the cooldown;
both browsers must load the same version.

**The picture moves to the frame's clock, not the wire's.** A wire delivers to its own rhythm —
two snapshots in one tick, none the next — and a client that applied each as it arrived drew to
Expand Down
60 changes: 57 additions & 3 deletions scripts/mutate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,62 @@ const MUTATIONS = [
{
name: 'predict records the intent but never flies it',
file: 'src/game/game.ts',
from: ' recordControls(s, controls)\n applyBfgInterlock(s, s.lastControls)\n s.ship.step(s.lastControls, STEP, dryCtx)',
to: ' recordControls(s, controls)',
from: ' s.ship.step(s.lastControls, STEP, watching ? predictionCtx : dryCtx)',
to: '',
},
{
name: 'correction replays audible effects',
file: 'src/game/game.ts',
from: 'audio: { ...audio, laser() {}, dash() {}, overheat() {} },',
to: 'audio,',
},
{
name: 'correction leaves the predicted weapon clock running',
file: 'src/game/game.ts',
from: ' ship.fireTimer = s.fireTimer',
to: '',
},
{
name: 'fresh prediction still shoots into nothing',
file: 'src/game/game.ts',
from: 'bolts: { ...bolts, fire: weapons.fire },',
to: 'bolts: { ...bolts, fire() {} },',
},
{
name: 'the delayed local bolts are drawn twice',
file: 'src/game/bolts.ts',
from: 'if (omitFaction !== undefined && pool[i].faction === omitFaction)',
to: 'if (false)',
},
{
name: 'the client draws cosmetic and authoritative local shots together',
file: 'src/game/game.ts',
from: 'bolts.render(alpha, presentingWeapons ? watcher.faction : undefined)',
to: 'bolts.render(alpha)',
},
{
name: 'a correction presents the same volley again',
file: 'src/game/weapon-presentation.ts',
from: 'fresh = volley > presented',
to: 'fresh = true',
},
{
name: 'a joined player hears the remote laser pitch',
file: 'src/game/weapon-presentation.ts',
from: 'audio.laser(true)',
to: 'audio.laser(false)',
},
{
name: 'a cosmetic collision calls authoritative damage',
file: 'src/game/weapon-presentation.ts',
from: 'bolts.update(dt, visible, hazards)',
to: 'bolts.update(dt, targets, hazards)',
},
{
name: 'a fresh match keeps the previous volley watermark',
file: 'src/game/weapon-presentation.ts',
from: 'clear() { bolts.clear(); presented = 0; volley = 0; fresh = false },',
to: 'clear() { bolts.clear() },',
},
{
name: 'the host never acknowledges an intent',
Expand Down Expand Up @@ -941,7 +995,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 = 699
const EXPECTED_ASSERTIONS = 718
const PASS_SUMMARY = 'All checks passed.'
const SUMMARY = /check\(s\) failed\.$|All checks passed\.$/

Expand Down
138 changes: 133 additions & 5 deletions scripts/simcheck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ import {
import { decodeIntent, encodeIntent, INTENT_FRAME_BYTES, INTENT_VERSION } from '../src/net/wire'
import { createLoopback } from '../src/net/channel'
import { createMatchLobby } from '../src/net/lobby'
import { decodeHello, decodeLobby, encodeHello, encodeLobby, type LobbyState } from '../src/net/session'
import { createWeaponPresentation } from '../src/game/weapon-presentation'
import { decodeHello, decodeLobby, encodeHello, encodeLobby, PROTOCOL_VERSION, type LobbyState } from '../src/net/session'
import { modeFromLocation } from '../src/net/browser'
import { createLinkMonitor, type LinkReport } from '../src/net/link'
import { createClient, createHost, decodeResult, decodeWelcome, encodeResult, encodeWelcome, FRAME, SNAPSHOT_DEPTH, SNAPSHOT_QUEUE } from '../src/net/session'
Expand Down Expand Up @@ -3158,6 +3159,7 @@ function shipStateFixture(seed: number): ShipState {
overdriveTimer: f(21),
shieldTimer: f(22),
solarExposure: f(23),
fireTimer: f(24),
shotsFired: seed * 97,
}
}
Expand Down Expand Up @@ -4204,8 +4206,8 @@ function testTheStickIsAttachedToTheShip(): void {
check('a seat that does not exist is not predicted, and nothing throws', !threw)
check('a seat that does is', p1.z !== p0.z || p1.y !== p0.y)

// Guns into nothing: a predicted shot must leave the bolt pool empty, or the
// host's next restore would flicker it out and the truth re-fire it later.
// Prediction must leave the authoritative bolt pool empty. Cosmetic tracers
// live separately and never appear in a captured world.
// Past the warp-in first: a hull cannot fire for its first 0.85 s, and a
// burst shorter than that would prove nothing either way.
for (let i = 0; i < 120; i++) solo.predict(0, controls({ fire: true, throttle: 1 }))
Expand All @@ -4214,7 +4216,7 @@ function testTheStickIsAttachedToTheShip(): void {
for (let i = 0; i < 30; i++) solo.step([controls({ fire: true, throttle: 1 })])
const steppedBolts = solo.capture().bolts.length
check('the predicted trigger was actually pulled', shotsPredicted > 0, `${shotsPredicted} shots`)
check('a predicted shot fires no bolt', predictedBolts === 0, `${predictedBolts} bolts in the pool`)
check('a predicted shot fires no authoritative bolt', predictedBolts === 0, `${predictedBolts} bolts in the pool`)
check('while a stepped one does', steppedBolts > 0, `${steppedBolts}`)
solo.dispose()
}
Expand Down Expand Up @@ -7776,7 +7778,7 @@ function testTheWingLaunchesItsReservations(): void {
bad.pump()
check('an old protocol is refused without reserving a seat', reason === 1 && !bad.a.open && badWing.state().seats[1].pilot === 'ai')
let malformed = 0
for (const bytes of [new Uint8Array([FRAME.HELLO, 2, 99]), new Uint8Array([FRAME.HELLO, 2]), new Uint8Array([...encodeHello(), 0])]) {
for (const bytes of [new Uint8Array([FRAME.HELLO, PROTOCOL_VERSION, 99]), new Uint8Array([FRAME.HELLO, PROTOCOL_VERSION]), new Uint8Array([...encodeHello(), 0])]) {
try { decodeHello(bytes) } catch { malformed++ }
}
check('unknown, short, and trailing hull claims fail decoding', malformed === 3)
Expand All @@ -7800,6 +7802,130 @@ function testTheWingLaunchesItsReservations(): void {
JSON.stringify(modeFromLocation('?host=' + v)) === '{"kind":"host","guest":"wasp","seats":2}'))
}

/** A tracer is a drawing; even a collision cannot call into authoritative damage. */
function testPredictedWeaponsAreOnlyPresentation(): void {
section('Predicted weapons present a volley once without deciding damage')
const audio = silentAudio(), weapons = createWeaponPresentation(audio)
const matrix = new THREE.Matrix4()
const visible = () => {
weapons.render(1)
let count = 0
for (let i = 0; i < weapons.mesh.count; i++) {
weapons.mesh.getMatrixAt(i, matrix)
if (matrix.determinant() !== 0) count++
}
return count
}
const shot = { origin: new THREE.Vector3(), direction: new THREE.Vector3(0, 0, -1),
speed: 100, damage: 99, faction: humanFaction(1), color: new THREE.Color('cyan') }
weapons.begin(0)
weapons.fire(shot)
weapons.fire({ ...shot, origin: new THREE.Vector3(1, 0, 0) })
weapons.laser()
check('two muzzles make two tracers but one sound', visible() === 2 && audio.laserCount === 1)
weapons.begin(0); weapons.fire(shot); weapons.laser()
check('revisiting a predicted volley does not repeat its effects', visible() === 2 && audio.laserCount === 1)
weapons.confirm(5); weapons.begin(4); weapons.fire(shot); weapons.laser()
check('taking over an acknowledged volley does not replay it', visible() === 2 && audio.laserCount === 1)
let damageCalls = 0
weapons.advance(0.1, [{ position: new THREE.Vector3(0, 0, -5), radius: 3,
alive: true, targetable: true, faction: FACTION_AI, takeDamage() { damageCalls++ } }], [])
check('a visible collision consumes tracers without calling real damage', visible() === 0 && damageCalls === 0)
weapons.clear(); weapons.begin(0); weapons.fire(shot); weapons.laser()
check('a new match can present its first volley again', visible() === 1 && audio.laserCount === 2)
weapons.advance(3, [], [])
check('missed cosmetic tracers expire', visible() === 0)
weapons.dispose()

const bolts = createBolts()
bolts.fire(shot); bolts.fire({ ...shot, faction: FACTION_AI })
const capture = () => {
const live: string[] = []
bolts.each((slot, bolt) => live.push(JSON.stringify({ slot, ...bolt })))
return live.join('|')
}
const before = capture()
bolts.render(1, humanFaction(1))
bolts.mesh.getMatrixAt(0, matrix)
const localHidden = matrix.determinant() === 0
bolts.mesh.getMatrixAt(1, matrix)
check('prediction hides only the delayed local bolts', localHidden && matrix.determinant() !== 0)
check('hiding a bolt cannot alter authoritative state', capture() === before)
bolts.render(1); bolts.mesh.getMatrixAt(0, matrix)
check('a host still draws its authoritative local bolts', matrix.determinant() !== 0)
bolts.dispose()
}

function testClientWeaponsUnderLatency(): void {
section('Joined Wasp weapons stay at the muzzle and do not sound on replay')
for (const loss of [0, 0.25]) {
const scene = new THREE.Scene(), audio = silentAudio(), pitches: boolean[] = []
audio.laser = local => { audio.laserCount++; pitches.push(local) }
const hostGame = newMatch(), game = newMatch({ scene, audio })
const host = createHost({ game: hostGame, setup: { ships: ['hornet', 'wasp'], seed: 876, respawn: true }, backfill: false })
host.start()
const wire = createLoopback({ latency: 6, loss, seed: 123 })
const client = createClient({ game, channel: wire.b })
host.accept(wire.a)
for (let i = 0; i < 7; i++) wire.pump()
const mesh = scene.getObjectByName('predicted-bolts') as THREE.InstancedMesh
const authority = scene.children.find(o => o instanceof THREE.InstancedMesh && o !== mesh) as THREE.InstancedMesh
const hull = new Ship(SHIPS.wasp, humanFaction(1))
const seen = new Set<number>(), matrix = new THREE.Matrix4(), origin = new THREE.Vector3()
let births = 0, muzzleError = 0, replaySounds = 0, delayedLocal = 0, duplicates = 0
for (let i = 0; i < 180; i++) {
client.tick(controls({ fire: true, throttle: 1 }))
const predicted = game.capture().seats[1]?.ship
game.render(1, 0)
for (const bolt of game.capture().bolts) {
if (bolt.faction !== humanFaction(1)) continue
delayedLocal++
authority.getMatrixAt(bolt.slot, matrix)
if (matrix.determinant() !== 0) duplicates++
}
if (predicted) {
const q = new THREE.Quaternion(predicted.quaternion.x, predicted.quaternion.y, predicted.quaternion.z, predicted.quaternion.w)
const p = new THREE.Vector3(predicted.position.x, predicted.position.y, predicted.position.z)
for (let slot = 0; slot < mesh.count; slot++) {
mesh.getMatrixAt(slot, matrix)
if (matrix.determinant() === 0 || seen.has(slot)) continue
seen.add(slot); births++
origin.setFromMatrixPosition(matrix)
muzzleError = Math.max(muzzleError, Math.min(...hull.visual.muzzles.map(m => origin.distanceTo(m.clone().applyQuaternion(q).add(p)))))
}
}
const sounds = audio.laserCount
host.tick(controls({ throttle: .6 })); wire.pump()
replaySounds += audio.laserCount - sounds
}
const hostShots = hostGame.snapshot(1)?.shotsFired ?? 0
console.log(` weapons loss=${loss}: host=${hostShots}, sounds=${audio.laserCount}, tracers=${births}, muzzle error=${muzzleError}`)
check(`latency/loss ${loss}: firing is audible at the Wasp cadence`, hostShots > 15 && audio.laserCount >= hostShots - 3 && audio.laserCount <= hostShots + 4)
check(`latency/loss ${loss}: every sound is a fresh local volley`, pitches.length > 15 && pitches.every(Boolean) && replaySounds === 0)
check(`latency/loss ${loss}: every tracer starts at the predicted muzzle`, births > 15 && births === audio.laserCount * SHIPS.wasp.barrels && muzzleError < .001)
check(`latency/loss ${loss}: delayed authoritative shots cannot double the picture`, delayedLocal > 15 && duplicates === 0)
host.close(); game.dispose(); hostGame.dispose(); hull.dispose()
}

const audio = silentAudio(), game = newMatch({ audio })
game.start({ ships: ['hornet', 'wasp'], local: 1, seed: 876 })
const state = game.capture()
state.seats[1].ship.warpTimer = 0
state.seats[1].ship.fireTimer = .1
const replay = Array.from({ length: 18 }, () => controls({ fire: true, dash: true }))
let dashes = 0, overheats = 0
// Supply counters when constructing a second game because contexts bind the methods.
const silent = newMatch({ audio: { ...audio, dash() { dashes++ }, overheat() { overheats++ } } })
silent.start({ ships: ['hornet', 'wasp'], local: 1, seed: 876 })
silent.apply(state); silent.reconcile(1, replay)
const first = silent.capture().seats[1].ship
silent.apply(state); silent.reconcile(1, replay)
const second = silent.capture().seats[1].ship
check('correction restores the weapon clock before replay', first.shotsFired > 0 && first.shotsFired === second.shotsFired && first.fireTimer === second.fireTimer)
check('replay emits no laser, dash, or overheat audio', audio.laserCount === 0 && dashes === 0 && overheats === 0)
game.dispose(); silent.dispose()
}

console.log('NEON ORBIT — headless simulation checks')
testPlayerBoltsKillEnemies()
testHullBarFadeCurve()
Expand Down Expand Up @@ -7865,6 +7991,8 @@ testARunMatchesItsRecordedBaseline()
testOneFrameDepictsOneInstant()
testALinkThatDropsIsNoticed()
testTheWingLaunchesItsReservations()
testPredictedWeaponsAreOnlyPresentation()
testClientWeaponsUnderLatency()

console.log(failures === 0 ? '\nAll checks passed.' : `\n${failures} check(s) failed.`)
process.exit(failures === 0 ? 0 : 1)
9 changes: 6 additions & 3 deletions src/game/bolts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export interface Bolts {
* smoothly — the one thing on screen moving faster than anything else, and
* the one thing not smoothed.
*/
render(alpha: number): void
render(alpha: number, omitFaction?: Faction): void
clear(): void
dispose(): void
/**
Expand Down Expand Up @@ -394,8 +394,11 @@ export function createBolts(): Bolts {
return hits
},

render(alpha) {
for (let i = 0; i < MAX_BOLTS; i++) writeInstance(i, pool[i], alpha)
render(alpha, omitFaction) {
for (let i = 0; i < MAX_BOLTS; i++) {
if (omitFaction !== undefined && pool[i].faction === omitFaction) mesh.setMatrixAt(i, hidden)
else writeInstance(i, pool[i], alpha)
}
mesh.instanceMatrix.needsUpdate = true
mesh.instanceColor!.needsUpdate = true
},
Expand Down
Loading