Files
syncer/README.md
Syncer Deploy 6361ecbdd7
All checks were successful
build / image (push) Successful in 41s
add secure acoustic DEAD AIR game
2026-08-31 13:17:58 -04:00

386 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Syncer
Five playable browser games built on one generic TypeScript multiplayer higher-order configuration. The engine runs authoritative servers, predicted clients, clocks, reconciliation, validation, privacy-aware replication, pluggable physics, and bandwidth-budgeted interest management around game-supplied rules.
## Structure
```text
apps/
server/ µWebSockets.js authoritative transport adapter
web/ React + Vite predicted client
packages/
engine/ Generic clocks, authority, prediction, validation, and protocol
shared/ Game definitions: Arena, Flux Relay, Royale, Bad Movers, and DEAD AIR
```
## Play
```bash
npm install
npm run dev
```
Open `http://localhost:5173`. Use the selector to switch between five games:
- **Sync Arena** — click **Deploy**, then use `WASD`, `Shift`, mouse look, left click, `R`, and weapon keys `1``3`.
- **Flux Relay** — hold `Space` or the on-screen thruster to pull the shared core toward your team's gate while managing private energy.
- **Syncer Royale** — click **Drop In**, then use `WASD`, `Shift`, mouse look, left click, and `R`. Survive 31 server bots, loot automatically, and stay inside the shrinking circle on a streamed 2 km island. Open it directly at `http://localhost:5173/#royale`.
- **Bad Movers** — use `WASD`, `Shift`, hold `Space` to grab, `E` to throw, and `F` to close your truck. Real Box3D rigid bodies run in WebAssembly on both the authority and predicted client. Open it directly at `http://localhost:5173/#movers`.
- **DEAD AIR** — use `WASD`, mouse look, left click, `E`, `F`, `Q`, and `R` to steal a screaming cursed microwave from five authority wardens. Hidden enemies are omitted from snapshots; only anonymous 8-way, 3-distance-band sound cues cross the wire. Open it directly at `http://localhost:5173/#dead-air`.
The arena includes pickups, armor, three weapons, headshots, reloads, respawns, a scoreboard, filtered spatial sound, and killcams; its server bots are disabled for player-only office matches. Flux Relay proves the API is not shooter-specific. Royale proves it can drive a much larger generated world. Bad Movers proves a developer can attach a third-party deterministic physics backend while keeping plain serializable state, authority, prediction, reconciliation, and transport generic. DEAD AIR proves secure multimodal perception: exact visible entities and deliberately lossy audio-only knowledge share one generic authority boundary without leaking hidden coordinates or identities.
## Define a game
`defineMultiplayerGame()` is the main developer surface. A contract names the five types that cross the trust boundary once; every rule callback is then inferred. Private authority and client-visible state remain different types, and internal events must be converted into per-player perceptions before transport:
```ts
interface MyGame {
authority: AuthorityState;
client: ClientState;
input: Input;
authorityEvent: AuthorityEvent;
perceptionEvent: PerceptionEvent;
}
export const game = defineMultiplayerGame<MyGame>({
clock: {
ticksPerSecond: 60,
snapshotsPerSecond: 20,
},
authority: {
createInitialState: () => createPrivateWorld(),
cloneState: clonePrivateWorld,
applyInput: applyAuthoritativeInput,
step: (state, context) => {
simulateAuthoritativePhysics(state, context.deltaSeconds);
context.emit(createInternalWorldEvent(state));
},
},
prediction: {
createInitialState: () => createVisibleWorld(),
cloneState: cloneVisibleWorld,
applyInput: applyPredictedInput,
step: simulatePredictedPhysics,
mergeSnapshot: preserveLocalPresentationState,
applyEvent: applySensoryCue,
},
visibility: {
createSnapshot: (authority, viewer) =>
projectOnlyWhatPlayerMaySee(authority, viewer.playerId),
perceive: (authority, event, viewer) =>
eventPlayerMaySense(authority, event, viewer.playerId),
validateClientState: validateReportedVisibleState,
// Optional: players with the same key share one encoded snapshot.
groupKey: (authority, viewer) => visibilityGroup(authority, viewer.playerId),
},
input: {
validate: (input, context) => isAllowed(input, context),
},
encoding: {
input: inputCodec,
clientState: visibleStateCodec,
perception: perceptionEventCodec,
},
});
const server = game.createServer();
const client = game.createClient();
const protocol = game.protocol;
```
`createJsonCodec()` is included for prototypes and low-volume messages; games can replace it with custom packed binary codecs without changing any rules. `defineNetworkedGame()` remains available as the lower-level surface used internally and by advanced integrations. Every existing HOF—lag compensation, time travel, and replay transport—accepts the result of either definition API.
Flux Relay in `packages/shared/src/flux-game.ts` is the compact reference implementation. Every game is mounted by the same `hostNetworkedGame()` transport adapter, demonstrating that the server loop has no game-specific knowledge.
## Attach a physics backend
`definePhysicsBackend<State>()` defines the lifecycle for a native, WASM, or JavaScript physics plug-in. The backend owns runtime handles while the game state stays serializable for snapshots and checkpoints:
```ts
const physics = definePhysicsBackend<GameState>()({
metadata: { name: "My Physics", runtime: "WebAssembly" },
initialize: createWorldFromState,
step: stepWorldAndWriteBackState,
reconcile: reconcileWorldToSnapshot,
reset: resetWorldFromState,
});
```
Bad Movers uses this API in `packages/shared/src/movers-box3d.ts` with Erin Catto's Box3D C17 engine compiled to WebAssembly SIMD. Both authority and prediction use a fixed 30 Hz step with four solver substeps; only the server decides damage, scoring, and winning.
## Stream stateful input safely
`withInputStream()` turns edge-only input delivery into an acknowledged
latest-state stream. Changes receive a new sequence immediately; unchanged
heartbeats resend the exact same packet, so they refresh the authority timeout
without executing an action twice. If the stream goes silent, the server
applies one developer-defined neutral input instead of allowing stuck movement:
```ts
const streamedGame = withInputStream(game, {
heartbeatRateHz: 20,
timeoutMs: 400,
inputsEqual: (left, right) =>
left.forward === right.forward &&
left.fire === right.fire &&
left.reload === right.reload,
neutralize: (lastInput) => ({
...lastInput,
forward: 0,
fire: false,
reload: false,
}),
// A heartbeat after a timeout may restore persistent state, but must strip
// edge actions that are not allowed to execute again.
resume: (lastClientInput) => ({
...lastClientInput,
reload: false,
}),
});
const inputStream = createInputStateStream(streamedGame);
inputStream.update(currentInput);
const emission = inputStream.consume(performance.now());
```
`emission.kind === "state"` receives a new `engine.createInput()` packet.
`"heartbeat"` resends the previously encoded packet verbatim. This preserves
exactly-once sequence semantics while the generic authority owns timeout and
resume behavior. Stream state is part of deterministic checkpoints, so time
travel and branching reproduce the same dead-man-switch decisions. Apply this
HOF before authority wrappers such as lag compensation and time travel.
## Scale a game with spatial replication
`withSpatialReplication()` wraps any networked game with a deterministic grid index, per-viewer interest selection, priorities, and an explicit byte budget. The ordinary visibility callback still runs first, so this layer can reduce an already-safe projection but can never reveal private authority state:
```ts
const largeWorldGame = withSpatialReplication(game, {
cellSize: 100,
bandwidthBudgetBytesPerSecond: 30_000,
reservedBytesPerSnapshot: 300,
viewerPosition: (authority, playerId) =>
authority.players.get(playerId) ?? null,
sources: [
{
category: "players",
maximumDistance: 360,
entities: (authority) => authority.players.values(),
estimatedBytes: 76,
priority: (_player, distance) => 1_000 - distance,
required: (player, playerId) => player.id === playerId,
},
{
category: "loot",
maximumDistance: 165,
entities: (authority) => authority.loot.values(),
estimatedBytes: 44,
},
],
projectSnapshot: (snapshot, selection) => ({
...snapshot,
players: snapshot.players.filter((player) =>
selection.has("players", player.id),
),
loot: snapshot.loot.filter((item) => selection.has("loot", item.id)),
}),
});
```
Syncer Royale in `packages/shared/src/royale-game.ts` is the large-world reference. The developer defines the categories, ranges, size estimates, priorities, required entities, and final projection; the HOF owns indexing and budget selection.
## Add action-specific lag compensation
`withLagCompensation()` owns one bounded private history ring for the game, but
each classified action chooses its own policy. The developer captures only the
authority data needed for validation—usually hitboxes rather than the entire
world:
```ts
const compensatedGame = withLagCompensation(game, {
historySeconds: 2,
captureState: (authority) => capturePlayerHitboxes(authority),
cloneHistoricalState: cloneHitboxes,
classifyAction(input) {
if (input.fire) return { type: "hitscan", aim: input.aim };
if (input.throw) return { type: "projectile", throw: input.throw };
return null;
},
cloneAction: (action) => structuredClone(action),
actions: {
hitscan: {
mode: "rewind",
maximumRewindMs: 250,
maximumFutureMs: 25,
outOfWindow: "clamp",
resolve: ({ currentState, historicalState, action, emit }) =>
resolveHistoricalShot(
currentState,
historicalState,
action,
emit,
),
},
projectile: {
mode: "fast-forward",
maximumRewindMs: 150,
resolve: ({ historicalState, catchUpTicks, action }) =>
spawnAndAdvanceProjectile(historicalState, action, catchUpTicks),
},
},
});
```
Input packets carry separate `targetTick` and `observedTick` values. The former
schedules smooth authoritative input; the latter identifies the presentation
that produced an action. Because `observedTick` is client supplied, the server
clamps or rejects it using the selected action policy. Resolution receives a
cloned historical view and mutable current state: the live match itself is
never rewound.
## Add deterministic time travel
`withTimeTravel()` is a higher-order layer over any networked game. Existing
server calls keep working, but accepted inputs, joins, leaves, state hashes, and
periodic private authority checkpoints are recorded automatically:
```ts
import {
defineNetworkedGame,
deterministicHash,
withLagCompensation,
withReplayTransport,
withTimeTravel,
} from "@syncer/engine";
const game = withTimeTravel(
defineNetworkedGame({
// The normal generic server, client, replication, validation, and codecs.
}),
{
createSeed: () => crypto.getRandomValues(new Uint32Array(1))[0]!,
initializeState: (authority, seed) => {
authority.random = createSeededRandom(seed);
},
hashState: deterministicHash,
checkpointIntervalTicks: 120,
verificationIntervalTicks: 1,
},
);
const server = game.createServer({ replaySeed: 42 });
// Use addPlayer(), submitInput(), and step() normally.
const recording = server.exportRecording();
const replay = game.createReplay(recording);
replay.seek(8_420);
// This passes through the game's normal privacy projection.
const playerSnapshot = replay.viewAs(playerId);
const playerPerceptions = replay.perceptionsAs(playerId);
// Fork the authoritative timeline and continue with different inputs.
const branch = replay.branch(8_420);
branch.submitInput(playerId, alternateInputPacket);
branch.step();
```
Replay recordings contain private authoritative checkpoints and must stay on
trusted infrastructure. A replay viewer should consume only `viewAs()` and
`perceptionsAs()`. Deterministic games must keep all randomness inside seeded,
cloned authority state; the replay verifier throws `ReplayDivergenceError` at
the first mismatching recorded tick.
For long-running live matches, pass `recordingHistoryTicks` to keep a rolling
private log. The exported recording exposes its first seekable `startTick` and
remains deterministic from that checkpoint:
```ts
const server = game.createServer({
replaySeed: 42,
recordingHistoryTicks: 60 * 30,
});
```
## Add secure replay transport
`withReplayTransport()` is the client-delivery layer. It retains a bounded ring
of snapshots and events *after* the normal per-viewer projection has run. A
ticket can therefore expose an attacker camera without exposing server truth or
enemies that attacker could not see or identify:
```ts
const liveGame = withReplayTransport(game, {
historySeconds: 30,
captureRateHz: 20,
listPerspectives: (authority) => authority.players.keys(),
createTickets(event, { tick, connectedPlayerIds }) {
if (
event.type !== "elimination" ||
!connectedPlayerIds.includes(event.victimId)
) return null;
return {
requesterId: event.victimId,
perspectiveId: event.killerId,
fromTick: Math.max(0, tick - 60 * 4),
toTick: tick,
playbackRate: 0.72,
};
},
authorizeReplay: ({ requesterId, perspectiveId, authorityState }) =>
authorityState.players.has(requesterId) &&
authorityState.players.has(perspectiveId),
});
```
The transport emits server-issued `replay-start`, `replay-frame`, and
`replay-end` binary messages. There is intentionally no client message that can
request an arbitrary perspective. Private `ReplayRecording` objects remain on
the server; only codec-cloned `ClientState` and `PerceptionEvent` projections
enter replay frames.
The shooter in `packages/shared/src/shooter-game.ts` is the large example. The server owns movement, collision, bot decisions, ammo, firing cadence, ray hits, damage, armor, eliminations, respawns, pickups, and scores. Clients predict only their permitted state.
Replication is perception-aware. A client snapshot includes itself, public pickups and scores, plus only opponents currently inside the server-approved field of view with line of sight. Hidden footsteps and gunfire become anonymous bearing/intensity/distance cues with no source ID or world coordinates.
## Engine responsibilities
- Fixed-step server and client simulation clocks with bounded catch-up.
- Deterministic input ordering, sequence acknowledgement, rejection, and replay.
- Private authoritative snapshot history and developer-defined client-state validation.
- Client prediction and reconciliation from server snapshots.
- Ping/pong RTT, jitter, clock-offset estimation, and adaptive input lead.
- Latest-state input heartbeats, duplicate-safe sequence reuse, and deterministic authority timeouts.
- Per-viewer state projection and internal-event-to-perception filtering.
- Generic binary message framing around developer-provided input, visible-state, and perception codecs.
- Optional visibility grouping for sharing encoded snapshots without weakening privacy.
- Bounded WebSocket backpressure and per-player message batching.
- Higher-order deterministic recording, checkpoint seeking, state-hash verification, privacy-filtered playback, and alternate-timeline branching.
- Bounded, policy-authorized replay transport with projected historical frames and instant attacker-perspective killcams.
- Per-action current-state, rewind, and fast-forward policies with server-clamped observed ticks.
The trust rule is simple: clients send inputs and optional state reports, never authoritative facts. The server validates inputs, advances private state, decides what each player can observe, and sends only that projection. A determined cheater can inspect anything delivered to their machine, so secret information must never enter `ClientState` or `PerceptionEvent` in the first place.
## Development
```bash
npm install
npm run dev
```
The SPA runs at `http://localhost:5173`; in development, gameplay connects directly to the authoritative WebSocket server at `http://localhost:3001` to keep the Vite proxy out of latency measurements.
### Live multiplayer profiler
Open `http://localhost:3001/api/profile` (or replace `localhost` with the host's LAN address) for a one-second rolling JSON profile. It reports Node CPU, memory, event-loop utilization/delay, and a separate server profile for each mounted game. Per-game and per-connection metrics include tick rate, simulation/projection/encoding/send cost, input and snapshot rates, bytes per second, buffered bytes, backpressured sends, and dropped sends. Profiling stays on the authoritative server and adds no browser render-loop instrumentation.
## Commands
- `npm run dev` starts the engine, shared rules, server, and SPA in watch mode.
- `npm run build` builds every workspace in dependency order.
- `npm run typecheck` checks every workspace.
- `npm test` runs engine tests plus deterministic backend-only shooter matches, codec checks, and privacy assertions.
- `npm start` runs the built authoritative server.