386 lines
17 KiB
Markdown
386 lines
17 KiB
Markdown
# 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.
|