Syncer Deploy b29b7697a6
All checks were successful
build / image (push) Successful in 34s
fix container glibc for uWebSockets
2026-08-28 10:54:35 -03:00
2026-08-28 10:46:42 -03:00
2026-08-28 10:54:35 -03:00
2026-08-28 10:46:42 -03:00

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

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

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 13.
  • 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:

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:

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:

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:

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:

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:

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

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.
Description
Generic authoritative multiplayer engine and playable demos
Readme 5 MiB
Languages
TypeScript 82.8%
JavaScript 8.6%
CSS 8.3%
Dockerfile 0.2%