This commit is contained in:
324
README.md
Normal file
324
README.md
Normal file
@@ -0,0 +1,324 @@
|
||||
# Syncer
|
||||
|
||||
Three 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, 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: Sync Arena, Flux Relay, and Syncer Royale
|
||||
```
|
||||
|
||||
## Play
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open `http://localhost:5173`. Use the selector to switch between three 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`.
|
||||
|
||||
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 is intentionally unrelated to a shooter: it has teams, a shared core, balanced server bots, exact private energy, per-viewer events, and no weapons, visibility cones, or 3D physics. Royale proves the same engine can drive a much larger world: the map is generated client-side from a public seed, while players, private loot, combat, and the storm remain server authoritative.
|
||||
|
||||
## 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. Both games are mounted by the same `hostNetworkedGame()` transport adapter, demonstrating that the server loop has no shooter knowledge.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
Reference in New Issue
Block a user