Syncer Deploy 8a274ecd30
All checks were successful
build / image (push) Successful in 39s
fix dead air strafe direction
2026-08-31 13:32:00 -04:00
2026-08-31 13:32:00 -04:00
2026-08-28 10:54:35 -03:00
2026-08-31 13:17:58 -04:00

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

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

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

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:

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:

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:

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

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%