deploy Syncer to a2acloud Kubernetes
Some checks failed
build / image (push) Failing after 28s

This commit is contained in:
Syncer Deploy
2026-08-28 10:46:42 -03:00
commit e93c1b026f
72 changed files with 17680 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
**/node_modules
**/dist
.git
.DS_Store
npm-debug.log*
.env
.env.*

View File

@@ -0,0 +1,29 @@
name: build
on:
push:
branches: [main]
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Build production image in Kubernetes
run: |
set -euo pipefail
image=registry.a2acloud.io/syncer/syncer
docker build --platform linux/amd64 --pull \
-t "$image:$GITHUB_SHA" \
-t "$image:latest" \
.
- name: Push immutable and release tags
run: |
set -euo pipefail
image=registry.a2acloud.io/syncer/syncer
docker push "$image:$GITHUB_SHA"
docker push "$image:latest"

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
**/node_modules/
**/dist/
.env
.env.*
.DS_Store
npm-debug.log*

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM --platform=$BUILDPLATFORM node:22-bookworm-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/engine/package.json packages/engine/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci
COPY tsconfig.base.json ./
COPY apps ./apps
COPY packages ./packages
RUN npm run build
FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
PORT=3001 \
STATIC_ROOT=/app/apps/web/dist
COPY package.json package-lock.json ./
COPY apps/server/package.json apps/server/package.json
COPY apps/web/package.json apps/web/package.json
COPY packages/engine/package.json packages/engine/package.json
COPY packages/shared/package.json packages/shared/package.json
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/apps/server/dist ./apps/server/dist
COPY --from=build /app/apps/web/dist ./apps/web/dist
COPY --from=build /app/packages/engine/dist ./packages/engine/dist
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
USER node
EXPOSE 3001
CMD ["node", "apps/server/dist/index.js"]

324
README.md Normal file
View 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.

23
apps/server/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "@syncer/server",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"start": "node dist/index.js"
},
"dependencies": {
"@syncer/engine": "0.0.0",
"@syncer/shared": "0.0.0",
"uWebSockets.js": "https://github.com/uNetworking/uWebSockets.js/archive/refs/tags/v20.67.0.tar.gz"
},
"devDependencies": {
"@types/node": "^26.4.0",
"tsx": "^4.23.12",
"typescript": "^7.0.2"
}
}

View File

@@ -0,0 +1,192 @@
export interface ClientFrameProfile {
fps: number;
averageMs: number;
p95Ms: number;
maximumMs: number;
slowFrames: number;
longTasks: number;
longTaskMs: number;
}
export interface ClientNetworkProfile {
roundTripTimeMs: number;
jitterMs: number;
clockOffsetMs: number;
inputLeadTicks: number;
connection: string;
validation: string;
}
export interface ClientRendererProfile {
drawCalls: number;
triangles: number;
points: number;
lines: number;
geometries: number;
textures: number;
gpuVendor: string;
gpuRenderer: string;
}
export interface ClientPerformanceSample {
sessionId: string;
game: string;
playerId: number | null;
capturedAt: string;
frame: ClientFrameProfile;
network: ClientNetworkProfile;
renderer: ClientRendererProfile | null;
device: {
viewportWidth: number;
viewportHeight: number;
devicePixelRatio: number;
hardwareConcurrency: number;
deviceMemoryGb: number | null;
visibility: string;
};
heap: {
usedBytes: number;
totalBytes: number;
limitBytes: number;
} | null;
}
interface StoredSession {
remoteAddress: string;
lastSeenAt: number;
samples: ClientPerformanceSample[];
}
export interface ClientProfileSnapshot {
sessionId: string;
remoteAddress: string;
game: string;
playerId: number | null;
lastSeenSecondsAgo: number;
latest: ClientPerformanceSample;
recent: {
samples: number;
averageFps: number;
minimumFps: number;
averageFrameMs: number;
worstFrameMs: number;
p95FrameMs: number;
slowFrames: number;
longTasks: number;
longTaskMs: number;
averageRoundTripTimeMs: number;
maximumRoundTripTimeMs: number;
averageJitterMs: number;
};
}
const maximumSamples = 30;
const staleSessionMs = 30_000;
export class ClientProfileStore {
private readonly sessions = new Map<string, StoredSession>();
record(sample: unknown, remoteAddress: string, now = performance.now()): boolean {
if (!isClientSample(sample)) return false;
let session = this.sessions.get(sample.sessionId);
if (!session) {
session = { remoteAddress, lastSeenAt: now, samples: [] };
this.sessions.set(sample.sessionId, session);
}
session.remoteAddress = remoteAddress;
session.lastSeenAt = now;
session.samples.push(sample);
if (session.samples.length > maximumSamples) session.samples.shift();
return true;
}
snapshot(now = performance.now()): ClientProfileSnapshot[] {
for (const [sessionId, session] of this.sessions) {
if (now - session.lastSeenAt > staleSessionMs) this.sessions.delete(sessionId);
}
return [...this.sessions.entries()]
.map(([sessionId, session]) => summarize(sessionId, session, now))
.sort((left, right) => left.game.localeCompare(right.game));
}
}
function summarize(
sessionId: string,
session: StoredSession,
now: number,
): ClientProfileSnapshot {
const latest = session.samples[session.samples.length - 1]!;
const frames = session.samples.map((sample) => sample.frame);
const networks = session.samples.map((sample) => sample.network);
const sum = (values: number[]) => values.reduce((total, value) => total + value, 0);
const average = (values: number[]) => values.length === 0 ? 0 : sum(values) / values.length;
return {
sessionId,
remoteAddress: session.remoteAddress,
game: latest.game,
playerId: latest.playerId,
lastSeenSecondsAgo: round((now - session.lastSeenAt) / 1_000, 2),
latest,
recent: {
samples: session.samples.length,
averageFps: round(average(frames.map((frame) => frame.fps)), 1),
minimumFps: round(Math.min(...frames.map((frame) => frame.fps)), 1),
averageFrameMs: round(average(frames.map((frame) => frame.averageMs)), 2),
worstFrameMs: round(Math.max(...frames.map((frame) => frame.maximumMs)), 2),
p95FrameMs: round(average(frames.map((frame) => frame.p95Ms)), 2),
slowFrames: sum(frames.map((frame) => frame.slowFrames)),
longTasks: sum(frames.map((frame) => frame.longTasks)),
longTaskMs: round(sum(frames.map((frame) => frame.longTaskMs)), 2),
averageRoundTripTimeMs: round(
average(networks.map((network) => network.roundTripTimeMs)),
2,
),
maximumRoundTripTimeMs: round(
Math.max(...networks.map((network) => network.roundTripTimeMs)),
2,
),
averageJitterMs: round(average(networks.map((network) => network.jitterMs)), 2),
},
};
}
function isClientSample(value: unknown): value is ClientPerformanceSample {
if (!value || typeof value !== "object") return false;
const sample = value as Partial<ClientPerformanceSample>;
return (
typeof sample.sessionId === "string" &&
sample.sessionId.length > 0 &&
sample.sessionId.length <= 128 &&
typeof sample.game === "string" &&
sample.game.length <= 32 &&
(sample.playerId === null || Number.isInteger(sample.playerId)) &&
isFiniteRecord(sample.frame, [
"fps",
"averageMs",
"p95Ms",
"maximumMs",
"slowFrames",
"longTasks",
"longTaskMs",
]) &&
isFiniteRecord(sample.network, [
"roundTripTimeMs",
"jitterMs",
"clockOffsetMs",
"inputLeadTicks",
]) &&
typeof sample.device === "object" &&
sample.device !== null
);
}
function isFiniteRecord(value: unknown, keys: string[]): boolean {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return keys.every((key) => typeof record[key] === "number" && Number.isFinite(record[key]));
}
function round(value: number, digits: number): number {
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}

View File

@@ -0,0 +1,284 @@
export type InboundMessageKind = "input" | "ping" | "state-report";
export interface StepTimingSample {
simulationMs: number;
perceptionMs: number;
snapshotMs: number;
replayMs: number;
sendMs: number;
totalMs: number;
authorityEvents: number;
snapshotDue: boolean;
}
interface MetricWindow {
startedAt: number;
elapsedMs: number;
steps: number;
simulationMs: number;
perceptionMs: number;
snapshotMs: number;
replayMs: number;
sendMs: number;
totalStepMs: number;
maximumStepMs: number;
authorityEvents: number;
snapshots: number;
inputMessages: number;
pingMessages: number;
stateReports: number;
inboundBytes: number;
outboundFrames: number;
outboundBytes: number;
backpressuredSends: number;
droppedSends: number;
maximumBufferedBytes: number;
}
interface ClientMetrics {
playerId: number;
remoteAddress: string;
connectedAt: number;
current: MetricWindow;
previous: MetricWindow;
bufferedBytes: number;
}
export interface RateProfile {
sampleSeconds: number;
stepsPerSecond: number;
averageStepMs: number;
maximumStepMs: number;
simulationMsPerStep: number;
perceptionMsPerStep: number;
snapshotMsPerStep: number;
replayMsPerStep: number;
sendMsPerStep: number;
authorityEventsPerSecond: number;
snapshotsPerSecond: number;
inputsPerSecond: number;
pingsPerSecond: number;
stateReportsPerSecond: number;
inboundBytesPerSecond: number;
outboundFramesPerSecond: number;
outboundBytesPerSecond: number;
backpressuredSendsPerSecond: number;
droppedSendsPerSecond: number;
maximumBufferedBytes: number;
}
export interface ClientProfileSnapshot {
playerId: number;
remoteAddress: string;
connectedSeconds: number;
bufferedBytes: number;
recent: RateProfile;
}
export interface GameProfileSnapshot {
path: string;
configuredTickRateHz: number;
connectedPlayers: number;
recent: RateProfile;
clients: ClientProfileSnapshot[];
}
/** Low-overhead one-second rolling profiler for a hosted networked game. */
export class GameServerProfiler {
private current: MetricWindow;
private previous: MetricWindow;
private readonly clients = new Map<number, ClientMetrics>();
constructor(
readonly path: string,
readonly tickRateHz: number,
now = performance.now(),
) {
this.current = createWindow(now);
this.previous = createWindow(now);
}
openClient(playerId: number, remoteAddress: string, now = performance.now()): void {
this.roll(now);
this.clients.set(playerId, {
playerId,
remoteAddress,
connectedAt: now,
current: createWindow(now),
previous: createWindow(now),
bufferedBytes: 0,
});
}
closeClient(playerId: number): void {
this.clients.delete(playerId);
}
recordInbound(
playerId: number,
kind: InboundMessageKind,
bytes: number,
now = performance.now(),
): void {
this.roll(now);
recordInbound(this.current, kind, bytes);
const client = this.clients.get(playerId);
if (client) recordInbound(client.current, kind, bytes);
}
recordOutbound(
playerId: number,
bytes: number,
sendResult: number,
bufferedBytes: number,
now = performance.now(),
): void {
this.roll(now);
recordOutbound(this.current, bytes, sendResult, bufferedBytes);
const client = this.clients.get(playerId);
if (client) {
client.bufferedBytes = bufferedBytes;
recordOutbound(client.current, bytes, sendResult, bufferedBytes);
}
}
recordStep(sample: StepTimingSample, now = performance.now()): void {
this.roll(now);
const window = this.current;
window.steps += 1;
window.simulationMs += sample.simulationMs;
window.perceptionMs += sample.perceptionMs;
window.snapshotMs += sample.snapshotMs;
window.replayMs += sample.replayMs;
window.sendMs += sample.sendMs;
window.totalStepMs += sample.totalMs;
window.maximumStepMs = Math.max(window.maximumStepMs, sample.totalMs);
window.authorityEvents += sample.authorityEvents;
if (sample.snapshotDue) window.snapshots += 1;
}
snapshot(now = performance.now()): GameProfileSnapshot {
this.roll(now);
return {
path: this.path,
configuredTickRateHz: this.tickRateHz,
connectedPlayers: this.clients.size,
recent: rateProfile(selectWindow(this.current, this.previous, now)),
clients: [...this.clients.values()]
.sort((left, right) => left.playerId - right.playerId)
.map((client) => ({
playerId: client.playerId,
remoteAddress: client.remoteAddress,
connectedSeconds: round((now - client.connectedAt) / 1_000, 2),
bufferedBytes: client.bufferedBytes,
recent: rateProfile(selectWindow(client.current, client.previous, now)),
})),
};
}
private roll(now: number): void {
if (now - this.current.startedAt >= 1_000) {
this.current.elapsedMs = Math.max(1, now - this.current.startedAt);
this.previous = this.current;
this.current = createWindow(now);
}
for (const client of this.clients.values()) {
if (now - client.current.startedAt < 1_000) continue;
client.current.elapsedMs = Math.max(1, now - client.current.startedAt);
client.previous = client.current;
client.current = createWindow(now);
}
}
}
function createWindow(now: number): MetricWindow {
return {
startedAt: now,
elapsedMs: 0,
steps: 0,
simulationMs: 0,
perceptionMs: 0,
snapshotMs: 0,
replayMs: 0,
sendMs: 0,
totalStepMs: 0,
maximumStepMs: 0,
authorityEvents: 0,
snapshots: 0,
inputMessages: 0,
pingMessages: 0,
stateReports: 0,
inboundBytes: 0,
outboundFrames: 0,
outboundBytes: 0,
backpressuredSends: 0,
droppedSends: 0,
maximumBufferedBytes: 0,
};
}
function recordInbound(window: MetricWindow, kind: InboundMessageKind, bytes: number): void {
window.inboundBytes += bytes;
if (kind === "input") window.inputMessages += 1;
else if (kind === "ping") window.pingMessages += 1;
else window.stateReports += 1;
}
function recordOutbound(
window: MetricWindow,
bytes: number,
sendResult: number,
bufferedBytes: number,
): void {
window.outboundFrames += 1;
window.outboundBytes += bytes;
if (sendResult === 0) window.backpressuredSends += 1;
if (sendResult === 2) window.droppedSends += 1;
window.maximumBufferedBytes = Math.max(window.maximumBufferedBytes, bufferedBytes);
}
function selectWindow(
current: MetricWindow,
previous: MetricWindow,
now: number,
): MetricWindow {
const currentElapsed = now - current.startedAt;
if (currentElapsed >= 350 || previous.elapsedMs === 0) {
return { ...current, elapsedMs: Math.max(1, currentElapsed) };
}
return previous;
}
function rateProfile(window: MetricWindow): RateProfile {
const seconds = Math.max(0.001, window.elapsedMs / 1_000);
const perSecond = (value: number) => round(value / seconds, 2);
const perStep = (value: number) =>
round(window.steps === 0 ? 0 : value / window.steps, 4);
return {
sampleSeconds: round(seconds, 3),
stepsPerSecond: perSecond(window.steps),
averageStepMs: perStep(window.totalStepMs),
maximumStepMs: round(window.maximumStepMs, 4),
simulationMsPerStep: perStep(window.simulationMs),
perceptionMsPerStep: perStep(window.perceptionMs),
snapshotMsPerStep: perStep(window.snapshotMs),
replayMsPerStep: perStep(window.replayMs),
sendMsPerStep: perStep(window.sendMs),
authorityEventsPerSecond: perSecond(window.authorityEvents),
snapshotsPerSecond: perSecond(window.snapshots),
inputsPerSecond: perSecond(window.inputMessages),
pingsPerSecond: perSecond(window.pingMessages),
stateReportsPerSecond: perSecond(window.stateReports),
inboundBytesPerSecond: perSecond(window.inboundBytes),
outboundFramesPerSecond: perSecond(window.outboundFrames),
outboundBytesPerSecond: perSecond(window.outboundBytes),
backpressuredSendsPerSecond: perSecond(window.backpressuredSends),
droppedSendsPerSecond: perSecond(window.droppedSends),
maximumBufferedBytes: window.maximumBufferedBytes,
};
}
function round(value: number, digits: number): number {
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}

View File

@@ -0,0 +1,295 @@
import {
FixedStepClock,
type BinaryProtocol,
type ClientStateReport,
type InputDecision,
type InputPacket,
type NetworkedServerStepResult,
type PlayerId,
type ReplayTicket,
type SnapshotBatch,
type StateSnapshot,
type ValidationResult,
} from "@syncer/engine";
import {
DISABLED,
type TemplatedApp,
type WebSocket,
} from "uWebSockets.js";
import {
GameServerProfiler,
type GameProfileSnapshot,
type InboundMessageKind,
} from "./game-profiler.js";
interface HostableEngine<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent> {
readonly playerIds: PlayerId[];
addPlayer(playerId: PlayerId): void;
removePlayer(playerId: PlayerId): void;
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision;
submitStateReport(playerId: PlayerId, report: ClientStateReport<ClientState>): ValidationResult | null;
step(): NetworkedServerStepResult<AuthorityEvent>;
createSnapshot(playerId: PlayerId, serverTime: number): StateSnapshot<ClientState>;
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[];
createPerceptions(playerId: PlayerId, events: readonly AuthorityEvent[]): PerceptionEvent[];
drainReplayTickets?(playerId: PlayerId): Array<ReplayTicket<ClientState, PerceptionEvent>>;
}
interface HostableGame<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent> {
tickRateHz: number;
protocol: BinaryProtocol<Input, ClientState, PerceptionEvent>;
createServer(): HostableEngine<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent>;
}
export interface HostedGameLoop {
readonly path: string;
advance(now: number): void;
profile(now?: number): GameProfileSnapshot;
}
/** Mounts any defined networked game on the shared WebSocket server. */
export function hostNetworkedGame<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent>(
app: TemplatedApp,
path: string,
game: HostableGame<AuthorityState, ClientState, Input, AuthorityEvent, PerceptionEvent>,
): HostedGameLoop {
const engine = game.createServer();
const protocol = game.protocol;
const clock = new FixedStepClock({ rateHz: game.tickRateHz, maxCatchUpSteps: 5 });
const profiler = new GameServerProfiler(path, game.tickRateHz);
const socketPlayers = new Map<WebSocket<unknown>, PlayerId>();
const playerSockets = new Map<PlayerId, WebSocket<unknown>>();
let nextPlayerId = 1;
app.ws<unknown>(path, {
compression: DISABLED,
maxPayloadLength: 512 * 1_024,
maxBackpressure: 256 * 1_024,
closeOnBackpressureLimit: true,
idleTimeout: 32,
sendPingsAutomatically: true,
open(socket) {
const playerId = nextPlayerId;
nextPlayerId = (nextPlayerId + 1) >>> 0;
socketPlayers.set(socket, playerId);
playerSockets.set(playerId, socket);
profiler.openClient(playerId, readRemoteAddress(socket));
engine.addPlayer(playerId);
sendFrame(
socket,
playerId,
protocol.encodeServer({
kind: "welcome",
playerId,
snapshot: engine.createSnapshot(playerId, performance.now()),
}),
);
},
message(socket, payload) {
const playerId = socketPlayers.get(socket);
if (playerId === undefined) {
socket.end(1011, "Missing player session");
return;
}
try {
const message = protocol.decodeClient(payload);
profiler.recordInbound(
playerId,
message.kind as InboundMessageKind,
payload.byteLength,
);
switch (message.kind) {
case "input": {
const decision = engine.submitInput(playerId, message.packet);
if (!decision.accepted) {
sendFrame(
socket,
playerId,
protocol.encodeServer({ kind: "reject-input", sequence: message.packet.sequence }),
);
}
break;
}
case "ping": {
const serverReceivedAt = performance.now();
sendFrame(
socket,
playerId,
protocol.encodeServer({
kind: "pong",
pong: { ...message.ping, serverReceivedAt, serverSentAt: performance.now() },
}),
);
break;
}
case "state-report": {
const validation = engine.submitStateReport(playerId, message.report);
if (validation) sendValidation(validation);
break;
}
}
} catch {
socket.end(1003, "Invalid game message");
}
},
close(socket) {
const playerId = socketPlayers.get(socket);
if (playerId === undefined) return;
socketPlayers.delete(socket);
playerSockets.delete(playerId);
profiler.closeClient(playerId);
engine.removePlayer(playerId);
},
});
function runStep(): void {
const startedAt = performance.now();
const result = engine.step();
const simulationEndedAt = performance.now();
const outbound = new Map<PlayerId, ArrayBuffer[]>();
for (const acknowledgement of result.acknowledgements) {
queueFrame(outbound, acknowledgement.playerId, protocol.encodeServer({
kind: "acknowledge",
sequence: acknowledgement.sequence,
}));
}
for (const validation of result.validations) {
queueFrame(outbound, validation.playerId, protocol.encodeServer({
kind: "validation",
tick: validation.tick,
valid: validation.valid,
}));
}
for (const playerId of engine.playerIds) {
for (const event of engine.createPerceptions(playerId, result.events)) {
queueFrame(outbound, playerId, protocol.encodeServer({ kind: "event", tick: result.tick, event }));
}
}
const perceptionEndedAt = performance.now();
if (result.snapshotDue) {
const serverTime = performance.now();
for (const batch of engine.createSnapshotBatches(serverTime)) {
const frame = protocol.encodeServer({
kind: "snapshot",
snapshot: { tick: result.tick, serverTime, state: batch.state },
});
for (const playerId of batch.playerIds) queueFrame(outbound, playerId, frame);
}
}
const snapshotEndedAt = performance.now();
if (engine.drainReplayTickets) {
for (const playerId of engine.playerIds) {
for (const ticket of engine.drainReplayTickets(playerId)) queueReplay(outbound, playerId, ticket);
}
}
const replayEndedAt = performance.now();
for (const [playerId, frames] of outbound) {
const socket = playerSockets.get(playerId);
socket?.cork(() => {
for (const frame of frames) sendFrame(socket, playerId, frame);
});
}
const endedAt = performance.now();
profiler.recordStep({
simulationMs: simulationEndedAt - startedAt,
perceptionMs: perceptionEndedAt - simulationEndedAt,
snapshotMs: snapshotEndedAt - perceptionEndedAt,
replayMs: replayEndedAt - snapshotEndedAt,
sendMs: endedAt - replayEndedAt,
totalMs: endedAt - startedAt,
authorityEvents: result.events.length,
snapshotDue: result.snapshotDue,
}, endedAt);
}
function sendValidation(validation: ValidationResult): void {
const socket = playerSockets.get(validation.playerId);
if (!socket) return;
sendFrame(
socket,
validation.playerId,
protocol.encodeServer({ kind: "validation", tick: validation.tick, valid: validation.valid }),
);
}
function sendFrame(
socket: WebSocket<unknown>,
playerId: PlayerId,
frame: ArrayBuffer,
): void {
const result = socket.send(frame, true, false);
profiler.recordOutbound(
playerId,
frame.byteLength,
result,
socket.getBufferedAmount(),
);
}
function queueReplay(
outbound: Map<PlayerId, ArrayBuffer[]>,
playerId: PlayerId,
ticket: ReplayTicket<ClientState, PerceptionEvent>,
): void {
queueFrame(outbound, playerId, protocol.encodeServer({
kind: "replay-start",
ticketId: ticket.ticketId,
perspectiveId: ticket.perspectiveId,
fromTick: ticket.fromTick,
toTick: ticket.toTick,
frameCount: ticket.frames.length,
playbackRate: ticket.playbackRate,
}));
for (const frame of ticket.frames) {
queueFrame(outbound, playerId, protocol.encodeServer({
kind: "replay-frame",
ticketId: ticket.ticketId,
tick: frame.tick,
state: frame.state,
events: frame.events,
}));
}
queueFrame(outbound, playerId, protocol.encodeServer({ kind: "replay-end", ticketId: ticket.ticketId }));
}
return {
path,
advance(now) {
clock.advance(now, runStep);
},
profile(now) {
return profiler.snapshot(now);
},
};
}
function readRemoteAddress(socket: WebSocket<unknown>): string {
const port = socket.getRemotePort();
const text = new TextDecoder().decode(socket.getRemoteAddressAsText());
if (text) return `${text}:${port}`;
const bytes = new Uint8Array(socket.getRemoteAddress());
if (bytes.length === 4) return `${[...bytes].join(".")}:${port}`;
if (bytes.length === 16) {
const groups: string[] = [];
for (let index = 0; index < bytes.length; index += 2) {
groups.push(((bytes[index] ?? 0) * 256 + (bytes[index + 1] ?? 0)).toString(16));
}
return `[${groups.join(":")}]:${port}`;
}
return port > 0 ? `unknown:${port}` : "unknown";
}
function queueFrame(
outbound: Map<PlayerId, ArrayBuffer[]>,
playerId: PlayerId,
frame: ArrayBuffer,
): void {
const frames = outbound.get(playerId);
if (frames) frames.push(frame);
else outbound.set(playerId, [frame]);
}

183
apps/server/src/index.ts Normal file
View File

@@ -0,0 +1,183 @@
import {
API_PATHS,
FLUX_SOCKET_PATH,
GAME_SOCKET_PATH,
ROYALE_SOCKET_PATH,
createShooterGame,
fluxGame,
royaleGame,
type ApiMessage,
} from "@syncer/shared";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { extname, join, relative } from "node:path";
import { App, type HttpResponse } from "uWebSockets.js";
import { ClientProfileStore } from "./client-profile-store.js";
import { hostNetworkedGame } from "./host-networked-game.js";
import { ServerProcessProfiler } from "./server-profiler.js";
const port = Number(process.env.PORT ?? 3001);
const app = App();
const processProfiler = new ServerProcessProfiler();
const clientProfiles = new ClientProfileStore();
const staticSite = loadStaticSite(process.env.STATIC_ROOT);
const gameLoops = [
hostNetworkedGame(app, GAME_SOCKET_PATH, createShooterGame({ botCount: 0 })),
hostNetworkedGame(app, FLUX_SOCKET_PATH, fluxGame),
hostNetworkedGame(app, ROYALE_SOCKET_PATH, royaleGame),
];
app
.post("/api/client-profile", (response) => {
const chunks: Buffer[] = [];
let bytes = 0;
let finished = false;
response.onAborted(() => {
finished = true;
});
response.onData((chunk, isLast) => {
if (finished) return;
bytes += chunk.byteLength;
if (bytes > 64 * 1_024) {
finished = true;
response.writeStatus("413 Payload Too Large").end();
return;
}
chunks.push(Buffer.from(new Uint8Array(chunk)));
if (!isLast) return;
finished = true;
try {
const sample: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (!clientProfiles.record(sample, readRemoteAddress(response))) {
response.writeStatus("400 Bad Request").end("Invalid profile sample");
return;
}
response
.writeStatus("204 No Content")
.writeHeader("Access-Control-Allow-Origin", "*")
.end();
} catch {
response.writeStatus("400 Bad Request").end("Invalid JSON");
}
});
})
.get("/api/profile", (response) => {
const now = performance.now();
response
.writeHeader("Content-Type", "application/json; charset=utf-8")
.writeHeader("Access-Control-Allow-Origin", "*")
.end(JSON.stringify({
timestamp: new Date().toISOString(),
process: processProfiler.snapshot(),
clients: clientProfiles.snapshot(now),
games: gameLoops.map((game) => game.profile(now)),
}, null, 2));
})
.get(API_PATHS.message, (response) => {
const body: ApiMessage = {
message: "Authoritative multiplayer simulations online",
timestamp: new Date().toISOString(),
};
response
.writeHeader("Content-Type", "application/json; charset=utf-8")
.end(JSON.stringify(body));
})
.any("/*", (response, request) => {
const url = request.getUrl();
const method = request.getMethod().toUpperCase();
const asset = staticSite?.files.get(url);
const fallback =
staticSite &&
(method === "GET" || method === "HEAD") &&
!url.startsWith("/api/") &&
!url.startsWith("/ws/")
? staticSite.index
: undefined;
const file = asset ?? fallback;
if (!file || (method !== "GET" && method !== "HEAD")) {
response.writeStatus("404 Not Found").end("Not found");
return;
}
response
.writeHeader("Content-Type", file.contentType)
.writeHeader(
"Cache-Control",
asset && url.startsWith("/assets/")
? "public, max-age=31536000, immutable"
: "no-cache",
);
response.end(method === "HEAD" ? undefined : file.body);
})
.listen(port, (listenSocket) => {
if (!listenSocket) {
console.error(`Could not listen on port ${port}`);
process.exitCode = 1;
return;
}
console.log(
`Authoritative server listening on http://localhost:${port} (${gameLoops
.map(({ path }) => path)
.join(", ")}); profiler: /api/profile`,
);
const timer = setInterval(() => {
const now = performance.now();
for (const game of gameLoops) game.advance(now);
}, 4);
timer.unref();
});
function readRemoteAddress(response: HttpResponse): string {
const port = response.getRemotePort();
const text = new TextDecoder().decode(response.getRemoteAddressAsText());
return text ? `${text}:${port}` : port > 0 ? `unknown:${port}` : "unknown";
}
interface StaticAsset {
body: Buffer;
contentType: string;
}
interface StaticSite {
files: Map<string, StaticAsset>;
index: StaticAsset;
}
function loadStaticSite(root: string | undefined): StaticSite | null {
if (!root || !existsSync(root)) return null;
const files = new Map<string, StaticAsset>();
for (const filePath of walkFiles(root)) {
const url = `/${relative(root, filePath).split("\\").join("/")}`;
files.set(url, {
body: readFileSync(filePath),
contentType: contentType(filePath),
});
}
const index = files.get("/index.html");
if (!index) throw new Error(`STATIC_ROOT has no index.html: ${root}`);
console.log(`Serving ${files.size} static files from ${root}`);
return { files, index };
}
function walkFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name);
return entry.isDirectory() ? walkFiles(path) : [path];
});
}
function contentType(filePath: string): string {
switch (extname(filePath)) {
case ".html": return "text/html; charset=utf-8";
case ".js": return "text/javascript; charset=utf-8";
case ".css": return "text/css; charset=utf-8";
case ".json": return "application/json; charset=utf-8";
case ".svg": return "image/svg+xml";
case ".png": return "image/png";
case ".jpg":
case ".jpeg": return "image/jpeg";
case ".webp": return "image/webp";
case ".ico": return "image/x-icon";
case ".woff2": return "font/woff2";
default: return "application/octet-stream";
}
}

View File

@@ -0,0 +1,101 @@
import {
monitorEventLoopDelay,
performance as nodePerformance,
type EventLoopUtilization,
} from "node:perf_hooks";
export interface ServerProcessProfile {
uptimeSeconds: number;
cpuPercent: number;
eventLoopUtilization: number;
eventLoopDelayMs: {
mean: number;
maximum: number;
p95: number;
p99: number;
};
memoryBytes: {
residentSet: number;
heapUsed: number;
heapTotal: number;
external: number;
arrayBuffers: number;
};
}
/** Samples Node process pressure independently of any particular game. */
export class ServerProcessProfiler {
private readonly delay = monitorEventLoopDelay({ resolution: 1 });
private previousCpu = process.cpuUsage();
private previousSampleAt = nodePerformance.now();
private previousUtilization: EventLoopUtilization =
nodePerformance.eventLoopUtilization();
private recent: ServerProcessProfile;
constructor() {
this.delay.enable();
this.recent = this.capture();
const timer = setInterval(() => {
this.recent = this.capture();
}, 1_000);
timer.unref();
}
snapshot(): ServerProcessProfile {
return {
...this.recent,
uptimeSeconds: round(process.uptime(), 2),
memoryBytes: readMemory(),
};
}
private capture(): ServerProcessProfile {
const now = nodePerformance.now();
const elapsedMs = Math.max(1, now - this.previousSampleAt);
const cpu = process.cpuUsage();
const cpuMicroseconds =
cpu.user - this.previousCpu.user + cpu.system - this.previousCpu.system;
const utilization = nodePerformance.eventLoopUtilization();
const utilizationDelta = nodePerformance.eventLoopUtilization(
utilization,
this.previousUtilization,
);
const profile: ServerProcessProfile = {
uptimeSeconds: round(process.uptime(), 2),
cpuPercent: round((cpuMicroseconds / (elapsedMs * 1_000)) * 100, 2),
eventLoopUtilization: round(utilizationDelta.utilization * 100, 2),
eventLoopDelayMs: {
mean: nanosecondsToMilliseconds(this.delay.mean),
maximum: nanosecondsToMilliseconds(this.delay.max),
p95: nanosecondsToMilliseconds(this.delay.percentile(95)),
p99: nanosecondsToMilliseconds(this.delay.percentile(99)),
},
memoryBytes: readMemory(),
};
this.previousCpu = cpu;
this.previousSampleAt = now;
this.previousUtilization = utilization;
this.delay.reset();
return profile;
}
}
function readMemory(): ServerProcessProfile["memoryBytes"] {
const memory = process.memoryUsage();
return {
residentSet: memory.rss,
heapUsed: memory.heapUsed,
heapTotal: memory.heapTotal,
external: memory.external,
arrayBuffers: memory.arrayBuffers,
};
}
function nanosecondsToMilliseconds(value: number): number {
return round(Number.isFinite(value) ? value / 1_000_000 : 0, 3);
}
function round(value: number, digits: number): number {
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
}

13
apps/server/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"sourceMap": true,
"types": ["node"]
},
"include": ["src"]
}

17
apps/web/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Sync Arena — an authoritative multiplayer 3D browser shooter"
/>
<meta name="theme-color" content="#071019" />
<title>Sync Arena</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
apps/web/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@syncer/web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
"@syncer/engine": "0.0.0",
"@syncer/shared": "0.0.0",
"@types/three": "^0.183.1",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"three": "^0.185.1"
},
"devDependencies": {
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"@vitejs/plugin-react": "^6.1.0",
"typescript": "^7.0.2",
"vite": "^8.2.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

241
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,241 @@
import {
WEAPONS,
Weapon,
playerDisplayName,
type PlayerKind,
} from "@syncer/shared";
import { useEffect, useState } from "react";
import { Arena3D } from "./Arena3D.js";
import { FluxGame } from "./FluxGame.js";
import { RoyaleGame } from "./RoyaleGame.js";
import { useGameClient } from "./useGameClient.js";
export function App() {
const [demo, setDemo] = useState<"arena" | "flux" | "royale">(() =>
window.location.hash === "#royale"
? "royale"
: window.location.hash === "#flux"
? "flux"
: "arena",
);
useEffect(() => {
window.history.replaceState(null, "", `#${demo}`);
}, [demo]);
return (
<>
{demo === "arena" ? <ShooterGame /> : demo === "flux" ? <FluxGame /> : <RoyaleGame />}
<nav className="game-switcher" aria-label="Example game selector">
<button className={demo === "arena" ? "is-active" : ""} onClick={() => setDemo("arena")} type="button">
ARENA
</button>
<button className={demo === "flux" ? "is-active" : ""} onClick={() => setDemo("flux")} type="button">
FLUX RELAY
</button>
<button className={demo === "royale" ? "is-active" : ""} onClick={() => setDemo("royale")} type="button">
SYNCER ROYALE
</button>
</nav>
</>
);
}
function ShooterGame() {
const client = useGameClient();
const viewedId = client.cameraPlayerId;
const local = viewedId === null ? undefined : client.world.players.get(viewedId);
const recentEntries = [...client.world.events].reverse();
const damageEntry = recentEntries.find((entry) => entry.event.type === "damage");
const damageEvent = damageEntry?.event.type === "damage" ? damageEntry.event : null;
const hitEntry = recentEntries.find(
(entry) => entry.event.type === "hit",
);
const soundEntry = recentEntries.find((entry) => entry.event.type === "sound");
const eliminationEntries = recentEntries
.filter((entry) => entry.event.type === "elimination")
.slice(0, 4);
const weapon = WEAPONS[local?.weapon ?? Weapon.PulseRifle];
const ammo = local?.ammo[local.weapon] ?? { magazine: 0, reserve: 0 };
const matchSeconds = Math.floor(client.world.match.elapsedTicks / 60);
const recentDamage = Boolean(
damageEntry && damageEvent && client.tick - damageEntry.receivedTick < 18,
);
const recentHit = Boolean(hitEntry && client.tick - hitEntry.receivedTick < 12);
const recentSound = Boolean(soundEntry && client.tick - soundEntry.receivedTick < 32);
return (
<main className="game">
<Arena3D
world={client.world}
playerId={viewedId}
presentationKey={client.presentationKey}
interactive={!client.replay}
/>
<div className="vignette" aria-hidden="true" />
{recentDamage && damageEvent && (
<div className="damage-flash" key={damageEvent.id} aria-hidden="true" />
)}
<header className="topbar">
<div className="brand">
<span className="brand-mark">S//</span>
<div>
<strong>SYNC ARENA</strong>
<small>AUTHORITATIVE 3D COMBAT</small>
</div>
</div>
<div className="match-clock">
<small>{client.replay ? "AUTHORIZED REPLAY" : "LIVE SIMULATION"}</small>
<strong>{formatClock(matchSeconds)}</strong>
</div>
<div className={`connection connection--${client.connection}`}>
<i />
<span>{client.connection}</span>
<b>{client.network.roundTripTime.toFixed(0)} MS</b>
</div>
</header>
<section className="telemetry" aria-label="Network telemetry">
<Telemetry label={client.replay ? "REPLAY" : "SERVER"} value={client.tick.toString().padStart(6, "0")} />
<Telemetry label="JITTER" value={`${client.network.jitter.toFixed(1)} MS`} />
<Telemetry label="LEAD" value={`${client.inputLeadTicks} T`} />
<Telemetry label="STATE" value={client.validation.toUpperCase()} />
<Telemetry label="VISIBLE" value={`${Math.max(0, client.world.players.size - 1)} HOSTILES`} />
</section>
<section className="leaderboard" aria-label="Leaderboard">
<small>ARENA LEADERS</small>
{client.world.scoreboard.slice(0, 5).map((entry, index) => (
<div
className={entry.id === client.playerId ? "leaderboard__local" : undefined}
key={entry.id}
>
<b>{index + 1}</b>
<span>{playerDisplayName(entry.id, entry.kind)}</span>
<strong>{entry.kills}</strong>
</div>
))}
</section>
<section className="kill-feed" aria-live="polite">
{eliminationEntries.map((entry) => {
if (entry.event.type !== "elimination") return null;
return (
<div key={entry.event.id}>
<span>{labelFor(entry.event.killerId, client.world.scoreboard)}</span>
<b></b>
<span>{labelFor(entry.event.victimId, client.world.scoreboard)}</span>
</div>
);
})}
</section>
{recentSound && soundEntry?.event.type === "sound" && (
<div
className="sound-indicator"
style={{
transform: `translate(-50%, -50%) rotate(${soundEntry.event.bearingRadians}rad)`,
opacity: 0.35 + soundEntry.event.intensity * 0.65,
}}
aria-hidden="true"
>
<i />
</div>
)}
<div className={`crosshair${recentHit ? " crosshair--damage" : ""}`}>
<i /><i /><i /><i />
</div>
{recentHit && hitEntry?.event.type === "hit" && (
<div className="hit-confirm" aria-hidden="true">
{hitEntry.event.eliminated
? "ELIMINATED"
: hitEntry.event.critical
? "CRITICAL"
: "HIT"}
</div>
)}
{client.replay && (
<section className="killcam-panel" aria-label="Killcam playback">
<div className="killcam-panel__title">
<small>SERVER-AUTHORIZED PERSPECTIVE</small>
<strong>
KILLCAM // {labelFor(client.replay.perspectiveId, client.world.scoreboard)}
</strong>
</div>
<div className="killcam-panel__timeline">
<i style={{ width: `${client.replay.progress * 100}%` }} />
</div>
<div className="killcam-panel__meta">
<span>TICK {client.replay.currentTick.toString().padStart(6, "0")}</span>
<span>{client.replay.playbackRate.toFixed(2)}×</span>
<span>PROJECTED VIEW · NO SERVER TRUTH</span>
</div>
</section>
)}
{!client.replay && local && !local.alive ? (
<section className="respawn-panel">
<small>NEURAL LINK INTERRUPTED</small>
<strong>REDEPLOYING {Math.ceil(local.respawnTicks / 60)}</strong>
</section>
) : null}
<footer className="hud">
<div className="health-block">
<small>INTEGRITY · ARMOR {local?.armor ?? 0}</small>
<strong>{(local?.health ?? 0).toString().padStart(3, "0")}</strong>
<div className="health-track">
<i style={{ width: `${local?.health ?? 0}%` }} />
</div>
</div>
<div className="mission-note">
<span>FREE FOR ALL · FIRST TO {client.world.match.scoreLimit}</span>
<small>SERVER BOTS · PRIVATE VISIBILITY · SPATIAL AUDIO</small>
</div>
<div className="weapon-block">
<div>
<small>{weapon.name.toUpperCase()}</small>
<span>{local?.reloadTicks ? "RELOADING" : `${local?.weapon === Weapon.PulseRifle ? "01" : local?.weapon === Weapon.Scattergun ? "02" : "03"} / ${weapon.shortName}`}</span>
</div>
<strong>{ammo.magazine}<i>/ {ammo.reserve}</i></strong>
</div>
<div className="score-block">
<span><small>K</small>{scoreFor(client.playerId, client.world.scoreboard)?.kills ?? 0}</span>
<span><small>D</small>{scoreFor(client.playerId, client.world.scoreboard)?.deaths ?? 0}</span>
</div>
</footer>
</main>
);
}
function Telemetry({ label, value }: { label: string; value: string }) {
return <div><small>{label}</small><strong>{value}</strong></div>;
}
function scoreFor(
id: number | null,
scoreboard: ReadonlyArray<{ id: number; kind: PlayerKind; kills: number; deaths: number }>,
) {
return id === null ? undefined : scoreboard.find((entry) => entry.id === id);
}
function labelFor(
id: number,
scoreboard: ReadonlyArray<{ id: number; kind: PlayerKind }>,
): string {
const kind = scoreboard.find((entry) => entry.id === id)?.kind ?? "bot";
return playerDisplayName(id, kind);
}
function formatClock(totalSeconds: number): string {
const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, "0");
const seconds = (totalSeconds % 60).toString().padStart(2, "0");
return `${minutes}:${seconds}`;
}

756
apps/web/src/Arena3D.tsx Normal file
View File

@@ -0,0 +1,756 @@
import { useEffect, useRef, useState, type MutableRefObject } from "react";
import * as THREE from "three";
import {
ARENA_BLOCKS,
ARENA_HALF_SIZE,
PLAYER_EYE_HEIGHT,
WEAPONS,
Weapon,
type PickupState,
type PlayerState,
type ShooterPerception,
type ShooterWorldState,
} from "@syncer/shared";
import { ArenaAudio } from "./audio.js";
interface Arena3DProps {
world: ShooterWorldState;
playerId: number | null;
presentationKey: string;
interactive: boolean;
}
interface TemporaryEffect {
object: THREE.Object3D;
expiresAt: number;
createdAt?: number;
floatDistance?: number;
}
interface SceneRuntime {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
players: Map<number, THREE.Group>;
pickups: Map<number, THREE.Group>;
effects: TemporaryEffect[];
animated: THREE.Object3D[];
weapon: THREE.Group;
weaponAccent: THREE.MeshStandardMaterial;
muzzle: THREE.PointLight;
muzzleCore: THREE.Mesh;
muzzleUntil: number;
lastLocalImpact: { x: number; y: number; z: number; at: number } | null;
audio: ArenaAudio;
resizeObserver: ResizeObserver;
animationFrame: number;
}
export function Arena3D({
world,
playerId,
presentationKey,
interactive,
}: Arena3DProps) {
const hostRef = useRef<HTMLDivElement>(null);
const worldRef = useRef(world);
const playerIdRef = useRef(playerId);
const lastEventIdRef = useRef(0);
const presentationKeyRef = useRef("live");
const runtimeRef = useRef<SceneRuntime | null>(null);
const [locked, setLocked] = useState(false);
worldRef.current = world;
playerIdRef.current = playerId;
if (presentationKeyRef.current !== presentationKey) {
lastEventIdRef.current = presentationKey === "live"
? world.events.reduce(
(latest, entry) => Math.max(latest, entry.event.id),
0,
)
: 0;
presentationKeyRef.current = presentationKey;
}
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: "high-performance",
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.6));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.38;
host.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b1822);
scene.fog = new THREE.FogExp2(0x10212c, 0.014);
const camera = new THREE.PerspectiveCamera(79, 1, 0.045, 100);
camera.rotation.order = "YXZ";
scene.add(camera);
buildLights(scene);
const animated = buildArena(scene);
const { group: weapon, muzzle, muzzleCore, accent } = buildWeapon();
camera.add(weapon);
const runtime: SceneRuntime = {
renderer,
scene,
camera,
players: new Map(),
pickups: new Map(),
effects: [],
animated,
weapon,
weaponAccent: accent,
muzzle,
muzzleCore,
muzzleUntil: 0,
lastLocalImpact: null,
audio: new ArenaAudio(),
resizeObserver: new ResizeObserver(() => resize(runtime, host)),
animationFrame: 0,
};
runtimeRef.current = runtime;
runtime.resizeObserver.observe(host);
resize(runtime, host);
const animate = (time: number) => {
updateRuntime(
runtime,
worldRef.current,
playerIdRef.current,
lastEventIdRef,
time,
);
renderer.render(scene, camera);
runtime.animationFrame = window.requestAnimationFrame(animate);
};
runtime.animationFrame = window.requestAnimationFrame(animate);
const pointerLockChanged = () =>
setLocked(document.pointerLockElement === renderer.domElement);
document.addEventListener("pointerlockchange", pointerLockChanged);
return () => {
document.removeEventListener("pointerlockchange", pointerLockChanged);
runtime.resizeObserver.disconnect();
window.cancelAnimationFrame(runtime.animationFrame);
runtime.audio.dispose();
scene.traverse((object) => {
if (object instanceof THREE.Sprite) {
object.material.map?.dispose();
object.material.dispose();
return;
}
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
object.geometry.dispose();
const materials = Array.isArray(object.material)
? object.material
: [object.material];
for (const material of materials) material.dispose();
});
renderer.dispose();
renderer.domElement.remove();
runtimeRef.current = null;
};
}, []);
const deploy = () => {
if (!interactive) return;
const runtime = runtimeRef.current;
const canvas = hostRef.current?.querySelector("canvas");
if (!runtime || !canvas || !canvas.isConnected) return;
runtime.audio.unlock();
if (document.pointerLockElement) {
setLocked(true);
return;
}
void canvas.requestPointerLock().catch(() => setLocked(false));
};
return (
<div className="viewport" ref={hostRef}>
{interactive && !locked && (
<button className="deploy" type="button" onClick={deploy}>
<span>CLICK TO DEPLOY</span>
<small>WASD · SHIFT · MOUSE · FIRE · 1/2/3 WEAPONS · R RELOAD</small>
</button>
)}
</div>
);
}
function buildLights(scene: THREE.Scene): void {
scene.add(new THREE.AmbientLight(0x9bc8dc, 0.72));
scene.add(new THREE.HemisphereLight(0xa8ddf4, 0x3a3028, 2.35));
const sun = new THREE.DirectionalLight(0xe8f6ff, 3.2);
sun.position.set(-9, 19, 5);
sun.castShadow = true;
sun.shadow.mapSize.set(1_024, 1_024);
sun.shadow.camera.left = -22;
sun.shadow.camera.right = 22;
sun.shadow.camera.top = 22;
sun.shadow.camera.bottom = -22;
scene.add(sun);
const fill = new THREE.DirectionalLight(0x78bcd6, 1.15);
fill.position.set(11, 8, -12);
scene.add(fill);
for (const [x, z] of [
[0, -11],
[0, 11],
[-11, 0],
[11, 0],
] as const) {
const light = new THREE.PointLight(0xb8e8ff, 18, 17, 1.8);
light.position.set(x, 5.5, z);
scene.add(light);
}
for (const [x, z, color] of [
[-15.5, -15.5, 0x17cfff],
[15.5, 15.5, 0xff842b],
[15.5, -15.5, 0x17cfff],
[-15.5, 15.5, 0xff842b],
] as const) {
const light = new THREE.PointLight(color, 22, 16, 2);
light.position.set(x, 2.2, z);
scene.add(light);
}
}
function buildArena(scene: THREE.Scene): THREE.Object3D[] {
const animated: THREE.Object3D[] = [];
const textureLoader = new THREE.TextureLoader();
const texture = textureLoader.load("/assets/arena-panels.png");
texture.colorSpace = THREE.SRGBColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.anisotropy = 8;
texture.repeat.set(2.5, 2.5);
const floorTexture = textureLoader.load("/assets/arena-panels.png");
floorTexture.colorSpace = THREE.SRGBColorSpace;
floorTexture.wrapS = THREE.RepeatWrapping;
floorTexture.wrapT = THREE.RepeatWrapping;
floorTexture.anisotropy = 8;
floorTexture.repeat.set(5, 5);
const floorMaterial = new THREE.MeshStandardMaterial({
map: floorTexture,
color: 0x6f7a80,
roughness: 0.72,
metalness: 0.48,
});
const floor = new THREE.Mesh(
new THREE.PlaneGeometry(ARENA_HALF_SIZE * 2, ARENA_HALF_SIZE * 2),
floorMaterial,
);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
const grid = new THREE.GridHelper(
ARENA_HALF_SIZE * 2,
36,
0x2d819a,
0x18313b,
);
grid.position.y = 0.015;
for (const material of Array.isArray(grid.material) ? grid.material : [grid.material]) {
material.transparent = true;
material.opacity = 0.3;
}
scene.add(grid);
const panelMaterial = new THREE.MeshStandardMaterial({
map: texture,
color: 0x97a4aa,
roughness: 0.58,
metalness: 0.54,
});
const coverMaterial = new THREE.MeshStandardMaterial({
color: 0x35424b,
roughness: 0.47,
metalness: 0.72,
});
const reactorMaterial = new THREE.MeshStandardMaterial({
color: 0x112933,
emissive: 0x007c93,
emissiveIntensity: 1.9,
roughness: 0.3,
metalness: 0.8,
});
for (const block of ARENA_BLOCKS) {
const material =
block.style === "reactor"
? reactorMaterial
: block.style === "wall"
? panelMaterial
: coverMaterial;
const mesh = new THREE.Mesh(
new THREE.BoxGeometry(block.width, block.height, block.depth),
material,
);
mesh.position.set(block.x, block.height / 2, block.z);
mesh.castShadow = true;
mesh.receiveShadow = true;
scene.add(mesh);
if (block.style === "reactor") {
for (let index = 0; index < 3; index += 1) {
const ring = new THREE.Mesh(
new THREE.TorusGeometry(2.45 + index * 0.2, 0.035, 8, 36),
new THREE.MeshBasicMaterial({
color: index % 2 ? 0xffa240 : 0x31ddff,
transparent: true,
opacity: 0.78,
}),
);
ring.position.set(0, 1.05 + index * 1.05, 0);
ring.rotation.x = Math.PI / 2;
ring.userData.spin = (index % 2 ? -1 : 1) * (0.00018 + index * 0.00004);
scene.add(ring);
animated.push(ring);
}
}
}
const boundaryGeometry = [
[0, -ARENA_HALF_SIZE - 0.3, ARENA_HALF_SIZE * 2 + 1.2, 0.65],
[0, ARENA_HALF_SIZE + 0.3, ARENA_HALF_SIZE * 2 + 1.2, 0.65],
[-ARENA_HALF_SIZE - 0.3, 0, 0.65, ARENA_HALF_SIZE * 2 + 1.2],
[ARENA_HALF_SIZE + 0.3, 0, 0.65, ARENA_HALF_SIZE * 2 + 1.2],
] as const;
for (const [x, z, width, depth] of boundaryGeometry) {
const wall = new THREE.Mesh(
new THREE.BoxGeometry(width, 3.6, depth),
panelMaterial,
);
wall.position.set(x, 1.8, z);
wall.castShadow = true;
wall.receiveShadow = true;
scene.add(wall);
}
const stripMaterial = new THREE.MeshStandardMaterial({
color: 0xa1efff,
emissive: 0x00a8cd,
emissiveIntensity: 4,
roughness: 0.2,
});
for (let index = -14; index <= 14; index += 7) {
for (const z of [-17.75, 17.75]) {
const strip = new THREE.Mesh(
new THREE.BoxGeometry(3.2, 0.05, 0.13),
stripMaterial,
);
strip.position.set(index, 0.035, z);
scene.add(strip);
}
}
return animated;
}
function buildWeapon(): {
group: THREE.Group;
muzzle: THREE.PointLight;
muzzleCore: THREE.Mesh;
accent: THREE.MeshStandardMaterial;
} {
const group = new THREE.Group();
group.position.set(0.36, -0.3, -0.56);
const dark = new THREE.MeshStandardMaterial({
color: 0x121920,
roughness: 0.3,
metalness: 0.9,
});
const accent = new THREE.MeshStandardMaterial({
color: 0x5de4c7,
emissive: 0x0aa68d,
emissiveIntensity: 3.8,
roughness: 0.2,
});
const body = new THREE.Mesh(new THREE.BoxGeometry(0.22, 0.2, 0.62), dark);
body.position.z = -0.16;
group.add(body);
const housing = new THREE.Mesh(
new THREE.BoxGeometry(0.31, 0.12, 0.3),
dark,
);
housing.position.set(0, -0.05, -0.33);
group.add(housing);
const barrel = new THREE.Mesh(
new THREE.CylinderGeometry(0.036, 0.047, 0.5, 10),
dark,
);
barrel.rotation.x = Math.PI / 2;
barrel.position.set(0, 0.025, -0.57);
group.add(barrel);
const rail = new THREE.Mesh(new THREE.BoxGeometry(0.105, 0.045, 0.38), accent);
rail.position.set(0, 0.116, -0.18);
group.add(rail);
const muzzleCore = new THREE.Mesh(
new THREE.SphereGeometry(0.075, 10, 8),
new THREE.MeshBasicMaterial({ color: 0xc8ffff }),
);
muzzleCore.position.set(0, 0.025, -0.83);
muzzleCore.visible = false;
group.add(muzzleCore);
const muzzle = new THREE.PointLight(0x8deeff, 0, 3.5);
muzzle.position.copy(muzzleCore.position);
group.add(muzzle);
return { group, muzzle, muzzleCore, accent };
}
function updateRuntime(
runtime: SceneRuntime,
world: ShooterWorldState,
playerId: number | null,
lastEventId: MutableRefObject<number>,
time: number,
): void {
const local = playerId === null ? undefined : world.players.get(playerId);
if (local) {
runtime.camera.position.set(local.x, PLAYER_EYE_HEIGHT, local.z);
runtime.camera.rotation.set(local.pitch, -local.yaw, 0);
runtime.weapon.visible = local.alive;
const moving = Math.hypot(local.velocityX, local.velocityZ);
runtime.weapon.position.y =
-0.3 + Math.sin(time * 0.013) * Math.min(0.021, moving * 0.003);
runtime.weapon.position.x =
0.36 + Math.cos(time * 0.0065) * Math.min(0.008, moving * 0.0012);
const accentColor = new THREE.Color(WEAPONS[local.weapon].accent);
runtime.weaponAccent.color.copy(accentColor);
runtime.weaponAccent.emissive.copy(accentColor).multiplyScalar(0.52);
}
runtime.muzzle.intensity = time < runtime.muzzleUntil ? 14 : 0;
runtime.muzzleCore.visible = time < runtime.muzzleUntil;
const seen = new Set<number>();
for (const player of world.players.values()) {
if (player.id === playerId || !player.alive) continue;
seen.add(player.id);
let model = runtime.players.get(player.id);
if (!model) {
model = createPlayerModel(player);
runtime.players.set(player.id, model);
runtime.scene.add(model);
}
model.visible = true;
model.position.x += (player.x - model.position.x) * 0.28;
model.position.z += (player.z - model.position.z) * 0.28;
model.rotation.y = -player.yaw;
updatePlayerHealthBar(model, player.health);
}
for (const [id, model] of runtime.players) {
if (!seen.has(id)) model.visible = false;
}
updatePickupModels(runtime, world.pickups, time);
for (const entry of world.events) {
if (entry.event.id <= lastEventId.current) continue;
handlePerception(runtime, entry.event, playerId, time);
lastEventId.current = Math.max(lastEventId.current, entry.event.id);
}
for (const object of runtime.animated) {
object.rotation.z = time * Number(object.userData.spin ?? 0);
}
for (let index = runtime.effects.length - 1; index >= 0; index -= 1) {
const effect = runtime.effects[index]!;
if (effect.createdAt !== undefined && effect.floatDistance !== undefined) {
const progress = clamp01(
(time - effect.createdAt) / (effect.expiresAt - effect.createdAt),
);
effect.object.position.y =
Number(effect.object.userData.baseY ?? effect.object.position.y) +
progress * effect.floatDistance;
const material = effect.object instanceof THREE.Sprite
? effect.object.material
: null;
if (material) material.opacity = 1 - progress;
}
if (time < effect.expiresAt) continue;
runtime.scene.remove(effect.object);
effect.object.traverse((child) => {
if (!(child instanceof THREE.Mesh || child instanceof THREE.Line)) return;
child.geometry.dispose();
const materials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of materials) {
if ("map" in material && material.map instanceof THREE.Texture) {
material.map.dispose();
}
material.dispose();
}
});
if (effect.object instanceof THREE.Sprite) {
effect.object.material.map?.dispose();
effect.object.material.dispose();
}
runtime.effects.splice(index, 1);
}
}
function updatePickupModels(
runtime: SceneRuntime,
pickups: ReadonlyMap<number, PickupState>,
time: number,
): void {
for (const pickup of pickups.values()) {
let model = runtime.pickups.get(pickup.id);
if (!model) {
model = createPickupModel(pickup);
runtime.pickups.set(pickup.id, model);
runtime.scene.add(model);
}
model.visible = pickup.active;
model.rotation.y = time * 0.0012 + pickup.id;
model.position.y = 0.55 + Math.sin(time * 0.003 + pickup.id) * 0.1;
}
}
function handlePerception(
runtime: SceneRuntime,
event: ShooterPerception,
playerId: number | null,
time: number,
): void {
runtime.audio.play(event);
if (event.type === "hit") {
const impact = runtime.lastLocalImpact;
if (impact && time - impact.at < 500) {
const popup = createDamagePopup(event.amount, event.critical, event.eliminated);
popup.position.set(impact.x, Math.max(0.7, impact.y + 0.36), impact.z);
popup.userData.baseY = popup.position.y;
runtime.scene.add(popup);
runtime.effects.push({
object: popup,
createdAt: time,
expiresAt: time + 850,
floatDistance: 0.72,
});
}
return;
}
if (event.type !== "shot") return;
const color = new THREE.Color(WEAPONS[event.weapon].accent);
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(event.originX, event.originY, event.originZ),
new THREE.Vector3(event.endX, event.endY, event.endZ),
]);
const tracer = new THREE.Line(
geometry,
new THREE.LineBasicMaterial({
color,
transparent: true,
opacity: event.weapon === Weapon.RailRifle ? 0.95 : 0.72,
}),
);
runtime.scene.add(tracer);
runtime.effects.push({ object: tracer, expiresAt: time + (event.weapon === Weapon.RailRifle ? 150 : 75) });
if (event.impact !== "miss") {
const impact = new THREE.Mesh(
new THREE.SphereGeometry(event.weapon === Weapon.Scattergun ? 0.09 : 0.065, 8, 6),
new THREE.MeshBasicMaterial({ color }),
);
impact.position.set(event.endX, event.endY, event.endZ);
runtime.scene.add(impact);
runtime.effects.push({ object: impact, expiresAt: time + 140 });
}
if (event.sourceId === playerId) {
runtime.muzzleUntil = time + 52;
runtime.muzzle.color.copy(color);
runtime.lastLocalImpact = {
x: event.endX,
y: event.endY,
z: event.endZ,
at: time,
};
}
}
function createPlayerModel(player: PlayerState): THREE.Group {
const group = new THREE.Group();
group.position.set(player.x, 0, player.z);
const primary = new THREE.MeshStandardMaterial({
color: player.kind === "bot" ? 0xcb6229 : 0x2f9dbb,
roughness: 0.45,
metalness: 0.58,
});
const dark = new THREE.MeshStandardMaterial({
color: 0x10171d,
roughness: 0.36,
metalness: 0.76,
});
const visor = new THREE.MeshStandardMaterial({
color: player.kind === "bot" ? 0xffc078 : 0x9ef3ff,
emissive: player.kind === "bot" ? 0xb52d05 : 0x00a8d4,
emissiveIntensity: 3.4,
});
const torso = new THREE.Mesh(
new THREE.CylinderGeometry(0.35, 0.43, 0.88, 8),
primary,
);
torso.position.y = 0.91;
torso.castShadow = true;
group.add(torso);
const chest = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.17, 0.16), dark);
chest.position.set(0, 1.03, -0.35);
group.add(chest);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.27, 12, 8), dark);
head.position.y = 1.55;
head.castShadow = true;
group.add(head);
const face = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.1, 0.06), visor);
face.position.set(0, 1.57, -0.245);
group.add(face);
const weapon = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.13, 0.72), dark);
weapon.position.set(0.34, 1.08, -0.38);
group.add(weapon);
const healthBackground = new THREE.Sprite(
new THREE.SpriteMaterial({
color: 0x081015,
opacity: 0.86,
transparent: true,
depthWrite: false,
}),
);
healthBackground.position.set(0, 2.04, 0);
healthBackground.scale.set(0.9, 0.12, 1);
healthBackground.renderOrder = 4;
group.add(healthBackground);
const healthMaterial = new THREE.SpriteMaterial({
color: 0x55ef9a,
transparent: true,
depthWrite: false,
});
const healthFill = new THREE.Sprite(healthMaterial);
healthFill.position.set(0, 2.04, 0.002);
healthFill.scale.set(0.8, 0.065, 1);
healthFill.renderOrder = 5;
group.add(healthFill);
group.userData.healthFill = healthFill;
group.userData.healthMaterial = healthMaterial;
return group;
}
function updatePlayerHealthBar(model: THREE.Group, health: number): void {
const fill = model.userData.healthFill as THREE.Sprite | undefined;
const material = model.userData.healthMaterial as
| THREE.SpriteMaterial
| undefined;
if (!fill || !material) return;
const ratio = clamp01(health / 100);
fill.scale.x = Math.max(0.012, 0.8 * ratio);
material.color.setHex(
ratio > 0.55 ? 0x55ef9a : ratio > 0.25 ? 0xffb04d : 0xff5c4f,
);
}
function createDamagePopup(
amount: number,
critical: boolean,
eliminated: boolean,
): THREE.Sprite {
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 112;
const context = canvas.getContext("2d");
if (context) {
context.clearRect(0, 0, canvas.width, canvas.height);
context.textAlign = "center";
context.textBaseline = "middle";
context.font = critical
? "800 52px IBM Plex Mono, monospace"
: "700 48px IBM Plex Mono, monospace";
context.lineWidth = 10;
context.strokeStyle = "rgba(2, 7, 10, 0.92)";
const label = eliminated
? `${amount} ELIM`
: critical
? `${amount} CRIT`
: `${amount}`;
context.strokeText(label, canvas.width / 2, canvas.height / 2);
context.fillStyle = eliminated
? "#ff774f"
: critical
? "#ffc05d"
: "#dffaff";
context.fillText(label, canvas.width / 2, canvas.height / 2);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false,
depthWrite: false,
}),
);
sprite.scale.set(1.75, 0.61, 1);
sprite.renderOrder = 20;
return sprite;
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
function createPickupModel(pickup: PickupState): THREE.Group {
const group = new THREE.Group();
group.position.set(pickup.x, 0.55, pickup.z);
const color =
pickup.kind === "health"
? 0x54ef9b
: pickup.kind === "ammo"
? 0xffb04d
: new THREE.Color(WEAPONS[pickup.weapon].accent).getHex();
const material = new THREE.MeshStandardMaterial({
color,
emissive: color,
emissiveIntensity: 1.4,
roughness: 0.28,
metalness: 0.68,
});
const core = new THREE.Mesh(
pickup.kind === "health"
? new THREE.OctahedronGeometry(0.3)
: pickup.kind === "ammo"
? new THREE.BoxGeometry(0.5, 0.28, 0.32)
: new THREE.TorusKnotGeometry(0.2, 0.065, 40, 7),
material,
);
core.castShadow = true;
group.add(core);
const light = new THREE.PointLight(color, 3.5, 3);
group.add(light);
return group;
}
function resize(runtime: SceneRuntime, host: HTMLDivElement): void {
const width = Math.max(1, host.clientWidth);
const height = Math.max(1, host.clientHeight);
runtime.renderer.setSize(width, height, false);
runtime.camera.aspect = width / height;
runtime.camera.updateProjectionMatrix();
}

111
apps/web/src/FluxGame.tsx Normal file
View File

@@ -0,0 +1,111 @@
import type { FluxPlayerView, FluxTeam } from "@syncer/shared";
import { useFluxClient } from "./useFluxClient.js";
export function FluxGame() {
const client = useFluxClient();
const local = client.world.players.find((player) => player.id === client.playerId);
const recent = [...client.world.events].reverse()[0];
const energy = local?.energy ?? 0;
const corePercent = (client.world.core + 1) * 50;
return (
<main className={`flux-game flux-game--${local?.team ?? "cyan"}`}>
<div className="flux-grid" aria-hidden="true" />
<header className="flux-header">
<div>
<small>SYNCER // SECOND GAME</small>
<strong>FLUX RELAY</strong>
</div>
<section className="flux-score" aria-label="Team score">
<b>{client.world.cyanScore.toString().padStart(2, "0")}</b>
<span>ROUND {client.world.round.toString().padStart(2, "0")}</span>
<b>{client.world.orangeScore.toString().padStart(2, "0")}</b>
</section>
<div className={`flux-connection flux-connection--${client.connection}`}>
<i />
<span>{client.connection}</span>
<small>{client.network.roundTripTime.toFixed(0)} ms</small>
</div>
</header>
<section className="flux-arena" aria-label="Flux Relay arena">
<TeamRoster team="cyan" players={client.world.players} localId={client.playerId} />
<div className="flux-field">
<div className="flux-gate flux-gate--cyan"><span>CYAN GATE</span></div>
<div className="flux-lane">
<div className="flux-midline" />
<div
className="flux-core"
style={{ left: `${corePercent}%` }}
aria-label={`Core position ${client.world.core.toFixed(2)}`}
>
<i /><i /><i />
</div>
</div>
<div className="flux-gate flux-gate--orange"><span>ORANGE GATE</span></div>
</div>
<TeamRoster team="orange" players={client.world.players} localId={client.playerId} />
</section>
{recent && client.tick - recent.receivedTick < 80 ? (
<div className={`flux-event flux-event--${recent.event.team}`}>
{recent.event.type === "round-won"
? `${recent.event.team.toUpperCase()} CAPTURED THE CORE`
: recent.event.scope === "self"
? "REACTOR OVERHEATED · RELEASE TO RECOVER"
: `${recent.event.team.toUpperCase()} THRUSTER FELL SILENT`}
</div>
) : null}
<footer className="flux-controls">
<div className="flux-identity">
<small>YOUR LINK</small>
<strong>{local ? `${local.team.toUpperCase()} // P${local.id}` : "CONNECTING"}</strong>
<span>Other players' exact energy is never replicated.</span>
</div>
<button
className={`flux-thrust${local?.thrust ? " flux-thrust--active" : ""}`}
onPointerDown={() => client.setThrust(true)}
onPointerUp={() => client.setThrust(false)}
onPointerCancel={() => client.setThrust(false)}
onPointerLeave={() => client.setThrust(false)}
type="button"
>
<small>HOLD SPACE OR PRESS</small>
<strong>{local?.thrust ? "THRUSTING" : "ENGAGE THRUSTER"}</strong>
</button>
<div className="flux-energy">
<div><small>PRIVATE ENERGY</small><strong>{Math.round(energy)}%</strong></div>
<span><i style={{ width: `${energy}%` }} /></span>
<em>Tick {client.tick} · lead {client.inputLeadTicks}t · {client.validation}</em>
</div>
</footer>
</main>
);
}
function TeamRoster({
team,
players,
localId,
}: {
team: FluxTeam;
players: FluxPlayerView[];
localId: number | null;
}) {
return (
<div className={`flux-roster flux-roster--${team}`}>
<small>{team} links</small>
{players.filter((player) => player.team === team).map((player) => (
<div
className={`${player.thrust ? "flux-player--active" : ""}${player.id === localId ? " flux-player--local" : ""}`}
key={player.id}
>
<i />
<span>{player.bot ? "BOT" : `P${player.id}`}</span>
<b>{player.energy === null ? "PRIVATE" : `${Math.round(player.energy)}%`}</b>
</div>
))}
</div>
);
}

540
apps/web/src/Royale3D.tsx Normal file
View File

@@ -0,0 +1,540 @@
import { useEffect, useRef, useState, type MutableRefObject } from "react";
import * as THREE from "three";
import {
ROYALE_CHUNK_SIZE,
ROYALE_WORLD_RADIUS,
generateRoyaleChunk,
royaleChunkCoordinate,
type RoyaleClientState,
type RoyaleLootView,
type RoyalePlayerView,
} from "@syncer/shared";
interface Royale3DProps {
world: RoyaleClientState;
playerId: number | null;
}
interface TemporaryEffect {
object: THREE.Object3D;
expiresAt: number;
startedAt?: number;
start?: THREE.Vector3;
end?: THREE.Vector3;
}
interface RoyaleRuntime {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
viewWeapon: THREE.Group;
viewMuzzle: THREE.PointLight;
viewMuzzleCore: THREE.Mesh;
muzzleUntil: number;
chunks: Map<string, THREE.Group>;
players: Map<number, THREE.Group>;
loot: Map<number, THREE.Group>;
storm: THREE.Mesh<THREE.RingGeometry, THREE.MeshBasicMaterial>;
effects: TemporaryEffect[];
resizeObserver: ResizeObserver;
animationFrame: number;
}
export function Royale3D({ world, playerId }: Royale3DProps) {
const hostRef = useRef<HTMLDivElement>(null);
const worldRef = useRef(world);
const playerIdRef = useRef(playerId);
const runtimeRef = useRef<RoyaleRuntime | null>(null);
const lastEventIdRef = useRef(0);
const [locked, setLocked] = useState(false);
worldRef.current = world;
playerIdRef.current = playerId;
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.18;
host.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x8bb4c2);
scene.fog = new THREE.FogExp2(0x9cb9bc, 0.0022);
const camera = new THREE.PerspectiveCamera(78, 1, 0.045, 1_100);
camera.rotation.order = "YXZ";
scene.add(camera);
const {
group: viewWeapon,
muzzle: viewMuzzle,
muzzleCore: viewMuzzleCore,
} = buildViewWeapon();
camera.add(viewWeapon);
scene.add(new THREE.HemisphereLight(0xcbe8ed, 0x253426, 2.2));
const sun = new THREE.DirectionalLight(0xfff0cf, 3.25);
sun.position.set(-180, 280, 90);
sun.castShadow = true;
sun.shadow.mapSize.set(1_024, 1_024);
sun.shadow.camera.left = -100;
sun.shadow.camera.right = 100;
sun.shadow.camera.top = 100;
sun.shadow.camera.bottom = -100;
scene.add(sun);
const water = new THREE.Mesh(
new THREE.PlaneGeometry(2_500, 2_500),
new THREE.MeshStandardMaterial({ color: 0x1a6276, roughness: 0.32, metalness: 0.22 }),
);
water.rotation.x = -Math.PI / 2;
water.position.y = -1.2;
scene.add(water);
const storm = new THREE.Mesh(
new THREE.RingGeometry(0.994, 1, 160),
new THREE.MeshBasicMaterial({ color: 0x68d8ff, transparent: true, opacity: 0.84, side: THREE.DoubleSide }),
);
storm.rotation.x = -Math.PI / 2;
storm.position.y = 0.24;
scene.add(storm);
const runtime: RoyaleRuntime = {
renderer,
scene,
camera,
viewWeapon,
viewMuzzle,
viewMuzzleCore,
muzzleUntil: 0,
chunks: new Map(),
players: new Map(),
loot: new Map(),
storm,
effects: [],
resizeObserver: new ResizeObserver(() => resize(runtime, host)),
animationFrame: 0,
};
runtimeRef.current = runtime;
runtime.resizeObserver.observe(host);
resize(runtime, host);
const animate = (time: number) => {
updateRuntime(runtime, worldRef.current, playerIdRef.current, lastEventIdRef, time);
renderer.render(scene, camera);
runtime.animationFrame = window.requestAnimationFrame(animate);
};
runtime.animationFrame = window.requestAnimationFrame(animate);
const pointerLockChanged = () => setLocked(document.pointerLockElement === renderer.domElement);
document.addEventListener("pointerlockchange", pointerLockChanged);
return () => {
document.removeEventListener("pointerlockchange", pointerLockChanged);
runtime.resizeObserver.disconnect();
window.cancelAnimationFrame(runtime.animationFrame);
scene.traverse((object) => disposeObject(object));
renderer.dispose();
renderer.domElement.remove();
runtimeRef.current = null;
};
}, []);
const deploy = () => {
const canvas = runtimeRef.current?.renderer.domElement;
if (!canvas) return;
void canvas.requestPointerLock().catch(() => setLocked(false));
};
return (
<div className="royale-viewport" ref={hostRef}>
{!locked && (
<button className="royale-deploy" type="button" onClick={deploy}>
<small>PROCEDURAL ISLAND // LIVE AUTHORITY</small>
<strong>DROP IN</strong>
<span>WASD · MOUSE AIM · FIRE · SHIFT SPRINT · R RELOAD</span>
</button>
)}
</div>
);
}
function updateRuntime(
runtime: RoyaleRuntime,
world: RoyaleClientState,
playerId: number | null,
lastEventIdRef: MutableRefObject<number>,
time: number,
): void {
const local = world.players.find((player) => player.id === playerId);
if (local) {
updateChunks(runtime, local.x, local.z);
runtime.camera.position.set(local.x, local.altitude + 1.55, local.z);
runtime.camera.rotation.set(local.pitch, Math.PI + local.yaw, 0);
runtime.viewWeapon.visible = local.alive;
const moving = Math.hypot(local.velocityX, local.velocityZ);
runtime.viewWeapon.position.x =
0.36 + Math.cos(time * 0.0065) * Math.min(0.009, moving * 0.0012);
runtime.viewWeapon.position.y =
-0.31 + Math.sin(time * 0.013) * Math.min(0.024, moving * 0.003);
} else runtime.viewWeapon.visible = false;
runtime.viewMuzzle.intensity = time < runtime.muzzleUntil ? 14 : 0;
runtime.viewMuzzleCore.visible = time < runtime.muzzleUntil;
runtime.storm.position.x = world.stormX;
runtime.storm.position.z = world.stormZ;
runtime.storm.scale.set(world.stormRadius, world.stormRadius, 1);
runtime.storm.material.opacity = 0.62 + Math.sin(time * 0.003) * 0.18;
updatePlayers(runtime, world.players, playerId, time);
updateLoot(runtime, world.loot, time);
updateEffects(runtime, world, playerId, lastEventIdRef, time);
}
function updateChunks(runtime: RoyaleRuntime, x: number, z: number): void {
const centerX = royaleChunkCoordinate(x);
const centerZ = royaleChunkCoordinate(z);
const wanted = new Set<string>();
const radius = 3;
for (let offsetX = -radius; offsetX <= radius; offsetX += 1) {
for (let offsetZ = -radius; offsetZ <= radius; offsetZ += 1) {
const chunkX = centerX + offsetX;
const chunkZ = centerZ + offsetZ;
const chunkCenterX = (chunkX + 0.5) * ROYALE_CHUNK_SIZE;
const chunkCenterZ = (chunkZ + 0.5) * ROYALE_CHUNK_SIZE;
if (Math.hypot(chunkCenterX, chunkCenterZ) > ROYALE_WORLD_RADIUS + 90) continue;
const key = `${chunkX}:${chunkZ}`;
wanted.add(key);
if (!runtime.chunks.has(key)) {
const group = buildChunk(chunkX, chunkZ);
runtime.chunks.set(key, group);
runtime.scene.add(group);
}
}
}
for (const [key, group] of runtime.chunks) {
if (wanted.has(key)) continue;
runtime.chunks.delete(key);
runtime.scene.remove(group);
group.traverse((object) => disposeObject(object));
}
}
function buildChunk(chunkX: number, chunkZ: number): THREE.Group {
const chunk = generateRoyaleChunk(chunkX, chunkZ);
const group = new THREE.Group();
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(ROYALE_CHUNK_SIZE, ROYALE_CHUNK_SIZE),
new THREE.MeshStandardMaterial({ color: chunk.tint, roughness: 0.93, metalness: 0.02 }),
);
ground.rotation.x = -Math.PI / 2;
ground.position.set(
chunkX * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2,
0,
chunkZ * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2,
);
ground.receiveShadow = true;
group.add(ground);
const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x4b514c, roughness: 0.88 });
if (chunk.roadX) {
const road = new THREE.Mesh(new THREE.PlaneGeometry(ROYALE_CHUNK_SIZE, 18), roadMaterial);
road.rotation.x = -Math.PI / 2;
road.position.set(ground.position.x, 0.035, ground.position.z);
group.add(road);
}
if (chunk.roadZ) {
const road = new THREE.Mesh(new THREE.PlaneGeometry(18, ROYALE_CHUNK_SIZE), roadMaterial.clone());
road.rotation.x = -Math.PI / 2;
road.position.set(ground.position.x, 0.04, ground.position.z);
group.add(road);
}
for (const obstacle of chunk.obstacles) {
if (obstacle.kind === "building") {
const building = new THREE.Mesh(
new THREE.BoxGeometry(obstacle.width, obstacle.height, obstacle.depth),
new THREE.MeshStandardMaterial({ color: 0x707b72, roughness: 0.78, metalness: 0.12 }),
);
building.position.set(obstacle.x, obstacle.height / 2, obstacle.z);
building.castShadow = true;
building.receiveShadow = true;
group.add(building);
const roof = new THREE.Mesh(
new THREE.BoxGeometry(obstacle.width + 0.6, 0.35, obstacle.depth + 0.6),
new THREE.MeshStandardMaterial({ color: 0x30383a, roughness: 0.7 }),
);
roof.position.set(obstacle.x, obstacle.height + 0.18, obstacle.z);
group.add(roof);
} else if (obstacle.kind === "tree") {
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.65, 0.9, obstacle.height * 0.48, 7),
new THREE.MeshStandardMaterial({ color: 0x58442d, roughness: 1 }),
);
trunk.position.set(obstacle.x, obstacle.height * 0.24, obstacle.z);
trunk.castShadow = true;
group.add(trunk);
const crown = new THREE.Mesh(
new THREE.ConeGeometry(obstacle.width * 1.25, obstacle.height * 0.72, 8),
new THREE.MeshStandardMaterial({ color: 0x244f34, roughness: 0.92 }),
);
crown.position.set(obstacle.x, obstacle.height * 0.7, obstacle.z);
crown.castShadow = true;
group.add(crown);
} else {
const rock = new THREE.Mesh(
new THREE.DodecahedronGeometry(obstacle.width * 0.48, 0),
new THREE.MeshStandardMaterial({ color: 0x646a65, roughness: 0.96 }),
);
rock.scale.y = obstacle.height / obstacle.width;
rock.position.set(obstacle.x, obstacle.height * 0.34, obstacle.z);
rock.rotation.set(0.2, obstacle.x * 0.1, -0.12);
rock.castShadow = true;
group.add(rock);
}
}
return group;
}
function updatePlayers(
runtime: RoyaleRuntime,
players: RoyalePlayerView[],
playerId: number | null,
time: number,
): void {
const visible = new Set(players.map((player) => player.id));
for (const player of players) {
let group = runtime.players.get(player.id);
if (!group) {
group = buildPlayer(player.id === playerId, player.bot);
runtime.players.set(player.id, group);
runtime.scene.add(group);
}
group.position.set(player.x, player.altitude, player.z);
group.rotation.y = player.yaw;
group.visible = player.alive && player.id !== playerId;
const canopy = group.getObjectByName("canopy");
if (canopy) canopy.visible = player.altitude > 0;
const pulse = group.getObjectByName("pulse") as THREE.PointLight | undefined;
if (pulse) pulse.intensity = player.firing ? 15 : 2 + Math.sin(time * 0.005 + player.id) * 0.8;
}
for (const [id, group] of runtime.players) {
if (visible.has(id)) continue;
runtime.players.delete(id);
runtime.scene.remove(group);
group.traverse((object) => disposeObject(object));
}
}
function buildPlayer(local: boolean, bot: boolean): THREE.Group {
const group = new THREE.Group();
const color = local ? 0x69efff : bot ? 0xff8257 : 0xf7e9bd;
const body = new THREE.Mesh(
new THREE.CapsuleGeometry(0.72, 1.35, 4, 8),
new THREE.MeshStandardMaterial({ color, roughness: 0.45, metalness: 0.28 }),
);
body.position.y = 1.35;
body.castShadow = true;
group.add(body);
const weapon = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.18, 1.55),
new THREE.MeshStandardMaterial({ color: 0x202a2d, metalness: 0.65, roughness: 0.35 }),
);
weapon.position.set(0.42, 1.45, 0.55);
group.add(weapon);
const pulse = new THREE.PointLight(color, 3, 12, 2);
pulse.name = "pulse";
pulse.position.y = 1.8;
group.add(pulse);
const canopy = new THREE.Group();
canopy.name = "canopy";
const wing = new THREE.Mesh(
new THREE.SphereGeometry(3.4, 16, 8, 0, Math.PI * 2, 0, Math.PI * 0.48),
new THREE.MeshStandardMaterial({ color: local ? 0x4ddff8 : 0x3c4747, side: THREE.DoubleSide, roughness: 0.66 }),
);
wing.scale.z = 0.42;
wing.position.y = 5.2;
canopy.add(wing);
group.add(canopy);
return group;
}
function buildViewWeapon(): {
group: THREE.Group;
muzzle: THREE.PointLight;
muzzleCore: THREE.Mesh;
} {
const group = new THREE.Group();
group.position.set(0.36, -0.31, -0.62);
group.visible = false;
const body = new THREE.Mesh(
new THREE.BoxGeometry(0.18, 0.2, 0.9),
new THREE.MeshStandardMaterial({
color: 0x263438,
metalness: 0.72,
roughness: 0.28,
}),
);
body.position.z = -0.18;
group.add(body);
const rail = new THREE.Mesh(
new THREE.BoxGeometry(0.1, 0.06, 0.58),
new THREE.MeshStandardMaterial({
color: 0x76e9ef,
emissive: 0x1b8792,
emissiveIntensity: 0.72,
metalness: 0.48,
roughness: 0.3,
}),
);
rail.position.set(0, 0.12, -0.25);
group.add(rail);
const muzzleCore = new THREE.Mesh(
new THREE.SphereGeometry(0.07, 10, 8),
new THREE.MeshBasicMaterial({ color: 0xffe0a0 }),
);
muzzleCore.position.set(0, 0, -0.72);
muzzleCore.visible = false;
group.add(muzzleCore);
const muzzle = new THREE.PointLight(0xffc66d, 0, 3.5, 2);
muzzle.position.copy(muzzleCore.position);
group.add(muzzle);
return { group, muzzle, muzzleCore };
}
function updateLoot(runtime: RoyaleRuntime, loot: RoyaleLootView[], time: number): void {
const visible = new Set(loot.map((item) => item.id));
for (const item of loot) {
let group = runtime.loot.get(item.id);
if (!group) {
group = buildLoot(item);
runtime.loot.set(item.id, group);
runtime.scene.add(group);
}
group.position.set(item.x, 0.7 + Math.sin(time * 0.002 + item.id) * 0.18, item.z);
group.rotation.y = time * 0.0006 + item.id;
}
for (const [id, group] of runtime.loot) {
if (visible.has(id)) continue;
runtime.loot.delete(id);
runtime.scene.remove(group);
group.traverse((object) => disposeObject(object));
}
}
function buildLoot(item: RoyaleLootView): THREE.Group {
const group = new THREE.Group();
const color = item.kind === "weapon" ? 0xffc75f : item.kind === "ammo" ? 0x8ceaff : item.kind === "armor" ? 0x9b8cff : 0x71ef9c;
const mesh = new THREE.Mesh(
item.kind === "weapon" ? new THREE.BoxGeometry(1.3, 0.28, 0.45) : new THREE.OctahedronGeometry(0.62),
new THREE.MeshStandardMaterial({ color, emissive: color, emissiveIntensity: 0.7, metalness: 0.45, roughness: 0.3 }),
);
mesh.castShadow = true;
group.add(mesh);
group.add(new THREE.PointLight(color, 6, 9, 2));
return group;
}
function updateEffects(
runtime: RoyaleRuntime,
world: RoyaleClientState,
playerId: number | null,
lastEventIdRef: MutableRefObject<number>,
time: number,
): void {
for (const entry of world.events) {
if (entry.event.id <= lastEventIdRef.current || entry.event.type !== "shot") continue;
const event = entry.event;
if (event.sourceId === playerId) runtime.muzzleUntil = time + 85;
const horizontal = Math.cos(event.pitch);
const direction = new THREE.Vector3(
Math.sin(event.yaw) * horizontal,
Math.sin(event.pitch),
Math.cos(event.yaw) * horizontal,
).normalize();
const start = new THREE.Vector3(event.x, event.y, event.z)
.addScaledVector(direction, 1.35);
const end = new THREE.Vector3(
event.x + direction.x * 86,
event.y + direction.y * 86,
event.z + direction.z * 86,
);
const length = start.distanceTo(end);
const trail = new THREE.Mesh(
new THREE.CylinderGeometry(0.028, 0.055, length, 6),
new THREE.MeshBasicMaterial({
color: 0xffd27a,
transparent: true,
opacity: 0.72,
depthWrite: false,
}),
);
trail.position.copy(start).add(end).multiplyScalar(0.5);
trail.quaternion.setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
direction,
);
runtime.scene.add(trail);
runtime.effects.push({ object: trail, expiresAt: time + 165 });
const bullet = new THREE.Group();
const core = new THREE.Mesh(
new THREE.SphereGeometry(0.13, 10, 8),
new THREE.MeshBasicMaterial({ color: 0xfff1bd }),
);
bullet.add(core);
bullet.add(new THREE.PointLight(0xffbd5c, 10, 5, 2));
bullet.position.copy(start);
runtime.scene.add(bullet);
runtime.effects.push({
object: bullet,
startedAt: time,
expiresAt: time + 230,
start,
end,
});
}
lastEventIdRef.current = world.events.reduce(
(latest, entry) => Math.max(latest, entry.event.id),
lastEventIdRef.current,
);
runtime.effects = runtime.effects.filter((effect) => {
if (
effect.startedAt !== undefined &&
effect.start &&
effect.end
) {
const progress = clamp01(
(time - effect.startedAt) / (effect.expiresAt - effect.startedAt),
);
effect.object.position.lerpVectors(effect.start, effect.end, progress);
}
if (effect.expiresAt > time) return true;
runtime.scene.remove(effect.object);
effect.object.traverse((object) => disposeObject(object));
return false;
});
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
function resize(runtime: RoyaleRuntime, host: HTMLDivElement): void {
const width = Math.max(1, host.clientWidth);
const height = Math.max(1, host.clientHeight);
runtime.renderer.setSize(width, height, false);
runtime.camera.aspect = width / height;
runtime.camera.updateProjectionMatrix();
}
function disposeObject(object: THREE.Object3D): void {
if (!(object instanceof THREE.Mesh || object instanceof THREE.Line)) return;
object.geometry.dispose();
const materials = Array.isArray(object.material) ? object.material : [object.material];
for (const material of materials) material.dispose();
}

179
apps/web/src/RoyaleGame.tsx Normal file
View File

@@ -0,0 +1,179 @@
import {
ROYALE_WEAPONS,
type RoyaleClientState,
type RoyalePlayerView,
} from "@syncer/shared";
import { Royale3D } from "./Royale3D.js";
import { useRoyaleClient } from "./useRoyaleClient.js";
export function RoyaleGame() {
const client = useRoyaleClient();
const local = client.world.players.find((player) => player.id === client.playerId);
const recent = [...client.world.events].reverse();
const damage = recent.find((entry) => entry.event.type === "damage");
const hit = recent.find((entry) => entry.event.type === "hit");
const pickup = recent.find((entry) => entry.event.type === "pickup");
const eliminations = recent
.filter((entry) => entry.event.type === "elimination")
.slice(0, 4);
const weapon = ROYALE_WEAPONS[local?.weapon ?? "pistol"];
const stormDistance = local
? Math.hypot(local.x - client.world.stormX, local.z - client.world.stormZ) -
client.world.stormRadius
: 0;
const recentDamage = Boolean(
damage && client.tick - damage.receivedTick < 18,
);
const recentHit = Boolean(hit && client.tick - hit.receivedTick < 12);
return (
<main className="royale-game">
<Royale3D world={client.world} playerId={client.playerId} />
<div className="royale-grade" aria-hidden="true" />
{recentDamage ? <div className="royale-damage" aria-hidden="true" /> : null}
<header className="royale-header">
<div className="royale-brand">
<small>SYNCER // LARGE WORLD PROOF</small>
<strong>SYNCER ROYALE</strong>
</div>
<div className="royale-status">
<span><b>{client.world.aliveCount}</b> ALIVE</span>
<span>ROUND <b>{client.world.round}</b></span>
<span className={stormDistance > 0 ? "is-danger" : ""}>
{stormDistance > 0 ? `${Math.ceil(stormDistance)}M OUTSIDE` : `${Math.round(client.world.stormRadius)}M CIRCLE`}
</span>
</div>
<div className={`royale-live royale-live--${client.connection}`}>
<i />
<span>{client.connection}</span>
<small>{client.network.roundTripTime.toFixed(0)} ms · {client.validation}</small>
</div>
</header>
<section className="royale-stream" aria-label="Spatial stream telemetry">
<small>SPATIAL STREAM</small>
<div><span>CHUNK</span><b>{client.world.stream.chunkX}:{client.world.stream.chunkZ}</b></div>
<div><span>VISIBLE</span><b>{client.world.players.length - 1} / {client.world.aliveCount - 1}</b></div>
<div><span>ENTITIES</span><b>{client.world.stream.candidateCount - client.world.stream.droppedCount}</b></div>
<div><span>BUDGET</span><b>{formatBytes(client.world.stream.usedBytes)} / {formatBytes(client.world.stream.budgetBytes)}</b></div>
<div><span>TICK</span><b>{client.tick}</b></div>
</section>
<Radar world={client.world} local={local} />
<section className="royale-feed" aria-live="polite">
{eliminations.map((entry) => {
if (entry.event.type !== "elimination") return null;
return (
<div key={entry.event.id}>
<span>{combatant(entry.event.killerId)}</span>
<b>ELIMINATED</b>
<span>{combatant(entry.event.victimId)}</span>
</div>
);
})}
</section>
<div className={`royale-crosshair${recentHit ? " royale-crosshair--hit" : ""}`}>
<i /><i /><i /><i />
</div>
{recentHit && hit?.event.type === "hit" ? (
<div className="royale-hit">{hit.event.eliminated ? "ELIMINATION" : `${hit.event.amount} DAMAGE`}</div>
) : null}
{pickup &&
pickup.event.type === "pickup" &&
client.tick - pickup.receivedTick < 45 ? (
<div className="royale-pickup">
ACQUIRED // {pickup.event.kind === "weapon" ? ROYALE_WEAPONS[pickup.event.weapon].name : `${pickup.event.kind} +${pickup.event.amount}`}
</div>
) : null}
{local?.altitude ? (
<section className="royale-drop">
<small>PARACHUTE INSERTION</small>
<strong>{Math.ceil(local.altitude)} M</strong>
<span><i style={{ height: `${Math.min(100, local.altitude / 1.1)}%` }} /></span>
</section>
) : null}
{local && !local.alive ? (
<section className="royale-eliminated">
<small>OPERATOR ELIMINATED</small>
<strong>{client.world.resetTicks > 0 ? `NEW ISLAND IN ${Math.ceil(client.world.resetTicks / 30)}` : "SPECTATING AUTHORITY"}</strong>
</section>
) : null}
{client.world.winnerId !== null ? (
<section className="royale-winner">
<small>LAST OPERATOR STANDING</small>
<strong>{combatant(client.world.winnerId)} WINS ROUND {client.world.round}</strong>
</section>
) : null}
<footer className="royale-hud">
<div className="royale-vitals">
<small>HEALTH</small>
<strong>{local?.health ?? 0}</strong>
<span><i style={{ width: `${local?.health ?? 0}%` }} /></span>
<em>ARMOR {local?.armor ?? 0}</em>
</div>
<div className="royale-mission">
<strong>2 KM PROCEDURAL ISLAND</strong>
<small>PUBLIC TERRAIN SEED · PRIVATE LOOT · 31 AUTHORITY BOTS</small>
</div>
<div className="royale-weapon">
<small>{weapon.name}</small>
<strong>{local?.magazine ?? 0}<i>/ {local?.reserve ?? 0}</i></strong>
<span>{local?.reloadTicks ? "RELOADING" : local?.weapon.toUpperCase() ?? "PISTOL"}</span>
</div>
</footer>
</main>
);
}
function Radar({ world, local }: { world: RoyaleClientState; local: RoyalePlayerView | undefined }) {
const scale = 150;
return (
<section className="royale-radar" aria-label="Local spatial radar">
<div className="royale-radar__ring" />
<i className="royale-radar__self" />
{local
? world.players
.filter((player) => player.id !== local.id)
.map((player) => (
<i
className="royale-radar__enemy"
key={player.id}
style={{
left: `${50 + ((player.x - local.x) / scale) * 50}%`,
top: `${50 + ((player.z - local.z) / scale) * 50}%`,
}}
/>
))
: null}
{local
? world.loot.map((item) => (
<i
className="royale-radar__loot"
key={item.id}
style={{
left: `${50 + ((item.x - local.x) / scale) * 50}%`,
top: `${50 + ((item.z - local.z) / scale) * 50}%`,
}}
/>
))
: null}
<small>150 M LOCAL CELL</small>
</section>
);
}
function combatant(id: number): string {
return id === 0 ? "THE STORM" : id >= 30_000 ? `BOT ${id - 29_999}` : `PLAYER ${id}`;
}
function formatBytes(bytes: number): string {
return bytes >= 1_000 ? `${(bytes / 1_000).toFixed(1)}K` : `${bytes}B`;
}

142
apps/web/src/audio.ts Normal file
View File

@@ -0,0 +1,142 @@
import { Weapon, type ShooterPerception, type WeaponId } from "@syncer/shared";
export class ArenaAudio {
private context: AudioContext | null = null;
private master: GainNode | null = null;
unlock(): void {
if (!this.context) {
this.context = new AudioContext();
this.master = this.context.createGain();
this.master.gain.value = 0.26;
this.master.connect(this.context.destination);
}
void this.context.resume();
}
play(event: ShooterPerception): void {
if (!this.context || !this.master || this.context.state !== "running") return;
switch (event.type) {
case "shot":
this.shot(event.weapon, 0, 0.48);
if (event.impact === "player") this.tone(760, 520, 0.07, "square");
break;
case "sound":
if (event.sound === "footstep") this.footstep(event.bearingRadians, event.intensity);
else this.shot(event.weapon, event.bearingRadians, event.intensity);
break;
case "hit":
this.tone(
event.eliminated ? 520 : event.critical ? 920 : 760,
event.eliminated ? 180 : event.critical ? 620 : 520,
0.08,
event.critical ? "square" : "triangle",
);
break;
case "damage":
this.damage(event.bearingRadians);
break;
case "elimination":
this.tone(510, 180, 0.12, "triangle");
break;
case "pickup":
this.tone(440, 860, 0.15, "sine");
break;
case "respawn":
this.tone(220, 660, 0.28, "sine");
break;
}
}
dispose(): void {
void this.context?.close();
this.context = null;
this.master = null;
}
private footstep(bearing: number, intensity: number): void {
const context = this.context!;
const now = context.currentTime;
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(105, now);
oscillator.frequency.exponentialRampToValueAtTime(48, now + 0.09);
gain.gain.setValueAtTime(Math.max(0.012, intensity * 0.2), now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13);
oscillator.connect(gain).connect(this.panner(bearing));
oscillator.start(now);
oscillator.stop(now + 0.14);
}
private shot(weapon: WeaponId, bearing: number, intensity: number): void {
const context = this.context!;
const now = context.currentTime;
const panner = this.panner(bearing);
const duration = weapon === Weapon.RailRifle ? 0.23 : 0.16;
const noise = context.createBufferSource();
const buffer = context.createBuffer(1, Math.floor(context.sampleRate * duration), context.sampleRate);
const samples = buffer.getChannelData(0);
for (let index = 0; index < samples.length; index += 1) {
samples[index] = (Math.random() * 2 - 1) * Math.exp(-index / (samples.length * 0.18));
}
noise.buffer = buffer;
const filter = context.createBiquadFilter();
filter.type = weapon === Weapon.PulseRifle ? "bandpass" : "lowpass";
filter.frequency.value = weapon === Weapon.PulseRifle ? 1_500 : weapon === Weapon.Scattergun ? 760 : 2_200;
filter.Q.value = weapon === Weapon.PulseRifle ? 0.8 : 1.25;
const gain = context.createGain();
gain.gain.setValueAtTime(Math.max(0.02, intensity * (weapon === Weapon.Scattergun ? 0.62 : 0.46)), now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
noise.connect(filter).connect(gain).connect(panner);
noise.start(now);
const thump = context.createOscillator();
const thumpGain = context.createGain();
const base = weapon === Weapon.Scattergun ? 88 : weapon === Weapon.RailRifle ? 210 : 145;
thump.frequency.setValueAtTime(base, now);
thump.frequency.exponentialRampToValueAtTime(45, now + 0.11);
thumpGain.gain.setValueAtTime(intensity * 0.24, now);
thumpGain.gain.exponentialRampToValueAtTime(0.001, now + 0.12);
thump.connect(thumpGain).connect(panner);
thump.start(now);
thump.stop(now + 0.13);
}
private damage(bearing: number): void {
const context = this.context!;
const now = context.currentTime;
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = "sawtooth";
oscillator.frequency.setValueAtTime(72, now);
oscillator.frequency.exponentialRampToValueAtTime(34, now + 0.2);
gain.gain.setValueAtTime(0.22, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.22);
oscillator.connect(gain).connect(this.panner(bearing));
oscillator.start(now);
oscillator.stop(now + 0.23);
}
private tone(from: number, to: number, duration: number, type: OscillatorType): void {
const context = this.context!;
const now = context.currentTime;
const oscillator = context.createOscillator();
const gain = context.createGain();
oscillator.type = type;
oscillator.frequency.setValueAtTime(from, now);
oscillator.frequency.exponentialRampToValueAtTime(to, now + duration);
gain.gain.setValueAtTime(0.09, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + duration);
oscillator.connect(gain).connect(this.master!);
oscillator.start(now);
oscillator.stop(now + duration + 0.01);
}
private panner(bearing: number): StereoPannerNode {
const panner = this.context!.createStereoPanner();
panner.pan.value = Math.max(-1, Math.min(1, Math.sin(bearing)));
panner.connect(this.master!);
return panner;
}
}

17
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.js";
import "./styles.css";
const root = document.querySelector<HTMLDivElement>("#root");
if (!root) {
throw new Error("Root element was not found");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

602
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,602 @@
@import url("https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@500;600;700;800&family=IBM+Plex+Mono:wght@400;500;600&display=swap");
:root {
font-family: "Barlow Condensed", system-ui, sans-serif;
color: #eaf7fb;
background: #05080c;
font-synthesis: none;
text-rendering: optimizeLegibility;
}
* { box-sizing: border-box; }
html, body, #root { width: 100%; height: 100%; margin: 0; overflow: hidden; }
button { font: inherit; }
.game-switcher {
position: fixed;
z-index: 30;
top: 78px;
left: 50%;
display: flex;
padding: 3px;
border: 1px solid rgb(99 225 255 / 22%);
background: rgb(3 11 16 / 78%);
backdrop-filter: blur(10px);
transform: translateX(-50%);
}
.game-switcher button {
padding: 5px 10px;
border: 0;
color: #5f7f8a;
background: transparent;
font-family: "IBM Plex Mono", monospace;
font-size: .48rem;
letter-spacing: .12em;
cursor: pointer;
}
.game-switcher button:hover { color: #caeff7; }
.game-switcher .is-active { color: #b9f4ff; background: rgb(75 213 243 / 14%); }
.game {
position: relative;
width: 100%;
height: 100%;
min-width: 320px;
overflow: hidden;
background: #05080c;
user-select: none;
}
.viewport, .viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.viewport canvas { display: block; }
.vignette {
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgb(3 7 11 / 52%), transparent 20%, transparent 78%, rgb(2 5 8 / 72%)),
radial-gradient(circle, transparent 48%, rgb(0 0 0 / 44%) 100%);
}
.topbar {
position: absolute;
top: 0;
left: 0;
display: grid;
width: 100%;
grid-template-columns: 1fr auto 1fr;
align-items: start;
padding: 24px 28px;
pointer-events: none;
}
.brand { display: flex; align-items: center; gap: 11px; }
.brand-mark {
display: grid;
width: 46px;
height: 40px;
place-items: center;
border: 1px solid rgb(104 225 255 / 55%);
color: #7ce8ff;
background: rgb(5 19 27 / 70%);
box-shadow: inset 0 0 20px rgb(0 185 230 / 12%);
font-family: "IBM Plex Mono", monospace;
font-size: 0.9rem;
font-weight: 600;
clip-path: polygon(0 0, 88% 0, 100% 24%, 100% 100%, 12% 100%, 0 76%);
}
.brand strong { display: block; font-size: 1rem; letter-spacing: .14em; }
.brand small { color: #72909b; font-family: "IBM Plex Mono", monospace; font-size: .56rem; letter-spacing: .12em; }
.match-clock { min-width: 124px; padding: 6px 22px 9px; text-align: center; background: linear-gradient(90deg, transparent, rgb(7 20 28 / 78%) 18%, rgb(7 20 28 / 78%) 82%, transparent); }
.match-clock small { display: block; color: #7698a4; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .15em; }
.match-clock strong { color: #e8faff; font-family: "IBM Plex Mono", monospace; font-size: 1.32rem; letter-spacing: .12em; }
.connection { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; padding: 8px 12px; border-right: 2px solid #62e0ff; background: linear-gradient(90deg, transparent, rgb(4 19 27 / 75%)); text-align: right; }
.connection i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #66e4ff; box-shadow: 0 0 12px #4bdcff; }
.connection span { color: #8eeaff; font-size: .66rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
.connection b { color: #7a939c; font-family: "IBM Plex Mono", monospace; font-size: .53rem; font-weight: 500; }
.connection--connecting, .connection--reconnecting { border-color: #ffa84e; }
.connection--connecting i, .connection--reconnecting i { background: #ffa84e; box-shadow: 0 0 12px #ffa84e; }
.connection--connecting span, .connection--reconnecting span { color: #ffc17d; }
.telemetry { position: absolute; top: 104px; left: 28px; display: flex; flex-direction: column; gap: 1px; pointer-events: none; }
.telemetry > div { display: grid; min-width: 112px; grid-template-columns: 45px 1fr; gap: 8px; padding: 5px 9px; border-left: 1px solid rgb(92 213 239 / 40%); background: linear-gradient(90deg, rgb(3 15 21 / 74%), transparent); }
.telemetry small { color: #63818c; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .1em; }
.telemetry strong { color: #a9c2ca; font-family: "IBM Plex Mono", monospace; font-size: .54rem; font-weight: 500; }
.kill-feed { position: absolute; top: 105px; right: 28px; display: flex; align-items: flex-end; flex-direction: column; gap: 5px; pointer-events: none; }
.kill-feed div { display: flex; align-items: center; gap: 8px; padding: 5px 9px; border-right: 1px solid rgb(255 153 63 / 55%); background: linear-gradient(90deg, transparent, rgb(20 12 7 / 75%)); color: #afc1c7; font-family: "IBM Plex Mono", monospace; font-size: .58rem; }
.kill-feed b { color: #ff9c45; font-size: .48rem; }
.leaderboard {
position: absolute;
top: 210px;
right: 28px;
width: 178px;
padding: 9px 0 5px;
border-top: 1px solid rgb(94 220 247 / 35%);
background: linear-gradient(90deg, transparent, rgb(3 14 20 / 58%));
pointer-events: none;
}
.leaderboard > small {
display: block;
margin: 0 8px 6px;
color: #6b8a95;
font-family: "IBM Plex Mono", monospace;
font-size: .48rem;
letter-spacing: .14em;
text-align: right;
}
.leaderboard > div {
display: grid;
grid-template-columns: 17px 1fr 18px;
gap: 6px;
padding: 3px 8px;
color: #8ca4ad;
font-family: "IBM Plex Mono", monospace;
font-size: .52rem;
}
.leaderboard > div b { color: #506b75; font-weight: 500; }
.leaderboard > div strong { color: #d7eef5; text-align: right; }
.leaderboard .leaderboard__local {
color: #9aeeff;
background: linear-gradient(90deg, transparent, rgb(32 168 201 / 15%));
}
.crosshair { position: absolute; top: 50%; left: 50%; width: 40px; height: 40px; transform: translate(-50%, -50%); pointer-events: none; }
.crosshair::after { position: absolute; top: 18px; left: 18px; width: 4px; height: 4px; border: 1px solid rgb(225 248 255 / 90%); border-radius: 50%; content: ""; box-shadow: 0 0 8px rgb(80 223 255 / 80%); }
.crosshair i { position: absolute; display: block; background: rgb(224 249 255 / 88%); box-shadow: 0 0 4px rgb(46 214 255 / 60%); }
.crosshair i:nth-child(1), .crosshair i:nth-child(2) { top: 19px; width: 9px; height: 1px; }
.crosshair i:nth-child(1) { left: 2px; }
.crosshair i:nth-child(2) { right: 2px; }
.crosshair i:nth-child(3), .crosshair i:nth-child(4) { left: 19px; width: 1px; height: 9px; }
.crosshair i:nth-child(3) { top: 2px; }
.crosshair i:nth-child(4) { bottom: 2px; }
.crosshair--damage { animation: crosshair-hit .18s ease-out; }
@keyframes crosshair-hit { 50% { transform: translate(-50%, -50%) scale(1.5); filter: sepia(1) saturate(4); } }
.hit-confirm {
position: absolute;
top: calc(50% + 32px);
left: 50%;
color: #d9f9ff;
font-family: "IBM Plex Mono", monospace;
font-size: .48rem;
font-weight: 600;
letter-spacing: .18em;
text-shadow: 0 0 9px #66e5ff;
transform: translateX(-50%);
pointer-events: none;
}
.sound-indicator {
position: absolute;
z-index: 2;
top: 50%;
left: 50%;
width: 210px;
height: 210px;
border: 1px solid transparent;
border-radius: 50%;
pointer-events: none;
}
.sound-indicator i {
position: absolute;
top: -2px;
left: calc(50% - 10px);
width: 20px;
height: 2px;
background: #ffb761;
box-shadow: 0 0 10px #ff9e3e;
}
.deploy {
position: absolute;
z-index: 8;
top: 50%;
left: 50%;
display: flex;
width: min(400px, calc(100% - 40px));
flex-direction: column;
gap: 8px;
padding: 21px 28px;
border: 1px solid rgb(99 225 255 / 50%);
color: #dff9ff;
background: rgb(3 14 20 / 86%);
box-shadow: 0 0 80px rgb(0 178 221 / 12%), inset 0 0 28px rgb(0 178 221 / 6%);
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
cursor: crosshair;
transform: translate(-50%, -50%);
}
.deploy span { font-size: 1.25rem; font-weight: 800; letter-spacing: .2em; }
.deploy small { color: #6f939f; font-family: "IBM Plex Mono", monospace; font-size: .55rem; letter-spacing: .06em; }
.deploy:hover { border-color: #abf1ff; background: rgb(6 27 37 / 92%); }
.respawn-panel { position: absolute; top: 50%; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 16px 38px; border-block: 1px solid rgb(255 118 70 / 55%); background: linear-gradient(90deg, transparent, rgb(31 8 5 / 75%), transparent); transform: translate(-50%, -50%); pointer-events: none; }
.respawn-panel small { color: #c06c53; font-family: "IBM Plex Mono", monospace; font-size: .58rem; letter-spacing: .18em; }
.respawn-panel strong { color: #ffbea6; font-size: 1.4rem; letter-spacing: .15em; }
.killcam-panel {
position: absolute;
bottom: 126px;
left: 50%;
width: min(560px, calc(100% - 48px));
padding: 12px 15px 10px;
border-top: 1px solid rgb(255 164 79 / 62%);
border-bottom: 1px solid rgb(100 222 247 / 28%);
color: #e8f8fc;
background: linear-gradient(90deg, transparent, rgb(7 17 23 / 90%) 11%, rgb(7 17 23 / 90%) 89%, transparent);
transform: translateX(-50%);
pointer-events: none;
}
.killcam-panel__title { display: flex; align-items: end; justify-content: space-between; gap: 18px; }
.killcam-panel__title small { color: #8b6f5b; font-family: "IBM Plex Mono", monospace; font-size: .47rem; letter-spacing: .13em; }
.killcam-panel__title strong { color: #ffc28c; font-size: .87rem; letter-spacing: .16em; text-align: right; text-shadow: 0 0 14px rgb(255 134 48 / 40%); }
.killcam-panel__timeline { overflow: hidden; height: 3px; margin: 8px 0 6px; background: rgb(113 151 162 / 22%); transform: skewX(-22deg); }
.killcam-panel__timeline i { display: block; height: 100%; background: linear-gradient(90deg, #ff8e48, #7be5ff); box-shadow: 0 0 12px rgb(99 223 255 / 55%); }
.killcam-panel__meta { display: flex; justify-content: space-between; gap: 15px; color: #68838d; font-family: "IBM Plex Mono", monospace; font-size: .46rem; letter-spacing: .09em; }
.hud { position: absolute; right: 28px; bottom: 24px; left: 28px; display: grid; grid-template-columns: minmax(150px, .7fr) 1fr minmax(210px, .85fr) auto; align-items: end; gap: 30px; pointer-events: none; }
.health-block small, .weapon-block small { color: #77919a; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .15em; }
.health-block strong { display: block; margin-top: -2px; color: #d8f8ff; font-family: "IBM Plex Mono", monospace; font-size: 2.3rem; font-weight: 500; line-height: 1; }
.health-track { overflow: hidden; width: 150px; height: 3px; margin-top: 7px; background: rgb(117 150 160 / 28%); transform: skewX(-24deg); }
.health-track i { display: block; height: 100%; background: #65e2ff; box-shadow: 0 0 12px #42d8fb; transition: width .15s; }
.mission-note { align-self: end; padding-bottom: 1px; color: #92abb3; text-align: center; }
.mission-note span { display: block; font-size: .72rem; font-weight: 700; letter-spacing: .16em; }
.mission-note small { color: #5f7881; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .08em; }
.weapon-block { display: flex; align-items: end; justify-content: flex-end; gap: 18px; padding-right: 16px; border-right: 1px solid rgb(93 218 245 / 35%); text-align: right; }
.weapon-block div > span { display: block; margin-top: 4px; color: #5f7c86; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .08em; }
.weapon-block strong { color: #effcff; font-family: "IBM Plex Mono", monospace; font-size: 2.5rem; font-weight: 500; line-height: .9; }
.weapon-block strong i { color: #75909a; font-size: .72rem; font-style: normal; }
.score-block { display: flex; gap: 12px; }
.score-block span { display: flex; flex-direction: column; color: #d5edf3; font-family: "IBM Plex Mono", monospace; font-size: 1.2rem; }
.score-block small { color: #66838e; font-size: .5rem; }
.damage-flash { position: absolute; inset: 0; pointer-events: none; background: radial-gradient(circle, transparent 15%, rgb(158 22 7 / 28%)); animation: damage .3s ease-out forwards; }
@keyframes damage { from { opacity: 1; } to { opacity: 0; } }
.flux-game {
--team: #66e6ff;
position: relative;
display: grid;
width: 100%;
height: 100%;
min-width: 320px;
grid-template-rows: auto 1fr auto;
overflow: hidden;
color: #e8faff;
background:
radial-gradient(circle at 50% 45%, rgb(40 137 161 / 16%), transparent 26%),
radial-gradient(circle at 8% 50%, rgb(35 198 234 / 10%), transparent 25%),
radial-gradient(circle at 92% 50%, rgb(255 128 48 / 11%), transparent 25%),
#04080c;
user-select: none;
}
.flux-game--orange { --team: #ff9a4e; }
.flux-grid {
position: absolute;
inset: 0;
opacity: .18;
background-image:
linear-gradient(rgb(102 226 255 / 16%) 1px, transparent 1px),
linear-gradient(90deg, rgb(102 226 255 / 16%) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(circle at center, black, transparent 75%);
pointer-events: none;
}
.flux-header {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: start;
padding: 26px 34px;
}
.flux-header > div:first-child small,
.flux-identity small,
.flux-energy small {
display: block;
color: #66818a;
font-family: "IBM Plex Mono", monospace;
font-size: .48rem;
letter-spacing: .14em;
text-transform: uppercase;
}
.flux-header > div:first-child strong { display: block; color: #dffbff; font-size: 1.32rem; letter-spacing: .2em; }
.flux-score { display: grid; grid-template-columns: 54px auto 54px; align-items: center; gap: 15px; text-align: center; }
.flux-score b { color: #72eaff; font-family: "IBM Plex Mono", monospace; font-size: 1.6rem; font-weight: 500; text-shadow: 0 0 18px rgb(90 225 255 / 50%); }
.flux-score b:last-child { color: #ffa05d; text-shadow: 0 0 18px rgb(255 126 54 / 50%); }
.flux-score span { padding: 6px 18px; border-inline: 1px solid rgb(119 210 229 / 24%); color: #7d99a2; font-family: "IBM Plex Mono", monospace; font-size: .53rem; letter-spacing: .13em; }
.flux-connection { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; text-align: right; }
.flux-connection i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #66e4ff; box-shadow: 0 0 12px #4bdcff; }
.flux-connection span { color: #99eefd; font-size: .65rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
.flux-connection small { color: #627e87; font-family: "IBM Plex Mono", monospace; font-size: .48rem; }
.flux-connection--connecting i, .flux-connection--reconnecting i { background: #ff9c50; box-shadow: 0 0 12px #ff9c50; }
.flux-arena {
position: relative;
z-index: 1;
display: grid;
width: min(1200px, calc(100% - 64px));
grid-template-columns: 145px 1fr 145px;
align-items: center;
gap: 18px;
margin: auto;
}
.flux-field { display: grid; height: 230px; grid-template-columns: 80px 1fr 80px; align-items: center; }
.flux-lane {
position: relative;
height: 6px;
background: linear-gradient(90deg, #4be3ff, rgb(114 184 199 / 28%) 48%, rgb(114 184 199 / 28%) 52%, #ff8c42);
box-shadow: 0 0 24px rgb(75 224 255 / 10%);
}
.flux-lane::before,
.flux-lane::after { position: absolute; top: -42px; width: 1px; height: 90px; background: rgb(107 191 210 / 18%); content: ""; }
.flux-lane::before { left: 25%; }
.flux-lane::after { right: 25%; }
.flux-midline { position: absolute; top: -72px; left: 50%; width: 1px; height: 150px; background: linear-gradient(transparent, rgb(183 239 250 / 42%), transparent); }
.flux-core {
position: absolute;
top: 50%;
width: 74px;
height: 74px;
border: 1px solid #d6f9ff;
border-radius: 50%;
background: radial-gradient(circle, #e9fdff 0 8%, #6eeaff 10%, rgb(46 206 239 / 22%) 36%, transparent 38%);
box-shadow: 0 0 18px #62e6ff, 0 0 65px rgb(60 219 251 / 50%);
transform: translate(-50%, -50%);
transition: left 60ms linear;
}
.flux-core i { position: absolute; inset: -10px; border: 1px solid rgb(135 236 255 / 35%); border-radius: 50%; animation: flux-spin 4s linear infinite; }
.flux-core i:nth-child(2) { inset: -18px 8px; transform: rotate(64deg); animation-duration: 2.8s; }
.flux-core i:nth-child(3) { inset: 8px -18px; transform: rotate(-64deg); animation-duration: 3.4s; }
@keyframes flux-spin { to { transform: rotate(360deg); } }
.flux-gate { position: relative; height: 170px; border: 1px solid; opacity: .8; }
.flux-gate span { position: absolute; top: 50%; width: 110px; font-family: "IBM Plex Mono", monospace; font-size: .46rem; letter-spacing: .12em; text-align: center; transform: translateY(-50%) rotate(-90deg); }
.flux-gate--cyan { border-color: #53def9; border-right: 0; box-shadow: inset 18px 0 30px rgb(57 208 239 / 9%); }
.flux-gate--cyan span { left: -28px; color: #55dffb; }
.flux-gate--orange { border-color: #ff8c47; border-left: 0; box-shadow: inset -18px 0 30px rgb(255 125 48 / 9%); }
.flux-gate--orange span { right: -28px; color: #ff9554; }
.flux-roster { display: flex; flex-direction: column; gap: 7px; }
.flux-roster > small { margin-bottom: 5px; color: #526e77; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .13em; text-transform: uppercase; }
.flux-roster > div { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 7px; padding: 7px 8px; border-left: 1px solid rgb(88 211 238 / 24%); color: #77949d; background: linear-gradient(90deg, rgb(34 135 158 / 9%), transparent); font-family: "IBM Plex Mono", monospace; font-size: .48rem; }
.flux-roster--orange { text-align: right; }
.flux-roster--orange > div { grid-template-columns: auto 1fr 9px; border-right: 1px solid rgb(255 142 75 / 24%); border-left: 0; background: linear-gradient(90deg, transparent, rgb(159 77 34 / 9%)); }
.flux-roster--orange > div i { grid-column: 3; grid-row: 1; background: #70462f; }
.flux-roster--orange > div span { grid-column: 2; grid-row: 1; }
.flux-roster--orange > div b { grid-column: 1; grid-row: 1; }
.flux-roster > div i { width: 6px; height: 6px; border-radius: 50%; background: #376977; }
.flux-roster > div b { color: #465f68; font-size: .4rem; font-weight: 500; }
.flux-roster > .flux-player--active { color: #baf7ff; }
.flux-roster > .flux-player--active i { background: #6ceaff; box-shadow: 0 0 10px #5ee5ff; }
.flux-roster--orange > .flux-player--active i { background: #ff9d5d; box-shadow: 0 0 10px #ff8c45; }
.flux-roster > .flux-player--local { border-color: var(--team); background: linear-gradient(90deg, rgb(64 208 239 / 18%), transparent); }
.flux-event { position: absolute; z-index: 4; top: 26%; left: 50%; padding: 8px 24px; border-block: 1px solid; background: linear-gradient(90deg, transparent, rgb(4 17 23 / 88%) 18%, rgb(4 17 23 / 88%) 82%, transparent); font-family: "IBM Plex Mono", monospace; font-size: .58rem; letter-spacing: .13em; transform: translateX(-50%); pointer-events: none; }
.flux-event--cyan { border-color: rgb(84 223 250 / 50%); color: #8bedff; }
.flux-event--orange { border-color: rgb(255 144 79 / 50%); color: #ffad76; }
.flux-controls {
position: relative;
z-index: 3;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 36px;
padding: 24px 34px 30px;
border-top: 1px solid rgb(109 205 225 / 12%);
background: linear-gradient(180deg, transparent, rgb(2 8 12 / 75%));
}
.flux-identity strong { display: block; margin: 3px 0; color: var(--team); font-size: .88rem; letter-spacing: .13em; }
.flux-identity span { color: #536d76; font-family: "IBM Plex Mono", monospace; font-size: .46rem; }
.flux-thrust { min-width: 250px; padding: 13px 28px; border: 1px solid color-mix(in srgb, var(--team) 48%, transparent); color: #dffaff; background: rgb(6 23 30 / 84%); cursor: pointer; clip-path: polygon(10px 0, calc(100% - 10px) 0, 100% 10px, 100% calc(100% - 10px), calc(100% - 10px) 100%, 10px 100%, 0 calc(100% - 10px), 0 10px); }
.flux-thrust small { display: block; color: #5e7d87; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .11em; }
.flux-thrust strong { display: block; margin-top: 3px; font-size: .92rem; letter-spacing: .16em; }
.flux-thrust:hover { border-color: var(--team); }
.flux-thrust--active { color: #fff; background: color-mix(in srgb, var(--team) 20%, rgb(5 21 28)); box-shadow: 0 0 28px color-mix(in srgb, var(--team) 25%, transparent), inset 0 0 24px color-mix(in srgb, var(--team) 14%, transparent); }
.flux-energy { justify-self: end; width: 245px; }
.flux-energy > div { display: flex; align-items: end; justify-content: space-between; }
.flux-energy strong { color: var(--team); font-family: "IBM Plex Mono", monospace; font-size: 1.1rem; font-weight: 500; }
.flux-energy > span { display: block; overflow: hidden; height: 4px; margin: 7px 0; background: rgb(119 155 165 / 20%); transform: skewX(-20deg); }
.flux-energy > span i { display: block; height: 100%; background: var(--team); box-shadow: 0 0 13px var(--team); transition: width 80ms linear; }
.flux-energy em { display: block; color: #4c6871; font-family: "IBM Plex Mono", monospace; font-size: .43rem; font-style: normal; letter-spacing: .08em; text-align: right; }
.royale-game {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
color: #f1f5e9;
background: #17291f;
user-select: none;
}
.royale-viewport,
.royale-viewport canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.royale-viewport canvas { display: block; }
.royale-grade {
position: absolute;
inset: 0;
background:
linear-gradient(180deg, rgb(7 14 10 / 54%), transparent 24%, transparent 70%, rgb(4 9 7 / 70%)),
radial-gradient(circle, transparent 52%, rgb(3 7 5 / 44%));
pointer-events: none;
}
.royale-deploy {
position: absolute;
z-index: 12;
top: 50%;
left: 50%;
display: flex;
width: min(430px, calc(100% - 36px));
flex-direction: column;
gap: 7px;
padding: 24px 34px;
border: 1px solid rgb(198 231 161 / 55%);
color: #efffdc;
background: rgb(8 19 12 / 86%);
box-shadow: 0 0 80px rgb(99 177 67 / 17%), inset 0 0 34px rgb(124 202 82 / 7%);
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
cursor: crosshair;
transform: translate(-50%, -50%);
}
.royale-deploy small,
.royale-deploy span { color: #84977b; font-family: "IBM Plex Mono", monospace; font-size: .5rem; letter-spacing: .1em; }
.royale-deploy strong { font-size: 1.55rem; letter-spacing: .24em; }
.royale-deploy:hover { border-color: #e4ffc6; background: rgb(12 30 18 / 92%); }
.royale-header {
position: absolute;
z-index: 4;
top: 0;
left: 0;
display: grid;
width: 100%;
grid-template-columns: 1fr auto 1fr;
align-items: start;
padding: 24px 30px;
pointer-events: none;
}
.royale-brand small { display: block; color: #899987; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .13em; }
.royale-brand strong { display: block; margin-top: 2px; color: #efffda; font-size: 1.13rem; letter-spacing: .2em; text-shadow: 0 0 20px rgb(151 214 98 / 24%); }
.royale-status { display: flex; align-items: center; gap: 1px; background: rgb(8 19 12 / 65%); }
.royale-status span { padding: 7px 14px; border-inline: 1px solid rgb(208 232 187 / 11%); color: #9dab96; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .1em; }
.royale-status b { color: #efffd8; font-size: .74rem; font-weight: 500; }
.royale-status .is-danger { color: #ff8a65; }
.royale-live { justify-self: end; display: grid; grid-template-columns: auto auto; align-items: center; column-gap: 8px; text-align: right; }
.royale-live i { grid-row: 1 / span 2; width: 7px; height: 7px; border-radius: 50%; background: #b7f27c; box-shadow: 0 0 12px #a7eb67; }
.royale-live span { color: #c9f4a4; font-size: .64rem; font-weight: 800; letter-spacing: .14em; text-transform: uppercase; }
.royale-live small { color: #758172; font-family: "IBM Plex Mono", monospace; font-size: .46rem; }
.royale-live--connecting i,
.royale-live--reconnecting i { background: #ffad68; box-shadow: 0 0 12px #ff9a4d; }
.royale-stream {
position: absolute;
z-index: 4;
top: 108px;
left: 30px;
width: 174px;
padding: 9px 10px;
border-left: 1px solid rgb(187 226 153 / 35%);
background: linear-gradient(90deg, rgb(8 18 12 / 80%), transparent);
pointer-events: none;
}
.royale-stream > small { display: block; margin-bottom: 6px; color: #81917d; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .14em; }
.royale-stream div { display: grid; grid-template-columns: 58px 1fr; padding: 2px 0; font-family: "IBM Plex Mono", monospace; font-size: .45rem; }
.royale-stream span { color: #60705e; }
.royale-stream b { color: #b3c8a7; font-weight: 500; }
.royale-radar {
position: absolute;
z-index: 4;
top: 106px;
right: 30px;
overflow: hidden;
width: 148px;
height: 148px;
border: 1px solid rgb(190 226 157 / 35%);
border-radius: 50%;
background:
linear-gradient(rgb(176 215 144 / 8%) 1px, transparent 1px),
linear-gradient(90deg, rgb(176 215 144 / 8%) 1px, transparent 1px),
rgb(5 15 9 / 74%);
background-size: 18px 18px;
box-shadow: inset 0 0 30px rgb(83 141 61 / 12%);
pointer-events: none;
}
.royale-radar__ring { position: absolute; inset: 24%; border: 1px solid rgb(194 231 164 / 15%); border-radius: 50%; }
.royale-radar i { position: absolute; display: block; transform: translate(-50%, -50%); }
.royale-radar__self { top: 50%; left: 50%; width: 7px; height: 7px; border: 1px solid #ecffd6; transform: translate(-50%, -50%) rotate(45deg) !important; box-shadow: 0 0 8px #beef8f; }
.royale-radar__enemy { width: 5px; height: 5px; border-radius: 50%; background: #ff7957; box-shadow: 0 0 7px #ff5d3d; }
.royale-radar__loot { width: 3px; height: 3px; background: #78dcff; box-shadow: 0 0 5px #68d7ff; }
.royale-radar small { position: absolute; right: 0; bottom: 11px; left: 0; color: #6f806c; font-family: "IBM Plex Mono", monospace; font-size: .39rem; letter-spacing: .11em; text-align: center; }
.royale-feed { position: absolute; z-index: 4; top: 274px; right: 30px; display: flex; align-items: flex-end; flex-direction: column; gap: 4px; pointer-events: none; }
.royale-feed div { display: flex; gap: 7px; padding: 4px 8px; border-right: 1px solid rgb(255 137 92 / 35%); color: #b7c1b2; background: linear-gradient(90deg, transparent, rgb(12 17 12 / 68%)); font-family: "IBM Plex Mono", monospace; font-size: .43rem; }
.royale-feed b { color: #ff9068; font-size: .38rem; }
.royale-crosshair { position: absolute; z-index: 3; top: 50%; left: 50%; width: 42px; height: 42px; transform: translate(-50%, -50%); pointer-events: none; }
.royale-crosshair::after { position: absolute; top: 19px; left: 19px; width: 4px; height: 4px; border: 1px solid #f4ffe5; border-radius: 50%; content: ""; }
.royale-crosshair i { position: absolute; background: rgb(241 255 224 / 88%); box-shadow: 0 0 5px rgb(177 239 113 / 65%); }
.royale-crosshair i:nth-child(1), .royale-crosshair i:nth-child(2) { top: 20px; width: 9px; height: 1px; }
.royale-crosshair i:nth-child(1) { left: 1px; }
.royale-crosshair i:nth-child(2) { right: 1px; }
.royale-crosshair i:nth-child(3), .royale-crosshair i:nth-child(4) { left: 20px; width: 1px; height: 9px; }
.royale-crosshair i:nth-child(3) { top: 1px; }
.royale-crosshair i:nth-child(4) { bottom: 1px; }
.royale-crosshair--hit { animation: royale-hit .16s ease-out; }
@keyframes royale-hit { 50% { transform: translate(-50%, -50%) scale(1.55); filter: sepia(1) saturate(5); } }
.royale-hit,
.royale-pickup { position: absolute; z-index: 4; left: 50%; color: #f0ffdf; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .15em; text-shadow: 0 0 9px #a9e877; transform: translateX(-50%); pointer-events: none; }
.royale-hit { top: calc(50% + 33px); }
.royale-pickup { bottom: 126px; color: #9fe7ff; text-shadow: 0 0 9px #55cfff; }
.royale-drop { position: absolute; z-index: 4; top: 50%; right: 33px; display: grid; grid-template-columns: auto 5px; gap: 4px 12px; color: #dff8ff; transform: translateY(-50%); pointer-events: none; }
.royale-drop small { color: #7d989c; font-family: "IBM Plex Mono", monospace; font-size: .42rem; letter-spacing: .12em; }
.royale-drop strong { grid-column: 1; font-family: "IBM Plex Mono", monospace; font-size: 1.4rem; font-weight: 500; text-align: right; }
.royale-drop span { grid-column: 2; grid-row: 1 / span 2; position: relative; overflow: hidden; width: 4px; height: 60px; background: rgb(137 183 190 / 22%); }
.royale-drop i { position: absolute; right: 0; bottom: 0; left: 0; background: #9cecff; box-shadow: 0 0 9px #6edfff; }
.royale-eliminated,
.royale-winner { position: absolute; z-index: 5; top: 50%; left: 50%; display: flex; flex-direction: column; align-items: center; gap: 5px; width: min(600px, calc(100% - 40px)); padding: 18px; border-block: 1px solid rgb(255 116 76 / 55%); background: linear-gradient(90deg, transparent, rgb(30 11 7 / 83%), transparent); transform: translate(-50%, -50%); pointer-events: none; }
.royale-eliminated small,
.royale-winner small { color: #bd735e; font-family: "IBM Plex Mono", monospace; font-size: .5rem; letter-spacing: .17em; }
.royale-eliminated strong,
.royale-winner strong { color: #ffd0bc; font-size: 1.25rem; letter-spacing: .18em; }
.royale-winner { border-color: rgb(184 239 129 / 58%); background: linear-gradient(90deg, transparent, rgb(12 29 9 / 86%), transparent); }
.royale-winner small { color: #8cad72; }
.royale-winner strong { color: #e8ffd0; }
.royale-damage { position: absolute; inset: 0; z-index: 2; background: radial-gradient(circle, transparent 22%, rgb(145 22 10 / 32%)); animation: damage .32s ease-out forwards; pointer-events: none; }
.royale-hud { position: absolute; z-index: 4; right: 30px; bottom: 24px; left: 30px; display: grid; grid-template-columns: 230px 1fr 260px; align-items: end; gap: 28px; pointer-events: none; }
.royale-vitals small,
.royale-weapon small { color: #83907d; font-family: "IBM Plex Mono", monospace; font-size: .48rem; letter-spacing: .13em; }
.royale-vitals strong { display: block; color: #efffde; font-family: "IBM Plex Mono", monospace; font-size: 2.15rem; font-weight: 500; line-height: 1; }
.royale-vitals > span { display: block; overflow: hidden; width: 180px; height: 4px; margin: 6px 0; background: rgb(155 174 144 / 24%); transform: skewX(-22deg); }
.royale-vitals > span i { display: block; height: 100%; background: #a8ed72; box-shadow: 0 0 12px #9ce362; }
.royale-vitals em { color: #7fa0ad; font-family: "IBM Plex Mono", monospace; font-size: .45rem; font-style: normal; }
.royale-mission { text-align: center; }
.royale-mission strong { display: block; color: #d4dfca; font-size: .7rem; letter-spacing: .17em; }
.royale-mission small { color: #71806d; font-family: "IBM Plex Mono", monospace; font-size: .43rem; letter-spacing: .08em; }
.royale-weapon { justify-self: end; min-width: 240px; padding-right: 14px; border-right: 1px solid rgb(194 225 166 / 30%); text-align: right; }
.royale-weapon strong { display: block; color: #f6ffec; font-family: "IBM Plex Mono", monospace; font-size: 2.35rem; font-weight: 500; line-height: .9; }
.royale-weapon strong i { color: #81907d; font-size: .72rem; font-style: normal; }
.royale-weapon span { color: #6e8069; font-family: "IBM Plex Mono", monospace; font-size: .45rem; letter-spacing: .1em; }
@media (max-width: 760px) {
.game-switcher { top: 70px; }
.topbar { padding: 16px; grid-template-columns: 1fr auto; }
.match-clock { display: none; }
.brand small, .telemetry, .mission-note { display: none; }
.kill-feed { top: 82px; right: 16px; }
.leaderboard { display: none; }
.hud { right: 16px; bottom: 16px; left: 16px; grid-template-columns: 1fr 1fr; gap: 18px; }
.weapon-block { grid-column: 2; grid-row: 1; }
.score-block { display: none; }
.killcam-panel { bottom: 104px; }
.killcam-panel__title small, .killcam-panel__meta span:last-child { display: none; }
.flux-header { grid-template-columns: 1fr auto; padding: 18px; }
.flux-score { grid-column: 1 / -1; grid-row: 2; margin-top: 38px; }
.flux-arena { width: calc(100% - 24px); grid-template-columns: 1fr; }
.flux-roster { display: none; }
.flux-field { height: 180px; grid-template-columns: 45px 1fr 45px; }
.flux-gate { height: 125px; }
.flux-controls { grid-template-columns: 1fr; gap: 13px; padding: 16px 20px 20px; }
.flux-identity { display: none; }
.flux-energy { justify-self: stretch; width: auto; }
.flux-thrust { grid-row: 1; min-width: 0; }
.royale-header { padding: 16px; grid-template-columns: 1fr auto; }
.royale-status { grid-column: 1 / -1; grid-row: 2; justify-self: center; margin-top: 42px; }
.royale-stream { top: 112px; left: 16px; }
.royale-radar { top: 112px; right: 16px; width: 112px; height: 112px; }
.royale-feed { display: none; }
.royale-hud { right: 16px; bottom: 14px; left: 16px; grid-template-columns: 1fr 1fr; gap: 18px; }
.royale-mission { display: none; }
.royale-weapon { min-width: 0; }
.royale-drop { right: 16px; }
}

View File

@@ -0,0 +1,208 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FixedStepClock, type NetworkStats } from "@syncer/engine";
import {
FLUX_SOCKET_PATH,
fluxGame,
type FluxClientState,
type FluxInput,
} from "@syncer/shared";
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
export interface FluxClientView {
connection: ConnectionStatus;
validation: ValidationStatus;
playerId: number | null;
tick: number;
inputLeadTicks: number;
world: FluxClientState;
network: NetworkStats;
setThrust(active: boolean): void;
}
const emptyNetworkStats: NetworkStats = {
roundTripTime: 0,
jitter: 0,
clockOffset: 0,
samples: 0,
};
function socketUrl(): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
if (window.location.protocol === "http:" && window.location.port === "5173") {
return `ws://${window.location.hostname}:3001${FLUX_SOCKET_PATH}`;
}
return `${protocol}//${window.location.host}${FLUX_SOCKET_PATH}`;
}
export function useFluxClient(): FluxClientView {
const engine = useMemo(() => fluxGame.createClient(), []);
const protocol = fluxGame.protocol;
const clock = useMemo(
() => new FixedStepClock({ rateHz: fluxGame.tickRateHz, maxCatchUpSteps: 5 }),
[],
);
const setThrustRef = useRef<(active: boolean) => void>(() => undefined);
const setThrust = useCallback((active: boolean) => setThrustRef.current(active), []);
const [view, setView] = useState<Omit<FluxClientView, "setThrust">>({
connection: "connecting",
validation: "waiting",
playerId: null,
tick: 0,
inputLeadTicks: 1,
world: fluxGame.client.createInitialState(),
network: emptyNetworkStats,
});
useEffect(() => {
let active = true;
let socket: WebSocket | undefined;
let retryTimer: number | undefined;
let animationFrame: number | undefined;
let connection: ConnectionStatus = "connecting";
let validation: ValidationStatus = "waiting";
let input: FluxInput = { thrust: false };
const publish = () => {
if (!active) return;
setView({
connection,
validation,
playerId: engine.localPlayerId,
tick: engine.tick,
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(fluxGame.tickRateHz),
world: fluxGame.client.cloneState(engine.currentState as FluxClientState),
network: engine.networkClock.stats,
});
};
const send = (frame: ArrayBuffer) => {
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
};
const sendInput = () => {
if (!engine.initialized) return;
send(protocol.encodeClient({ kind: "input", packet: engine.createInput(input) }));
};
const updateThrust = (thrust: boolean) => {
if (input.thrust === thrust) return;
input = { thrust };
sendInput();
publish();
};
setThrustRef.current = updateThrust;
const connect = () => {
connection = engine.initialized ? "reconnecting" : "connecting";
publish();
socket = new WebSocket(socketUrl());
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (!active) return;
connection = "live";
publish();
});
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
if (!active || !(event.data instanceof ArrayBuffer)) return;
try {
const message = protocol.decodeServer(event.data);
switch (message.kind) {
case "welcome":
engine.initialize(message.playerId, message.snapshot);
clock.reset(performance.now());
input = { thrust: false };
validation = "waiting";
sendInput();
break;
case "snapshot":
engine.reconcile(message.snapshot);
break;
case "acknowledge":
engine.acknowledge(message.sequence);
break;
case "pong":
engine.networkClock.receivePong(message.pong, performance.now());
break;
case "validation":
validation = message.valid ? "valid" : "invalid";
break;
case "reject-input":
engine.reject(message.sequence);
validation = "invalid";
break;
case "event":
engine.receiveEvent(message.event, message.tick);
break;
case "replay-start":
case "replay-frame":
case "replay-end":
break;
}
publish();
} catch {
socket?.close(1003, "Invalid game frame");
}
});
socket.addEventListener("close", () => {
if (!active) return;
input = { thrust: false };
connection = "reconnecting";
publish();
retryTimer = window.setTimeout(connect, 1_000);
});
socket.addEventListener("error", () => socket?.close());
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code !== "Space") return;
event.preventDefault();
updateThrust(true);
};
const handleKeyUp = (event: KeyboardEvent) => {
if (event.code !== "Space") return;
event.preventDefault();
updateThrust(false);
};
const release = () => updateThrust(false);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", release);
const pingTimer = window.setInterval(() => {
send(protocol.encodeClient({
kind: "ping",
ping: engine.networkClock.createPing(performance.now()),
}));
}, 1_000);
const validationTimer = window.setInterval(() => {
if (engine.initialized) {
send(protocol.encodeClient({
kind: "state-report",
report: engine.createStateReport(),
}));
}
}, 2_000);
const animate = (now: number) => {
clock.advance(now, () => engine.step());
publish();
animationFrame = window.requestAnimationFrame(animate);
};
connect();
animationFrame = window.requestAnimationFrame(animate);
return () => {
active = false;
setThrustRef.current = () => undefined;
window.clearTimeout(retryTimer);
window.clearInterval(pingTimer);
window.clearInterval(validationTimer);
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", release);
socket?.close();
};
}, [clock, engine, protocol]);
return { ...view, setThrust };
}

View File

@@ -0,0 +1,487 @@
import { useEffect, useMemo, useState } from "react";
import {
FixedStepClock,
type NetworkStats,
type ProjectedReplayFrame,
} from "@syncer/engine";
import {
GAME_SOCKET_PATH,
WEAPONS,
Weapon,
shooterGame,
type ShooterInput,
type ShooterPerception,
type ShooterWorldState,
} from "@syncer/shared";
export type ConnectionStatus = "connecting" | "live" | "reconnecting";
export type ValidationStatus = "waiting" | "valid" | "invalid";
export interface ReplayPlaybackView {
ticketId: number;
perspectiveId: number;
fromTick: number;
toTick: number;
currentTick: number;
progress: number;
playbackRate: number;
}
export interface GameClientView {
connection: ConnectionStatus;
validation: ValidationStatus;
playerId: number | null;
cameraPlayerId: number | null;
presentationKey: string;
tick: number;
inputLeadTicks: number;
world: ShooterWorldState;
network: NetworkStats;
replay: ReplayPlaybackView | null;
}
interface IncomingReplay {
ticketId: number;
perspectiveId: number;
fromTick: number;
toTick: number;
frameCount: number;
playbackRate: number;
frames: Array<ProjectedReplayFrame<ShooterWorldState, ShooterPerception>>;
}
interface ActiveReplay extends IncomingReplay {
startedAt: number;
durationMilliseconds: number;
}
const emptyNetworkStats: NetworkStats = {
roundTripTime: 0,
jitter: 0,
clockOffset: 0,
samples: 0,
};
const neutralInput: ShooterInput = {
strafe: 0,
forward: 0,
yaw: 0,
pitch: 0,
fire: false,
sprint: false,
reload: false,
weapon: Weapon.PulseRifle,
};
function getSocketUrl(): string {
const socketProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
if (window.location.protocol === "http:" && window.location.port === "5173") {
return `ws://${window.location.hostname}:3001${GAME_SOCKET_PATH}`;
}
return `${socketProtocol}//${window.location.host}${GAME_SOCKET_PATH}`;
}
export function useGameClient(): GameClientView {
const engine = useMemo(() => shooterGame.createClient(), []);
const protocol = shooterGame.protocol;
const simulationClock = useMemo(
() => new FixedStepClock({ rateHz: shooterGame.tickRateHz, maxCatchUpSteps: 5 }),
[],
);
const [view, setView] = useState<GameClientView>({
connection: "connecting",
validation: "waiting",
playerId: null,
cameraPlayerId: null,
presentationKey: "live",
tick: 0,
inputLeadTicks: 1,
world: shooterGame.client.createInitialState(),
network: emptyNetworkStats,
replay: null,
});
useEffect(() => {
let active = true;
let socket: WebSocket | undefined;
let retryTimer: number | undefined;
let animationFrame: number | undefined;
let connection: ConnectionStatus = "connecting";
let validation: ValidationStatus = "waiting";
const pressedKeys = new Set<string>();
let input: ShooterInput = { ...neutralInput };
let lastSentInput: ShooterInput | null = null;
let inputDirty = true;
const incomingReplays = new Map<number, IncomingReplay>();
let activeReplay: ActiveReplay | null = null;
const publishView = () => {
if (!active) return;
const now = performance.now();
let world = shooterGame.client.cloneState(
engine.currentState as ShooterWorldState,
);
let tick = engine.tick;
let cameraPlayerId = engine.localPlayerId;
let replayView: ReplayPlaybackView | null = null;
if (activeReplay) {
const progress = clamp(
(now - activeReplay.startedAt) / activeReplay.durationMilliseconds,
0,
1,
);
if (progress >= 1) {
activeReplay = null;
} else {
const targetTick =
activeReplay.fromTick +
(activeReplay.toTick - activeReplay.fromTick) * progress;
const frame = frameAtOrBefore(activeReplay.frames, targetTick);
if (frame) {
world = shooterGame.client.cloneState(frame.state);
world.events = frame.events.map((event) => ({
receivedTick: frame.tick,
event: { ...event },
}));
tick = frame.tick;
cameraPlayerId = activeReplay.perspectiveId;
replayView = {
ticketId: activeReplay.ticketId,
perspectiveId: activeReplay.perspectiveId,
fromTick: activeReplay.fromTick,
toTick: activeReplay.toTick,
currentTick: frame.tick,
progress,
playbackRate: activeReplay.playbackRate,
};
}
}
}
setView({
connection,
validation,
playerId: engine.localPlayerId,
cameraPlayerId,
presentationKey: replayView ? `replay-${replayView.ticketId}` : "live",
tick,
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(shooterGame.tickRateHz),
world,
network: engine.networkClock.stats,
replay: replayView,
});
};
const send = (message: ArrayBuffer) => {
if (socket?.readyState === WebSocket.OPEN) socket.send(message);
};
const sendInput = (force = false) => {
if (!engine.initialized || (!force && !inputDirty)) return;
if (!force && lastSentInput && inputsEqual(input, lastSentInput)) {
inputDirty = false;
return;
}
const packet = engine.createInput(input);
send(protocol.encodeClient({ kind: "input", packet }));
lastSentInput = { ...input };
inputDirty = false;
};
const updateMovement = () => {
input = {
...input,
strafe: Number(pressedKeys.has("d")) - Number(pressedKeys.has("a")),
forward: Number(pressedKeys.has("w")) - Number(pressedKeys.has("s")),
sprint: pressedKeys.has("shift"),
};
inputDirty = true;
sendInput();
};
const connect = () => {
connection = engine.initialized ? "reconnecting" : "connecting";
publishView();
socket = new WebSocket(getSocketUrl());
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (!active) return;
connection = "live";
publishView();
});
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
if (!active || !(event.data instanceof ArrayBuffer)) return;
try {
const message = protocol.decodeServer(event.data);
switch (message.kind) {
case "welcome": {
activeReplay = null;
incomingReplays.clear();
engine.initialize(message.playerId, message.snapshot);
simulationClock.reset(performance.now());
const local = message.snapshot.state.players.get(message.playerId);
input = {
...neutralInput,
yaw: local?.yaw ?? 0,
pitch: local?.pitch ?? 0,
weapon: local?.weapon ?? Weapon.PulseRifle,
};
lastSentInput = null;
inputDirty = true;
validation = "waiting";
sendInput(true);
break;
}
case "snapshot":
engine.reconcile(message.snapshot);
break;
case "acknowledge":
engine.acknowledge(message.sequence);
break;
case "pong":
engine.networkClock.receivePong(message.pong, performance.now());
break;
case "validation":
validation = message.valid ? "valid" : "invalid";
break;
case "reject-input":
engine.reject(message.sequence);
validation = "invalid";
break;
case "event":
engine.receiveEvent(message.event, message.tick);
break;
case "replay-start": {
if (message.frameCount === 0 || message.frameCount > 4_096) break;
incomingReplays.set(message.ticketId, {
ticketId: message.ticketId,
perspectiveId: message.perspectiveId,
fromTick: message.fromTick,
toTick: message.toTick,
frameCount: message.frameCount,
playbackRate: message.playbackRate,
frames: [],
});
break;
}
case "replay-frame": {
const replay = incomingReplays.get(message.ticketId);
if (
!replay ||
replay.frames.length >= replay.frameCount ||
message.tick < replay.fromTick ||
message.tick > replay.toTick
) {
break;
}
replay.frames.push({
tick: message.tick,
state: message.state,
events: message.events,
});
break;
}
case "replay-end": {
const replay = incomingReplays.get(message.ticketId);
incomingReplays.delete(message.ticketId);
if (!replay || replay.frames.length !== replay.frameCount) break;
replay.frames.sort((left, right) => left.tick - right.tick);
const replayTicks = Math.max(1, replay.toTick - replay.fromTick);
activeReplay = {
...replay,
startedAt: performance.now(),
durationMilliseconds: Math.max(
1_200,
(replayTicks / shooterGame.tickRateHz / replay.playbackRate) * 1_000,
),
};
releaseControls();
break;
}
}
publishView();
} catch {
socket?.close(1003, "Invalid game frame");
}
});
socket.addEventListener("close", () => {
if (!active) return;
activeReplay = null;
incomingReplays.clear();
connection = "reconnecting";
publishView();
retryTimer = window.setTimeout(connect, 1_000);
});
socket.addEventListener("error", () => socket?.close());
};
const animate = (now: number) => {
simulationClock.advance(now, () => engine.step());
publishView();
animationFrame = window.requestAnimationFrame(animate);
};
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if (["w", "a", "s", "d", "shift", "r", "1", "2", "3"].includes(key)) event.preventDefault();
if (activeReplay) return;
if (["w", "a", "s", "d", "shift"].includes(key)) {
pressedKeys.add(key);
updateMovement();
} else if (key === "r") {
input = { ...input, reload: true };
inputDirty = true;
sendInput();
} else if (key === "1" || key === "2" || key === "3") {
input = {
...input,
weapon:
key === "1"
? Weapon.PulseRifle
: key === "2"
? Weapon.Scattergun
: Weapon.RailRifle,
};
inputDirty = true;
sendInput();
}
};
const handleKeyUp = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if (activeReplay) return;
if (["w", "a", "s", "d", "shift"].includes(key)) {
pressedKeys.delete(key);
updateMovement();
} else if (key === "r") {
input = { ...input, reload: false };
inputDirty = true;
sendInput();
}
};
const handleMouseMove = (event: MouseEvent) => {
if (activeReplay || !document.pointerLockElement) return;
input = {
...input,
yaw: normalizeAngle(input.yaw + event.movementX * 0.00225),
pitch: clamp(input.pitch - event.movementY * 0.0019, -1.25, 1.25),
};
inputDirty = true;
};
const handleMouseDown = (event: MouseEvent) => {
if (activeReplay || event.button !== 0 || !document.pointerLockElement) return;
input = { ...input, fire: true };
inputDirty = true;
sendInput();
};
const handleMouseUp = (event: MouseEvent) => {
if (event.button !== 0) return;
if (activeReplay) return;
input = { ...input, fire: false };
inputDirty = true;
sendInput();
};
const releaseControls = () => {
pressedKeys.clear();
input = { ...input, strafe: 0, forward: 0, fire: false, sprint: false, reload: false };
inputDirty = true;
sendInput(true);
};
const handlePointerLockChange = () => {
if (!document.pointerLockElement) releaseControls();
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mousedown", handleMouseDown);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("blur", releaseControls);
document.addEventListener("pointerlockchange", handlePointerLockChange);
const inputTimer = window.setInterval(
() => sendInput(input.fire && WEAPONS[input.weapon].automatic),
1_000 / 30,
);
const pingTimer = window.setInterval(() => {
send(protocol.encodeClient({ kind: "ping", ping: engine.networkClock.createPing(performance.now()) }));
}, 1_000);
const validationTimer = window.setInterval(() => {
if (engine.initialized) {
send(protocol.encodeClient({ kind: "state-report", report: engine.createStateReport() }));
}
}, 2_000);
connect();
animationFrame = window.requestAnimationFrame(animate);
return () => {
active = false;
window.clearTimeout(retryTimer);
window.clearInterval(inputTimer);
window.clearInterval(pingTimer);
window.clearInterval(validationTimer);
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mousedown", handleMouseDown);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("blur", releaseControls);
document.removeEventListener("pointerlockchange", handlePointerLockChange);
socket?.close();
};
}, [engine, protocol, simulationClock]);
return view;
}
function inputsEqual(left: ShooterInput, right: ShooterInput): boolean {
return (
left.strafe === right.strafe &&
left.forward === right.forward &&
left.yaw === right.yaw &&
left.pitch === right.pitch &&
left.fire === right.fire &&
left.sprint === right.sprint &&
left.reload === right.reload &&
left.weapon === right.weapon
);
}
function normalizeAngle(value: number): number {
return Math.atan2(Math.sin(value), Math.cos(value));
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}
function frameAtOrBefore<State, Event>(
frames: ReadonlyArray<ProjectedReplayFrame<State, Event>>,
targetTick: number,
): ProjectedReplayFrame<State, Event> | undefined {
let low = 0;
let high = frames.length - 1;
let selected: ProjectedReplayFrame<State, Event> | undefined;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const frame = frames[middle]!;
if (frame.tick <= targetTick) {
selected = frame;
low = middle + 1;
} else {
high = middle - 1;
}
}
return selected ?? frames[0];
}

View File

@@ -0,0 +1,323 @@
import { useEffect, useMemo, useState } from "react";
import { FixedStepClock, type NetworkStats } from "@syncer/engine";
import {
ROYALE_SOCKET_PATH,
royaleGame,
type RoyaleClientState,
type RoyaleInput,
} from "@syncer/shared";
import type { ConnectionStatus, ValidationStatus } from "./useGameClient.js";
export interface RoyaleClientView {
connection: ConnectionStatus;
validation: ValidationStatus;
playerId: number | null;
tick: number;
inputLeadTicks: number;
world: RoyaleClientState;
network: NetworkStats;
}
const emptyNetworkStats: NetworkStats = {
roundTripTime: 0,
jitter: 0,
clockOffset: 0,
samples: 0,
};
const neutralInput: RoyaleInput = {
forward: 0,
strafe: 0,
yaw: 0,
pitch: 0,
fire: false,
sprint: false,
reload: false,
};
function socketUrls(): string[] {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const proxied = `${protocol}//${window.location.host}${ROYALE_SOCKET_PATH}`;
if (window.location.protocol !== "http:" || window.location.port !== "5173") {
return [proxied];
}
return [
`ws://${window.location.hostname}:3001${ROYALE_SOCKET_PATH}`,
proxied,
];
}
export function useRoyaleClient(): RoyaleClientView {
const engine = useMemo(() => royaleGame.createClient(), []);
const protocol = royaleGame.protocol;
const clock = useMemo(
() => new FixedStepClock({ rateHz: royaleGame.tickRateHz, maxCatchUpSteps: 5 }),
[],
);
const [view, setView] = useState<RoyaleClientView>({
connection: "connecting",
validation: "waiting",
playerId: null,
tick: 0,
inputLeadTicks: 1,
world: royaleGame.client.createInitialState(),
network: emptyNetworkStats,
});
useEffect(() => {
let active = true;
let socket: WebSocket | undefined;
let retryTimer: number | undefined;
let animationFrame: number | undefined;
let connection: ConnectionStatus = "connecting";
let validation: ValidationStatus = "waiting";
let input: RoyaleInput = { ...neutralInput };
let lastSent: RoyaleInput | null = null;
let inputDirty = false;
let socketUrlIndex = 0;
const connectionUrls = socketUrls();
const pressed = new Set<string>();
const publish = () => {
if (!active) return;
setView({
connection,
validation,
playerId: engine.localPlayerId,
tick: engine.tick,
inputLeadTicks: engine.networkClock.recommendedInputLeadTicks(royaleGame.tickRateHz),
world: royaleGame.client.cloneState(engine.currentState as RoyaleClientState),
network: engine.networkClock.stats,
});
};
const send = (frame: ArrayBuffer) => {
if (socket?.readyState === WebSocket.OPEN) socket.send(frame);
};
const sendInput = (force = false) => {
if (!engine.initialized) return;
if (!force && !inputDirty) return;
if (!force && lastSent && inputsEqual(lastSent, input)) {
inputDirty = false;
return;
}
const packet = engine.createInput(input);
send(protocol.encodeClient({ kind: "input", packet }));
lastSent = { ...input };
inputDirty = false;
};
const updateMovement = () => {
input = {
...input,
forward: Number(pressed.has("w")) - Number(pressed.has("s")),
strafe: Number(pressed.has("d")) - Number(pressed.has("a")),
sprint: pressed.has("shift"),
};
inputDirty = true;
sendInput();
};
const connect = () => {
let opened = false;
connection = engine.initialized ? "reconnecting" : "connecting";
publish();
socket = new WebSocket(connectionUrls[socketUrlIndex]!);
socket.binaryType = "arraybuffer";
socket.addEventListener("open", () => {
if (!active) return;
opened = true;
connection = "live";
publish();
});
socket.addEventListener("message", (event: MessageEvent<ArrayBuffer>) => {
if (!active || !(event.data instanceof ArrayBuffer)) return;
try {
const message = protocol.decodeServer(event.data);
switch (message.kind) {
case "welcome": {
engine.initialize(message.playerId, message.snapshot);
clock.reset(performance.now());
const local = message.snapshot.state.players.find(
(player) => player.id === message.playerId,
);
input = {
...neutralInput,
yaw: local?.yaw ?? 0,
pitch: local?.pitch ?? 0,
};
lastSent = null;
inputDirty = true;
validation = "waiting";
sendInput(true);
break;
}
case "snapshot":
engine.reconcile(message.snapshot);
break;
case "acknowledge":
engine.acknowledge(message.sequence);
break;
case "pong":
engine.networkClock.receivePong(message.pong, performance.now());
break;
case "validation":
validation = message.valid ? "valid" : "invalid";
break;
case "reject-input":
engine.reject(message.sequence);
validation = "invalid";
break;
case "event":
engine.receiveEvent(message.event, message.tick);
break;
case "replay-start":
case "replay-frame":
case "replay-end":
break;
}
publish();
} catch {
socket?.close(1003, "Invalid game frame");
}
});
socket.addEventListener("close", () => {
if (!active) return;
if (!opened && connectionUrls.length > 1) {
socketUrlIndex = (socketUrlIndex + 1) % connectionUrls.length;
} else if (opened) {
socketUrlIndex = 0;
}
connection = "reconnecting";
publish();
retryTimer = window.setTimeout(connect, 1_000);
});
socket.addEventListener("error", () => socket?.close());
};
const keyDown = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if (["w", "a", "s", "d", "shift", "r"].includes(key)) event.preventDefault();
if (["w", "a", "s", "d", "shift"].includes(key)) {
pressed.add(key);
updateMovement();
} else if (key === "r") {
input = { ...input, reload: true };
inputDirty = true;
sendInput(true);
}
};
const keyUp = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if (["w", "a", "s", "d", "shift"].includes(key)) {
pressed.delete(key);
updateMovement();
} else if (key === "r") {
input = { ...input, reload: false };
inputDirty = true;
sendInput(true);
}
};
const mouseMove = (event: MouseEvent) => {
if (!document.pointerLockElement) return;
input = {
...input,
yaw: normalizeAngle(input.yaw - event.movementX * 0.0027),
pitch: clamp(input.pitch - event.movementY * 0.0022, -1.25, 1.25),
};
inputDirty = true;
};
const mouseDown = (event: MouseEvent) => {
if (event.button !== 0 || !document.pointerLockElement) return;
input = { ...input, fire: true };
inputDirty = true;
sendInput(true);
};
const mouseUp = (event: MouseEvent) => {
if (event.button !== 0) return;
input = { ...input, fire: false };
inputDirty = true;
sendInput(true);
};
const release = () => {
pressed.clear();
input = { ...input, forward: 0, strafe: 0, sprint: false, fire: false, reload: false };
inputDirty = true;
sendInput(true);
};
const pointerLockChange = () => {
if (!document.pointerLockElement) release();
};
window.addEventListener("keydown", keyDown);
window.addEventListener("keyup", keyUp);
window.addEventListener("mousemove", mouseMove);
window.addEventListener("mousedown", mouseDown);
window.addEventListener("mouseup", mouseUp);
window.addEventListener("blur", release);
document.addEventListener("pointerlockchange", pointerLockChange);
const inputTimer = window.setInterval(() => {
sendInput(input.fire);
}, 1_000 / royaleGame.tickRateHz);
const pingTimer = window.setInterval(() => {
send(protocol.encodeClient({
kind: "ping",
ping: engine.networkClock.createPing(performance.now()),
}));
}, 1_000);
const validationTimer = window.setInterval(() => {
if (engine.initialized) {
send(protocol.encodeClient({
kind: "state-report",
report: engine.createStateReport(),
}));
}
}, 2_000);
const animate = (now: number) => {
clock.advance(now, () => engine.step());
publish();
animationFrame = window.requestAnimationFrame(animate);
};
connect();
animationFrame = window.requestAnimationFrame(animate);
return () => {
active = false;
window.clearTimeout(retryTimer);
window.clearInterval(inputTimer);
window.clearInterval(pingTimer);
window.clearInterval(validationTimer);
if (animationFrame !== undefined) window.cancelAnimationFrame(animationFrame);
window.removeEventListener("keydown", keyDown);
window.removeEventListener("keyup", keyUp);
window.removeEventListener("mousemove", mouseMove);
window.removeEventListener("mousedown", mouseDown);
window.removeEventListener("mouseup", mouseUp);
window.removeEventListener("blur", release);
document.removeEventListener("pointerlockchange", pointerLockChange);
socket?.close();
};
}, [clock, engine, protocol]);
return view;
}
function inputsEqual(left: RoyaleInput, right: RoyaleInput): boolean {
return (
left.forward === right.forward &&
left.strafe === right.strafe &&
left.yaw === right.yaw &&
left.pitch === right.pitch &&
left.fire === right.fire &&
left.sprint === right.sprint &&
left.reload === right.reload
);
}
function normalizeAngle(value: number): number {
return Math.atan2(Math.sin(value), Math.cos(value));
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

13
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}

15
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://localhost:3001",
"/ws": {
target: "ws://localhost:3001",
ws: true,
},
},
},
});

89
deploy/kubernetes.yaml Normal file
View File

@@ -0,0 +1,89 @@
apiVersion: v1
kind: Namespace
metadata:
name: syncer
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: syncer
namespace: syncer
labels:
app.kubernetes.io/name: syncer
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: syncer
template:
metadata:
labels:
app.kubernetes.io/name: syncer
spec:
containers:
- name: syncer
image: registry.a2acloud.io/syncer/syncer:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 3001
readinessProbe:
httpGet:
path: /api/message
port: http
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet:
path: /api/message
port: http
initialDelaySeconds: 10
periodSeconds: 15
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: "2"
memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: syncer
namespace: syncer
spec:
selector:
app.kubernetes.io/name: syncer
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: syncer
namespace: syncer
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
traefik.ingress.kubernetes.io/router.entrypoints: web,websecure
spec:
ingressClassName: traefik
tls:
- hosts:
- syncer.a2acloud.io
secretName: syncer-a2acloud-io-tls
rules:
- host: syncer.a2acloud.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: syncer
port:
name: http

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

2205
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "syncer",
"version": "0.0.0",
"private": true,
"type": "module",
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"predev": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared",
"dev": "concurrently --kill-others-on-fail --names engine,shared,server,web --prefix-colors blue,magenta,cyan,green \"npm run dev --workspace @syncer/engine\" \"npm run dev --workspace @syncer/shared\" \"npm run dev --workspace @syncer/server\" \"npm run dev --workspace @syncer/web\"",
"build": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared && npm run build --workspace @syncer/server && npm run build --workspace @syncer/web",
"pretypecheck": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared",
"typecheck": "npm run typecheck --workspace @syncer/engine && npm run typecheck --workspace @syncer/shared && npm run typecheck --workspace @syncer/server && npm run typecheck --workspace @syncer/web",
"test": "npm run build --workspace @syncer/engine && npm run build --workspace @syncer/shared && node --test packages/engine/test/*.test.mjs packages/shared/test/*.test.mjs",
"start": "npm run start --workspace @syncer/server"
},
"devDependencies": {
"concurrently": "^10.0.5",
"typescript": "^7.0.2"
}
}

View File

@@ -0,0 +1,25 @@
{
"name": "@syncer/engine",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"dev": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^7.0.2"
}
}

View File

@@ -0,0 +1,185 @@
import { NetworkClock } from "./network-clock.js";
import type {
ClientStateReport,
GameDefinition,
InputContext,
InputPacket,
PlayerId,
StateSnapshot,
} from "./types.js";
interface PredictedInput<Input> {
packet: InputPacket<Input>;
applied: boolean;
}
export class PredictedEngine<State, Input> {
readonly game: GameDefinition<State, Input>;
readonly networkClock = new NetworkClock();
private state: State;
private playerId: PlayerId | null = null;
private currentTick = 0;
private nextInputSequence = 1;
private acknowledgedSequence = 0;
private pendingInputs: PredictedInput<Input>[] = [];
constructor(game: GameDefinition<State, Input>) {
this.game = game;
this.state = game.createInitialState();
}
get tick(): number {
return this.currentTick;
}
get localPlayerId(): PlayerId | null {
return this.playerId;
}
get currentState(): Readonly<State> {
return this.state;
}
get initialized(): boolean {
return this.playerId !== null;
}
initialize(playerId: PlayerId, snapshot: StateSnapshot<State>): void {
this.playerId = playerId;
this.currentTick = snapshot.tick;
this.state = this.game.cloneState(snapshot.state);
this.pendingInputs = [];
this.acknowledgedSequence = 0;
}
createInput(input: Input, leadTicks?: number): InputPacket<Input> {
if (this.playerId === null) {
throw new Error("Client engine has not received a welcome snapshot");
}
const targetTick =
this.currentTick +
(leadTicks ??
this.networkClock.recommendedInputLeadTicks(this.game.tickRateHz));
const packet: InputPacket<Input> = {
sequence: this.nextInputSequence,
targetTick,
observedTick: this.currentTick,
input,
};
const context = this.inputContext(packet);
if (!this.game.validateInput(input, context)) {
throw new Error("Refusing to send invalid local input");
}
this.nextInputSequence = (this.nextInputSequence + 1) >>> 0;
this.pendingInputs.push({ packet, applied: false });
return packet;
}
acknowledge(sequence: number): void {
this.acknowledgedSequence = Math.max(this.acknowledgedSequence, sequence);
this.pendingInputs = this.pendingInputs.filter(
(pending) => pending.packet.sequence > this.acknowledgedSequence,
);
}
reject(sequence: number): void {
this.pendingInputs = this.pendingInputs.filter(
(pending) => pending.packet.sequence !== sequence,
);
}
step(): void {
if (this.playerId === null) {
return;
}
this.currentTick += 1;
this.applyDueInputs();
this.game.step(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
});
this.assertValidState();
}
reconcile(snapshot: StateSnapshot<State>): void {
if (
this.playerId === null ||
snapshot.tick < this.currentTick - this.game.tickRateHz * 2
) {
return;
}
const predictedTick = Math.max(this.currentTick, snapshot.tick);
this.state = this.game.cloneState(snapshot.state);
this.currentTick = snapshot.tick;
for (const pending of this.pendingInputs) {
pending.applied = false;
}
this.applyDueInputs();
while (this.currentTick < predictedTick) {
this.currentTick += 1;
this.applyDueInputs();
this.game.step(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
});
}
this.assertValidState();
}
createStateReport(): ClientStateReport<State> {
return {
tick: this.currentTick,
state: this.game.cloneState(this.state),
};
}
private applyDueInputs(): void {
for (const pending of this.pendingInputs) {
if (!pending.applied && pending.packet.targetTick <= this.currentTick) {
this.game.applyInput(
this.state,
pending.packet.input,
this.inputContext(pending.packet),
);
pending.applied = true;
}
}
}
private inputContext(packet: InputPacket<Input>): InputContext {
if (this.playerId === null) {
throw new Error("Client engine has not been initialized");
}
return {
playerId: this.playerId,
sequence: packet.sequence,
targetTick: packet.targetTick,
observedTick: packet.observedTick ?? packet.targetTick,
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
};
}
private assertValidState(): void {
if (
this.game.validateState &&
!this.game.validateState(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
})
) {
throw new Error(
`Client prediction produced invalid state at tick ${this.currentTick}`,
);
}
}
}

View File

@@ -0,0 +1,55 @@
export interface FixedStepClockOptions {
rateHz: number;
maxCatchUpSteps?: number;
}
export class FixedStepClock {
readonly stepMilliseconds: number;
private readonly maxCatchUpSteps: number;
private previousTime: number | null = null;
private accumulator = 0;
constructor(options: FixedStepClockOptions) {
if (!Number.isFinite(options.rateHz) || options.rateHz <= 0) {
throw new RangeError("Clock rate must be a positive finite number");
}
this.stepMilliseconds = 1_000 / options.rateHz;
this.maxCatchUpSteps = options.maxCatchUpSteps ?? 5;
}
advance(now: number, step: () => void): number {
if (this.previousTime === null) {
this.previousTime = now;
return 0;
}
const elapsed = Math.max(0, now - this.previousTime);
this.previousTime = now;
this.accumulator += Math.min(
elapsed,
this.stepMilliseconds * this.maxCatchUpSteps,
);
let steps = 0;
while (
this.accumulator >= this.stepMilliseconds &&
steps < this.maxCatchUpSteps
) {
step();
this.accumulator -= this.stepMilliseconds;
steps += 1;
}
return steps;
}
get interpolationAlpha(): number {
return this.accumulator / this.stepMilliseconds;
}
reset(now?: number): void {
this.previousTime = now ?? null;
this.accumulator = 0;
}
}

View File

@@ -0,0 +1,38 @@
import { PredictedEngine } from "./client.js";
import { AuthoritativeEngine, type ServerEngineOptions } from "./server.js";
import type { GameDefinition } from "./types.js";
export interface DefinedGame<State, Input>
extends GameDefinition<State, Input> {
createServer(options?: ServerEngineOptions): AuthoritativeEngine<State, Input>;
createClient(): PredictedEngine<State, Input>;
}
export function defineGame<State, Input>(
definition: GameDefinition<State, Input>,
): DefinedGame<State, Input> {
if (!Number.isInteger(definition.tickRateHz) || definition.tickRateHz <= 0) {
throw new RangeError("tickRateHz must be a positive integer");
}
if (
!Number.isInteger(definition.snapshotRateHz) ||
definition.snapshotRateHz <= 0 ||
definition.snapshotRateHz > definition.tickRateHz ||
definition.tickRateHz % definition.snapshotRateHz !== 0
) {
throw new RangeError(
"snapshotRateHz must be a positive divisor of tickRateHz",
);
}
return Object.freeze({
...definition,
createServer(options?: ServerEngineOptions) {
return new AuthoritativeEngine(definition, options);
},
createClient() {
return new PredictedEngine(definition);
},
});
}

View File

@@ -0,0 +1,90 @@
import {
defineNetworkedGame,
type DefinedNetworkedGame,
} from "./define-networked-game.js";
import type {
ClientSimulationRules,
ReplicationRules,
ServerSimulationRules,
} from "./networked-types.js";
import type { BinaryCodec, InputContext } from "./types.js";
/**
* Names the five types that cross a multiplayer game's trust boundaries.
* Define this once, then every callback in defineMultiplayerGame is inferred.
*/
export interface MultiplayerGameContract {
authority: unknown;
client: unknown;
input: unknown;
authorityEvent: unknown;
perceptionEvent: unknown;
}
export interface MultiplayerGameConfiguration<
Contract extends MultiplayerGameContract,
> {
clock: {
ticksPerSecond: number;
snapshotsPerSecond: number;
};
/** Private rules that run only on trusted authority. */
authority: ServerSimulationRules<
Contract["authority"],
Contract["input"],
Contract["authorityEvent"]
>;
/** Rules the client may run speculatively between snapshots. */
prediction: ClientSimulationRules<
Contract["client"],
Contract["input"],
Contract["perceptionEvent"]
>;
/** The only authority data and events a particular player may receive. */
visibility: ReplicationRules<
Contract["authority"],
Contract["client"],
Contract["authorityEvent"],
Contract["perceptionEvent"]
>;
input: {
validate(input: Contract["input"], context: InputContext): boolean;
};
encoding: {
input: BinaryCodec<Contract["input"]>;
clientState: BinaryCodec<Contract["client"]>;
perception?: BinaryCodec<Contract["perceptionEvent"]>;
};
}
/**
* Developer-facing game-definition API. It compiles the readable game sections
* into the same low-level definition consumed by every engine HOF.
*/
export function defineMultiplayerGame<
Contract extends MultiplayerGameContract,
>(
configuration: MultiplayerGameConfiguration<Contract>,
): DefinedNetworkedGame<
Contract["authority"],
Contract["client"],
Contract["input"],
Contract["authorityEvent"],
Contract["perceptionEvent"]
> {
return defineNetworkedGame({
tickRateHz: configuration.clock.ticksPerSecond,
snapshotRateHz: configuration.clock.snapshotsPerSecond,
server: configuration.authority,
client: configuration.prediction,
replication: configuration.visibility,
validateInput: configuration.input.validate,
codecs: {
input: configuration.encoding.input,
state: configuration.encoding.clientState,
...(configuration.encoding.perception
? { event: configuration.encoding.perception }
: {}),
},
});
}

View File

@@ -0,0 +1,93 @@
import { NetworkedPredictedEngine } from "./networked-client.js";
import { NetworkedAuthoritativeEngine } from "./networked-server.js";
import type { NetworkedGameDefinition } from "./networked-types.js";
import { createBinaryProtocol, type BinaryProtocol } from "./protocol.js";
import type { ServerEngineOptions } from "./server.js";
export interface DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent = never,
PerceptionEvent = never,
> extends NetworkedGameDefinition<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
> {
protocol: BinaryProtocol<Input, ClientState, PerceptionEvent>;
createServer(
options?: ServerEngineOptions,
): NetworkedAuthoritativeEngine<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>;
createClient(): NetworkedPredictedEngine<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>;
}
export function defineNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent = never,
PerceptionEvent = never,
>(
definition: NetworkedGameDefinition<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>,
): DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
> {
validateRates(definition.tickRateHz, definition.snapshotRateHz);
const protocol = createBinaryProtocol<Input, ClientState, PerceptionEvent>(
definition.codecs,
);
return Object.freeze({
...definition,
protocol,
createServer(options?: ServerEngineOptions) {
return new NetworkedAuthoritativeEngine(definition, options);
},
createClient() {
return new NetworkedPredictedEngine(definition);
},
});
}
function validateRates(tickRateHz: number, snapshotRateHz: number): void {
if (!Number.isInteger(tickRateHz) || tickRateHz <= 0) {
throw new RangeError("tickRateHz must be a positive integer");
}
if (
!Number.isInteger(snapshotRateHz) ||
snapshotRateHz <= 0 ||
snapshotRateHz > tickRateHz ||
tickRateHz % snapshotRateHz !== 0
) {
throw new RangeError(
"snapshotRateHz must be a positive divisor of tickRateHz",
);
}
}

View File

@@ -0,0 +1,113 @@
const fnvOffset = 0xcbf29ce484222325n;
const fnvPrime = 0x100000001b3n;
const uint64Mask = 0xffffffffffffffffn;
/**
* Produces a stable 64-bit hash for deterministic simulation state.
* Maps, sets, and object keys are sorted so insertion order does not affect it.
* Cyclic structures and executable values are rejected deliberately.
*/
export function deterministicHash(value: unknown): string {
const canonical = canonicalize(value, new WeakSet<object>());
let hash = fnvOffset;
for (let index = 0; index < canonical.length; index += 1) {
const code = canonical.charCodeAt(index);
hash ^= BigInt(code & 0xff);
hash = (hash * fnvPrime) & uint64Mask;
hash ^= BigInt(code >>> 8);
hash = (hash * fnvPrime) & uint64Mask;
}
return hash.toString(16).padStart(16, "0");
}
function canonicalize(value: unknown, ancestors: WeakSet<object>): string {
if (value === null) return "null";
switch (typeof value) {
case "undefined":
return "undefined";
case "boolean":
return value ? "boolean:true" : "boolean:false";
case "number":
if (Number.isNaN(value)) return "number:NaN";
if (value === Infinity) return "number:+Infinity";
if (value === -Infinity) return "number:-Infinity";
if (Object.is(value, -0)) return "number:-0";
return `number:${value}`;
case "bigint":
return `bigint:${value}`;
case "string":
return `string:${JSON.stringify(value)}`;
case "symbol":
case "function":
throw new TypeError(`Cannot hash simulation value of type ${typeof value}`);
case "object":
return canonicalizeObject(value, ancestors);
}
throw new TypeError("Unsupported simulation value");
}
function canonicalizeObject(value: object, ancestors: WeakSet<object>): string {
if (ancestors.has(value)) {
throw new TypeError("Cannot hash cyclic simulation state");
}
ancestors.add(value);
try {
if (Array.isArray(value)) {
return `array:[${value.map((entry) => canonicalize(entry, ancestors)).join(",")}]`;
}
if (value instanceof Date) {
return `date:${value.toISOString()}`;
}
if (value instanceof Map) {
const entries = [...value].map(([key, entryValue]) => [
canonicalize(key, ancestors),
canonicalize(entryValue, ancestors),
] as const);
entries.sort(([left], [right]) => compareText(left, right));
return `map:{${entries
.map(([key, entryValue]) => `${key}=>${entryValue}`)
.join(",")}}`;
}
if (value instanceof Set) {
const entries = [...value].map((entry) => canonicalize(entry, ancestors));
entries.sort(compareText);
return `set:{${entries.join(",")}}`;
}
if (value instanceof ArrayBuffer) {
return `bytes:${bytesToHex(new Uint8Array(value))}`;
}
if (ArrayBuffer.isView(value)) {
return `${value.constructor.name}:${bytesToHex(
new Uint8Array(value.buffer, value.byteOffset, value.byteLength),
)}`;
}
const record = value as Record<string, unknown>;
const keys = Object.keys(record).sort(compareText);
return `object:{${keys
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key], ancestors)}`)
.join(",")}}`;
} finally {
ancestors.delete(value);
}
}
function bytesToHex(bytes: Uint8Array): string {
let result = "";
for (const byte of bytes) result += byte.toString(16).padStart(2, "0");
return result;
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}

View File

@@ -0,0 +1,113 @@
export { FixedStepClock, type FixedStepClockOptions } from "./clock.js";
export { deterministicHash } from "./deterministic-hash.js";
export { PredictedEngine } from "./client.js";
export {
defineGame,
type DefinedGame,
} from "./define-game.js";
export {
defineNetworkedGame,
type DefinedNetworkedGame,
} from "./define-networked-game.js";
export {
defineMultiplayerGame,
type MultiplayerGameConfiguration,
type MultiplayerGameContract,
} from "./define-multiplayer-game.js";
export {
createJsonCodec,
type JsonCodecOptions,
} from "./json-codec.js";
export {
withLagCompensation,
type LagCompensatedAction,
type LagCompensationActionPolicy,
type LagCompensationDefinition,
type LagCompensationMode,
type LagCompensationPolicies,
type LagCompensationResolutionContext,
} from "./lag-compensation.js";
export { NetworkedPredictedEngine } from "./networked-client.js";
export {
NetworkedAuthoritativeEngine,
type NetworkedServerStepResult,
type NetworkedSimulationCheckpoint,
} from "./networked-server.js";
export type {
ClientEventContext,
ClientSimulationRules,
EmittingInputContext,
EmittingPlayerContext,
EmittingTickContext,
NetworkedGameDefinition,
ReplicationRules,
ServerSimulationRules,
SnapshotBatch,
} from "./networked-types.js";
export { NetworkClock, type NetworkStats } from "./network-clock.js";
export {
createBinaryProtocol,
type BinaryProtocol,
type ClientWireMessage,
type ServerWireMessage,
} from "./protocol.js";
export {
ReplayTransportAuthoritativeEngine,
withReplayTransport,
type ProjectedReplayFrame,
type ReplayAuthorizationContext,
type ReplayTicket,
type ReplayTicketPlan,
type ReplayTransportDefinition,
type ReplayTransportNetworkedGame,
type ReplayTriggerContext,
} from "./replay-transport.js";
export {
AuthoritativeEngine,
type Acknowledgement,
type InputDecision,
type ServerEngineOptions,
type ServerStepResult,
} from "./server.js";
export {
SpatialGridIndex,
withSpatialReplication,
type SpatialPoint,
type SpatialReplicationController,
type SpatialReplicationDefinition,
type SpatialReplicationEntity,
type SpatialReplicationSelection,
type SpatialReplicationSource,
type SpatialReplicationViewerContext,
type SpatiallyReplicatedNetworkedGame,
} from "./spatial-replication.js";
export {
ReplayDivergenceError,
ReplaySession,
TimeTravelAuthoritativeEngine,
withTimeTravel,
type ReplayCheckpoint,
type ReplayCommand,
type ReplayFrame,
type ReplayRecording,
type ReplayStateHash,
type ReplayVerification,
type TimeTravelDefinition,
type TimeTravelNetworkedGame,
type TimeTravelServerOptions,
} from "./time-travel.js";
export type {
BinaryCodec,
ClientStateContext,
ClientStateReport,
GameDefinition,
InputContext,
InputPacket,
PingPacket,
PlayerContext,
PlayerId,
PongPacket,
StateSnapshot,
TickContext,
ValidationResult,
} from "./types.js";

View File

@@ -0,0 +1,45 @@
import type { BinaryCodec } from "./types.js";
export interface JsonCodecOptions<Value> {
serialize?(value: Value): unknown;
deserialize?(value: unknown): Value;
}
interface Utf8Encoder {
encode(value: string): Uint8Array;
}
interface Utf8Decoder {
decode(value: Uint8Array): string;
}
type Utf8Globals = typeof globalThis & {
TextEncoder: new () => Utf8Encoder;
TextDecoder: new (
label?: string,
options?: { fatal?: boolean },
) => Utf8Decoder;
};
/** Creates a UTF-8 JSON codec for prototypes and low-volume game messages. */
export function createJsonCodec<Value>(
options: JsonCodecOptions<Value> = {},
): BinaryCodec<Value> {
const utf8 = globalThis as Utf8Globals;
const encoder = new utf8.TextEncoder();
const decoder = new utf8.TextDecoder("utf-8", { fatal: true });
return {
encode(value) {
const json = JSON.stringify(options.serialize?.(value) ?? value);
if (json === undefined) {
throw new TypeError("The JSON codec cannot encode undefined");
}
return encoder.encode(json);
},
decode(payload) {
const value: unknown = JSON.parse(decoder.decode(payload));
return options.deserialize ? options.deserialize(value) : (value as Value);
},
};
}

View File

@@ -0,0 +1,425 @@
import {
defineNetworkedGame,
type DefinedNetworkedGame,
} from "./define-networked-game.js";
import type { EmittingInputContext } from "./networked-types.js";
import type { PlayerId, TickContext } from "./types.js";
export type LagCompensationMode = "current" | "rewind" | "fast-forward";
export interface LagCompensatedAction {
type: string;
}
export interface LagCompensationResolutionContext<
AuthorityState,
HistoricalState,
Action,
AuthorityEvent,
> {
currentState: AuthorityState;
historicalState: Readonly<HistoricalState>;
action: Readonly<Action>;
playerId: PlayerId;
sequence: number;
requestedTick: number;
resolvedTick: number;
currentTick: number;
rewindTicks: number;
catchUpTicks: number;
clamped: boolean;
emit(event: AuthorityEvent): void;
}
export interface LagCompensationActionPolicy<
AuthorityState,
HistoricalState,
Action,
AuthorityEvent,
> {
mode: LagCompensationMode;
maximumRewindMs?: number;
maximumFutureMs?: number;
outOfWindow?: "clamp" | "reject";
validate?(
context: LagCompensationResolutionContext<
AuthorityState,
HistoricalState,
Action,
AuthorityEvent
>,
): boolean;
resolve(
context: LagCompensationResolutionContext<
AuthorityState,
HistoricalState,
Action,
AuthorityEvent
>,
): void;
}
export type LagCompensationPolicies<
AuthorityState,
HistoricalState,
Action extends LagCompensatedAction,
AuthorityEvent,
> = {
[Kind in Action["type"]]: LagCompensationActionPolicy<
AuthorityState,
HistoricalState,
Extract<Action, { type: Kind }>,
AuthorityEvent
>;
};
export interface LagCompensationDefinition<
AuthorityState,
Input,
AuthorityEvent,
HistoricalState,
Action extends LagCompensatedAction,
> {
historySeconds: number;
captureState(
state: Readonly<AuthorityState>,
context: TickContext,
): HistoricalState;
cloneHistoricalState(state: Readonly<HistoricalState>): HistoricalState;
classifyAction(
input: Readonly<Input>,
context: EmittingInputContext<AuthorityEvent>,
): Action | null;
cloneAction(action: Readonly<Action>): Action;
actions: LagCompensationPolicies<
AuthorityState,
HistoricalState,
Action,
AuthorityEvent
>;
}
interface HistoricalFrame<HistoricalState> {
tick: number;
state: HistoricalState;
}
interface QueuedAction<Action> {
action: Action;
playerId: PlayerId;
sequence: number;
requestedTick: number;
}
interface LagRuntime<HistoricalState, Action> {
frames: Array<HistoricalFrame<HistoricalState>>;
queuedActions: Array<QueuedAction<Action>>;
}
/**
* Adds one private authority-history ring to a game and resolves only classified
* actions against it. The live world is never rewound or exposed to clients.
*/
export function withLagCompensation<
AuthorityState extends object,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
HistoricalState,
Action extends LagCompensatedAction,
>(
game: DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>,
definition: LagCompensationDefinition<
AuthorityState,
Input,
AuthorityEvent,
HistoricalState,
Action
>,
): DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
> {
const historyTicks = normalizeHistoryTicks(
game.tickRateHz,
definition.historySeconds,
);
validatePolicies(definition.actions);
const runtimes = new WeakMap<
AuthorityState,
LagRuntime<HistoricalState, Action>
>();
const ensureRuntime = (
state: AuthorityState,
): LagRuntime<HistoricalState, Action> => {
const existing = runtimes.get(state);
if (existing) return existing;
const created = { frames: [], queuedActions: [] };
runtimes.set(state, created);
return created;
};
const capture = (state: AuthorityState, context: TickContext): void => {
const runtime = ensureRuntime(state);
const frame = {
tick: context.tick,
state: definition.cloneHistoricalState(
definition.captureState(state, context),
),
};
if (runtime.frames.at(-1)?.tick === context.tick) {
runtime.frames[runtime.frames.length - 1] = frame;
} else {
runtime.frames.push(frame);
}
const earliestTick = context.tick - historyTicks;
while (runtime.frames[0] && runtime.frames[0].tick < earliestTick) {
runtime.frames.shift();
}
};
return defineNetworkedGame({
...game,
server: {
...game.server,
createInitialState() {
const state = game.server.createInitialState();
ensureRuntime(state);
return state;
},
initializeState(state, context) {
game.server.initializeState?.(state, context);
capture(state, context);
},
cloneState(state) {
const cloned = game.server.cloneState(state);
const source = ensureRuntime(state);
runtimes.set(cloned, {
frames: source.frames.map((frame) => ({
tick: frame.tick,
state: definition.cloneHistoricalState(frame.state),
})),
queuedActions: source.queuedActions.map((queued) => ({
...queued,
action: definition.cloneAction(queued.action),
})),
});
return cloned;
},
cloneStateForHistory(state) {
return game.server.cloneStateForHistory?.(state) ??
game.server.cloneState(state);
},
addPlayer(state, context) {
game.server.addPlayer?.(state, context);
capture(state, {
tick: context.tick,
deltaSeconds: 1 / game.tickRateHz,
});
},
removePlayer(state, context) {
game.server.removePlayer?.(state, context);
capture(state, {
tick: context.tick,
deltaSeconds: 1 / game.tickRateHz,
});
},
applyInput(state, input, context) {
game.server.applyInput(state, input, context);
const action = definition.classifyAction(input, context);
if (!action) return;
const policy = definition.actions[action.type as Action["type"]] as
| LagCompensationActionPolicy<
AuthorityState,
HistoricalState,
Action,
AuthorityEvent
>
| undefined;
if (!policy) return;
ensureRuntime(state).queuedActions.push({
action: definition.cloneAction(action),
playerId: context.playerId,
sequence: context.sequence,
requestedTick: context.observedTick,
});
},
step(state, context) {
game.server.step(state, context);
const runtime = ensureRuntime(state);
const queuedActions = runtime.queuedActions;
runtime.queuedActions = [];
for (const queued of queuedActions) {
resolveQueuedAction(
state,
context,
runtime.frames,
queued,
game.tickRateHz,
historyTicks,
definition,
);
}
capture(state, context);
},
},
});
}
function resolveQueuedAction<
AuthorityState,
Input,
AuthorityEvent,
HistoricalState,
Action extends LagCompensatedAction,
>(
state: AuthorityState,
tickContext: TickContext & { emit(event: AuthorityEvent): void },
frames: readonly HistoricalFrame<HistoricalState>[],
queued: QueuedAction<Action>,
tickRateHz: number,
historyTicks: number,
definition: LagCompensationDefinition<
AuthorityState,
Input,
AuthorityEvent,
HistoricalState,
Action
>,
): void {
const policy = definition.actions[queued.action.type as Action["type"]] as
| LagCompensationActionPolicy<
AuthorityState,
HistoricalState,
Action,
AuthorityEvent
>
| undefined;
if (!policy) return;
const maximumRewindTicks = Math.min(
historyTicks,
millisecondsToTicks(
policy.maximumRewindMs ?? (historyTicks / tickRateHz) * 1_000,
tickRateHz,
),
);
const maximumFutureTicks = millisecondsToTicks(
policy.maximumFutureMs ?? 0,
tickRateHz,
);
const minimumTick = Math.max(0, tickContext.tick - maximumRewindTicks);
const maximumTick = tickContext.tick + maximumFutureTicks;
const outsideWindow =
queued.requestedTick < minimumTick || queued.requestedTick > maximumTick;
if (outsideWindow && policy.outOfWindow === "reject") return;
const boundedRequestedTick = clamp(
queued.requestedTick,
minimumTick,
tickContext.tick,
);
let resolvedTick = tickContext.tick;
let historicalState: HistoricalState;
if (policy.mode === "current") {
historicalState = definition.cloneHistoricalState(
definition.captureState(state, tickContext),
);
} else {
const frame = frameAtOrBefore(frames, boundedRequestedTick);
if (!frame) return;
resolvedTick = frame.tick;
historicalState = definition.cloneHistoricalState(frame.state);
}
const resolutionContext: LagCompensationResolutionContext<
AuthorityState,
HistoricalState,
Action,
AuthorityEvent
> = {
currentState: state,
historicalState,
action: queued.action,
playerId: queued.playerId,
sequence: queued.sequence,
requestedTick: queued.requestedTick,
resolvedTick,
currentTick: tickContext.tick,
rewindTicks: Math.max(0, tickContext.tick - resolvedTick),
catchUpTicks:
policy.mode === "fast-forward"
? Math.max(0, tickContext.tick - resolvedTick)
: 0,
clamped: resolvedTick !== queued.requestedTick,
emit: tickContext.emit,
};
if (policy.validate && !policy.validate(resolutionContext)) return;
policy.resolve(resolutionContext);
}
function frameAtOrBefore<HistoricalState>(
frames: readonly HistoricalFrame<HistoricalState>[],
tick: number,
): HistoricalFrame<HistoricalState> | undefined {
let low = 0;
let high = frames.length - 1;
let selected: HistoricalFrame<HistoricalState> | undefined;
while (low <= high) {
const middle = Math.floor((low + high) / 2);
const frame = frames[middle]!;
if (frame.tick <= tick) {
selected = frame;
low = middle + 1;
} else {
high = middle - 1;
}
}
return selected ?? frames[0];
}
function normalizeHistoryTicks(tickRateHz: number, seconds: number): number {
if (!Number.isFinite(seconds) || seconds <= 0) {
throw new RangeError("historySeconds must be positive");
}
return Math.max(1, Math.ceil(seconds * tickRateHz));
}
function validatePolicies(
policies: Record<string, { mode: LagCompensationMode; maximumRewindMs?: number; maximumFutureMs?: number }>,
): void {
for (const [kind, policy] of Object.entries(policies)) {
if (!["current", "rewind", "fast-forward"].includes(policy.mode)) {
throw new RangeError(`Unknown lag compensation mode for ${kind}`);
}
for (const [name, value] of [
["maximumRewindMs", policy.maximumRewindMs],
["maximumFutureMs", policy.maximumFutureMs],
] as const) {
if (value !== undefined && (!Number.isFinite(value) || value < 0)) {
throw new RangeError(`${name} for ${kind} must be non-negative`);
}
}
}
}
function millisecondsToTicks(milliseconds: number, tickRateHz: number): number {
return Math.max(0, Math.floor((milliseconds / 1_000) * tickRateHz));
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,92 @@
import type { PingPacket, PongPacket } from "./types.js";
export interface NetworkStats {
roundTripTime: number;
jitter: number;
clockOffset: number;
samples: number;
}
interface ClockSample {
roundTripTime: number;
offset: number;
}
export class NetworkClock {
private nextPingId = 1;
private readonly recentSamples: ClockSample[] = [];
private smoothedRtt = 0;
private smoothedJitter = 0;
private offset = 0;
createPing(clientNow: number): PingPacket {
const ping = {
id: this.nextPingId,
clientSentAt: clientNow,
};
this.nextPingId = (this.nextPingId + 1) >>> 0;
return ping;
}
receivePong(pong: PongPacket, clientReceivedAt: number): NetworkStats {
const serverProcessing = Math.max(
0,
pong.serverSentAt - pong.serverReceivedAt,
);
const roundTripTime = Math.max(
0,
clientReceivedAt - pong.clientSentAt - serverProcessing,
);
const offset =
((pong.serverReceivedAt - pong.clientSentAt) +
(pong.serverSentAt - clientReceivedAt)) /
2;
const previousRtt = this.smoothedRtt;
this.smoothedRtt =
this.recentSamples.length === 0
? roundTripTime
: previousRtt * 0.8 + roundTripTime * 0.2;
this.smoothedJitter =
this.recentSamples.length === 0
? 0
: this.smoothedJitter * 0.8 +
Math.abs(roundTripTime - previousRtt) * 0.2;
this.recentSamples.push({ roundTripTime, offset });
if (this.recentSamples.length > 10) {
this.recentSamples.shift();
}
const bestSample = this.recentSamples.reduce((best, sample) =>
sample.roundTripTime < best.roundTripTime ? sample : best,
);
this.offset = bestSample.offset;
return this.stats;
}
toServerTime(clientNow: number): number {
return clientNow + this.offset;
}
recommendedInputLeadTicks(tickRateHz: number, maximum = 8): number {
const tickMilliseconds = 1_000 / tickRateHz;
const latencyBudget = this.smoothedRtt / 2 + this.smoothedJitter * 2;
return Math.max(
1,
Math.min(maximum, Math.ceil(latencyBudget / tickMilliseconds) + 1),
);
}
get stats(): NetworkStats {
return {
roundTripTime: this.smoothedRtt,
jitter: this.smoothedJitter,
clockOffset: this.offset,
samples: this.recentSamples.length,
};
}
}

View File

@@ -0,0 +1,214 @@
import { NetworkClock } from "./network-clock.js";
import type {
ClientStateReport,
InputContext,
InputPacket,
PlayerId,
StateSnapshot,
} from "./types.js";
import type { NetworkedGameDefinition } from "./networked-types.js";
interface PredictedInput<Input> {
packet: InputPacket<Input>;
applied: boolean;
}
export class NetworkedPredictedEngine<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
> {
readonly game: NetworkedGameDefinition<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>;
readonly networkClock = new NetworkClock();
private state: ClientState;
private playerId: PlayerId | null = null;
private currentTick = 0;
private nextInputSequence = 1;
private acknowledgedSequence = 0;
private pendingInputs: PredictedInput<Input>[] = [];
constructor(
game: NetworkedGameDefinition<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>,
) {
this.game = game;
this.state = game.client.createInitialState();
}
get tick(): number {
return this.currentTick;
}
get localPlayerId(): PlayerId | null {
return this.playerId;
}
get currentState(): Readonly<ClientState> {
return this.state;
}
get initialized(): boolean {
return this.playerId !== null;
}
initialize(playerId: PlayerId, snapshot: StateSnapshot<ClientState>): void {
this.playerId = playerId;
this.currentTick = snapshot.tick;
this.state = this.game.client.cloneState(snapshot.state);
this.pendingInputs = [];
this.acknowledgedSequence = 0;
}
createInput(input: Input, leadTicks?: number): InputPacket<Input> {
if (this.playerId === null) {
throw new Error("Client engine has not received a welcome snapshot");
}
const targetTick =
this.currentTick +
(leadTicks ??
this.networkClock.recommendedInputLeadTicks(this.game.tickRateHz));
const packet = {
sequence: this.nextInputSequence,
targetTick,
observedTick: this.currentTick,
input,
};
if (!this.game.validateInput(input, this.inputContext(packet))) {
throw new Error("Refusing to send invalid local input");
}
this.nextInputSequence = (this.nextInputSequence + 1) >>> 0;
this.pendingInputs.push({ packet, applied: false });
return packet;
}
acknowledge(sequence: number): void {
this.acknowledgedSequence = Math.max(this.acknowledgedSequence, sequence);
this.pendingInputs = this.pendingInputs.filter(
(pending) => pending.packet.sequence > this.acknowledgedSequence,
);
}
reject(sequence: number): void {
this.pendingInputs = this.pendingInputs.filter(
(pending) => pending.packet.sequence !== sequence,
);
}
step(): void {
if (this.playerId === null) {
return;
}
this.currentTick += 1;
this.applyDueInputs();
this.game.client.step(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
});
this.assertValidState();
}
reconcile(snapshot: StateSnapshot<ClientState>): void {
if (
this.playerId === null ||
snapshot.tick < this.currentTick - this.game.tickRateHz * 2
) {
return;
}
const predictedTick = Math.max(this.currentTick, snapshot.tick);
const context = {
tick: snapshot.tick,
deltaSeconds: 1 / this.game.tickRateHz,
};
this.state = this.game.client.mergeSnapshot
? this.game.client.mergeSnapshot(this.state, snapshot.state, context)
: this.game.client.cloneState(snapshot.state);
this.currentTick = snapshot.tick;
for (const pending of this.pendingInputs) {
pending.applied = false;
}
this.applyDueInputs();
while (this.currentTick < predictedTick) {
this.currentTick += 1;
this.applyDueInputs();
this.game.client.step(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
});
}
this.assertValidState();
}
receiveEvent(event: PerceptionEvent, tick: number): void {
this.game.client.applyEvent?.(this.state, event, { tick });
this.assertValidState();
}
createStateReport(): ClientStateReport<ClientState> {
return {
tick: this.currentTick,
state: this.game.client.cloneState(this.state),
};
}
private applyDueInputs(): void {
for (const pending of this.pendingInputs) {
if (!pending.applied && pending.packet.targetTick <= this.currentTick) {
this.game.client.applyInput(
this.state,
pending.packet.input,
this.inputContext(pending.packet),
);
pending.applied = true;
}
}
}
private inputContext(packet: InputPacket<Input>): InputContext {
if (this.playerId === null) {
throw new Error("Client engine has not been initialized");
}
return {
playerId: this.playerId,
sequence: packet.sequence,
targetTick: packet.targetTick,
observedTick: packet.observedTick ?? packet.targetTick,
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
};
}
private assertValidState(): void {
if (
this.game.client.validateState &&
!this.game.client.validateState(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
})
) {
throw new Error(
`Client prediction produced invalid state at tick ${this.currentTick}`,
);
}
}
}

View File

@@ -0,0 +1,472 @@
import type {
ClientStateReport,
InputContext,
InputPacket,
PlayerId,
StateSnapshot,
ValidationResult,
} from "./types.js";
import type {
NetworkedGameDefinition,
SnapshotBatch,
} from "./networked-types.js";
import type {
Acknowledgement,
InputDecision,
ServerEngineOptions,
} from "./server.js";
export interface NetworkedServerStepResult<AuthorityEvent> {
tick: number;
acknowledgements: Acknowledgement[];
validations: ValidationResult[];
events: AuthorityEvent[];
snapshotDue: boolean;
}
export interface NetworkedSimulationCheckpoint<
AuthorityState,
Input,
AuthorityEvent,
> {
tick: number;
state: AuthorityState;
playerIds: PlayerId[];
lastReceivedSequences: Array<readonly [PlayerId, number]>;
acknowledgedSequences: Array<readonly [PlayerId, number]>;
queuedInputs: Array<{
playerId: PlayerId;
packet: InputPacket<Input>;
}>;
pendingEvents: AuthorityEvent[];
}
interface QueuedInput<Input> {
playerId: PlayerId;
packet: InputPacket<Input>;
}
interface QueuedReport<ClientState> {
playerId: PlayerId;
report: ClientStateReport<ClientState>;
}
export class NetworkedAuthoritativeEngine<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
> {
readonly game: NetworkedGameDefinition<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>;
private readonly historyTicks: number;
private readonly maxPastTicks: number;
private readonly maxFutureTicks: number;
private readonly snapshotEveryTicks: number;
private readonly players = new Set<PlayerId>();
private readonly lastReceivedSequence = new Map<PlayerId, number>();
private readonly acknowledgedSequence = new Map<PlayerId, number>();
private readonly history = new Map<number, AuthorityState>();
private queuedInputs: QueuedInput<Input>[] = [];
private queuedReports: QueuedReport<ClientState>[] = [];
private pendingEvents: AuthorityEvent[] = [];
private state: AuthorityState;
private currentTick = 0;
constructor(
game: NetworkedGameDefinition<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>,
options: ServerEngineOptions = {},
) {
this.game = game;
this.historyTicks = options.historyTicks ?? game.tickRateHz * 2;
this.maxPastTicks = options.maxPastTicks ?? 2;
this.maxFutureTicks = options.maxFutureTicks ?? game.tickRateHz;
this.snapshotEveryTicks = game.tickRateHz / game.snapshotRateHz;
this.state = game.server.createInitialState();
game.server.initializeState?.(this.state, {
tick: 0,
deltaSeconds: 1 / game.tickRateHz,
});
this.assertValidState();
this.storeHistory();
}
get tick(): number {
return this.currentTick;
}
get currentState(): Readonly<AuthorityState> {
return this.state;
}
get playerIds(): PlayerId[] {
return [...this.players];
}
addPlayer(playerId: PlayerId): void {
if (this.players.has(playerId)) {
return;
}
this.players.add(playerId);
this.game.server.addPlayer?.(this.state, {
playerId,
tick: this.currentTick,
emit: (event) => this.pendingEvents.push(event),
});
this.assertValidState();
this.storeHistory();
}
removePlayer(playerId: PlayerId): void {
if (!this.players.delete(playerId)) {
return;
}
this.game.server.removePlayer?.(this.state, {
playerId,
tick: this.currentTick,
emit: (event) => this.pendingEvents.push(event),
});
this.lastReceivedSequence.delete(playerId);
this.acknowledgedSequence.delete(playerId);
this.queuedInputs = this.queuedInputs.filter(
(queued) => queued.playerId !== playerId,
);
this.queuedReports = this.queuedReports.filter(
(queued) => queued.playerId !== playerId,
);
this.assertValidState();
this.storeHistory();
}
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision {
if (!this.players.has(playerId)) {
return { accepted: false, reason: "unknown-player" };
}
const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0;
if (packet.sequence <= previousSequence) {
return { accepted: false, reason: "duplicate" };
}
if (packet.targetTick < this.currentTick - this.maxPastTicks) {
return { accepted: false, reason: "past" };
}
if (packet.targetTick > this.currentTick + this.maxFutureTicks) {
return { accepted: false, reason: "future" };
}
const context = this.inputContext(playerId, packet);
if (!this.game.validateInput(packet.input, context)) {
return { accepted: false, reason: "invalid" };
}
this.lastReceivedSequence.set(playerId, packet.sequence);
this.queuedInputs.push({ playerId, packet });
return { accepted: true };
}
submitStateReport(
playerId: PlayerId,
report: ClientStateReport<ClientState>,
): ValidationResult | null {
if (report.tick > this.currentTick + this.maxFutureTicks) {
return {
playerId,
tick: report.tick,
valid: false,
reason: "too-far-ahead",
};
}
if (report.tick > this.currentTick) {
this.queuedReports = this.queuedReports.filter(
(queued) => queued.playerId !== playerId,
);
this.queuedReports.push({ playerId, report });
return null;
}
return this.validateReport(playerId, report);
}
step(): NetworkedServerStepResult<AuthorityEvent> {
this.currentTick += 1;
const deltaSeconds = 1 / this.game.tickRateHz;
const events = this.pendingEvents;
this.pendingEvents = [];
const emit = (event: AuthorityEvent) => events.push(event);
const acknowledgementByPlayer = new Map<PlayerId, number>();
const dueInputs: QueuedInput<Input>[] = [];
const futureInputs: QueuedInput<Input>[] = [];
for (const queued of this.queuedInputs) {
(queued.packet.targetTick <= this.currentTick
? dueInputs
: futureInputs
).push(queued);
}
this.queuedInputs = futureInputs;
dueInputs.sort(
(left, right) =>
left.packet.targetTick - right.packet.targetTick ||
left.playerId - right.playerId ||
left.packet.sequence - right.packet.sequence,
);
for (const queued of dueInputs) {
const { playerId, packet } = queued;
this.game.server.applyInput(this.state, packet.input, {
...this.inputContext(playerId, packet),
emit,
});
const previousAck = this.acknowledgedSequence.get(playerId) ?? 0;
if (packet.sequence > previousAck) {
this.acknowledgedSequence.set(playerId, packet.sequence);
acknowledgementByPlayer.set(playerId, packet.sequence);
}
}
this.game.server.step(this.state, {
tick: this.currentTick,
deltaSeconds,
emit,
});
this.assertValidState();
this.storeHistory();
const validations: ValidationResult[] = [];
const futureReports: QueuedReport<ClientState>[] = [];
for (const queued of this.queuedReports) {
if (queued.report.tick <= this.currentTick) {
validations.push(
this.validateReport(queued.playerId, queued.report),
);
} else {
futureReports.push(queued);
}
}
this.queuedReports = futureReports;
return {
tick: this.currentTick,
acknowledgements: [...acknowledgementByPlayer].map(
([playerId, sequence]) => ({ playerId, sequence }),
),
validations,
events,
snapshotDue: this.currentTick % this.snapshotEveryTicks === 0,
};
}
createSnapshot(
playerId: PlayerId,
serverTime: number,
): StateSnapshot<ClientState> {
return {
tick: this.currentTick,
serverTime,
state: this.game.replication.createSnapshot(this.state, {
playerId,
tick: this.currentTick,
}),
};
}
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[] {
const groups = new Map<string | number, PlayerId[]>();
for (const playerId of this.players) {
const context = { playerId, tick: this.currentTick };
const key =
this.game.replication.groupKey?.(this.state, context) ?? playerId;
const group = groups.get(key);
if (group) {
group.push(playerId);
} else {
groups.set(key, [playerId]);
}
}
return [...groups.values()].map((playerIds) => ({
playerIds,
state: this.createSnapshot(playerIds[0]!, serverTime).state,
}));
}
createPerceptions(
playerId: PlayerId,
events: readonly AuthorityEvent[],
): PerceptionEvent[] {
if (!this.game.replication.perceive) {
return [];
}
const perceptions: PerceptionEvent[] = [];
for (const event of events) {
const perception = this.game.replication.perceive(
this.state,
event,
{ playerId, tick: this.currentTick },
);
if (perception !== null) {
perceptions.push(perception);
}
}
return perceptions;
}
createSimulationCheckpoint(): NetworkedSimulationCheckpoint<
AuthorityState,
Input,
AuthorityEvent
> {
return {
tick: this.currentTick,
state: this.game.server.cloneState(this.state),
playerIds: [...this.players],
lastReceivedSequences: [...this.lastReceivedSequence],
acknowledgedSequences: [...this.acknowledgedSequence],
queuedInputs: this.queuedInputs.map(({ playerId, packet }) => ({
playerId,
packet: this.cloneInputPacket(packet),
})),
pendingEvents: [...this.pendingEvents],
};
}
restoreSimulationCheckpoint(
checkpoint: NetworkedSimulationCheckpoint<
AuthorityState,
Input,
AuthorityEvent
>,
): void {
if (!Number.isInteger(checkpoint.tick) || checkpoint.tick < 0) {
throw new RangeError("checkpoint tick must be a non-negative integer");
}
this.currentTick = checkpoint.tick;
this.state = this.game.server.cloneState(checkpoint.state);
this.players.clear();
for (const playerId of checkpoint.playerIds) this.players.add(playerId);
this.lastReceivedSequence.clear();
for (const [playerId, sequence] of checkpoint.lastReceivedSequences) {
this.lastReceivedSequence.set(playerId, sequence);
}
this.acknowledgedSequence.clear();
for (const [playerId, sequence] of checkpoint.acknowledgedSequences) {
this.acknowledgedSequence.set(playerId, sequence);
}
this.queuedInputs = checkpoint.queuedInputs.map(({ playerId, packet }) => ({
playerId,
packet: this.cloneInputPacket(packet),
}));
this.queuedReports = [];
this.pendingEvents = [...checkpoint.pendingEvents];
this.history.clear();
this.assertValidState();
this.storeHistory();
}
private inputContext(
playerId: PlayerId,
packet: InputPacket<Input>,
): InputContext {
return {
playerId,
sequence: packet.sequence,
targetTick: packet.targetTick,
observedTick: packet.observedTick ?? packet.targetTick,
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
};
}
private cloneInputPacket(packet: InputPacket<Input>): InputPacket<Input> {
const encoded = this.game.codecs.input.encode(packet.input);
return {
sequence: packet.sequence,
targetTick: packet.targetTick,
observedTick: packet.observedTick ?? packet.targetTick,
input: this.game.codecs.input.decode(encoded),
};
}
private validateReport(
playerId: PlayerId,
report: ClientStateReport<ClientState>,
): ValidationResult {
const authoritative = this.history.get(report.tick);
if (!authoritative) {
return {
playerId,
tick: report.tick,
valid: false,
reason: "outside-history",
};
}
const valid = this.game.replication.validateClientState
? this.game.replication.validateClientState(
authoritative,
report.state,
{ playerId, tick: report.tick },
)
: (this.game.client.validateState?.(report.state, {
tick: report.tick,
deltaSeconds: 1 / this.game.tickRateHz,
}) ?? true);
return {
playerId,
tick: report.tick,
valid,
...(valid ? {} : { reason: "mismatch" as const }),
};
}
private assertValidState(): void {
if (
this.game.server.validateState &&
!this.game.server.validateState(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
})
) {
throw new Error(`Game produced invalid state at tick ${this.currentTick}`);
}
}
private storeHistory(): void {
this.history.set(
this.currentTick,
this.game.server.cloneStateForHistory?.(this.state) ??
this.game.server.cloneState(this.state),
);
const oldestTick = this.currentTick - this.historyTicks;
for (const tick of this.history.keys()) {
if (tick < oldestTick) {
this.history.delete(tick);
}
}
}
}

View File

@@ -0,0 +1,125 @@
import type {
BinaryCodec,
ClientStateContext,
InputContext,
PlayerContext,
PlayerId,
TickContext,
} from "./types.js";
export interface EmittingTickContext<Event> extends TickContext {
emit(event: Event): void;
}
export interface EmittingInputContext<Event> extends InputContext {
emit(event: Event): void;
}
export interface EmittingPlayerContext<Event> extends PlayerContext {
emit(event: Event): void;
}
export interface ClientEventContext {
tick: number;
}
export interface ServerSimulationRules<AuthorityState, Input, AuthorityEvent> {
createInitialState(): AuthorityState;
/** Runs after all higher-order initializers have finished creating state. */
initializeState?(state: AuthorityState, context: TickContext): void;
cloneState(state: AuthorityState): AuthorityState;
/** Optional cheaper clone used only for client-state validation history. */
cloneStateForHistory?(state: AuthorityState): AuthorityState;
step(
state: AuthorityState,
context: EmittingTickContext<AuthorityEvent>,
): void;
applyInput(
state: AuthorityState,
input: Input,
context: EmittingInputContext<AuthorityEvent>,
): void;
validateState?(state: AuthorityState, context: TickContext): boolean;
addPlayer?(
state: AuthorityState,
context: EmittingPlayerContext<AuthorityEvent>,
): void;
removePlayer?(
state: AuthorityState,
context: EmittingPlayerContext<AuthorityEvent>,
): void;
}
export interface ClientSimulationRules<ClientState, Input, PerceptionEvent> {
createInitialState(): ClientState;
cloneState(state: ClientState): ClientState;
step(state: ClientState, context: TickContext): void;
applyInput(state: ClientState, input: Input, context: InputContext): void;
validateState?(state: ClientState, context: TickContext): boolean;
mergeSnapshot?(
predicted: ClientState,
snapshot: ClientState,
context: TickContext,
): ClientState;
applyEvent?(
state: ClientState,
event: PerceptionEvent,
context: ClientEventContext,
): void;
}
export interface ReplicationRules<
AuthorityState,
ClientState,
AuthorityEvent,
PerceptionEvent,
> {
createSnapshot(
authoritative: AuthorityState,
context: PlayerContext,
): ClientState;
validateClientState?(
authoritative: AuthorityState,
candidate: ClientState,
context: ClientStateContext,
): boolean;
perceive?(
authoritative: AuthorityState,
event: AuthorityEvent,
context: PlayerContext,
): PerceptionEvent | null;
groupKey?(
authoritative: AuthorityState,
context: PlayerContext,
): string | number;
}
export interface NetworkedGameDefinition<
AuthorityState,
ClientState,
Input,
AuthorityEvent = never,
PerceptionEvent = never,
> {
tickRateHz: number;
snapshotRateHz: number;
server: ServerSimulationRules<AuthorityState, Input, AuthorityEvent>;
client: ClientSimulationRules<ClientState, Input, PerceptionEvent>;
replication: ReplicationRules<
AuthorityState,
ClientState,
AuthorityEvent,
PerceptionEvent
>;
validateInput(input: Input, context: InputContext): boolean;
codecs: {
input: BinaryCodec<Input>;
state: BinaryCodec<ClientState>;
event?: BinaryCodec<PerceptionEvent>;
};
}
export interface SnapshotBatch<ClientState> {
playerIds: PlayerId[];
state: ClientState;
}

View File

@@ -0,0 +1,401 @@
import type {
BinaryCodec,
ClientStateReport,
InputPacket,
PingPacket,
PlayerId,
PongPacket,
StateSnapshot,
} from "./types.js";
const enum ServerOpcode {
Welcome = 1,
Snapshot = 2,
Acknowledge = 3,
Pong = 4,
Validation = 5,
RejectInput = 6,
Event = 7,
ReplayStart = 8,
ReplayFrame = 9,
ReplayEnd = 10,
}
const enum ClientOpcode {
Input = 16,
Ping = 17,
StateReport = 18,
}
export type ClientWireMessage<Input, State> =
| { kind: "input"; packet: InputPacket<Input> }
| { kind: "ping"; ping: PingPacket }
| { kind: "state-report"; report: ClientStateReport<State> };
export type ServerWireMessage<State, Event = never> =
| { kind: "welcome"; playerId: PlayerId; snapshot: StateSnapshot<State> }
| { kind: "snapshot"; snapshot: StateSnapshot<State> }
| { kind: "acknowledge"; sequence: number }
| { kind: "pong"; pong: PongPacket }
| { kind: "validation"; tick: number; valid: boolean }
| { kind: "reject-input"; sequence: number }
| { kind: "event"; tick: number; event: Event }
| {
kind: "replay-start";
ticketId: number;
perspectiveId: PlayerId;
fromTick: number;
toTick: number;
frameCount: number;
playbackRate: number;
}
| {
kind: "replay-frame";
ticketId: number;
tick: number;
state: State;
events: Event[];
}
| { kind: "replay-end"; ticketId: number };
export interface BinaryProtocol<Input, State, Event = never> {
encodeClient(message: ClientWireMessage<Input, State>): ArrayBuffer;
decodeClient(payload: ArrayBuffer): ClientWireMessage<Input, State>;
encodeServer(message: ServerWireMessage<State, Event>): ArrayBuffer;
decodeServer(payload: ArrayBuffer): ServerWireMessage<State, Event>;
}
export function createBinaryProtocol<Input, State, Event = never>(codecs: {
input: BinaryCodec<Input>;
state: BinaryCodec<State>;
event?: BinaryCodec<Event>;
}): BinaryProtocol<Input, State, Event> {
return {
encodeClient(message) {
switch (message.kind) {
case "input": {
const input = codecs.input.encode(message.packet.input);
const payload = createFrame(ClientOpcode.Input, 13, input);
const view = new DataView(payload);
view.setUint32(1, message.packet.sequence, true);
view.setUint32(5, message.packet.targetTick, true);
view.setUint32(
9,
message.packet.observedTick ?? message.packet.targetTick,
true,
);
return payload;
}
case "ping": {
const payload = createFrame(ClientOpcode.Ping, 13);
const view = new DataView(payload);
view.setUint32(1, message.ping.id, true);
view.setFloat64(5, message.ping.clientSentAt, true);
return payload;
}
case "state-report": {
const state = codecs.state.encode(message.report.state);
const payload = createFrame(ClientOpcode.StateReport, 5, state);
new DataView(payload).setUint32(1, message.report.tick, true);
return payload;
}
}
},
decodeClient(payload) {
const view = new DataView(payload);
switch (view.getUint8(0)) {
case ClientOpcode.Input:
assertLength(payload, 13);
return {
kind: "input",
packet: {
sequence: view.getUint32(1, true),
targetTick: view.getUint32(5, true),
observedTick: view.getUint32(9, true),
input: codecs.input.decode(new Uint8Array(payload, 13)),
},
};
case ClientOpcode.Ping:
assertExactLength(payload, 13);
return {
kind: "ping",
ping: {
id: view.getUint32(1, true),
clientSentAt: view.getFloat64(5, true),
},
};
case ClientOpcode.StateReport:
assertLength(payload, 5);
return {
kind: "state-report",
report: {
tick: view.getUint32(1, true),
state: codecs.state.decode(new Uint8Array(payload, 5)),
},
};
default:
throw new RangeError("Unknown client message opcode");
}
},
encodeServer(message) {
switch (message.kind) {
case "welcome": {
const state = codecs.state.encode(message.snapshot.state);
const payload = createFrame(ServerOpcode.Welcome, 17, state);
const view = new DataView(payload);
view.setUint32(1, message.playerId, true);
view.setUint32(5, message.snapshot.tick, true);
view.setFloat64(9, message.snapshot.serverTime, true);
return payload;
}
case "snapshot": {
const state = codecs.state.encode(message.snapshot.state);
const payload = createFrame(ServerOpcode.Snapshot, 13, state);
const view = new DataView(payload);
view.setUint32(1, message.snapshot.tick, true);
view.setFloat64(5, message.snapshot.serverTime, true);
return payload;
}
case "acknowledge": {
const payload = createFrame(ServerOpcode.Acknowledge, 5);
new DataView(payload).setUint32(1, message.sequence, true);
return payload;
}
case "pong": {
const payload = createFrame(ServerOpcode.Pong, 29);
const view = new DataView(payload);
view.setUint32(1, message.pong.id, true);
view.setFloat64(5, message.pong.clientSentAt, true);
view.setFloat64(13, message.pong.serverReceivedAt, true);
view.setFloat64(21, message.pong.serverSentAt, true);
return payload;
}
case "validation": {
const payload = createFrame(ServerOpcode.Validation, 6);
const view = new DataView(payload);
view.setUint32(1, message.tick, true);
view.setUint8(5, message.valid ? 1 : 0);
return payload;
}
case "reject-input": {
const payload = createFrame(ServerOpcode.RejectInput, 5);
new DataView(payload).setUint32(1, message.sequence, true);
return payload;
}
case "event": {
if (!codecs.event) {
throw new Error("This protocol has no event codec");
}
const event = codecs.event.encode(message.event);
const payload = createFrame(ServerOpcode.Event, 5, event);
new DataView(payload).setUint32(1, message.tick, true);
return payload;
}
case "replay-start": {
if (message.frameCount > 65_535) {
throw new RangeError("Replay frame count exceeds protocol limit");
}
const payload = createFrame(ServerOpcode.ReplayStart, 23);
const view = new DataView(payload);
view.setUint32(1, message.ticketId, true);
view.setUint32(5, message.perspectiveId, true);
view.setUint32(9, message.fromTick, true);
view.setUint32(13, message.toTick, true);
view.setUint16(17, message.frameCount, true);
view.setFloat32(19, message.playbackRate, true);
return payload;
}
case "replay-frame": {
if (message.events.length > 65_535) {
throw new RangeError("Replay event count exceeds protocol limit");
}
if (message.events.length > 0 && !codecs.event) {
throw new Error("This protocol has no event codec");
}
const state = codecs.state.encode(message.state);
const events = message.events.map((event) => codecs.event!.encode(event));
const bodyLength =
state.byteLength +
events.reduce((total, event) => total + 4 + event.byteLength, 0);
const payload = createFrame(ServerOpcode.ReplayFrame, 15 + bodyLength);
const view = new DataView(payload);
view.setUint32(1, message.ticketId, true);
view.setUint32(5, message.tick, true);
view.setUint16(9, events.length, true);
view.setUint32(11, state.byteLength, true);
const bytes = new Uint8Array(payload);
let offset = 15;
bytes.set(state, offset);
offset += state.byteLength;
for (const event of events) {
view.setUint32(offset, event.byteLength, true);
offset += 4;
bytes.set(event, offset);
offset += event.byteLength;
}
return payload;
}
case "replay-end": {
const payload = createFrame(ServerOpcode.ReplayEnd, 5);
new DataView(payload).setUint32(1, message.ticketId, true);
return payload;
}
}
},
decodeServer(payload) {
const view = new DataView(payload);
switch (view.getUint8(0)) {
case ServerOpcode.Welcome:
assertLength(payload, 17);
return {
kind: "welcome",
playerId: view.getUint32(1, true),
snapshot: {
tick: view.getUint32(5, true),
serverTime: view.getFloat64(9, true),
state: codecs.state.decode(new Uint8Array(payload, 17)),
},
};
case ServerOpcode.Snapshot:
assertLength(payload, 13);
return {
kind: "snapshot",
snapshot: {
tick: view.getUint32(1, true),
serverTime: view.getFloat64(5, true),
state: codecs.state.decode(new Uint8Array(payload, 13)),
},
};
case ServerOpcode.Acknowledge:
assertExactLength(payload, 5);
return {
kind: "acknowledge",
sequence: view.getUint32(1, true),
};
case ServerOpcode.Pong:
assertExactLength(payload, 29);
return {
kind: "pong",
pong: {
id: view.getUint32(1, true),
clientSentAt: view.getFloat64(5, true),
serverReceivedAt: view.getFloat64(13, true),
serverSentAt: view.getFloat64(21, true),
},
};
case ServerOpcode.Validation:
assertExactLength(payload, 6);
return {
kind: "validation",
tick: view.getUint32(1, true),
valid: view.getUint8(5) === 1,
};
case ServerOpcode.RejectInput:
assertExactLength(payload, 5);
return {
kind: "reject-input",
sequence: view.getUint32(1, true),
};
case ServerOpcode.Event:
assertLength(payload, 5);
if (!codecs.event) {
throw new Error("This protocol has no event codec");
}
return {
kind: "event",
tick: view.getUint32(1, true),
event: codecs.event.decode(new Uint8Array(payload, 5)),
};
case ServerOpcode.ReplayStart:
assertExactLength(payload, 23);
return {
kind: "replay-start",
ticketId: view.getUint32(1, true),
perspectiveId: view.getUint32(5, true),
fromTick: view.getUint32(9, true),
toTick: view.getUint32(13, true),
frameCount: view.getUint16(17, true),
playbackRate: view.getFloat32(19, true),
};
case ServerOpcode.ReplayFrame: {
assertLength(payload, 15);
const ticketId = view.getUint32(1, true);
const tick = view.getUint32(5, true);
const eventCount = view.getUint16(9, true);
const stateLength = view.getUint32(11, true);
assertAvailable(payload, 15, stateLength);
const state = codecs.state.decode(
new Uint8Array(payload, 15, stateLength),
);
let offset = 15 + stateLength;
const events: Event[] = [];
for (let index = 0; index < eventCount; index += 1) {
if (!codecs.event) throw new Error("This protocol has no event codec");
assertAvailable(payload, offset, 4);
const eventLength = view.getUint32(offset, true);
offset += 4;
assertAvailable(payload, offset, eventLength);
events.push(
codecs.event.decode(new Uint8Array(payload, offset, eventLength)),
);
offset += eventLength;
}
if (offset !== payload.byteLength) {
throw new RangeError("Replay frame has trailing bytes");
}
return { kind: "replay-frame", ticketId, tick, state, events };
}
case ServerOpcode.ReplayEnd:
assertExactLength(payload, 5);
return {
kind: "replay-end",
ticketId: view.getUint32(1, true),
};
default:
throw new RangeError("Unknown server message opcode");
}
},
};
}
function createFrame(
opcode: number,
headerLength: number,
body?: Uint8Array,
): ArrayBuffer {
const payload = new ArrayBuffer(headerLength + (body?.byteLength ?? 0));
const bytes = new Uint8Array(payload);
bytes[0] = opcode;
if (body) {
bytes.set(body, headerLength);
}
return payload;
}
function assertLength(payload: ArrayBuffer, minimum: number): void {
if (payload.byteLength < minimum) {
throw new RangeError(`Message must be at least ${minimum} bytes`);
}
}
function assertExactLength(payload: ArrayBuffer, expected: number): void {
if (payload.byteLength !== expected) {
throw new RangeError(`Message must be exactly ${expected} bytes`);
}
}
function assertAvailable(
payload: ArrayBuffer,
offset: number,
length: number,
): void {
if (
!Number.isInteger(offset) ||
!Number.isInteger(length) ||
offset < 0 ||
length < 0 ||
offset > payload.byteLength - length
) {
throw new RangeError("Message body is truncated");
}
}

View File

@@ -0,0 +1,482 @@
import type { DefinedNetworkedGame } from "./define-networked-game.js";
import type { NetworkedServerStepResult } from "./networked-server.js";
import type { SnapshotBatch } from "./networked-types.js";
import type { InputDecision } from "./server.js";
import {
type ReplayRecording,
type ReplayStateHash,
type TimeTravelAuthoritativeEngine,
type TimeTravelNetworkedGame,
type TimeTravelServerOptions,
} from "./time-travel.js";
import type {
ClientStateReport,
InputPacket,
PlayerId,
StateSnapshot,
ValidationResult,
} from "./types.js";
export interface ProjectedReplayFrame<ClientState, PerceptionEvent> {
tick: number;
state: ClientState;
events: PerceptionEvent[];
}
export interface ReplayTicket<ClientState, PerceptionEvent> {
ticketId: number;
requesterId: PlayerId;
perspectiveId: PlayerId;
issuedAtTick: number;
fromTick: number;
toTick: number;
playbackRate: number;
frames: Array<ProjectedReplayFrame<ClientState, PerceptionEvent>>;
}
export interface ReplayTicketPlan {
requesterId: PlayerId;
perspectiveId: PlayerId;
fromTick: number;
toTick: number;
playbackRate?: number;
}
export interface ReplayTriggerContext<AuthorityState> {
tick: number;
authorityState: Readonly<AuthorityState>;
connectedPlayerIds: readonly PlayerId[];
}
export interface ReplayAuthorizationContext<AuthorityState>
extends ReplayTicketPlan {
currentTick: number;
authorityState: Readonly<AuthorityState>;
connectedPlayerIds: readonly PlayerId[];
}
export interface ReplayTransportDefinition<AuthorityState, AuthorityEvent> {
/** Maximum age of the projected frame ring and private deterministic log. */
historySeconds: number;
/** Baseline frame rate. Event ticks are captured even between baseline frames. */
captureRateHz?: number;
/** Perspectives worth retaining. Defaults to connected network players. */
listPerspectives?(
authorityState: Readonly<AuthorityState>,
connectedPlayerIds: readonly PlayerId[],
): Iterable<PlayerId>;
/** Trusted server hook that can create automatic tickets from authority events. */
createTickets?(
event: Readonly<AuthorityEvent>,
context: ReplayTriggerContext<AuthorityState>,
): ReplayTicketPlan | readonly ReplayTicketPlan[] | null;
/** Every automatic or manual ticket passes through this policy. */
authorizeReplay?(
context: ReplayAuthorizationContext<AuthorityState>,
): boolean;
maxPendingTicketsPerPlayer?: number;
}
interface NormalizedReplayTransportDefinition<AuthorityState, AuthorityEvent>
extends ReplayTransportDefinition<AuthorityState, AuthorityEvent> {
historyTicks: number;
captureEveryTicks: number;
maxPendingTicketsPerPlayer: number;
}
export type ReplayTransportNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
Seed,
StateHash extends ReplayStateHash,
> = Omit<
TimeTravelNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
Seed,
StateHash
>,
"createServer"
> & {
createServer(
options?: TimeTravelServerOptions<Seed>,
): ReplayTransportAuthoritativeEngine<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
Seed,
StateHash
>;
};
/**
* Keeps client-safe historical projections around a private authoritative
* engine. Raw authority checkpoints never cross this boundary.
*/
export class ReplayTransportAuthoritativeEngine<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
Seed,
StateHash extends ReplayStateHash,
> {
readonly game: DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>;
private readonly engine: TimeTravelAuthoritativeEngine<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
Seed,
StateHash
>;
private readonly replay: NormalizedReplayTransportDefinition<
AuthorityState,
AuthorityEvent
>;
private readonly history = new Map<
PlayerId,
Array<ProjectedReplayFrame<ClientState, PerceptionEvent>>
>();
private readonly pendingTickets = new Map<
PlayerId,
Array<ReplayTicket<ClientState, PerceptionEvent>>
>();
private nextTicketId = 1;
constructor(
game: DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>,
engine: TimeTravelAuthoritativeEngine<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
Seed,
StateHash
>,
replay: NormalizedReplayTransportDefinition<
AuthorityState,
AuthorityEvent
>,
) {
this.game = game;
this.engine = engine;
this.replay = replay;
}
get tick(): number {
return this.engine.tick;
}
get currentState(): Readonly<AuthorityState> {
return this.engine.currentState;
}
get playerIds(): PlayerId[] {
return this.engine.playerIds;
}
addPlayer(playerId: PlayerId): void {
this.engine.addPlayer(playerId);
this.capture([], true);
}
removePlayer(playerId: PlayerId): void {
this.engine.removePlayer(playerId);
this.pendingTickets.delete(playerId);
this.capture([], true);
}
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision {
return this.engine.submitInput(playerId, packet);
}
submitStateReport(
playerId: PlayerId,
report: ClientStateReport<ClientState>,
): ValidationResult | null {
return this.engine.submitStateReport(playerId, report);
}
step(): NetworkedServerStepResult<AuthorityEvent> {
const result = this.engine.step();
const shouldCapture =
result.tick % this.replay.captureEveryTicks === 0 ||
result.events.length > 0;
if (shouldCapture) this.capture(result.events, true);
this.pruneHistory();
if (this.replay.createTickets) {
const context: ReplayTriggerContext<AuthorityState> = {
tick: result.tick,
authorityState: this.currentState,
connectedPlayerIds: this.playerIds,
};
for (const event of result.events) {
const plans = this.replay.createTickets(event, context);
if (!plans) continue;
for (const plan of Array.isArray(plans) ? plans : [plans]) {
this.issueReplay(plan);
}
}
}
return result;
}
createSnapshot(
playerId: PlayerId,
serverTime: number,
): StateSnapshot<ClientState> {
return this.engine.createSnapshot(playerId, serverTime);
}
createSnapshotBatches(serverTime: number): SnapshotBatch<ClientState>[] {
return this.engine.createSnapshotBatches(serverTime);
}
createPerceptions(
playerId: PlayerId,
events: readonly AuthorityEvent[],
): PerceptionEvent[] {
return this.engine.createPerceptions(playerId, events);
}
exportRecording(): ReplayRecording<
AuthorityState,
Input,
AuthorityEvent,
Seed,
StateHash
> {
return this.engine.exportRecording();
}
/** Trusted-server API. The configured authorization policy still applies. */
issueReplay(
plan: ReplayTicketPlan,
): ReplayTicket<ClientState, PerceptionEvent> | null {
if (!this.playerIds.includes(plan.requesterId)) return null;
if (!validTick(plan.fromTick) || !validTick(plan.toTick)) return null;
if (plan.toTick < plan.fromTick || plan.toTick > this.tick) return null;
const authorization: ReplayAuthorizationContext<AuthorityState> = {
...plan,
currentTick: this.tick,
authorityState: this.currentState,
connectedPlayerIds: this.playerIds,
};
const authorized = this.replay.authorizeReplay
? this.replay.authorizeReplay(authorization)
: plan.requesterId === plan.perspectiveId;
if (!authorized) return null;
const perspectiveHistory = this.history.get(plan.perspectiveId);
if (!perspectiveHistory) return null;
const selected = perspectiveHistory.filter(
({ tick }) => tick >= plan.fromTick && tick <= plan.toTick,
);
if (selected.length === 0) return null;
const first = selected[0]!;
const last = selected[selected.length - 1]!;
const ticket: ReplayTicket<ClientState, PerceptionEvent> = {
ticketId: this.allocateTicketId(),
requesterId: plan.requesterId,
perspectiveId: plan.perspectiveId,
issuedAtTick: this.tick,
fromTick: first.tick,
toTick: last.tick,
playbackRate: clamp(plan.playbackRate ?? 1, 0.1, 4),
frames: selected.map((frame) => this.cloneFrame(frame)),
};
const queue = this.pendingTickets.get(plan.requesterId) ?? [];
queue.push(ticket);
while (queue.length > this.replay.maxPendingTicketsPerPlayer) queue.shift();
this.pendingTickets.set(plan.requesterId, queue);
return ticket;
}
drainReplayTickets(
playerId: PlayerId,
): Array<ReplayTicket<ClientState, PerceptionEvent>> {
const tickets = this.pendingTickets.get(playerId) ?? [];
this.pendingTickets.delete(playerId);
return tickets;
}
private capture(events: readonly AuthorityEvent[], replaceSameTick: boolean): void {
const connected = this.playerIds;
const perspectives = this.replay.listPerspectives
? this.replay.listPerspectives(this.currentState, connected)
: connected;
const uniquePerspectives = new Set(perspectives);
for (const perspectiveId of uniquePerspectives) {
if (!validPlayerId(perspectiveId)) continue;
const snapshot = this.engine.createSnapshot(perspectiveId, 0);
const perceptions = this.engine.createPerceptions(perspectiveId, events);
const frame: ProjectedReplayFrame<ClientState, PerceptionEvent> = {
tick: this.tick,
state: this.cloneState(snapshot.state),
events: perceptions.map((event) => this.cloneEvent(event)),
};
const frames = this.history.get(perspectiveId) ?? [];
if (replaceSameTick && frames.at(-1)?.tick === this.tick) {
frames[frames.length - 1] = frame;
} else {
frames.push(frame);
}
this.history.set(perspectiveId, frames);
}
}
private pruneHistory(): void {
const earliestTick = Math.max(0, this.tick - this.replay.historyTicks);
for (const [perspectiveId, frames] of this.history) {
const firstRetained = frames.findIndex(({ tick }) => tick >= earliestTick);
if (firstRetained === -1) {
this.history.delete(perspectiveId);
continue;
}
if (firstRetained > 0) frames.splice(0, firstRetained);
}
}
private cloneFrame(
frame: ProjectedReplayFrame<ClientState, PerceptionEvent>,
): ProjectedReplayFrame<ClientState, PerceptionEvent> {
return {
tick: frame.tick,
state: this.cloneState(frame.state),
events: frame.events.map((event) => this.cloneEvent(event)),
};
}
private cloneState(state: ClientState): ClientState {
return this.game.codecs.state.decode(this.game.codecs.state.encode(state));
}
private cloneEvent(event: PerceptionEvent): PerceptionEvent {
const codec = this.game.codecs.event;
if (!codec) {
throw new Error("Replay perceptions require an event codec");
}
return codec.decode(codec.encode(event));
}
private allocateTicketId(): number {
const ticketId = this.nextTicketId;
this.nextTicketId = this.nextTicketId === 0xffff_ffff
? 1
: this.nextTicketId + 1;
return ticketId;
}
}
export function withReplayTransport<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
Seed,
StateHash extends ReplayStateHash,
>(
game: TimeTravelNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
Seed,
StateHash
>,
definition: ReplayTransportDefinition<AuthorityState, AuthorityEvent>,
): ReplayTransportNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
Seed,
StateHash
> {
const replay = normalizeReplayDefinition(game, definition);
return Object.freeze({
...game,
createServer(options: TimeTravelServerOptions<Seed> = {}) {
const engine = game.createServer({
...options,
recordingHistoryTicks:
options.recordingHistoryTicks ?? replay.historyTicks,
});
return new ReplayTransportAuthoritativeEngine(engine.game, engine, replay);
},
});
}
function normalizeReplayDefinition<AuthorityState, AuthorityEvent>(
game: { tickRateHz: number; snapshotRateHz: number },
definition: ReplayTransportDefinition<AuthorityState, AuthorityEvent>,
): NormalizedReplayTransportDefinition<AuthorityState, AuthorityEvent> {
if (!Number.isFinite(definition.historySeconds) || definition.historySeconds <= 0) {
throw new RangeError("historySeconds must be positive");
}
const captureRateHz = definition.captureRateHz ?? game.snapshotRateHz;
if (
!Number.isInteger(captureRateHz) ||
captureRateHz <= 0 ||
game.tickRateHz % captureRateHz !== 0
) {
throw new RangeError("captureRateHz must divide tickRateHz evenly");
}
const maxPendingTicketsPerPlayer =
definition.maxPendingTicketsPerPlayer ?? 2;
if (!Number.isInteger(maxPendingTicketsPerPlayer) || maxPendingTicketsPerPlayer <= 0) {
throw new RangeError("maxPendingTicketsPerPlayer must be a positive integer");
}
return {
...definition,
historyTicks: Math.ceil(definition.historySeconds * game.tickRateHz),
captureEveryTicks: game.tickRateHz / captureRateHz,
maxPendingTicketsPerPlayer,
};
}
function validPlayerId(value: number): boolean {
return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff;
}
function validTick(value: number): boolean {
return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,306 @@
import type {
ClientStateContext,
ClientStateReport,
GameDefinition,
InputContext,
InputPacket,
PlayerId,
StateSnapshot,
ValidationResult,
} from "./types.js";
export interface ServerEngineOptions {
historyTicks?: number;
maxPastTicks?: number;
maxFutureTicks?: number;
}
export interface InputDecision {
accepted: boolean;
reason?: "unknown-player" | "duplicate" | "invalid" | "past" | "future";
}
export interface Acknowledgement {
playerId: PlayerId;
sequence: number;
}
export interface ServerStepResult {
tick: number;
acknowledgements: Acknowledgement[];
validations: ValidationResult[];
snapshotDue: boolean;
}
interface QueuedInput<Input> {
playerId: PlayerId;
packet: InputPacket<Input>;
}
interface QueuedReport<State> {
playerId: PlayerId;
report: ClientStateReport<State>;
}
export class AuthoritativeEngine<State, Input> {
readonly game: GameDefinition<State, Input>;
private readonly historyTicks: number;
private readonly maxPastTicks: number;
private readonly maxFutureTicks: number;
private readonly snapshotEveryTicks: number;
private readonly players = new Set<PlayerId>();
private readonly lastReceivedSequence = new Map<PlayerId, number>();
private readonly acknowledgedSequence = new Map<PlayerId, number>();
private readonly history = new Map<number, State>();
private queuedInputs: QueuedInput<Input>[] = [];
private queuedReports: QueuedReport<State>[] = [];
private state: State;
private currentTick = 0;
constructor(
game: GameDefinition<State, Input>,
options: ServerEngineOptions = {},
) {
this.game = game;
this.historyTicks = options.historyTicks ?? game.tickRateHz * 2;
this.maxPastTicks = options.maxPastTicks ?? 2;
this.maxFutureTicks = options.maxFutureTicks ?? game.tickRateHz;
this.snapshotEveryTicks = game.tickRateHz / game.snapshotRateHz;
this.state = game.createInitialState();
this.storeHistory();
}
get tick(): number {
return this.currentTick;
}
get currentState(): Readonly<State> {
return this.state;
}
addPlayer(playerId: PlayerId): void {
if (this.players.has(playerId)) {
return;
}
this.players.add(playerId);
this.game.addPlayer?.(this.state, { playerId, tick: this.currentTick });
this.assertValidState();
this.storeHistory();
}
removePlayer(playerId: PlayerId): void {
if (!this.players.delete(playerId)) {
return;
}
this.game.removePlayer?.(this.state, {
playerId,
tick: this.currentTick,
});
this.lastReceivedSequence.delete(playerId);
this.acknowledgedSequence.delete(playerId);
this.queuedInputs = this.queuedInputs.filter(
(queued) => queued.playerId !== playerId,
);
this.queuedReports = this.queuedReports.filter(
(queued) => queued.playerId !== playerId,
);
this.assertValidState();
this.storeHistory();
}
submitInput(playerId: PlayerId, packet: InputPacket<Input>): InputDecision {
if (!this.players.has(playerId)) {
return { accepted: false, reason: "unknown-player" };
}
const previousSequence = this.lastReceivedSequence.get(playerId) ?? 0;
if (packet.sequence <= previousSequence) {
return { accepted: false, reason: "duplicate" };
}
if (packet.targetTick < this.currentTick - this.maxPastTicks) {
return { accepted: false, reason: "past" };
}
if (packet.targetTick > this.currentTick + this.maxFutureTicks) {
return { accepted: false, reason: "future" };
}
const context = this.inputContext(playerId, packet);
if (!this.game.validateInput(packet.input, context)) {
return { accepted: false, reason: "invalid" };
}
this.lastReceivedSequence.set(playerId, packet.sequence);
this.queuedInputs.push({ playerId, packet });
return { accepted: true };
}
submitStateReport(
playerId: PlayerId,
report: ClientStateReport<State>,
): ValidationResult | null {
if (report.tick > this.currentTick + this.maxFutureTicks) {
return {
playerId,
tick: report.tick,
valid: false,
reason: "too-far-ahead",
};
}
if (report.tick > this.currentTick) {
this.queuedReports = this.queuedReports.filter(
(queued) => queued.playerId !== playerId,
);
this.queuedReports.push({ playerId, report });
return null;
}
return this.validateReport(playerId, report);
}
step(): ServerStepResult {
this.currentTick += 1;
const deltaSeconds = 1 / this.game.tickRateHz;
const acknowledgementByPlayer = new Map<PlayerId, number>();
const dueInputs: QueuedInput<Input>[] = [];
const futureInputs: QueuedInput<Input>[] = [];
for (const queued of this.queuedInputs) {
(queued.packet.targetTick <= this.currentTick
? dueInputs
: futureInputs
).push(queued);
}
this.queuedInputs = futureInputs;
dueInputs.sort(
(left, right) =>
left.packet.targetTick - right.packet.targetTick ||
left.playerId - right.playerId ||
left.packet.sequence - right.packet.sequence,
);
for (const queued of dueInputs) {
const { playerId, packet } = queued;
this.game.applyInput(
this.state,
packet.input,
this.inputContext(playerId, packet),
);
const previousAck = this.acknowledgedSequence.get(playerId) ?? 0;
if (packet.sequence > previousAck) {
this.acknowledgedSequence.set(playerId, packet.sequence);
acknowledgementByPlayer.set(playerId, packet.sequence);
}
}
this.game.step(this.state, {
tick: this.currentTick,
deltaSeconds,
});
this.assertValidState();
this.storeHistory();
const validations: ValidationResult[] = [];
const futureReports: QueuedReport<State>[] = [];
for (const queued of this.queuedReports) {
if (queued.report.tick <= this.currentTick) {
validations.push(
this.validateReport(queued.playerId, queued.report),
);
} else {
futureReports.push(queued);
}
}
this.queuedReports = futureReports;
return {
tick: this.currentTick,
acknowledgements: [...acknowledgementByPlayer].map(
([playerId, sequence]) => ({ playerId, sequence }),
),
validations,
snapshotDue: this.currentTick % this.snapshotEveryTicks === 0,
};
}
createSnapshot(serverTime: number): StateSnapshot<State> {
return {
tick: this.currentTick,
serverTime,
state: this.game.cloneState(this.state),
};
}
private inputContext(
playerId: PlayerId,
packet: InputPacket<Input>,
): InputContext {
return {
playerId,
sequence: packet.sequence,
targetTick: packet.targetTick,
observedTick: packet.observedTick ?? packet.targetTick,
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
};
}
private validateReport(
playerId: PlayerId,
report: ClientStateReport<State>,
): ValidationResult {
const authoritative = this.history.get(report.tick);
if (!authoritative) {
return {
playerId,
tick: report.tick,
valid: false,
reason: "outside-history",
};
}
const context: ClientStateContext = { playerId, tick: report.tick };
const valid = this.game.validateClientState
? this.game.validateClientState(authoritative, report.state, context)
: (this.game.validateState?.(report.state, {
tick: report.tick,
deltaSeconds: 1 / this.game.tickRateHz,
}) ?? true);
return {
playerId,
tick: report.tick,
valid,
...(valid ? {} : { reason: "mismatch" as const }),
};
}
private assertValidState(): void {
if (
this.game.validateState &&
!this.game.validateState(this.state, {
tick: this.currentTick,
deltaSeconds: 1 / this.game.tickRateHz,
})
) {
throw new Error(`Game produced invalid state at tick ${this.currentTick}`);
}
}
private storeHistory(): void {
this.history.set(this.currentTick, this.game.cloneState(this.state));
const oldestTick = this.currentTick - this.historyTicks;
for (const tick of this.history.keys()) {
if (tick < oldestTick) {
this.history.delete(tick);
}
}
}
}

View File

@@ -0,0 +1,474 @@
import {
defineNetworkedGame,
type DefinedNetworkedGame,
} from "./define-networked-game.js";
import type { ReplicationRules } from "./networked-types.js";
import type { PlayerContext, PlayerId } from "./types.js";
export interface SpatialPoint {
x: number;
z: number;
}
export interface SpatialReplicationEntity extends SpatialPoint {
id: string | number;
}
export interface SpatialReplicationViewerContext extends PlayerContext {
position: SpatialPoint;
}
export interface SpatialReplicationSource<AuthorityState> {
category: string;
maximumDistance: number;
entities(
authority: Readonly<AuthorityState>,
): Iterable<SpatialReplicationEntity>;
estimatedBytes:
| number
| ((
entity: SpatialReplicationEntity,
context: SpatialReplicationViewerContext,
) => number);
priority?:
| number
| ((
entity: SpatialReplicationEntity,
distance: number,
context: SpatialReplicationViewerContext,
) => number);
required?(entity: SpatialReplicationEntity, playerId: PlayerId): boolean;
}
export interface SpatialReplicationSelection {
readonly playerId: PlayerId;
readonly tick: number;
readonly position: SpatialPoint;
readonly budgetBytes: number;
readonly usedBytes: number;
readonly candidateCount: number;
readonly droppedCount: number;
has(category: string, id: string | number): boolean;
ids(category: string): ReadonlySet<string | number>;
}
export interface SpatialReplicationDefinition<
AuthorityState,
ClientState,
AuthorityEvent,
PerceptionEvent,
> {
cellSize: number;
bandwidthBudgetBytesPerSecond: number;
/** Bytes reserved for non-entity match state in every snapshot. */
reservedBytesPerSnapshot?: number;
viewerPosition(
authority: Readonly<AuthorityState>,
playerId: PlayerId,
): SpatialPoint | null;
sources: readonly SpatialReplicationSource<AuthorityState>[];
projectSnapshot(
snapshot: ClientState,
selection: SpatialReplicationSelection,
context: PlayerContext,
): ClientState;
perceive?(
authority: Readonly<AuthorityState>,
event: Readonly<AuthorityEvent>,
perception: PerceptionEvent,
selection: SpatialReplicationSelection,
context: PlayerContext,
): PerceptionEvent | null;
}
export interface SpatialReplicationController<AuthorityState> {
select(
authority: Readonly<AuthorityState>,
context: PlayerContext,
): SpatialReplicationSelection;
}
export type SpatiallyReplicatedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
> = DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
> & {
spatialReplication: SpatialReplicationController<AuthorityState>;
};
interface IndexedEntity extends SpatialPoint {
entity: SpatialReplicationEntity;
source: SpatialReplicationSource<unknown>;
}
interface Candidate extends IndexedEntity {
distance: number;
estimatedBytes: number;
priority: number;
required: boolean;
}
/** A deterministic two-dimensional uniform grid used by spatial replication. */
export class SpatialGridIndex<Value extends SpatialPoint> {
private readonly cells = new Map<string, Value[]>();
constructor(readonly cellSize: number) {
if (!Number.isFinite(cellSize) || cellSize <= 0) {
throw new RangeError("cellSize must be a positive finite number");
}
}
insert(value: Value): void {
if (!Number.isFinite(value.x) || !Number.isFinite(value.z)) {
throw new RangeError("spatial coordinates must be finite");
}
const key = this.key(this.coordinate(value.x), this.coordinate(value.z));
const cell = this.cells.get(key);
if (cell) cell.push(value);
else this.cells.set(key, [value]);
}
query(position: SpatialPoint, radius: number): Value[] {
if (!Number.isFinite(radius) || radius < 0) {
throw new RangeError("query radius must be a non-negative finite number");
}
const minimumX = this.coordinate(position.x - radius);
const maximumX = this.coordinate(position.x + radius);
const minimumZ = this.coordinate(position.z - radius);
const maximumZ = this.coordinate(position.z + radius);
const radiusSquared = radius * radius;
const values: Value[] = [];
for (let cellX = minimumX; cellX <= maximumX; cellX += 1) {
for (let cellZ = minimumZ; cellZ <= maximumZ; cellZ += 1) {
for (const value of this.cells.get(this.key(cellX, cellZ)) ?? []) {
const dx = value.x - position.x;
const dz = value.z - position.z;
if (dx * dx + dz * dz <= radiusSquared) values.push(value);
}
}
}
return values;
}
private coordinate(value: number): number {
return Math.floor(value / this.cellSize);
}
private key(x: number, z: number): string {
return `${x}:${z}`;
}
}
/** Adds deterministic interest management and bandwidth budgeting to a game. */
export function withSpatialReplication<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent,
>(
game: DefinedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>,
definition: SpatialReplicationDefinition<
AuthorityState,
ClientState,
AuthorityEvent,
PerceptionEvent
>,
): SpatiallyReplicatedNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
> {
validateDefinition(game.snapshotRateHz, definition);
let cachedAuthority: Readonly<AuthorityState> | null = null;
let cachedTick = -1;
let cachedIndex = new SpatialGridIndex<IndexedEntity>(definition.cellSize);
const maximumDistance = Math.max(
0,
...definition.sources.map((source) => source.maximumDistance),
);
const budgetBytes = Math.max(
0,
Math.floor(
definition.bandwidthBudgetBytesPerSecond / game.snapshotRateHz -
(definition.reservedBytesPerSnapshot ?? 0),
),
);
const controller: SpatialReplicationController<AuthorityState> = {
select(authority, context) {
const position = definition.viewerPosition(authority, context.playerId);
if (!position) {
return createSelection(
context,
{ x: 0, z: 0 },
budgetBytes,
[],
new Map(),
0,
);
}
if (authority !== cachedAuthority || context.tick !== cachedTick) {
cachedAuthority = authority;
cachedTick = context.tick;
cachedIndex = buildIndex(authority, definition);
}
const viewerContext: SpatialReplicationViewerContext = {
...context,
position,
};
const candidates = cachedIndex
.query(position, maximumDistance)
.map((indexed): Candidate | null => {
const source = indexed.source as SpatialReplicationSource<AuthorityState>;
const distance = Math.hypot(
indexed.entity.x - position.x,
indexed.entity.z - position.z,
);
if (distance > source.maximumDistance) return null;
const estimatedBytes = normalizeNonNegativeInteger(
typeof source.estimatedBytes === "function"
? source.estimatedBytes(indexed.entity, viewerContext)
: source.estimatedBytes,
`${source.category}.estimatedBytes`,
);
const priorityValue =
typeof source.priority === "function"
? source.priority(indexed.entity, distance, viewerContext)
: (source.priority ?? 0);
return {
...indexed,
distance,
estimatedBytes,
priority: Number.isFinite(priorityValue) ? priorityValue : 0,
required: source.required?.(indexed.entity, context.playerId) ?? false,
};
})
.filter((candidate): candidate is Candidate => candidate !== null)
.sort(compareCandidates);
const selected = new Map<string, Set<string | number>>();
let usedBytes = 0;
let droppedCount = 0;
for (const candidate of candidates) {
if (!candidate.required && usedBytes + candidate.estimatedBytes > budgetBytes) {
droppedCount += 1;
continue;
}
let ids = selected.get(candidate.source.category);
if (!ids) {
ids = new Set();
selected.set(candidate.source.category, ids);
}
if (ids.has(candidate.entity.id)) continue;
ids.add(candidate.entity.id);
usedBytes += candidate.estimatedBytes;
}
return createSelection(
context,
position,
budgetBytes,
candidates,
selected,
droppedCount,
usedBytes,
);
},
};
const replication: ReplicationRules<
AuthorityState,
ClientState,
AuthorityEvent,
PerceptionEvent
> = {
...game.replication,
createSnapshot(authority, context) {
const snapshot = game.replication.createSnapshot(authority, context);
return definition.projectSnapshot(
snapshot,
controller.select(authority, context),
context,
);
},
...(game.replication.perceive
? {
perceive(authority, event, context) {
const perception = game.replication.perceive!(
authority,
event,
context,
);
if (perception === null) return null;
return definition.perceive
? definition.perceive(
authority,
event,
perception,
controller.select(authority, context),
context,
)
: perception;
},
}
: {}),
// Spatial selections are viewer-specific even when a base game groups views.
groupKey: (_authority, { playerId }) => playerId,
};
const wrapped = defineNetworkedGame<
AuthorityState,
ClientState,
Input,
AuthorityEvent,
PerceptionEvent
>({
tickRateHz: game.tickRateHz,
snapshotRateHz: game.snapshotRateHz,
server: game.server,
client: game.client,
validateInput: game.validateInput,
codecs: game.codecs,
replication,
});
return Object.freeze({ ...wrapped, spatialReplication: controller });
}
function buildIndex<AuthorityState, ClientState, AuthorityEvent, PerceptionEvent>(
authority: Readonly<AuthorityState>,
definition: SpatialReplicationDefinition<
AuthorityState,
ClientState,
AuthorityEvent,
PerceptionEvent
>,
): SpatialGridIndex<IndexedEntity> {
const index = new SpatialGridIndex<IndexedEntity>(definition.cellSize);
for (const source of definition.sources) {
for (const entity of source.entities(authority)) {
index.insert({
x: entity.x,
z: entity.z,
entity,
source: source as SpatialReplicationSource<unknown>,
});
}
}
return index;
}
function createSelection(
context: PlayerContext,
position: SpatialPoint,
budgetBytes: number,
candidates: readonly Candidate[],
selected: ReadonlyMap<string, ReadonlySet<string | number>>,
droppedCount: number,
usedBytes = 0,
): SpatialReplicationSelection {
const empty = new Set<string | number>();
return Object.freeze({
playerId: context.playerId,
tick: context.tick,
position: { ...position },
budgetBytes,
usedBytes,
candidateCount: candidates.length,
droppedCount,
has(category: string, id: string | number) {
return selected.get(category)?.has(id) ?? false;
},
ids(category: string) {
return selected.get(category) ?? empty;
},
});
}
function compareCandidates(left: Candidate, right: Candidate): number {
return (
Number(right.required) - Number(left.required) ||
right.priority - left.priority ||
left.distance - right.distance ||
left.source.category.localeCompare(right.source.category) ||
stableId(left.entity.id).localeCompare(stableId(right.entity.id))
);
}
function stableId(id: string | number): string {
return `${typeof id}:${String(id)}`;
}
function validateDefinition<AuthorityState, ClientState, AuthorityEvent, PerceptionEvent>(
snapshotRateHz: number,
definition: SpatialReplicationDefinition<
AuthorityState,
ClientState,
AuthorityEvent,
PerceptionEvent
>,
): void {
if (!Number.isFinite(definition.cellSize) || definition.cellSize <= 0) {
throw new RangeError("cellSize must be a positive finite number");
}
if (
!Number.isFinite(definition.bandwidthBudgetBytesPerSecond) ||
definition.bandwidthBudgetBytesPerSecond <= 0
) {
throw new RangeError("bandwidthBudgetBytesPerSecond must be positive");
}
if (
definition.reservedBytesPerSnapshot !== undefined &&
(!Number.isFinite(definition.reservedBytesPerSnapshot) ||
definition.reservedBytesPerSnapshot < 0)
) {
throw new RangeError("reservedBytesPerSnapshot must be non-negative");
}
const categories = new Set<string>();
for (const source of definition.sources) {
if (!source.category || categories.has(source.category)) {
throw new Error(`spatial source categories must be unique: ${source.category}`);
}
categories.add(source.category);
if (!Number.isFinite(source.maximumDistance) || source.maximumDistance < 0) {
throw new RangeError(`${source.category}.maximumDistance must be non-negative`);
}
if (typeof source.estimatedBytes === "number") {
normalizeNonNegativeInteger(
source.estimatedBytes,
`${source.category}.estimatedBytes`,
);
}
}
if (!Number.isInteger(snapshotRateHz) || snapshotRateHz <= 0) {
throw new RangeError("snapshotRateHz must be positive");
}
}
function normalizeNonNegativeInteger(value: number, name: string): number {
if (!Number.isFinite(value) || value < 0) {
throw new RangeError(`${name} must be non-negative and finite`);
}
return Math.ceil(value);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,80 @@
export type PlayerId = number;
export interface TickContext {
tick: number;
deltaSeconds: number;
}
export interface InputContext extends TickContext {
playerId: PlayerId;
sequence: number;
targetTick: number;
/** Client simulation tick whose world presentation produced this input. */
observedTick: number;
}
export interface PlayerContext {
playerId: PlayerId;
tick: number;
}
export interface ClientStateContext extends PlayerContext {}
export interface GameDefinition<State, Input> {
tickRateHz: number;
snapshotRateHz: number;
createInitialState(): State;
cloneState(state: State): State;
step(state: State, context: TickContext): void;
applyInput(state: State, input: Input, context: InputContext): void;
validateInput(input: Input, context: InputContext): boolean;
validateState?(state: State, context: TickContext): boolean;
validateClientState?(
authoritative: State,
candidate: State,
context: ClientStateContext,
): boolean;
addPlayer?(state: State, context: PlayerContext): void;
removePlayer?(state: State, context: PlayerContext): void;
}
export interface InputPacket<Input> {
sequence: number;
targetTick: number;
/** Optional for backwards compatibility; servers fall back to targetTick. */
observedTick?: number;
input: Input;
}
export interface StateSnapshot<State> {
tick: number;
serverTime: number;
state: State;
}
export interface ClientStateReport<State> {
tick: number;
state: State;
}
export interface PingPacket {
id: number;
clientSentAt: number;
}
export interface PongPacket extends PingPacket {
serverReceivedAt: number;
serverSentAt: number;
}
export interface BinaryCodec<Value> {
encode(value: Value): Uint8Array;
decode(payload: Uint8Array): Value;
}
export interface ValidationResult {
playerId: PlayerId;
tick: number;
valid: boolean;
reason?: "mismatch" | "outside-history" | "too-far-ahead";
}

View File

@@ -0,0 +1,874 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
FixedStepClock,
NetworkClock,
ReplayDivergenceError,
SpatialGridIndex,
createBinaryProtocol,
createJsonCodec,
deterministicHash,
defineGame,
defineMultiplayerGame,
defineNetworkedGame,
withLagCompensation,
withReplayTransport,
withSpatialReplication,
withTimeTravel,
} from "../dist/index.js";
test("spatial replication indexes, prioritizes, budgets, and projects per viewer", () => {
const grid = new SpatialGridIndex(10);
grid.insert({ id: "negative", x: -4, z: -7 });
grid.insert({ id: "near", x: 6, z: 2 });
grid.insert({ id: "far", x: 40, z: 40 });
assert.deepEqual(
grid.query({ x: 0, z: 0 }, 10).map(({ id }) => id).sort(),
["near", "negative"],
);
const codec = createJsonCodec();
const base = defineMultiplayerGame({
clock: { ticksPerSecond: 10, snapshotsPerSecond: 10 },
authority: {
createInitialState: () => ({
entities: [
{ id: 1, x: 0, z: 0 },
{ id: 2, x: 10, z: 0 },
{ id: 3, x: 20, z: 0 },
{ id: 4, x: 200, z: 0 },
],
}),
cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })) }),
applyInput() {},
step() {},
},
prediction: {
createInitialState: () => ({ entities: [], events: [] }),
cloneState: (state) => ({ entities: state.entities.map((entity) => ({ ...entity })), events: [...state.events] }),
applyInput() {},
step() {},
applyEvent(state, event) {
state.events.push(event);
},
},
visibility: {
createSnapshot: (authority) => ({
entities: authority.entities.map((entity) => ({ ...entity })),
events: [],
}),
perceive: (_authority, event) => event,
},
input: { validate: () => true },
encoding: { input: codec, clientState: codec, perception: codec },
});
const game = withSpatialReplication(base, {
cellSize: 16,
bandwidthBudgetBytesPerSecond: 1_000,
viewerPosition: (authority, playerId) => authority.entities.find(({ id }) => id === playerId) ?? null,
sources: [
{
category: "entities",
maximumDistance: 100,
entities: (authority) => authority.entities,
estimatedBytes: 40,
required: (entity, playerId) => entity.id === playerId,
},
],
projectSnapshot(snapshot, selection) {
return {
...snapshot,
entities: snapshot.entities.filter((entity) => selection.has("entities", entity.id)),
};
},
perceive(_authority, event, perception, selection) {
return selection.has("entities", event.sourceId) ? perception : null;
},
});
const server = game.createServer();
server.addPlayer(1);
assert.deepEqual(
server.createSnapshot(1, 0).state.entities.map(({ id }) => id),
[1, 2],
);
const selection = game.spatialReplication.select(server.currentState, {
playerId: 1,
tick: server.tick,
});
assert.equal(selection.budgetBytes, 100);
assert.equal(selection.usedBytes, 80);
assert.equal(selection.candidateCount, 3);
assert.equal(selection.droppedCount, 1);
assert.deepEqual(server.createPerceptions(1, [{ sourceId: 2 }]), [{ sourceId: 2 }]);
assert.deepEqual(server.createPerceptions(1, [{ sourceId: 3 }]), []);
});
test("the developer-facing API compiles named sections into a networked game", () => {
const codec = createJsonCodec();
const game = defineMultiplayerGame({
clock: { ticksPerSecond: 12, snapshotsPerSecond: 4 },
authority: {
createInitialState: () => ({ value: 0 }),
cloneState: (state) => ({ ...state }),
applyInput(state, input) {
state.value += input.amount;
},
step() {},
},
prediction: {
createInitialState: () => ({ value: 0 }),
cloneState: (state) => ({ ...state }),
applyInput(state, input) {
state.value += input.amount;
},
step() {},
},
visibility: {
createSnapshot: (authority) => ({ value: authority.value }),
},
input: {
validate: (input) => Number.isFinite(input.amount),
},
encoding: {
input: codec,
clientState: codec,
},
});
assert.equal(game.tickRateHz, 12);
assert.equal(game.snapshotRateHz, 4);
const server = game.createServer();
server.addPlayer(1);
assert.equal(
server.submitInput(1, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: { amount: 3 },
}).accepted,
true,
);
server.step();
assert.equal(server.currentState.value, 3);
assert.deepEqual(codec.decode(codec.encode({ unicode: "flux ⚡" })), {
unicode: "flux ⚡",
});
});
const testGame = defineGame({
tickRateHz: 10,
snapshotRateHz: 5,
createInitialState: () => ({ position: 0, velocity: 0 }),
cloneState: (state) => ({ ...state }),
validateInput: (input) =>
Number.isFinite(input.axis) && Math.abs(input.axis) <= 1,
applyInput: (state, input) => {
state.velocity = input.axis;
},
step: (state, { deltaSeconds }) => {
state.position += state.velocity * deltaSeconds;
},
validateState: (state) =>
Number.isFinite(state.position) && Number.isFinite(state.velocity),
validateClientState: (authoritative, candidate) =>
Math.abs(authoritative.position - candidate.position) < 0.000_001,
});
test("defineGame creates matching authoritative and predicted runtimes", () => {
const server = testGame.createServer();
const client = testGame.createClient();
server.addPlayer(7);
client.initialize(7, server.createSnapshot(0));
const packet = client.createInput({ axis: 1 }, 1);
assert.deepEqual(server.submitInput(7, packet), { accepted: true });
client.step();
const result = server.step();
assert.equal(result.snapshotDue, false);
assert.deepEqual(result.acknowledgements, [
{ playerId: 7, sequence: packet.sequence },
]);
client.acknowledge(packet.sequence);
client.reconcile(server.createSnapshot(100));
assert.equal(client.currentState.position, server.currentState.position);
const valid = server.submitStateReport(7, client.createStateReport());
assert.equal(valid?.valid, true);
const invalid = server.submitStateReport(7, {
tick: server.tick,
state: { position: 99, velocity: 0 },
});
assert.equal(invalid?.valid, false);
assert.deepEqual(
server.submitInput(7, {
sequence: packet.sequence + 1,
targetTick: server.tick + 1,
input: { axis: 2 },
}),
{ accepted: false, reason: "invalid" },
);
});
test("fixed-step clocks cap catch-up work", () => {
const clock = new FixedStepClock({ rateHz: 10, maxCatchUpSteps: 3 });
let steps = 0;
assert.equal(clock.advance(0, () => steps++), 0);
assert.equal(clock.advance(100, () => steps++), 1);
assert.equal(clock.advance(1_000, () => steps++), 3);
assert.equal(steps, 4);
});
test("network clock estimates RTT, offset, and input lead", () => {
const clock = new NetworkClock();
const ping = clock.createPing(100);
const stats = clock.receivePong(
{
...ping,
serverReceivedAt: 160,
serverSentAt: 162,
},
202,
);
assert.equal(stats.roundTripTime, 100);
assert.equal(stats.clockOffset, 10);
assert.equal(clock.toServerTime(300), 310);
assert.equal(clock.recommendedInputLeadTicks(60), 4);
});
test("binary protocol round-trips generic inputs, state, and control frames", () => {
const numberCodec = {
encode(value) {
const payload = new Uint8Array(8);
new DataView(payload.buffer).setFloat64(0, value, true);
return payload;
},
decode(payload) {
return new DataView(
payload.buffer,
payload.byteOffset,
payload.byteLength,
).getFloat64(0, true);
},
};
const protocol = createBinaryProtocol({
input: numberCodec,
state: numberCodec,
event: numberCodec,
});
const input = {
kind: "input",
packet: {
sequence: 4,
targetTick: 20,
observedTick: 16,
input: 0.75,
},
};
assert.deepEqual(protocol.decodeClient(protocol.encodeClient(input)), input);
const welcome = {
kind: "welcome",
playerId: 9,
snapshot: { tick: 18, serverTime: 42.5, state: 3.25 },
};
assert.deepEqual(protocol.decodeServer(protocol.encodeServer(welcome)), welcome);
const rejection = { kind: "reject-input", sequence: 44 };
assert.deepEqual(
protocol.decodeServer(protocol.encodeServer(rejection)),
rejection,
);
const replayStart = {
kind: "replay-start",
ticketId: 12,
perspectiveId: 9,
fromTick: 100,
toTick: 160,
frameCount: 2,
playbackRate: 0.75,
};
assert.deepEqual(
protocol.decodeServer(protocol.encodeServer(replayStart)),
replayStart,
);
const replayFrame = {
kind: "replay-frame",
ticketId: 12,
tick: 120,
state: 3.5,
events: [4.5, 5.5],
};
assert.deepEqual(
protocol.decodeServer(protocol.encodeServer(replayFrame)),
replayFrame,
);
const truncatedReplayFrame = protocol.encodeServer(replayFrame).slice(0, -1);
assert.throws(() => protocol.decodeServer(truncatedReplayFrame), RangeError);
const replayEnd = { kind: "replay-end", ticketId: 12 };
assert.deepEqual(
protocol.decodeServer(protocol.encodeServer(replayEnd)),
replayEnd,
);
});
test("networked games keep authority private and derive viewer perceptions", () => {
const jsonCodec = {
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
};
const privateGame = defineNetworkedGame({
tickRateHz: 10,
snapshotRateHz: 5,
server: {
createInitialState: () => ({
publicValues: new Map(),
secrets: new Map(),
}),
cloneState: (state) => ({
publicValues: new Map(state.publicValues),
secrets: new Map(state.secrets),
}),
addPlayer(state, { playerId }) {
state.publicValues.set(playerId, 0);
state.secrets.set(playerId, `secret-${playerId}`);
},
applyInput(state, input, { playerId, emit }) {
state.publicValues.set(playerId, input);
emit({ source: playerId, privateValue: state.secrets.get(playerId) });
},
step() {},
},
client: {
createInitialState: () => ({ ownValue: 0, cues: [] }),
cloneState: (state) => ({ ...state, cues: [...state.cues] }),
applyInput(state, input) {
state.ownValue = input;
},
step() {},
applyEvent(state, event) {
state.cues.push(event);
},
},
replication: {
createSnapshot(authority, { playerId }) {
return {
ownValue: authority.publicValues.get(playerId) ?? 0,
cues: [],
};
},
validateClientState(authority, candidate, { playerId }) {
return candidate.ownValue === authority.publicValues.get(playerId);
},
perceive(_authority, event, { playerId }) {
return event.source === playerId ? null : "heard-an-event";
},
},
validateInput: (input) => Number.isFinite(input),
codecs: { input: jsonCodec, state: jsonCodec, event: jsonCodec },
});
const server = privateGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
const snapshot = server.createSnapshot(1, 0);
assert.deepEqual(snapshot.state, { ownValue: 0, cues: [] });
assert.equal("secrets" in snapshot.state, false);
assert.equal(
server.submitInput(2, { sequence: 1, targetTick: 1, input: 5 }).accepted,
true,
);
const result = server.step();
assert.deepEqual(server.createPerceptions(1, result.events), [
"heard-an-event",
]);
assert.deepEqual(server.createPerceptions(2, result.events), []);
const client = privateGame.createClient();
client.initialize(1, snapshot);
client.receiveEvent("heard-an-event", result.tick);
assert.deepEqual(client.currentState.cues, ["heard-an-event"]);
const eventFrame = privateGame.protocol.encodeServer({
kind: "event",
tick: result.tick,
event: "heard-an-event",
});
assert.deepEqual(privateGame.protocol.decodeServer(eventFrame), {
kind: "event",
tick: result.tick,
event: "heard-an-event",
});
});
const lagJsonCodec = {
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
};
const lagBaseGame = defineNetworkedGame({
tickRateHz: 10,
snapshotRateHz: 5,
server: {
createInitialState: () => ({ positions: {}, resolutions: [] }),
cloneState: (state) => ({
positions: { ...state.positions },
resolutions: state.resolutions.map((entry) => ({ ...entry })),
}),
addPlayer(state, { playerId }) {
state.positions[playerId] = 0;
},
applyInput(state, input, { playerId }) {
if (input.moveTo !== undefined) state.positions[playerId] = input.moveTo;
},
step() {},
},
client: {
createInitialState: () => ({ positions: {}, resolutions: [] }),
cloneState: (state) => structuredClone(state),
applyInput() {},
step() {},
},
replication: {
createSnapshot: (state) => structuredClone(state),
},
validateInput: (input) =>
(input.moveTo === undefined || Number.isFinite(input.moveTo)) &&
(input.action === undefined ||
["rewind", "current", "projectile", "strict"].includes(input.action)),
codecs: { input: lagJsonCodec, state: lagJsonCodec, event: lagJsonCodec },
});
const lagPolicyGame = withLagCompensation(lagBaseGame, {
historySeconds: 2,
captureState: (state) => ({ positions: { ...state.positions } }),
cloneHistoricalState: (state) => ({ positions: { ...state.positions } }),
classifyAction: (input) =>
input.action ? { type: input.action, targetId: input.targetId ?? 2 } : null,
cloneAction: (action) => ({ ...action }),
actions: {
rewind: {
mode: "rewind",
maximumRewindMs: 1_000,
resolve: recordLagResolution,
},
current: {
mode: "current",
resolve: recordLagResolution,
},
projectile: {
mode: "fast-forward",
maximumRewindMs: 1_000,
resolve: recordLagResolution,
},
strict: {
mode: "rewind",
maximumRewindMs: 100,
outOfWindow: "reject",
resolve: recordLagResolution,
},
},
});
const lagReplayGame = withTimeTravel(lagPolicyGame, {
createSeed: () => 0,
hashState: deterministicHash,
checkpointIntervalTicks: 2,
verificationIntervalTicks: 1,
});
function recordLagResolution(context) {
context.currentState.resolutions.push({
type: context.action.type,
requestedTick: context.requestedTick,
resolvedTick: context.resolvedTick,
currentTick: context.currentTick,
rewindTicks: context.rewindTicks,
catchUpTicks: context.catchUpTicks,
historicalPosition:
context.historicalState.positions[context.action.targetId],
currentPosition: context.currentState.positions[context.action.targetId],
});
}
test("lag-compensation HOF applies an independent policy per action", () => {
const server = lagPolicyGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
server.submitInput(2, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: { moveTo: 5 },
});
server.step();
server.step();
server.submitInput(1, {
sequence: 1,
targetTick: 3,
observedTick: 0,
input: { action: "rewind", targetId: 2 },
});
server.step();
server.submitInput(1, {
sequence: 2,
targetTick: 4,
observedTick: 0,
input: { action: "current", targetId: 2 },
});
server.step();
server.submitInput(1, {
sequence: 3,
targetTick: 5,
observedTick: 2,
input: { action: "projectile", targetId: 2 },
});
server.step();
server.submitInput(1, {
sequence: 4,
targetTick: 6,
observedTick: 0,
input: { action: "strict", targetId: 2 },
});
server.step();
assert.equal(server.currentState.positions[2], 5);
assert.deepEqual(server.currentState.resolutions, [
{
type: "rewind",
requestedTick: 0,
resolvedTick: 0,
currentTick: 3,
rewindTicks: 3,
catchUpTicks: 0,
historicalPosition: 0,
currentPosition: 5,
},
{
type: "current",
requestedTick: 0,
resolvedTick: 4,
currentTick: 4,
rewindTicks: 0,
catchUpTicks: 0,
historicalPosition: 5,
currentPosition: 5,
},
{
type: "projectile",
requestedTick: 2,
resolvedTick: 2,
currentTick: 5,
rewindTicks: 3,
catchUpTicks: 3,
historicalPosition: 5,
currentPosition: 5,
},
]);
});
test("lag-compensated actions replay deterministically through checkpoints", () => {
const server = lagReplayGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
server.submitInput(2, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: { moveTo: 5 },
});
server.step();
server.step();
server.submitInput(1, {
sequence: 1,
targetTick: 3,
observedTick: 0,
input: { action: "rewind", targetId: 2 },
});
for (let tick = 2; tick < 6; tick += 1) server.step();
const replay = lagReplayGame.createReplay(server.exportRecording());
assert.deepEqual(replay.seek(6).state, server.currentState);
assert.equal(replay.currentState.resolutions[0].historicalPosition, 0);
});
const replayCodec = {
encode: (value) => new TextEncoder().encode(JSON.stringify(value)),
decode: (payload) => JSON.parse(new TextDecoder().decode(payload)),
};
const replayDefinition = defineNetworkedGame({
tickRateHz: 10,
snapshotRateHz: 5,
server: {
createInitialState: () => ({
seed: 0,
secret: "uninitialized",
positions: new Map(),
velocities: new Map(),
}),
cloneState: (state) => ({
...state,
positions: new Map(state.positions),
velocities: new Map(state.velocities),
}),
addPlayer(state, { playerId, emit }) {
state.positions.set(playerId, state.seed);
state.velocities.set(playerId, 0);
emit({ type: "joined", playerId });
},
removePlayer(state, { playerId }) {
state.positions.delete(playerId);
state.velocities.delete(playerId);
},
applyInput(state, input, { playerId, emit }) {
state.velocities.set(playerId, input.axis);
emit({ type: "moved", playerId });
},
step(state, { deltaSeconds }) {
for (const [playerId, velocity] of state.velocities) {
state.positions.set(
playerId,
(state.positions.get(playerId) ?? 0) + velocity * deltaSeconds,
);
}
},
},
client: {
createInitialState: () => ({ ownPosition: 0, cues: [] }),
cloneState: (state) => ({ ...state, cues: [...state.cues] }),
applyInput(state, input) {
state.ownPosition += input.axis / 10;
},
step() {},
applyEvent(state, event) {
state.cues.push(event);
},
},
replication: {
createSnapshot(authority, { playerId }) {
return {
ownPosition: authority.positions.get(playerId) ?? 0,
cues: [],
};
},
perceive(_authority, event, { playerId }) {
return event.playerId === playerId ? null : `heard-${event.type}`;
},
},
validateInput: (input) =>
Number.isFinite(input.axis) && Math.abs(input.axis) <= 1,
codecs: {
input: replayCodec,
state: replayCodec,
event: replayCodec,
},
});
const replayGame = withTimeTravel(replayDefinition, {
createSeed: () => 1,
initializeState(state, seed) {
state.seed = seed;
state.secret = `secret-${seed}`;
},
hashState: deterministicHash,
checkpointIntervalTicks: 2,
verificationIntervalTicks: 1,
});
const replayTransportGame = withReplayTransport(replayGame, {
historySeconds: 0.6,
captureRateHz: 5,
createTickets(event, { tick }) {
return event.type === "joined" && event.playerId === 2
? {
requesterId: 1,
perspectiveId: 2,
fromTick: 0,
toTick: tick,
}
: null;
},
authorizeReplay: ({ requesterId, perspectiveId }) =>
requesterId === 1 && perspectiveId === 2,
});
test("time-travel HOF records, seeks, verifies, filters, and branches", () => {
const server = replayGame.createServer({ replaySeed: 7 });
server.addPlayer(1);
server.addPlayer(2);
assert.equal(
server.submitInput(1, {
sequence: 1,
targetTick: 1,
input: { axis: 1 },
}).accepted,
true,
);
assert.equal(
server.submitInput(2, {
sequence: 1,
targetTick: 1,
input: { axis: -0.5 },
}).accepted,
true,
);
for (let tick = 0; tick < 3; tick += 1) server.step();
assert.equal(
server.submitInput(1, {
sequence: 2,
targetTick: 5,
input: { axis: 0.25 },
}).accepted,
true,
);
for (let tick = 3; tick < 6; tick += 1) server.step();
const recording = server.exportRecording();
assert.equal(recording.seed, 7);
assert.equal(recording.durationTicks, 6);
assert.deepEqual(
recording.checkpoints.map(({ tick }) => tick),
[0, 2, 4, 6],
);
const replay = replayGame.createReplay(recording);
const finalFrame = replay.seek(6);
assert.deepEqual(finalFrame.state, server.currentState);
assert.equal(finalFrame.hash, deterministicHash(server.currentState));
const privateView = replay.viewAs(1, 123);
assert.equal(privateView.tick, 6);
assert.equal("secret" in privateView.state, false);
assert.deepEqual(Object.keys(privateView.state).sort(), ["cues", "ownPosition"]);
replay.seek(1);
assert.deepEqual(replay.perceptionsAs(1), ["heard-joined", "heard-moved"]);
const rewindPosition = replay.currentState.positions.get(1);
replay.seek(6);
assert.notEqual(replay.currentState.positions.get(1), rewindPosition);
const branch = replay.branch(2);
assert.equal(
branch.submitInput(1, {
sequence: 2,
targetTick: 3,
input: { axis: -1 },
}).accepted,
true,
);
while (branch.tick < 6) branch.step();
assert.notEqual(
deterministicHash(branch.currentState),
deterministicHash(server.currentState),
);
const branchReplay = replayGame.createReplay(branch.exportRecording());
assert.deepEqual(branchReplay.seek(6).state, branch.currentState);
assert.deepEqual(replay.seek(6).state, server.currentState);
});
test("time-travel replay pinpoints deterministic divergence", () => {
const server = replayGame.createServer({ replaySeed: 4 });
server.addPlayer(1);
server.submitInput(1, {
sequence: 1,
targetTick: 1,
input: { axis: 1 },
});
for (let tick = 0; tick < 3; tick += 1) server.step();
const recording = server.exportRecording();
const corrupted = {
...recording,
verifications: recording.verifications.map((entry) =>
entry.tick === 3 ? { ...entry, hash: "corrupted" } : entry,
),
};
const replay = replayGame.createReplay(corrupted);
assert.throws(() => replay.seek(3), ReplayDivergenceError);
});
test("rolling authority recordings stay bounded and remain seekable", () => {
const server = replayGame.createServer({
replaySeed: 8,
recordingHistoryTicks: 4,
});
server.addPlayer(1);
for (let tick = 0; tick < 10; tick += 1) server.step();
const recording = server.exportRecording();
assert.equal(recording.startTick, 6);
assert.equal(recording.durationTicks, 10);
assert.ok(recording.verifications.every(({ tick }) => tick >= 6));
assert.ok(recording.checkpoints.every(({ tick }) => tick >= 6));
const replay = replayGame.createReplay(recording);
assert.equal(replay.startTick, 6);
assert.deepEqual(replay.seek(10).state, server.currentState);
assert.throws(() => replay.seek(5), RangeError);
});
test("replay transport issues only authorized projected frames from a bounded ring", () => {
const server = replayTransportGame.createServer({ replaySeed: 11 });
server.addPlayer(1);
server.addPlayer(2);
for (let tick = 0; tick < 12; tick += 1) server.step();
const automaticTickets = server.drainReplayTickets(1);
assert.equal(automaticTickets.length, 1);
assert.equal(automaticTickets[0].perspectiveId, 2);
assert.equal(
server.issueReplay({
requesterId: 1,
perspectiveId: 1,
fromTick: 0,
toTick: 12,
}),
null,
);
const ticket = server.issueReplay({
requesterId: 1,
perspectiveId: 2,
fromTick: 0,
toTick: 12,
playbackRate: 0.5,
});
assert.ok(ticket);
assert.equal(ticket.requesterId, 1);
assert.equal(ticket.perspectiveId, 2);
assert.equal(ticket.fromTick, 6);
assert.equal(ticket.toTick, 12);
assert.equal(ticket.playbackRate, 0.5);
assert.ok(ticket.frames.length > 1);
assert.ok(
ticket.frames.every(
({ state }) =>
!Object.hasOwn(state, "secret") &&
Object.keys(state).sort().join(",") === "cues,ownPosition",
),
);
assert.deepEqual(server.drainReplayTickets(1), [ticket]);
assert.deepEqual(server.drainReplayTickets(1), []);
assert.equal(server.exportRecording().startTick, 6);
});

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"lib": ["ES2022"]
},
"include": ["src"]
}

View File

@@ -0,0 +1,28 @@
{
"name": "@syncer/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"dependencies": {
"@syncer/engine": "0.0.0"
},
"scripts": {
"dev": "tsc --watch --preserveWatchOutput",
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^7.0.2"
}
}

View File

@@ -0,0 +1,231 @@
export interface ArenaBlock {
id: string;
x: number;
z: number;
width: number;
depth: number;
height: number;
style: "reactor" | "wall" | "crate" | "column";
}
export interface ArenaSpawn {
x: number;
z: number;
yaw: number;
}
export interface ArenaPoint {
x: number;
z: number;
}
export const ARENA_HALF_SIZE = 18;
export const PLAYER_RADIUS = 0.44;
export const PLAYER_EYE_HEIGHT = 1.58;
export const PLAYER_HEIGHT = 1.8;
export const ARENA_BLOCKS: readonly ArenaBlock[] = [
{ id: "reactor", x: 0, z: 0, width: 4.2, depth: 4.2, height: 4.8, style: "reactor" },
{ id: "north-spine", x: 0, z: -10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" },
{ id: "south-spine", x: 0, z: 10.2, width: 8.2, depth: 1.2, height: 2.8, style: "wall" },
{ id: "west-spine", x: -10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" },
{ id: "east-spine", x: 10.2, z: 0, width: 1.2, depth: 8.2, height: 2.8, style: "wall" },
{ id: "nw-cover-a", x: -7, z: -7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" },
{ id: "nw-cover-b", x: -7, z: -4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" },
{ id: "se-cover-a", x: 7, z: 7, width: 3.2, depth: 1.5, height: 1.35, style: "crate" },
{ id: "se-cover-b", x: 7, z: 4.7, width: 1.5, depth: 3, height: 2.1, style: "crate" },
{ id: "ne-cover-a", x: 7, z: -7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" },
{ id: "sw-cover-a", x: -7, z: 7, width: 1.5, depth: 3.2, height: 1.55, style: "crate" },
{ id: "northwest-column", x: -14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
{ id: "northeast-column", x: 14, z: -13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
{ id: "southwest-column", x: -14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
{ id: "southeast-column", x: 14, z: 13, width: 1.8, depth: 1.8, height: 4.2, style: "column" },
] as const;
export const ARENA_SPAWNS: readonly ArenaSpawn[] = [
{ x: -14.5, z: -9.5, yaw: 2.35 },
{ x: 14.5, z: 9.5, yaw: -0.78 },
{ x: 14.5, z: -9.5, yaw: -2.35 },
{ x: -14.5, z: 9.5, yaw: 0.78 },
{ x: 0, z: -15, yaw: Math.PI },
{ x: 0, z: 15, yaw: 0 },
{ x: -15, z: 0, yaw: Math.PI / 2 },
{ x: 15, z: 0, yaw: -Math.PI / 2 },
] as const;
export const BOT_PATROL_POINTS: readonly ArenaPoint[] = [
{ x: -13, z: -8 },
{ x: -5.5, z: -12.5 },
{ x: 5.5, z: -12.5 },
{ x: 13, z: -8 },
{ x: 12.5, z: 4 },
{ x: 8, z: 12.5 },
{ x: -3.5, z: 13.5 },
{ x: -12.5, z: 8 },
{ x: -13.5, z: -3.5 },
{ x: -5.8, z: -4.8 },
{ x: 5.8, z: 4.8 },
{ x: 5.5, z: -5.5 },
] as const;
export function hasLineOfSight(
fromX: number,
fromZ: number,
toX: number,
toZ: number,
): boolean {
for (const block of ARENA_BLOCKS) {
if (segmentIntersectsExpandedBlock(fromX, fromZ, toX, toZ, block, 0.08)) {
return false;
}
}
return true;
}
export function collidesWithArena(x: number, z: number, radius = PLAYER_RADIUS): boolean {
if (
x - radius < -ARENA_HALF_SIZE ||
x + radius > ARENA_HALF_SIZE ||
z - radius < -ARENA_HALF_SIZE ||
z + radius > ARENA_HALF_SIZE
) {
return true;
}
return ARENA_BLOCKS.some((block) =>
circleIntersectsBlock(x, z, radius, block),
);
}
export function rayBlockDistance(
originX: number,
originY: number,
originZ: number,
directionX: number,
directionY: number,
directionZ: number,
maximumDistance: number,
): number | null {
let nearest: number | null = null;
for (const block of ARENA_BLOCKS) {
const distance = rayAabbDistance(
originX,
originY,
originZ,
directionX,
directionY,
directionZ,
block.x - block.width / 2,
0,
block.z - block.depth / 2,
block.x + block.width / 2,
block.height,
block.z + block.depth / 2,
);
if (
distance !== null &&
distance >= 0 &&
distance <= maximumDistance &&
(nearest === null || distance < nearest)
) {
nearest = distance;
}
}
return nearest;
}
function circleIntersectsBlock(
x: number,
z: number,
radius: number,
block: ArenaBlock,
): boolean {
const halfWidth = block.width / 2;
const halfDepth = block.depth / 2;
const nearestX = Math.max(block.x - halfWidth, Math.min(x, block.x + halfWidth));
const nearestZ = Math.max(block.z - halfDepth, Math.min(z, block.z + halfDepth));
const dx = x - nearestX;
const dz = z - nearestZ;
return dx * dx + dz * dz < radius * radius;
}
function segmentIntersectsExpandedBlock(
fromX: number,
fromZ: number,
toX: number,
toZ: number,
block: ArenaBlock,
padding: number,
): boolean {
const directionX = toX - fromX;
const directionZ = toZ - fromZ;
const distance = Math.hypot(directionX, directionZ);
if (distance < 0.0001) {
return false;
}
const hit = rayAabbDistance(
fromX,
1.2,
fromZ,
directionX / distance,
0,
directionZ / distance,
block.x - block.width / 2 - padding,
0,
block.z - block.depth / 2 - padding,
block.x + block.width / 2 + padding,
block.height,
block.z + block.depth / 2 + padding,
);
return hit !== null && hit > 0.05 && hit < distance - 0.05;
}
function rayAabbDistance(
originX: number,
originY: number,
originZ: number,
directionX: number,
directionY: number,
directionZ: number,
minimumX: number,
minimumY: number,
minimumZ: number,
maximumX: number,
maximumY: number,
maximumZ: number,
): number | null {
let minimumDistance = -Infinity;
let maximumDistance = Infinity;
for (const [origin, direction, minimum, maximum] of [
[originX, directionX, minimumX, maximumX],
[originY, directionY, minimumY, maximumY],
[originZ, directionZ, minimumZ, maximumZ],
] as const) {
if (Math.abs(direction) < 0.000001) {
if (origin < minimum || origin > maximum) {
return null;
}
continue;
}
const inverse = 1 / direction;
let near = (minimum - origin) * inverse;
let far = (maximum - origin) * inverse;
if (near > far) {
[near, far] = [far, near];
}
minimumDistance = Math.max(minimumDistance, near);
maximumDistance = Math.min(maximumDistance, far);
if (maximumDistance < minimumDistance) {
return null;
}
}
return maximumDistance >= 0 ? Math.max(0, minimumDistance) : null;
}

View File

@@ -0,0 +1,364 @@
import {
createJsonCodec,
defineMultiplayerGame,
} from "@syncer/engine";
import type {
FluxAuthorityEvent,
FluxAuthorityPlayer,
FluxAuthorityState,
FluxClientState,
FluxGameContract,
FluxInput,
FluxPerception,
FluxPlayerView,
FluxTeam,
} from "./flux-types.js";
export const FLUX_SOCKET_PATH = "/ws/flux";
export const FLUX_TICK_RATE = 30;
export const FLUX_SNAPSHOT_RATE = 10;
export const FLUX_ROUND_RESET_TICKS = 60;
export const FLUX_BOT_IDS = [20_001, 20_002] as const;
const maximumEnergy = 100;
const energyDrainPerSecond = 31;
const energyRecoveryPerSecond = 20;
const coreAcceleration = 0.92;
const coreDragPerSecond = 0.86;
const eventLifetimeTicks = FLUX_TICK_RATE * 4;
const inputCodec = createJsonCodec<FluxInput>();
const stateCodec = createJsonCodec<FluxClientState>();
const perceptionCodec = createJsonCodec<FluxPerception>();
/**
* A complete second game built through the public authoring API. Flux Relay is
* a team tug-of-war: hold thrust, manage private energy, and pull the shared
* core through your team's gate.
*/
export const fluxGame = defineMultiplayerGame<FluxGameContract>({
clock: {
ticksPerSecond: FLUX_TICK_RATE,
snapshotsPerSecond: FLUX_SNAPSHOT_RATE,
},
authority: {
createInitialState: createAuthorityState,
cloneState: cloneAuthorityState,
addPlayer(state, { playerId }) {
state.players.push(createPlayer(playerId, leastPopulatedTeam(state), false));
sortPlayers(state.players);
},
removePlayer(state, { playerId }) {
state.players = state.players.filter((player) => player.id !== playerId);
},
applyInput(state, input, { playerId }) {
const player = state.players.find((candidate) => candidate.id === playerId);
if (!player || player.bot) return;
player.thrust = input.thrust && !player.overheated;
},
step(state, { tick, deltaSeconds, emit }) {
state.elapsedTicks += 1;
if (state.resetTicks > 0) {
state.resetTicks -= 1;
state.core *= 0.88;
state.velocity = 0;
if (state.resetTicks === 0) state.core = 0;
updatePlayers(state, tick, deltaSeconds, emit, false);
return;
}
updatePlayers(state, tick, deltaSeconds, emit, true);
const cyanForce = activePlayerCount(state, "cyan");
const orangeForce = activePlayerCount(state, "orange");
const signedForce = orangeForce - cyanForce;
state.velocity += signedForce * coreAcceleration * deltaSeconds;
state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds);
state.core += state.velocity * deltaSeconds;
if (Math.abs(state.core) >= 1) {
const team: FluxTeam = state.core < 0 ? "cyan" : "orange";
state.core = team === "cyan" ? -1 : 1;
state.velocity = 0;
state.resetTicks = FLUX_ROUND_RESET_TICKS;
state.round += 1;
if (team === "cyan") state.cyanScore += 1;
else state.orangeScore += 1;
emit({
id: state.nextEventId++,
type: "round-won",
team,
round: state.round,
});
}
},
validateState(state) {
return (
Number.isFinite(state.core) &&
Number.isFinite(state.velocity) &&
Math.abs(state.core) <= 1.000_001 &&
state.players.every(
(player) =>
Number.isFinite(player.energy) &&
player.energy >= 0 &&
player.energy <= maximumEnergy,
)
);
},
},
prediction: {
createInitialState: createClientState,
cloneState: cloneClientState,
applyInput(state, input, { playerId }) {
const player = state.players.find((candidate) => candidate.id === playerId);
if (player && player.energy !== null) {
player.thrust = input.thrust && player.energy > 0;
}
},
step(state, { tick, deltaSeconds }) {
state.elapsedTicks += 1;
state.events = state.events.filter(
(entry) => entry.receivedTick >= tick - eventLifetimeTicks,
);
const ownPlayer = state.players.find((player) => player.energy !== null);
if (ownPlayer) updateVisibleEnergy(ownPlayer, deltaSeconds);
if (state.resetTicks > 0) {
state.resetTicks -= 1;
state.core *= 0.88;
state.velocity = 0;
if (state.resetTicks === 0) state.core = 0;
return;
}
const cyanForce = visibleActiveCount(state, "cyan");
const orangeForce = visibleActiveCount(state, "orange");
state.velocity += (orangeForce - cyanForce) * coreAcceleration * deltaSeconds;
state.velocity *= Math.pow(coreDragPerSecond, deltaSeconds);
state.core = clamp(state.core + state.velocity * deltaSeconds, -1, 1);
},
mergeSnapshot(predicted, snapshot, { tick }) {
const merged = cloneClientState(snapshot);
merged.events = predicted.events
.filter((entry) => entry.receivedTick >= tick - eventLifetimeTicks)
.map((entry) => ({
receivedTick: entry.receivedTick,
event: { ...entry.event },
}));
return merged;
},
applyEvent(state, event, { tick }) {
if (state.events.some((entry) => entry.event.id === event.id)) return;
state.events.push({ receivedTick: tick, event: { ...event } });
},
validateState(state) {
const ownEnergy = state.players.find((player) => player.energy !== null)?.energy;
return (
Number.isFinite(state.core) &&
Math.abs(state.core) <= 1.000_001 &&
(ownEnergy === undefined ||
(ownEnergy !== null && ownEnergy >= 0 && ownEnergy <= maximumEnergy))
);
},
},
visibility: {
createSnapshot(authority, { playerId }) {
return {
core: authority.core,
velocity: authority.velocity,
round: authority.round,
cyanScore: authority.cyanScore,
orangeScore: authority.orangeScore,
resetTicks: authority.resetTicks,
elapsedTicks: authority.elapsedTicks,
players: authority.players.map((player) => ({
id: player.id,
team: player.team,
bot: player.bot,
thrust: player.thrust,
energy: player.id === playerId ? player.energy : null,
})),
events: [],
};
},
validateClientState(authority, candidate, { playerId }) {
const expected = authority.players.find((player) => player.id === playerId);
const reported = candidate.players.find((player) => player.id === playerId);
return Boolean(
expected &&
reported &&
reported.energy !== null &&
Math.abs(reported.energy - expected.energy) <= 12 &&
Math.abs(candidate.core - authority.core) <= 0.3,
);
},
perceive(_authority, event, { playerId }) {
if (event.type === "round-won") return { ...event };
return {
id: event.id,
type: "overheated",
team: event.team,
scope: event.playerId === playerId ? "self" : "field",
};
},
},
input: {
validate(input) {
return typeof input === "object" && input !== null && typeof input.thrust === "boolean";
},
},
encoding: {
input: inputCodec,
clientState: stateCodec,
perception: perceptionCodec,
},
});
function createAuthorityState(): FluxAuthorityState {
return {
core: 0,
velocity: 0,
round: 1,
cyanScore: 0,
orangeScore: 0,
resetTicks: 0,
elapsedTicks: 0,
nextEventId: 1,
players: [
createPlayer(FLUX_BOT_IDS[0], "cyan", true),
createPlayer(FLUX_BOT_IDS[1], "orange", true),
],
};
}
function createClientState(): FluxClientState {
return {
core: 0,
velocity: 0,
round: 1,
cyanScore: 0,
orangeScore: 0,
resetTicks: 0,
elapsedTicks: 0,
players: [],
events: [],
};
}
function createPlayer(
id: number,
team: FluxTeam,
bot: boolean,
): FluxAuthorityPlayer {
return {
id,
team,
bot,
thrust: false,
energy: maximumEnergy,
overheated: false,
};
}
function updatePlayers(
state: FluxAuthorityState,
tick: number,
deltaSeconds: number,
emit: (event: FluxAuthorityEvent) => void,
allowThrust: boolean,
): void {
for (const player of state.players) {
if (player.bot) {
// Both bots use the same deterministic cadence, so they animate the field
// without biasing a solo player's match.
player.thrust = allowThrust && tick % 150 < 92 && !player.overheated;
}
if (player.thrust && allowThrust && !player.overheated) {
player.energy = clamp(
player.energy - energyDrainPerSecond * deltaSeconds,
0,
maximumEnergy,
);
if (player.energy === 0) {
player.thrust = false;
player.overheated = true;
emit({
id: state.nextEventId++,
type: "overheated",
playerId: player.id,
team: player.team,
});
}
} else {
player.energy = clamp(
player.energy + energyRecoveryPerSecond * deltaSeconds,
0,
maximumEnergy,
);
if (player.overheated && player.energy >= maximumEnergy * 0.34) {
player.overheated = false;
}
}
}
}
function updateVisibleEnergy(player: FluxPlayerView, deltaSeconds: number): void {
if (player.energy === null) return;
player.energy = clamp(
player.energy +
(player.thrust ? -energyDrainPerSecond : energyRecoveryPerSecond) * deltaSeconds,
0,
maximumEnergy,
);
if (player.energy === 0) player.thrust = false;
}
function activePlayerCount(state: FluxAuthorityState, team: FluxTeam): number {
return state.players.filter(
(player) => player.team === team && player.thrust && !player.overheated,
).length;
}
function visibleActiveCount(state: FluxClientState, team: FluxTeam): number {
return state.players.filter((player) => player.team === team && player.thrust).length;
}
function leastPopulatedTeam(state: FluxAuthorityState): FluxTeam {
const humans = state.players.filter((player) => !player.bot);
const cyan = humans.filter((player) => player.team === "cyan").length;
const orange = humans.length - cyan;
return cyan <= orange ? "cyan" : "orange";
}
function cloneAuthorityState(state: FluxAuthorityState): FluxAuthorityState {
return {
...state,
players: state.players.map((player) => ({ ...player })),
};
}
function cloneClientState(state: FluxClientState): FluxClientState {
return {
...state,
players: state.players.map((player) => ({ ...player })),
events: state.events.map((entry) => ({
receivedTick: entry.receivedTick,
event: { ...entry.event },
})),
};
}
function sortPlayers(players: FluxAuthorityPlayer[]): void {
players.sort((left, right) => left.id - right.id);
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,88 @@
export type FluxTeam = "cyan" | "orange";
export interface FluxInput {
thrust: boolean;
}
export interface FluxAuthorityPlayer {
id: number;
team: FluxTeam;
bot: boolean;
thrust: boolean;
energy: number;
overheated: boolean;
}
export interface FluxAuthorityState {
core: number;
velocity: number;
round: number;
cyanScore: number;
orangeScore: number;
resetTicks: number;
elapsedTicks: number;
nextEventId: number;
players: FluxAuthorityPlayer[];
}
export interface FluxPlayerView {
id: number;
team: FluxTeam;
bot: boolean;
thrust: boolean;
/** Exact energy is private: null for every player except the viewer. */
energy: number | null;
}
export type FluxPerception =
| {
id: number;
type: "round-won";
team: FluxTeam;
round: number;
}
| {
id: number;
type: "overheated";
team: FluxTeam;
scope: "self" | "field";
};
export interface FluxPresentationEvent {
receivedTick: number;
event: FluxPerception;
}
export interface FluxClientState {
core: number;
velocity: number;
round: number;
cyanScore: number;
orangeScore: number;
resetTicks: number;
elapsedTicks: number;
players: FluxPlayerView[];
events: FluxPresentationEvent[];
}
export type FluxAuthorityEvent =
| {
id: number;
type: "round-won";
team: FluxTeam;
round: number;
}
| {
id: number;
type: "overheated";
playerId: number;
team: FluxTeam;
};
export interface FluxGameContract {
authority: FluxAuthorityState;
client: FluxClientState;
input: FluxInput;
authorityEvent: FluxAuthorityEvent;
perceptionEvent: FluxPerception;
}

View File

@@ -0,0 +1,16 @@
export const API_PATHS = { message: "/api/message" } as const;
export const GAME_SOCKET_PATH = "/ws/game";
export interface ApiMessage {
message: string;
timestamp: string;
}
export * from "./arena.js";
export * from "./flux-types.js";
export * from "./flux-game.js";
export * from "./royale-types.js";
export * from "./royale-map.js";
export * from "./royale-game.js";
export * from "./shooter-types.js";
export * from "./shooter-game.js";

View File

@@ -0,0 +1,834 @@
import {
createJsonCodec,
defineMultiplayerGame,
withSpatialReplication,
} from "@syncer/engine";
import {
ROYALE_CHUNK_SIZE,
ROYALE_MAP_SEED,
ROYALE_WORLD_RADIUS,
ROYALE_WORLD_SIZE,
collidesWithRoyaleMap,
hasRoyaleLineOfSight,
royaleChunkCoordinate,
royaleRandom,
} from "./royale-map.js";
import type {
RoyaleAuthorityEvent,
RoyaleAuthorityLoot,
RoyaleAuthorityPlayer,
RoyaleAuthorityState,
RoyaleClientState,
RoyaleGameContract,
RoyaleInput,
RoyaleLootKind,
RoyalePerception,
RoyalePlayerView,
RoyaleWeapon,
} from "./royale-types.js";
export const ROYALE_SOCKET_PATH = "/ws/royale";
export const ROYALE_TICK_RATE = 30;
export const ROYALE_SNAPSHOT_RATE = 10;
export const ROYALE_BOT_COUNT = 31;
export const ROYALE_BOT_ID_BASE = 30_000;
export const ROYALE_WEAPONS: Record<
RoyaleWeapon,
{
name: string;
damage: number;
range: number;
cooldownTicks: number;
magazine: number;
reserve: number;
reloadTicks: number;
}
> = {
pistol: { name: "Kestrel P9", damage: 19, range: 125, cooldownTicks: 8, magazine: 12, reserve: 48, reloadTicks: 38 },
rifle: { name: "VX-4 Carbine", damage: 15, range: 205, cooldownTicks: 4, magazine: 30, reserve: 120, reloadTicks: 52 },
marksman: { name: "Longwatch DMR", damage: 48, range: 340, cooldownTicks: 17, magazine: 8, reserve: 32, reloadTicks: 64 },
};
const movementAcceleration = 52;
const movementFriction = 10;
const walkSpeed = 11.5;
const sprintSpeed = 16.5;
const parachuteSpeed = 22;
const parachuteDescentPerSecond = 18;
const playerEyeHeight = 1.55;
const playerHitRadius = 1.25;
const presentationEventLifetime = ROYALE_TICK_RATE * 6;
const initialStormRadius = 900;
const finalStormRadius = 78;
const stormShrinkPerTick = 0.085;
const lootCount = 260;
const inputCodec = createJsonCodec<RoyaleInput>();
const stateCodec = createJsonCodec<RoyaleClientState>();
const eventCodec = createJsonCodec<RoyalePerception>();
const baseRoyaleGame = defineMultiplayerGame<RoyaleGameContract>({
clock: {
ticksPerSecond: ROYALE_TICK_RATE,
snapshotsPerSecond: ROYALE_SNAPSHOT_RATE,
},
authority: {
createInitialState: createAuthorityState,
cloneState: cloneAuthorityState,
addPlayer(state, { playerId }) {
state.players.push(createPlayer(state, playerId, false, 110));
state.players.sort((left, right) => left.id - right.id);
},
removePlayer(state, { playerId }) {
state.players = state.players.filter((player) => player.id !== playerId);
},
applyInput(state, input, { playerId }) {
const player = findPlayer(state, playerId);
if (!player || player.bot || !player.alive) return;
applyCommand(player, input);
if (input.reload) beginReload(player);
},
step(state, { tick, deltaSeconds, emit }) {
if (state.resetTicks > 0) {
state.resetTicks -= 1;
if (state.resetTicks === 0) resetMatch(state);
return;
}
state.elapsedTicks += 1;
state.stormRadius = Math.max(
finalStormRadius,
initialStormRadius - state.elapsedTicks * stormShrinkPerTick,
);
for (const player of state.players) {
if (!player.alive) continue;
updateTimers(player);
if (player.botState) updateBot(state, player, tick);
const landed = simulatePlayer(player, deltaSeconds);
if (landed) {
emit({ id: state.nextEventId++, type: "landed", playerId: player.id });
}
if (player.altitude <= 0) {
collectLoot(state, player, emit);
if (player.firing) tryFire(state, player, emit);
applyStormDamage(state, player, tick, emit);
}
}
const alive = state.players.filter((player) => player.alive);
if (state.elapsedTicks > ROYALE_TICK_RATE * 8 && alive.length <= 1 && state.players.length > 1) {
const winner = alive[0] ?? null;
state.winnerId = winner?.id ?? null;
state.resetTicks = ROYALE_TICK_RATE * 5;
if (winner) {
emit({
id: state.nextEventId++,
type: "winner",
playerId: winner.id,
round: state.round,
});
}
}
},
validateState(state) {
return (
Number.isFinite(state.stormRadius) &&
state.stormRadius >= finalStormRadius &&
state.players.every(
(player) =>
Number.isFinite(player.x + player.z + player.health + player.altitude) &&
Number.isFinite(player.yaw + player.pitch) &&
player.health >= 0 &&
player.health <= 100 &&
player.armor >= 0 &&
player.armor <= 100,
)
);
},
},
prediction: {
createInitialState: createClientState,
cloneState: cloneClientState,
applyInput(state, input, { playerId }) {
const player = state.players.find((candidate) => candidate.id === playerId);
if (!player || !player.alive) return;
player.inputForward = input.forward;
player.inputStrafe = input.strafe;
player.yaw = normalizeAngle(input.yaw);
player.pitch = clamp(input.pitch, -1.25, 1.25);
player.firing = input.fire;
player.sprinting = input.sprint;
if (input.reload && player.reloadTicks === 0) {
player.reloadTicks = ROYALE_WEAPONS[player.weapon].reloadTicks;
}
},
step(state, { tick, deltaSeconds }) {
state.elapsedTicks += 1;
state.stormRadius = Math.max(
finalStormRadius,
initialStormRadius - state.elapsedTicks * stormShrinkPerTick,
);
for (const player of state.players) {
if (!player.alive) continue;
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
if (player.reloadTicks > 0) player.reloadTicks -= 1;
simulateVisiblePlayer(player, deltaSeconds);
}
state.events = state.events.filter(
(entry) => entry.receivedTick >= tick - presentationEventLifetime,
);
},
mergeSnapshot(predicted, snapshot, { tick }) {
const merged = cloneClientState(snapshot);
merged.events = predicted.events
.filter((entry) => entry.receivedTick >= tick - presentationEventLifetime)
.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } }));
return merged;
},
applyEvent(state, event, { tick }) {
if (state.events.some((entry) => entry.event.id === event.id)) return;
state.events.push({ receivedTick: tick, event: { ...event } });
if (state.events.length > 64) state.events.shift();
},
validateState(state) {
return (
Number.isFinite(state.stormRadius) &&
state.players.every(
(player) =>
Number.isFinite(player.x + player.z + player.altitude) &&
Number.isFinite(player.yaw + player.pitch) &&
(player.health === null || (player.health >= 0 && player.health <= 100)),
)
);
},
},
visibility: {
createSnapshot(authority, { playerId }) {
const viewer = findPlayer(authority, playerId);
const players = viewer
? authority.players
.filter((target) => target.id === playerId || canSeePlayer(viewer, target))
.map((player) => clonePlayerView(player, player.id === playerId))
: [];
const loot = viewer
? authority.loot
.filter(
(item) =>
item.active && Math.hypot(item.x - viewer.x, item.z - viewer.z) <= 155,
)
.map(({ id, kind, weapon, amount, x, z }) => ({ id, kind, weapon, amount, x, z }))
: [];
return {
mapSeed: ROYALE_MAP_SEED,
worldSize: ROYALE_WORLD_SIZE,
chunkSize: ROYALE_CHUNK_SIZE,
players,
loot,
elapsedTicks: authority.elapsedTicks,
round: authority.round,
aliveCount: authority.players.filter((player) => player.alive).length,
stormX: authority.stormX,
stormZ: authority.stormZ,
stormRadius: authority.stormRadius,
resetTicks: authority.resetTicks,
winnerId: authority.winnerId,
stream: {
candidateCount: players.length + loot.length,
droppedCount: 0,
usedBytes: 0,
budgetBytes: 0,
chunkX: viewer ? royaleChunkCoordinate(viewer.x) : 0,
chunkZ: viewer ? royaleChunkCoordinate(viewer.z) : 0,
},
events: [],
};
},
validateClientState(authority, candidate, { playerId }) {
const expected = findPlayer(authority, playerId);
const reported = candidate.players.find((player) => player.id === playerId);
return Boolean(
expected &&
reported &&
reported.health === expected.health &&
Math.hypot(reported.x - expected.x, reported.z - expected.z) <= 5,
);
},
perceive(authority, event, { playerId }) {
return perceiveEvent(authority, event, playerId);
},
},
input: {
validate(input) {
return (
typeof input === "object" &&
input !== null &&
Number.isFinite(input.forward) &&
Number.isFinite(input.strafe) &&
Number.isFinite(input.yaw) &&
Number.isFinite(input.pitch) &&
Math.abs(input.forward) <= 1 &&
Math.abs(input.strafe) <= 1 &&
input.pitch >= -1.35 &&
input.pitch <= 1.35 &&
typeof input.fire === "boolean" &&
typeof input.sprint === "boolean" &&
typeof input.reload === "boolean"
);
},
},
encoding: {
input: inputCodec,
clientState: stateCodec,
perception: eventCodec,
},
});
export const royaleGame = withSpatialReplication(baseRoyaleGame, {
cellSize: ROYALE_CHUNK_SIZE,
bandwidthBudgetBytesPerSecond: 30_000,
reservedBytesPerSnapshot: 300,
viewerPosition(authority, playerId) {
const viewer = findPlayer(authority, playerId);
return viewer ? { x: viewer.x, z: viewer.z } : null;
},
sources: [
{
category: "players",
maximumDistance: 360,
entities: (authority) => authority.players.filter((player) => player.alive),
estimatedBytes: 76,
priority: (_entity, distance) => 1_000 - distance,
required: (entity, playerId) => entity.id === playerId,
},
{
category: "loot",
maximumDistance: 165,
entities: (authority) => authority.loot.filter((item) => item.active),
estimatedBytes: 44,
priority: (_entity, distance) => 300 - distance,
},
],
projectSnapshot(snapshot, selection) {
const visibleCandidates = snapshot.players.length + snapshot.loot.length;
const players = snapshot.players.filter((player) => selection.has("players", player.id));
const loot = snapshot.loot.filter((item) => selection.has("loot", item.id));
return {
...snapshot,
players,
loot,
stream: {
candidateCount: visibleCandidates,
droppedCount: visibleCandidates - players.length - loot.length,
usedBytes: players.length * 76 + loot.length * 44,
budgetBytes: selection.budgetBytes,
chunkX: royaleChunkCoordinate(selection.position.x),
chunkZ: royaleChunkCoordinate(selection.position.z),
},
};
},
perceive(_authority, _event, perception, selection) {
if (perception.type === "shot" && !selection.has("players", perception.sourceId)) {
return null;
}
return perception;
},
});
function createAuthorityState(): RoyaleAuthorityState {
const secretSeed = randomSeed();
const state: RoyaleAuthorityState = {
players: [],
loot: [],
elapsedTicks: 0,
round: 1,
stormX: (royaleRandom(secretSeed, 901) - 0.5) * 180,
stormZ: (royaleRandom(secretSeed, 902) - 0.5) * 180,
stormRadius: initialStormRadius,
resetTicks: 0,
winnerId: null,
nextEventId: 1,
secretSeed,
};
state.loot = createLoot(secretSeed);
for (let index = 0; index < ROYALE_BOT_COUNT; index += 1) {
const id = ROYALE_BOT_ID_BASE + index;
state.players.push(createPlayer(state, id, true, 0));
}
return state;
}
function createClientState(): RoyaleClientState {
return {
mapSeed: ROYALE_MAP_SEED,
worldSize: ROYALE_WORLD_SIZE,
chunkSize: ROYALE_CHUNK_SIZE,
players: [],
loot: [],
elapsedTicks: 0,
round: 1,
aliveCount: 0,
stormX: 0,
stormZ: 0,
stormRadius: initialStormRadius,
resetTicks: 0,
winnerId: null,
stream: { candidateCount: 0, droppedCount: 0, usedBytes: 0, budgetBytes: 0, chunkX: 0, chunkZ: 0 },
events: [],
};
}
function createPlayer(
state: RoyaleAuthorityState,
id: number,
bot: boolean,
altitude: number,
): RoyaleAuthorityPlayer {
const spawn = findSpawn(state.secretSeed, id + state.round * 101);
const weapon = bot && id % 5 === 0 ? "rifle" : "pistol";
const config = ROYALE_WEAPONS[weapon];
return {
id,
bot,
alive: true,
x: spawn.x,
z: spawn.z,
velocityX: 0,
velocityZ: 0,
yaw: spawn.yaw,
pitch: 0,
altitude,
health: 100,
armor: bot ? 20 : 0,
weapon,
magazine: config.magazine,
reserve: config.reserve,
cooldownTicks: 0,
reloadTicks: 0,
kills: 0,
inputForward: 0,
inputStrafe: 0,
firing: false,
sprinting: false,
botState: bot ? { phase: id % 360, targetId: null } : null,
};
}
function applyCommand(player: RoyaleAuthorityPlayer, input: RoyaleInput): void {
player.inputForward = input.forward;
player.inputStrafe = input.strafe;
player.yaw = normalizeAngle(input.yaw);
player.pitch = clamp(input.pitch, -1.25, 1.25);
player.firing = input.fire;
player.sprinting = input.sprint;
}
function updateBot(state: RoyaleAuthorityState, bot: RoyaleAuthorityPlayer, tick: number): void {
const target = nearestTarget(state, bot, 280);
bot.botState!.targetId = target?.id ?? null;
let targetX = state.stormX;
let targetZ = state.stormZ;
if (target) {
targetX = target.x;
targetZ = target.z;
} else {
const angle = (bot.botState!.phase + tick * 0.18) * (Math.PI / 180);
targetX += Math.cos(angle) * Math.min(190, state.stormRadius * 0.45);
targetZ += Math.sin(angle) * Math.min(190, state.stormRadius * 0.45);
}
const distance = Math.hypot(targetX - bot.x, targetZ - bot.z);
bot.yaw = Math.atan2(targetX - bot.x, targetZ - bot.z) + Math.sin(tick * 0.17 + bot.id) * 0.035;
bot.pitch = target
? clamp(Math.atan2(target.altitude - bot.altitude, Math.max(0.01, distance)), -1.25, 1.25)
: 0;
bot.inputForward = distance > 45 ? 1 : 0.24;
bot.inputStrafe = target ? Math.sin(tick * 0.08 + bot.id) * 0.7 : 0;
bot.sprinting = distance > 90;
bot.firing = Boolean(
target &&
distance <= ROYALE_WEAPONS[bot.weapon].range &&
hasRoyaleLineOfSight(bot.x, bot.z, target.x, target.z),
);
if (bot.magazine === 0) beginReload(bot);
}
function simulatePlayer(player: RoyaleAuthorityPlayer, deltaSeconds: number): boolean {
const wasAirborne = player.altitude > 0;
const speed =
player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed;
const sin = Math.sin(player.yaw);
const cos = Math.cos(player.yaw);
let directionX = sin * player.inputForward - cos * player.inputStrafe;
let directionZ = cos * player.inputForward + sin * player.inputStrafe;
const magnitude = Math.hypot(directionX, directionZ);
if (magnitude > 1) {
directionX /= magnitude;
directionZ /= magnitude;
}
player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds);
player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds);
if (magnitude < 0.01) {
player.velocityX *= Math.max(0, 1 - movementFriction * deltaSeconds);
player.velocityZ *= Math.max(0, 1 - movementFriction * deltaSeconds);
}
const nextX = player.x + player.velocityX * deltaSeconds;
const nextZ = player.z + player.velocityZ * deltaSeconds;
if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX;
else player.velocityX = 0;
if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ;
else player.velocityZ = 0;
if (player.altitude > 0) {
player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds);
}
return wasAirborne && player.altitude === 0;
}
function simulateVisiblePlayer(player: RoyalePlayerView & { inputForward?: number; inputStrafe?: number }, deltaSeconds: number): void {
const inputForward = player.inputForward ?? 0;
const inputStrafe = player.inputStrafe ?? 0;
const speed = player.altitude > 0 ? parachuteSpeed : player.sprinting ? sprintSpeed : walkSpeed;
const sin = Math.sin(player.yaw);
const cos = Math.cos(player.yaw);
let directionX = sin * inputForward - cos * inputStrafe;
let directionZ = cos * inputForward + sin * inputStrafe;
const magnitude = Math.hypot(directionX, directionZ);
if (magnitude > 1) {
directionX /= magnitude;
directionZ /= magnitude;
}
player.velocityX = approach(player.velocityX, directionX * speed, movementAcceleration * deltaSeconds);
player.velocityZ = approach(player.velocityZ, directionZ * speed, movementAcceleration * deltaSeconds);
const nextX = player.x + player.velocityX * deltaSeconds;
const nextZ = player.z + player.velocityZ * deltaSeconds;
if (player.altitude > 0 || !collidesWithRoyaleMap(nextX, player.z)) player.x = nextX;
if (player.altitude > 0 || !collidesWithRoyaleMap(player.x, nextZ)) player.z = nextZ;
if (player.altitude > 0) player.altitude = Math.max(0, player.altitude - parachuteDescentPerSecond * deltaSeconds);
}
function tryFire(
state: RoyaleAuthorityState,
shooter: RoyaleAuthorityPlayer,
emit: (event: RoyaleAuthorityEvent) => void,
): void {
if (shooter.cooldownTicks > 0 || shooter.reloadTicks > 0 || shooter.magazine <= 0) return;
const weapon = ROYALE_WEAPONS[shooter.weapon];
shooter.magazine -= 1;
shooter.cooldownTicks = weapon.cooldownTicks;
emit({
id: state.nextEventId++,
type: "shot",
sourceId: shooter.id,
x: shooter.x,
y: shooter.altitude + playerEyeHeight,
z: shooter.z,
yaw: shooter.yaw,
pitch: shooter.pitch,
});
const horizontal = Math.cos(shooter.pitch);
const directionX = Math.sin(shooter.yaw) * horizontal;
const directionY = Math.sin(shooter.pitch);
const directionZ = Math.cos(shooter.yaw) * horizontal;
const originY = shooter.altitude + playerEyeHeight;
let nearest: RoyaleAuthorityPlayer | null = null;
let nearestDistance = weapon.range;
for (const target of state.players) {
if (!target.alive || target.id === shooter.id) continue;
const offsetX = target.x - shooter.x;
const offsetY = target.altitude + playerEyeHeight - originY;
const offsetZ = target.z - shooter.z;
const forward =
offsetX * directionX + offsetY * directionY + offsetZ * directionZ;
if (forward <= 0 || forward >= nearestDistance) continue;
const offsetSquared = offsetX * offsetX + offsetY * offsetY + offsetZ * offsetZ;
const missDistance = Math.sqrt(Math.max(0, offsetSquared - forward * forward));
if (
missDistance > playerHitRadius ||
!hasRoyaleLineOfSight(shooter.x, shooter.z, target.x, target.z)
) continue;
nearest = target;
nearestDistance = forward;
}
if (nearest) damagePlayer(state, shooter.id, nearest, weapon.damage, emit);
if (shooter.magazine === 0) beginReload(shooter);
}
function damagePlayer(
state: RoyaleAuthorityState,
sourceId: number,
target: RoyaleAuthorityPlayer,
amount: number,
emit: (event: RoyaleAuthorityEvent) => void,
): void {
const absorbed = Math.min(target.armor, Math.ceil(amount * 0.55));
target.armor -= absorbed;
const healthDamage = Math.min(target.health, amount - absorbed);
target.health -= healthDamage;
emit({
id: state.nextEventId++,
type: "damage",
sourceId,
targetId: target.id,
amount: healthDamage,
health: target.health,
});
if (target.health > 0) return;
target.alive = false;
target.firing = false;
target.velocityX = 0;
target.velocityZ = 0;
const killer = findPlayer(state, sourceId);
if (killer) killer.kills += 1;
emit({ id: state.nextEventId++, type: "elimination", killerId: sourceId, victimId: target.id });
}
function applyStormDamage(
state: RoyaleAuthorityState,
player: RoyaleAuthorityPlayer,
tick: number,
emit: (event: RoyaleAuthorityEvent) => void,
): void {
if (tick % 15 !== 0) return;
if (Math.hypot(player.x - state.stormX, player.z - state.stormZ) <= state.stormRadius) return;
damagePlayer(state, 0, player, 3, emit);
}
function collectLoot(
state: RoyaleAuthorityState,
player: RoyaleAuthorityPlayer,
emit: (event: RoyaleAuthorityEvent) => void,
): void {
const item = state.loot.find(
(candidate) => candidate.active && Math.hypot(candidate.x - player.x, candidate.z - player.z) < 2.4,
);
if (!item) return;
item.active = false;
if (item.kind === "weapon") {
player.weapon = item.weapon;
player.magazine = ROYALE_WEAPONS[item.weapon].magazine;
player.reserve = Math.max(player.reserve, ROYALE_WEAPONS[item.weapon].reserve / 2);
} else if (item.kind === "ammo") {
player.reserve = Math.min(240, player.reserve + item.amount);
} else if (item.kind === "armor") {
player.armor = Math.min(100, player.armor + item.amount);
} else {
player.health = Math.min(100, player.health + item.amount);
}
emit({
id: state.nextEventId++,
type: "pickup",
playerId: player.id,
kind: item.kind,
weapon: item.weapon,
amount: item.amount,
});
}
function perceiveEvent(
authority: RoyaleAuthorityState,
event: RoyaleAuthorityEvent,
playerId: number,
): RoyalePerception | null {
const viewer = findPlayer(authority, playerId);
if (!viewer) return null;
switch (event.type) {
case "shot": {
const source = findPlayer(authority, event.sourceId);
if (source && (source.id === viewer.id || canSeePlayer(viewer, source))) {
return { ...event };
}
const dx = event.x - viewer.x;
const dz = event.z - viewer.z;
const distance = Math.hypot(dx, dz);
if (distance > 560) return null;
return {
id: event.id,
type: "gunfire",
bearing: normalizeAngle(Math.atan2(dx, dz) - viewer.yaw),
intensity: clamp(1 - distance / 560, 0.08, 1),
};
}
case "damage":
if (event.targetId === playerId) {
return { id: event.id, type: "damage", amount: event.amount, health: event.health };
}
if (event.sourceId === playerId) {
return { id: event.id, type: "hit", amount: event.amount, eliminated: event.health === 0 };
}
return null;
case "elimination":
return { ...event };
case "pickup":
return event.playerId === playerId
? { id: event.id, type: "pickup", kind: event.kind, weapon: event.weapon, amount: event.amount }
: null;
case "landed":
return event.playerId === playerId ? { id: event.id, type: "landed" } : null;
case "winner":
return { ...event };
}
}
function canSeePlayer(viewer: RoyaleAuthorityPlayer, target: RoyaleAuthorityPlayer): boolean {
if (!target.alive || target.id === viewer.id) return false;
const distance = Math.hypot(target.x - viewer.x, target.z - viewer.z);
return distance <= 225 && hasRoyaleLineOfSight(viewer.x, viewer.z, target.x, target.z);
}
function clonePlayerView(player: RoyaleAuthorityPlayer, owner: boolean): RoyalePlayerView {
return {
id: player.id,
bot: player.bot,
alive: player.alive,
x: player.x,
z: player.z,
velocityX: player.velocityX,
velocityZ: player.velocityZ,
yaw: player.yaw,
pitch: player.pitch,
altitude: player.altitude,
health: owner ? player.health : null,
armor: owner ? player.armor : null,
weapon: player.weapon,
magazine: owner ? player.magazine : null,
reserve: owner ? player.reserve : null,
cooldownTicks: player.cooldownTicks,
reloadTicks: player.reloadTicks,
kills: player.kills,
firing: player.firing,
sprinting: player.sprinting,
inputForward: owner ? player.inputForward : 0,
inputStrafe: owner ? player.inputStrafe : 0,
};
}
function createLoot(seed: number): RoyaleAuthorityLoot[] {
const loot: RoyaleAuthorityLoot[] = [];
for (let id = 1; id <= lootCount; id += 1) {
let position = findSpawn(seed ^ 0xa51c, id * 31);
const roll = royaleRandom(seed ^ 0xb771, id);
const kind: RoyaleLootKind = roll > 0.82 ? "weapon" : roll > 0.55 ? "ammo" : roll > 0.28 ? "armor" : "medkit";
const weapon: RoyaleWeapon = royaleRandom(seed ^ 0x991a, id) > 0.78 ? "marksman" : "rifle";
if (collidesWithRoyaleMap(position.x, position.z, 1.2)) position = findSpawn(seed ^ 0x4411, id * 67);
loot.push({
id,
kind,
weapon,
amount: kind === "ammo" ? 30 : kind === "armor" ? 35 : kind === "medkit" ? 30 : 1,
x: position.x,
z: position.z,
active: true,
});
}
return loot;
}
function findSpawn(seed: number, index: number): { x: number; z: number; yaw: number } {
for (let attempt = 0; attempt < 24; attempt += 1) {
const radius = 120 + Math.sqrt(royaleRandom(seed, index + attempt * 3)) * 690;
const angle = royaleRandom(seed ^ 0x7f4a, index + attempt * 3 + 1) * Math.PI * 2;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius;
if (!collidesWithRoyaleMap(x, z, 1.2)) return { x, z, yaw: angle + Math.PI };
}
return { x: 0, z: 0, yaw: 0 };
}
function nearestTarget(
state: RoyaleAuthorityState,
source: RoyaleAuthorityPlayer,
maximumDistance: number,
): RoyaleAuthorityPlayer | null {
let nearest: RoyaleAuthorityPlayer | null = null;
let distance = maximumDistance;
for (const candidate of state.players) {
if (!candidate.alive || candidate.id === source.id || candidate.altitude > 0) continue;
const candidateDistance = Math.hypot(candidate.x - source.x, candidate.z - source.z);
if (candidateDistance < distance) {
nearest = candidate;
distance = candidateDistance;
}
}
return nearest;
}
function beginReload(player: RoyaleAuthorityPlayer): void {
const weapon = ROYALE_WEAPONS[player.weapon];
if (player.reloadTicks > 0 || player.magazine >= weapon.magazine || player.reserve <= 0) return;
player.reloadTicks = weapon.reloadTicks;
player.firing = false;
}
function updateTimers(player: RoyaleAuthorityPlayer): void {
if (player.cooldownTicks > 0) player.cooldownTicks -= 1;
if (player.reloadTicks > 0) {
player.reloadTicks -= 1;
if (player.reloadTicks === 0) {
const capacity = ROYALE_WEAPONS[player.weapon].magazine;
const loaded = Math.min(capacity - player.magazine, player.reserve);
player.magazine += loaded;
player.reserve -= loaded;
}
}
}
function resetMatch(state: RoyaleAuthorityState): void {
state.elapsedTicks = 0;
state.round += 1;
state.winnerId = null;
state.stormRadius = initialStormRadius;
state.stormX = (royaleRandom(state.secretSeed, 901 + state.round) - 0.5) * 180;
state.stormZ = (royaleRandom(state.secretSeed, 902 + state.round) - 0.5) * 180;
state.loot = createLoot(state.secretSeed ^ state.round * 0x9e37);
for (const player of state.players) {
const fresh = createPlayer(state, player.id, player.bot, player.bot ? 0 : 110);
Object.assign(player, fresh, { kills: player.kills });
}
}
function cloneAuthorityState(state: RoyaleAuthorityState): RoyaleAuthorityState {
return {
...state,
players: state.players.map((player) => ({
...player,
botState: player.botState ? { ...player.botState } : null,
})),
loot: state.loot.map((item) => ({ ...item })),
};
}
function cloneClientState(state: RoyaleClientState): RoyaleClientState {
return {
...state,
players: state.players.map((player) => ({ ...player })),
loot: state.loot.map((item) => ({ ...item })),
stream: { ...state.stream },
events: state.events.map((entry) => ({ receivedTick: entry.receivedTick, event: { ...entry.event } })),
};
}
function findPlayer(state: RoyaleAuthorityState, playerId: number): RoyaleAuthorityPlayer | undefined {
return state.players.find((player) => player.id === playerId);
}
function randomSeed(): number {
return Math.floor(Math.random() * 0x1_0000_0000) >>> 0;
}
function approach(value: number, target: number, amount: number): number {
return value < target ? Math.min(target, value + amount) : Math.max(target, value - amount);
}
function normalizeAngle(value: number): number {
return Math.atan2(Math.sin(value), Math.cos(value));
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,143 @@
export const ROYALE_MAP_SEED = 0x51_9c_ae_27;
export const ROYALE_WORLD_SIZE = 2_000;
export const ROYALE_WORLD_RADIUS = 960;
export const ROYALE_CHUNK_SIZE = 100;
export type RoyaleBiome = "meadow" | "forest" | "highland" | "coast";
export type RoyaleObstacleKind = "building" | "rock" | "tree";
export interface RoyaleObstacle {
id: string;
kind: RoyaleObstacleKind;
x: number;
z: number;
width: number;
depth: number;
height: number;
}
export interface RoyaleMapChunk {
x: number;
z: number;
biome: RoyaleBiome;
tint: number;
elevation: number;
roadX: boolean;
roadZ: boolean;
obstacles: RoyaleObstacle[];
}
const chunkCache = new Map<string, RoyaleMapChunk>();
/** Public, deterministic static world generation shared by server and clients. */
export function generateRoyaleChunk(chunkX: number, chunkZ: number): RoyaleMapChunk {
const key = `${chunkX}:${chunkZ}`;
const cached = chunkCache.get(key);
if (cached) return cached;
const centerX = (chunkX + 0.5) * ROYALE_CHUNK_SIZE;
const centerZ = (chunkZ + 0.5) * ROYALE_CHUNK_SIZE;
const normalizedRadius = Math.hypot(centerX, centerZ) / ROYALE_WORLD_RADIUS;
const biomeNoise = random01(PROCEDURAL_SEED, chunkX, chunkZ, 1);
const biome: RoyaleBiome =
normalizedRadius > 0.84
? "coast"
: biomeNoise > 0.68
? "forest"
: biomeNoise < 0.2
? "highland"
: "meadow";
const roadX = Math.abs(chunkZ) % 5 === 0;
const roadZ = Math.abs(chunkX + 2) % 6 === 0;
const obstacleCount = biome === "coast" ? 2 : 4 + Math.floor(random01(PROCEDURAL_SEED, chunkX, chunkZ, 2) * 5);
const obstacles: RoyaleObstacle[] = [];
for (let index = 0; index < obstacleCount; index += 1) {
const localX = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 10 + index * 5) - 0.5) * 82;
const localZ = (random01(PROCEDURAL_SEED, chunkX, chunkZ, 11 + index * 5) - 0.5) * 82;
if ((roadZ && Math.abs(localX) < 11) || (roadX && Math.abs(localZ) < 11)) continue;
const kindRoll = random01(PROCEDURAL_SEED, chunkX, chunkZ, 12 + index * 5);
const kind: RoyaleObstacleKind =
kindRoll > 0.72 ? "building" : kindRoll > 0.38 ? "tree" : "rock";
const scale = random01(PROCEDURAL_SEED, chunkX, chunkZ, 13 + index * 5);
const width = kind === "building" ? 10 + scale * 15 : kind === "tree" ? 3.2 : 4 + scale * 7;
const depth = kind === "building" ? 9 + random01(PROCEDURAL_SEED, chunkX, chunkZ, 14 + index * 5) * 14 : width;
obstacles.push({
id: `${chunkX}:${chunkZ}:${index}`,
kind,
x: chunkX * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localX,
z: chunkZ * ROYALE_CHUNK_SIZE + ROYALE_CHUNK_SIZE / 2 + localZ,
width,
depth,
height: kind === "building" ? 7 + scale * 13 : kind === "tree" ? 8 + scale * 7 : 2.5 + scale * 5,
});
}
const chunk: RoyaleMapChunk = {
x: chunkX,
z: chunkZ,
biome,
tint:
biome === "coast" ? 0xb9a56e : biome === "forest" ? 0x315d3f : biome === "highland" ? 0x697064 : 0x52764b,
elevation: Math.round(random01(PROCEDURAL_SEED, chunkX, chunkZ, 3) * 3) * 0.16,
roadX,
roadZ,
obstacles,
};
chunkCache.set(key, chunk);
return chunk;
}
export function collidesWithRoyaleMap(x: number, z: number, radius = 0.72): boolean {
if (Math.hypot(x, z) + radius > ROYALE_WORLD_RADIUS) return true;
const chunkX = Math.floor(x / ROYALE_CHUNK_SIZE);
const chunkZ = Math.floor(z / ROYALE_CHUNK_SIZE);
for (let offsetX = -1; offsetX <= 1; offsetX += 1) {
for (let offsetZ = -1; offsetZ <= 1; offsetZ += 1) {
for (const obstacle of generateRoyaleChunk(chunkX + offsetX, chunkZ + offsetZ).obstacles) {
const nearestX = clamp(x, obstacle.x - obstacle.width / 2, obstacle.x + obstacle.width / 2);
const nearestZ = clamp(z, obstacle.z - obstacle.depth / 2, obstacle.z + obstacle.depth / 2);
const dx = x - nearestX;
const dz = z - nearestZ;
if (dx * dx + dz * dz < radius * radius) return true;
}
}
}
return false;
}
export function hasRoyaleLineOfSight(fromX: number, fromZ: number, toX: number, toZ: number): boolean {
const distance = Math.hypot(toX - fromX, toZ - fromZ);
const steps = Math.max(2, Math.ceil(distance / 4));
for (let step = 1; step < steps; step += 1) {
const ratio = step / steps;
if (collidesWithRoyaleMap(fromX + (toX - fromX) * ratio, fromZ + (toZ - fromZ) * ratio, 0.12)) {
return false;
}
}
return true;
}
export function royaleChunkCoordinate(value: number): number {
return Math.floor(value / ROYALE_CHUNK_SIZE);
}
export function royaleRandom(seed: number, index: number): number {
return random01(seed, index, index * 17 + 3, index * 97 + 11);
}
const PROCEDURAL_SEED = ROYALE_MAP_SEED;
function random01(seed: number, a: number, b: number, c: number): number {
let value = seed ^ Math.imul(a, 0x9e3779b1) ^ Math.imul(b, 0x85ebca77) ^ Math.imul(c, 0xc2b2ae3d);
value ^= value >>> 16;
value = Math.imul(value, 0x7feb352d);
value ^= value >>> 15;
value = Math.imul(value, 0x846ca68b);
value ^= value >>> 16;
return (value >>> 0) / 0x1_0000_0000;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}

View File

@@ -0,0 +1,160 @@
export type RoyaleWeapon = "pistol" | "rifle" | "marksman";
export type RoyaleLootKind = "weapon" | "ammo" | "armor" | "medkit";
export interface RoyaleInput {
forward: number;
strafe: number;
yaw: number;
pitch: number;
fire: boolean;
sprint: boolean;
reload: boolean;
}
export interface RoyaleBotState {
phase: number;
targetId: number | null;
}
export interface RoyaleAuthorityPlayer {
id: number;
bot: boolean;
alive: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
pitch: number;
altitude: number;
health: number;
armor: number;
weapon: RoyaleWeapon;
magazine: number;
reserve: number;
cooldownTicks: number;
reloadTicks: number;
kills: number;
inputForward: number;
inputStrafe: number;
firing: boolean;
sprinting: boolean;
botState: RoyaleBotState | null;
}
export interface RoyaleAuthorityLoot {
id: number;
kind: RoyaleLootKind;
weapon: RoyaleWeapon;
amount: number;
x: number;
z: number;
active: boolean;
}
export interface RoyaleAuthorityState {
players: RoyaleAuthorityPlayer[];
loot: RoyaleAuthorityLoot[];
elapsedTicks: number;
round: number;
stormX: number;
stormZ: number;
stormRadius: number;
resetTicks: number;
winnerId: number | null;
nextEventId: number;
secretSeed: number;
}
export interface RoyalePlayerView {
id: number;
bot: boolean;
alive: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
pitch: number;
altitude: number;
/** Exact combat state exists only on the owning player's projection. */
health: number | null;
armor: number | null;
weapon: RoyaleWeapon;
magazine: number | null;
reserve: number | null;
cooldownTicks: number;
reloadTicks: number;
kills: number;
firing: boolean;
sprinting: boolean;
inputForward: number;
inputStrafe: number;
}
export interface RoyaleLootView {
id: number;
kind: RoyaleLootKind;
weapon: RoyaleWeapon;
amount: number;
x: number;
z: number;
}
export interface RoyaleStreamStats {
candidateCount: number;
droppedCount: number;
usedBytes: number;
budgetBytes: number;
chunkX: number;
chunkZ: number;
}
export interface RoyaleClientState {
mapSeed: number;
worldSize: number;
chunkSize: number;
players: RoyalePlayerView[];
loot: RoyaleLootView[];
elapsedTicks: number;
round: number;
aliveCount: number;
stormX: number;
stormZ: number;
stormRadius: number;
resetTicks: number;
winnerId: number | null;
stream: RoyaleStreamStats;
events: RoyalePresentationEvent[];
}
export type RoyaleAuthorityEvent =
| { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number }
| { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number }
| { id: number; type: "elimination"; killerId: number; victimId: number }
| { id: number; type: "pickup"; playerId: number; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number }
| { id: number; type: "landed"; playerId: number }
| { id: number; type: "winner"; playerId: number; round: number };
export type RoyalePerception =
| { id: number; type: "shot"; sourceId: number; x: number; y: number; z: number; yaw: number; pitch: number }
| { id: number; type: "gunfire"; bearing: number; intensity: number }
| { id: number; type: "damage"; amount: number; health: number }
| { id: number; type: "hit"; amount: number; eliminated: boolean }
| { id: number; type: "elimination"; killerId: number; victimId: number }
| { id: number; type: "pickup"; kind: RoyaleLootKind; weapon: RoyaleWeapon; amount: number }
| { id: number; type: "landed" }
| { id: number; type: "winner"; playerId: number; round: number };
export interface RoyalePresentationEvent {
receivedTick: number;
event: RoyalePerception;
}
export interface RoyaleGameContract {
authority: RoyaleAuthorityState;
client: RoyaleClientState;
input: RoyaleInput;
authorityEvent: RoyaleAuthorityEvent;
perceptionEvent: RoyalePerception;
}

View File

@@ -0,0 +1,506 @@
import type { BinaryCodec } from "@syncer/engine";
import {
isWeaponId,
type DistanceBand,
type ImpactKind,
type LoadoutState,
type PickupKind,
type PlayerKind,
type PlayerState,
type PickupState,
type ScoreEntry,
type ShooterInput,
type ShooterPerception,
type ShooterWorldState,
type WeaponId,
} from "./shooter-types.js";
const INPUT_BYTES = 18;
const WORLD_HEADER_BYTES = 17;
const PLAYER_BYTES = 55;
const SCORE_BYTES = 9;
const PICKUP_BYTES = 15;
export const shooterInputCodec: BinaryCodec<ShooterInput> = {
encode(input) {
const payload = new Uint8Array(INPUT_BYTES);
const view = new DataView(payload.buffer);
view.setFloat32(0, input.strafe, true);
view.setFloat32(4, input.forward, true);
view.setFloat32(8, input.yaw, true);
view.setFloat32(12, input.pitch, true);
view.setUint8(
16,
(input.fire ? 1 : 0) | (input.sprint ? 2 : 0) | (input.reload ? 4 : 0),
);
view.setUint8(17, input.weapon);
return payload;
},
decode(payload) {
assertLength(payload, INPUT_BYTES, "Shooter input");
const view = viewOf(payload);
const weapon = view.getUint8(17);
if (!isWeaponId(weapon)) {
throw new RangeError("Unknown weapon in shooter input");
}
const flags = view.getUint8(16);
return {
strafe: view.getFloat32(0, true),
forward: view.getFloat32(4, true),
yaw: view.getFloat32(8, true),
pitch: view.getFloat32(12, true),
fire: Boolean(flags & 1),
sprint: Boolean(flags & 2),
reload: Boolean(flags & 4),
weapon,
};
},
};
export const shooterWorldCodec: BinaryCodec<ShooterWorldState> = {
encode(state) {
if (
state.players.size > 65_535 ||
state.scoreboard.length > 65_535 ||
state.pickups.size > 65_535
) {
throw new RangeError("Shooter world exceeds the binary protocol limit");
}
const length =
WORLD_HEADER_BYTES +
state.players.size * PLAYER_BYTES +
state.scoreboard.length * SCORE_BYTES +
state.pickups.size * PICKUP_BYTES;
const payload = new Uint8Array(length);
const view = new DataView(payload.buffer);
let offset = 0;
view.setUint8(offset, 1);
offset += 1;
view.setUint32(offset, state.match.elapsedTicks, true);
offset += 4;
view.setUint16(offset, state.match.scoreLimit, true);
offset += 2;
view.setUint32(offset, state.match.leaderId, true);
offset += 4;
view.setUint16(offset, state.players.size, true);
offset += 2;
view.setUint16(offset, state.scoreboard.length, true);
offset += 2;
view.setUint16(offset, state.pickups.size, true);
offset += 2;
const players = [...state.players.values()].sort((a, b) => a.id - b.id);
for (const player of players) {
offset = encodePlayer(view, offset, player);
}
for (const score of [...state.scoreboard].sort((a, b) => a.id - b.id)) {
offset = encodeScore(view, offset, score);
}
for (const pickup of [...state.pickups.values()].sort((a, b) => a.id - b.id)) {
view.setUint16(offset, pickup.id, true);
view.setUint8(offset + 2, pickupKindToByte(pickup.kind));
view.setUint8(offset + 3, pickup.weapon);
view.setFloat32(offset + 4, pickup.x, true);
view.setFloat32(offset + 8, pickup.z, true);
view.setUint8(offset + 12, pickup.active ? 1 : 0);
view.setUint16(offset + 13, clampUint16(pickup.respawnTicks), true);
offset += PICKUP_BYTES;
}
return payload;
},
decode(payload) {
if (payload.byteLength < WORLD_HEADER_BYTES) {
throw new RangeError("Shooter world is missing its header");
}
const view = viewOf(payload);
let offset = 0;
const version = view.getUint8(offset);
offset += 1;
if (version !== 1) {
throw new RangeError(`Unsupported shooter world version ${version}`);
}
const elapsedTicks = view.getUint32(offset, true);
offset += 4;
const scoreLimit = view.getUint16(offset, true);
offset += 2;
const leaderId = view.getUint32(offset, true);
offset += 4;
const playerCount = view.getUint16(offset, true);
offset += 2;
const scoreCount = view.getUint16(offset, true);
offset += 2;
const pickupCount = view.getUint16(offset, true);
offset += 2;
assertLength(
payload,
WORLD_HEADER_BYTES +
playerCount * PLAYER_BYTES +
scoreCount * SCORE_BYTES +
pickupCount * PICKUP_BYTES,
"Shooter world",
);
const players = new Map<number, PlayerState>();
for (let index = 0; index < playerCount; index += 1) {
const decoded = decodePlayer(view, offset);
players.set(decoded.player.id, decoded.player);
offset = decoded.offset;
}
const scoreboard: ScoreEntry[] = [];
for (let index = 0; index < scoreCount; index += 1) {
const id = view.getUint32(offset, true);
const kind = byteToPlayerKind(view.getUint8(offset + 4));
scoreboard.push({
id,
kind,
kills: view.getUint16(offset + 5, true),
deaths: view.getUint16(offset + 7, true),
});
offset += SCORE_BYTES;
}
const pickups = new Map<number, PickupState>();
for (let index = 0; index < pickupCount; index += 1) {
const id = view.getUint16(offset, true);
const weapon = view.getUint8(offset + 3);
if (!isWeaponId(weapon)) {
throw new RangeError("Unknown pickup weapon");
}
pickups.set(id, {
id,
kind: byteToPickupKind(view.getUint8(offset + 2)),
weapon,
x: view.getFloat32(offset + 4, true),
z: view.getFloat32(offset + 8, true),
active: Boolean(view.getUint8(offset + 12)),
respawnTicks: view.getUint16(offset + 13, true),
});
offset += PICKUP_BYTES;
}
return {
players,
pickups,
scoreboard,
match: { elapsedTicks, scoreLimit, leaderId },
events: [],
};
},
};
export const shooterEventCodec: BinaryCodec<ShooterPerception> = {
encode(event) {
const length = perceptionLength(event);
const payload = new Uint8Array(length);
const view = new DataView(payload.buffer);
view.setUint32(0, event.id, true);
view.setUint8(4, perceptionTypeToByte(event.type));
switch (event.type) {
case "shot":
view.setUint8(5, event.weapon);
view.setUint32(6, event.sourceId, true);
view.setFloat32(10, event.originX, true);
view.setFloat32(14, event.originY, true);
view.setFloat32(18, event.originZ, true);
view.setFloat32(22, event.endX, true);
view.setFloat32(26, event.endY, true);
view.setFloat32(30, event.endZ, true);
view.setUint8(34, impactToByte(event.impact));
break;
case "sound":
view.setUint8(5, event.sound === "gunshot" ? 0 : 1);
view.setUint8(6, event.weapon);
view.setFloat32(7, event.bearingRadians, true);
view.setFloat32(11, event.intensity, true);
view.setUint8(15, distanceBandToByte(event.distanceBand));
break;
case "damage":
view.setUint16(5, clampUint16(event.amount), true);
view.setUint16(7, clampUint16(event.health), true);
view.setFloat32(9, event.bearingRadians, true);
view.setUint8(13, event.critical ? 1 : 0);
break;
case "hit":
view.setUint16(5, clampUint16(event.amount), true);
view.setUint8(7, event.eliminated ? 1 : 0);
view.setUint8(8, event.critical ? 1 : 0);
break;
case "elimination":
view.setUint32(5, event.killerId, true);
view.setUint32(9, event.victimId, true);
view.setUint8(13, event.weapon);
view.setUint8(14, event.critical ? 1 : 0);
break;
case "pickup":
view.setUint8(5, pickupKindToByte(event.pickup));
view.setUint8(6, event.weapon);
view.setUint16(7, clampUint16(event.amount), true);
break;
case "respawn":
break;
}
return payload;
},
decode(payload) {
if (payload.byteLength < 5) {
throw new RangeError("Shooter event is missing its header");
}
const view = viewOf(payload);
const id = view.getUint32(0, true);
const type = byteToPerceptionType(view.getUint8(4));
switch (type) {
case "shot": {
assertLength(payload, 35, "Shot event");
const weapon = decodeWeapon(view.getUint8(5));
return {
id,
type,
weapon,
sourceId: view.getUint32(6, true),
originX: view.getFloat32(10, true),
originY: view.getFloat32(14, true),
originZ: view.getFloat32(18, true),
endX: view.getFloat32(22, true),
endY: view.getFloat32(26, true),
endZ: view.getFloat32(30, true),
impact: byteToImpact(view.getUint8(34)),
};
}
case "sound": {
assertLength(payload, 16, "Sound event");
return {
id,
type,
sound: view.getUint8(5) === 0 ? "gunshot" : "footstep",
weapon: decodeWeapon(view.getUint8(6)),
bearingRadians: view.getFloat32(7, true),
intensity: view.getFloat32(11, true),
distanceBand: byteToDistanceBand(view.getUint8(15)),
};
}
case "damage":
assertLength(payload, 14, "Damage event");
return {
id,
type,
amount: view.getUint16(5, true),
health: view.getUint16(7, true),
bearingRadians: view.getFloat32(9, true),
critical: Boolean(view.getUint8(13)),
};
case "hit":
assertLength(payload, 9, "Hit event");
return {
id,
type,
amount: view.getUint16(5, true),
eliminated: Boolean(view.getUint8(7)),
critical: Boolean(view.getUint8(8)),
};
case "elimination":
assertLength(payload, 15, "Elimination event");
return {
id,
type,
killerId: view.getUint32(5, true),
victimId: view.getUint32(9, true),
weapon: decodeWeapon(view.getUint8(13)),
critical: Boolean(view.getUint8(14)),
};
case "pickup":
assertLength(payload, 9, "Pickup event");
return {
id,
type,
pickup: byteToPickupKind(view.getUint8(5)),
weapon: decodeWeapon(view.getUint8(6)),
amount: view.getUint16(7, true),
};
case "respawn":
assertLength(payload, 5, "Respawn event");
return { id, type };
}
},
};
function encodePlayer(view: DataView, offset: number, player: PlayerState): number {
view.setUint32(offset, player.id, true);
view.setUint8(
offset + 4,
(player.kind === "bot" ? 1 : 0) | (player.alive ? 2 : 0),
);
view.setFloat32(offset + 5, player.x, true);
view.setFloat32(offset + 9, player.z, true);
view.setFloat32(offset + 13, player.velocityX, true);
view.setFloat32(offset + 17, player.velocityZ, true);
view.setFloat32(offset + 21, player.yaw, true);
view.setFloat32(offset + 25, player.pitch, true);
view.setUint8(offset + 29, clampByte(player.health));
view.setUint8(offset + 30, clampByte(player.armor));
view.setUint8(offset + 31, player.weapon);
let ammoOffset = offset + 32;
for (const ammo of player.ammo) {
view.setUint16(ammoOffset, clampUint16(ammo.magazine), true);
view.setUint16(ammoOffset + 2, clampUint16(ammo.reserve), true);
ammoOffset += 4;
}
view.setUint16(offset + 44, clampUint16(player.cooldownTicks), true);
view.setUint16(offset + 46, clampUint16(player.reloadTicks), true);
view.setUint16(offset + 48, clampUint16(player.respawnTicks), true);
view.setUint16(offset + 50, clampUint16(player.spawnProtectionTicks), true);
view.setInt8(offset + 52, Math.round(clamp(player.inputStrafe, -1, 1) * 127));
view.setInt8(offset + 53, Math.round(clamp(player.inputForward, -1, 1) * 127));
view.setUint8(offset + 54, (player.firing ? 1 : 0) | (player.sprinting ? 2 : 0));
return offset + PLAYER_BYTES;
}
function decodePlayer(view: DataView, offset: number): { player: PlayerState; offset: number } {
const flags = view.getUint8(offset + 4);
const weapon = decodeWeapon(view.getUint8(offset + 31));
const ammo = [] as unknown as LoadoutState;
let ammoOffset = offset + 32;
for (let index = 0; index < 3; index += 1) {
ammo[index] = {
magazine: view.getUint16(ammoOffset, true),
reserve: view.getUint16(ammoOffset + 2, true),
};
ammoOffset += 4;
}
const inputFlags = view.getUint8(offset + 54);
return {
player: {
id: view.getUint32(offset, true),
kind: flags & 1 ? "bot" : "human",
alive: Boolean(flags & 2),
x: view.getFloat32(offset + 5, true),
z: view.getFloat32(offset + 9, true),
velocityX: view.getFloat32(offset + 13, true),
velocityZ: view.getFloat32(offset + 17, true),
yaw: view.getFloat32(offset + 21, true),
pitch: view.getFloat32(offset + 25, true),
health: view.getUint8(offset + 29),
armor: view.getUint8(offset + 30),
weapon,
ammo,
cooldownTicks: view.getUint16(offset + 44, true),
reloadTicks: view.getUint16(offset + 46, true),
respawnTicks: view.getUint16(offset + 48, true),
spawnProtectionTicks: view.getUint16(offset + 50, true),
inputStrafe: view.getInt8(offset + 52) / 127,
inputForward: view.getInt8(offset + 53) / 127,
firing: Boolean(inputFlags & 1),
sprinting: Boolean(inputFlags & 2),
},
offset: offset + PLAYER_BYTES,
};
}
function encodeScore(view: DataView, offset: number, score: ScoreEntry): number {
view.setUint32(offset, score.id, true);
view.setUint8(offset + 4, score.kind === "bot" ? 1 : 0);
view.setUint16(offset + 5, clampUint16(score.kills), true);
view.setUint16(offset + 7, clampUint16(score.deaths), true);
return offset + SCORE_BYTES;
}
function perceptionLength(event: ShooterPerception): number {
switch (event.type) {
case "shot": return 35;
case "sound": return 16;
case "damage": return 14;
case "hit": return 9;
case "elimination": return 15;
case "pickup": return 9;
case "respawn": return 5;
}
}
function perceptionTypeToByte(type: ShooterPerception["type"]): number {
return type === "shot" ? 0 : type === "sound" ? 1 : type === "damage" ? 2 : type === "elimination" ? 3 : type === "pickup" ? 4 : type === "respawn" ? 5 : 6;
}
function byteToPerceptionType(value: number): ShooterPerception["type"] {
if (value === 0) return "shot";
if (value === 1) return "sound";
if (value === 2) return "damage";
if (value === 3) return "elimination";
if (value === 4) return "pickup";
if (value === 5) return "respawn";
if (value === 6) return "hit";
throw new RangeError("Unknown shooter perception type");
}
function pickupKindToByte(kind: PickupKind): number {
return kind === "health" ? 0 : kind === "ammo" ? 1 : 2;
}
function byteToPickupKind(value: number): PickupKind {
if (value === 0) return "health";
if (value === 1) return "ammo";
if (value === 2) return "weapon";
throw new RangeError("Unknown pickup kind");
}
function distanceBandToByte(band: DistanceBand): number {
return band === "near" ? 0 : band === "medium" ? 1 : 2;
}
function byteToDistanceBand(value: number): DistanceBand {
if (value === 0) return "near";
if (value === 1) return "medium";
if (value === 2) return "far";
throw new RangeError("Unknown distance band");
}
function impactToByte(impact: ImpactKind): number {
return impact === "wall" ? 0 : impact === "player" ? 1 : 2;
}
function byteToImpact(value: number): ImpactKind {
if (value === 0) return "wall";
if (value === 1) return "player";
if (value === 2) return "miss";
throw new RangeError("Unknown impact kind");
}
function byteToPlayerKind(value: number): PlayerKind {
if (value === 0) return "human";
if (value === 1) return "bot";
throw new RangeError("Unknown player kind");
}
function decodeWeapon(value: number): WeaponId {
if (!isWeaponId(value)) {
throw new RangeError("Unknown weapon");
}
return value;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}
function clampByte(value: number): number {
return Math.round(clamp(value, 0, 255));
}
function clampUint16(value: number): number {
return Math.round(clamp(value, 0, 65_535));
}
function viewOf(payload: Uint8Array): DataView {
return new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
}
function assertLength(payload: Uint8Array, expected: number, name: string): void {
if (payload.byteLength !== expected) {
throw new RangeError(`${name} must be exactly ${expected} bytes`);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
export const Weapon = {
PulseRifle: 0,
Scattergun: 1,
RailRifle: 2,
} as const;
export type WeaponId = (typeof Weapon)[keyof typeof Weapon];
export type PlayerKind = "human" | "bot";
export type PickupKind = "health" | "ammo" | "weapon";
export type DistanceBand = "near" | "medium" | "far";
export interface WeaponDefinition {
id: WeaponId;
name: string;
shortName: string;
damage: number;
pellets: number;
spread: number;
range: number;
cooldownTicks: number;
reloadTicks: number;
magazineSize: number;
startingReserve: number;
automatic: boolean;
accent: string;
}
export const WEAPONS: Readonly<Record<WeaponId, WeaponDefinition>> = {
[Weapon.PulseRifle]: { id: Weapon.PulseRifle, name: "Nova-9 Pulse Rifle", shortName: "NOVA-9", damage: 18, pellets: 1, spread: 0.012, range: 42, cooldownTicks: 6, reloadTicks: 92, magazineSize: 30, startingReserve: 120, automatic: true, accent: "#5de4c7" },
[Weapon.Scattergun]: { id: Weapon.Scattergun, name: "Arc-12 Scattergun", shortName: "ARC-12", damage: 10, pellets: 8, spread: 0.105, range: 16, cooldownTicks: 48, reloadTicks: 112, magazineSize: 8, startingReserve: 32, automatic: false, accent: "#ffad5c" },
[Weapon.RailRifle]: { id: Weapon.RailRifle, name: "Vantage Rail Rifle", shortName: "VANTAGE", damage: 72, pellets: 1, spread: 0.002, range: 58, cooldownTicks: 76, reloadTicks: 138, magazineSize: 4, startingReserve: 16, automatic: false, accent: "#a98cff" },
};
export interface ShooterInput {
strafe: number;
forward: number;
yaw: number;
pitch: number;
fire: boolean;
sprint: boolean;
reload: boolean;
weapon: WeaponId;
}
export interface AmmoState { magazine: number; reserve: number }
export type LoadoutState = [AmmoState, AmmoState, AmmoState];
export interface PlayerState {
id: number;
kind: PlayerKind;
alive: boolean;
x: number;
z: number;
velocityX: number;
velocityZ: number;
yaw: number;
pitch: number;
health: number;
armor: number;
weapon: WeaponId;
ammo: LoadoutState;
cooldownTicks: number;
reloadTicks: number;
respawnTicks: number;
spawnProtectionTicks: number;
inputStrafe: number;
inputForward: number;
firing: boolean;
sprinting: boolean;
}
export interface ScoreEntry { id: number; kind: PlayerKind; kills: number; deaths: number }
export interface PickupState {
id: number;
kind: PickupKind;
weapon: WeaponId;
x: number;
z: number;
active: boolean;
respawnTicks: number;
}
export interface MatchState { elapsedTicks: number; scoreLimit: number; leaderId: number }
export type SoundKind = "gunshot" | "footstep";
export type ImpactKind = "wall" | "player" | "miss";
export type ShooterPerception =
| { id: number; type: "shot"; weapon: WeaponId; sourceId: number; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind }
| { id: number; type: "sound"; sound: SoundKind; weapon: WeaponId; bearingRadians: number; intensity: number; distanceBand: DistanceBand }
| { id: number; type: "damage"; amount: number; health: number; bearingRadians: number; critical: boolean }
| { id: number; type: "hit"; amount: number; eliminated: boolean; critical: boolean }
| { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean }
| { id: number; type: "pickup"; pickup: PickupKind; weapon: WeaponId; amount: number }
| { id: number; type: "respawn" };
export interface TimedPerception { receivedTick: number; event: ShooterPerception }
export interface ShooterWorldState {
players: Map<number, PlayerState>;
pickups: Map<number, PickupState>;
scoreboard: ScoreEntry[];
match: MatchState;
events: TimedPerception[];
}
export interface BotMind { targetId: number | null; patrolIndex: number; nextThinkTick: number; strafeSign: number }
export interface AuthoritativePlayer extends PlayerState {
kills: number;
deaths: number;
lastFirePressed: boolean;
lastReloadPressed: boolean;
lastFootstepTick: number;
bot: BotMind | null;
}
export interface AuthoritativeShooterState {
players: Map<number, AuthoritativePlayer>;
pickups: Map<number, PickupState>;
elapsedTicks: number;
nextEventId: number;
}
export type ShooterAuthorityEvent =
| { id: number; type: "shot"; sourceId: number; weapon: WeaponId; originX: number; originY: number; originZ: number; endX: number; endY: number; endZ: number; impact: ImpactKind }
| { id: number; type: "damage"; sourceId: number; targetId: number; amount: number; health: number; critical: boolean }
| { id: number; type: "elimination"; killerId: number; victimId: number; weapon: WeaponId; critical: boolean }
| { id: number; type: "pickup"; playerId: number; pickup: PickupKind; weapon: WeaponId; amount: number }
| { id: number; type: "respawn"; playerId: number }
| { id: number; type: "footstep"; sourceId: number };
export function isWeaponId(value: number): value is WeaponId {
return value === 0 || value === 1 || value === 2;
}
export function playerDisplayName(id: number, kind: PlayerKind): string {
if (kind === "human") return `RANGER ${id}`;
const names = ["CIPHER", "ROOK", "MARA", "KESTREL", "VEX", "HALO"];
return names[Math.abs(id) % names.length] ?? `BOT ${id}`;
}

View File

@@ -0,0 +1,111 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
FLUX_BOT_IDS,
FLUX_SNAPSHOT_RATE,
FLUX_TICK_RATE,
fluxGame,
} from "../dist/index.js";
function createThrustPacket(sequence, targetTick, thrust = true) {
return {
sequence,
targetTick,
observedTick: Math.max(0, targetTick - 1),
input: { thrust },
};
}
test("Flux Relay is a complete game defined through the public authoring API", () => {
assert.equal(fluxGame.tickRateHz, FLUX_TICK_RATE);
assert.equal(fluxGame.snapshotRateHz, FLUX_SNAPSHOT_RATE);
const server = fluxGame.createServer();
server.addPlayer(1);
assert.deepEqual(server.submitInput(1, createThrustPacket(1, 1)), {
accepted: true,
});
const events = [];
for (let tick = 0; tick < 180; tick += 1) {
events.push(...server.step().events);
}
assert.ok(server.currentState.cyanScore >= 2);
assert.equal(server.currentState.orangeScore, 0);
assert.ok(
events.some((event) => event.type === "round-won" && event.team === "cyan"),
);
assert.ok(server.currentState.players.some((player) => player.id === FLUX_BOT_IDS[0]));
});
test("Flux snapshots disclose exact energy only to its owner", () => {
const server = fluxGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
const playerOneView = server.createSnapshot(1, 0).state;
const playerTwoView = server.createSnapshot(2, 0).state;
assert.equal(playerOneView.players.find((player) => player.id === 1)?.energy, 100);
assert.equal(playerOneView.players.find((player) => player.id === 2)?.energy, null);
assert.equal(playerTwoView.players.find((player) => player.id === 1)?.energy, null);
assert.equal(playerTwoView.players.find((player) => player.id === 2)?.energy, 100);
assert.ok(
playerOneView.players
.filter((player) => player.bot)
.every((player) => player.energy === null),
);
assert.equal("nextEventId" in playerOneView, false);
});
test("the same private Flux event becomes a self cue or anonymous field cue", () => {
const server = fluxGame.createServer();
server.addPlayer(1);
server.addPlayer(2);
const player = server.currentState.players.find((candidate) => candidate.id === 1);
player.energy = 0.2;
server.submitInput(1, createThrustPacket(1, 1));
const result = server.step();
const overheat = result.events.find((event) => event.type === "overheated");
assert.ok(overheat);
const ownCue = server.createPerceptions(1, [overheat])[0];
const fieldCue = server.createPerceptions(2, [overheat])[0];
assert.equal(ownCue.scope, "self");
assert.equal(fieldCue.scope, "field");
assert.equal("playerId" in ownCue, false);
assert.equal("playerId" in fieldCue, false);
});
test("Flux uses the generic protocol, prediction, acknowledgement, and reconciliation", () => {
const server = fluxGame.createServer();
server.addPlayer(7);
const client = fluxGame.createClient();
client.initialize(7, server.createSnapshot(7, 10));
const packet = client.createInput({ thrust: true }, 1);
const wirePacket = fluxGame.protocol.decodeClient(
fluxGame.protocol.encodeClient({ kind: "input", packet }),
);
assert.equal(wirePacket.kind, "input");
assert.deepEqual(wirePacket.packet, packet);
assert.equal(server.submitInput(7, packet).accepted, true);
client.step();
const result = server.step();
client.acknowledge(result.acknowledgements[0].sequence);
const wireSnapshot = fluxGame.protocol.decodeServer(
fluxGame.protocol.encodeServer({
kind: "snapshot",
snapshot: server.createSnapshot(7, 20),
}),
);
assert.equal(wireSnapshot.kind, "snapshot");
client.reconcile(wireSnapshot.snapshot);
const local = client.currentState.players.find((player) => player.id === 7);
assert.equal(local.thrust, true);
assert.ok(local.energy < 100);
});

View File

@@ -0,0 +1,219 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
ROYALE_BOT_COUNT,
ROYALE_BOT_ID_BASE,
ROYALE_CHUNK_SIZE,
ROYALE_MAP_SEED,
collidesWithRoyaleMap,
generateRoyaleChunk,
hasRoyaleLineOfSight,
royaleGame,
} from "../dist/index.js";
test("the public world seed deterministically generates a 2 km chunked island", () => {
const first = generateRoyaleChunk(-3, 4);
const second = generateRoyaleChunk(-3, 4);
const neighbor = generateRoyaleChunk(-2, 4);
assert.deepEqual(first, second);
assert.notDeepEqual(first, neighbor);
assert.equal(ROYAALE_MAP_SEED_FOR_TEST(), ROYALE_MAP_SEED);
assert.equal(ROYAALE_CHUNK_SIZE_FOR_TEST(), ROYALE_CHUNK_SIZE);
assert.ok(first.obstacles.every((obstacle) => obstacle.id.startsWith("-3:4:")));
});
test("Syncer Royale simulates a complete authority battle headlessly", () => {
const server = royaleGame.createServer();
const totals = { shots: 0, damage: 0, eliminations: 0, winners: 0 };
for (let tick = 0; tick < 6_000; tick += 1) {
for (const event of server.step().events) {
if (event.type === "shot") totals.shots += 1;
if (event.type === "damage") totals.damage += 1;
if (event.type === "elimination") totals.eliminations += 1;
if (event.type === "winner") totals.winners += 1;
}
}
assert.equal(server.currentState.players.length, ROYALE_BOT_COUNT);
assert.ok(totals.shots > 150, `expected firefights, saw ${totals.shots}`);
assert.ok(totals.damage > 50, `expected damage, saw ${totals.damage}`);
assert.ok(totals.eliminations > 15, `expected eliminations, saw ${totals.eliminations}`);
assert.ok(server.currentState.players.every((player) => Number.isFinite(player.x + player.z)));
});
test("Royale projections hide authority secrets and distant combatants", () => {
const server = royaleGame.createServer();
server.addPlayer(1);
const authority = server.currentState;
const viewer = authority.players.find((player) => player.id === 1);
const hidden = authority.players.find((player) => player.id === ROYALE_BOT_ID_BASE);
viewer.altitude = 0;
viewer.x = 0;
viewer.z = 0;
hidden.x = 430;
hidden.z = 0;
hidden.alive = true;
const snapshot = server.createSnapshot(1, 0).state;
assert.equal(snapshot.mapSeed, ROYALE_MAP_SEED);
assert.equal("secretSeed" in snapshot, false);
assert.ok(snapshot.players.some((player) => player.id === 1));
assert.ok(!snapshot.players.some((player) => player.id === hidden.id));
assert.ok(
snapshot.players
.filter((player) => player.id !== 1)
.every((player) => player.health === null && player.armor === null),
);
const [sound] = server.createPerceptions(1, [
{ id: 99, type: "shot", sourceId: hidden.id, x: hidden.x, y: 1.55, z: hidden.z, yaw: 0, pitch: 0 },
]);
assert.equal(sound.type, "gunfire");
assert.equal("sourceId" in sound, false);
assert.equal("x" in sound, false);
const [ownShot] = server.createPerceptions(1, [
{
id: 100,
type: "shot",
sourceId: viewer.id,
x: viewer.x,
y: 1.55,
z: viewer.z,
yaw: viewer.yaw,
pitch: viewer.pitch,
},
]);
assert.equal(ownShot.type, "shot");
assert.equal(ownShot.sourceId, viewer.id);
});
test("Royale enforces a viewer bandwidth budget after ordinary visibility", () => {
const server = royaleGame.createServer();
server.addPlayer(1);
const authority = server.currentState;
const viewer = authority.players.find((player) => player.id === 1);
viewer.altitude = 0;
viewer.x = 0;
viewer.z = 0;
for (const bot of authority.players.filter((player) => player.bot)) {
bot.x = 800;
bot.z = 0;
}
for (let index = 0; index < 100; index += 1) {
const item = authority.loot[index];
item.active = true;
item.x = (index % 10) * 2 - 9;
item.z = Math.floor(index / 10) * 2 - 9;
}
const snapshot = server.createSnapshot(1, 0).state;
assert.ok(snapshot.stream.droppedCount > 0);
assert.ok(snapshot.stream.usedBytes <= snapshot.stream.budgetBytes);
assert.ok(snapshot.loot.length < 100);
assert.equal(snapshot.players.filter((player) => player.id === 1).length, 1);
});
test("Royale input and projected state round-trip through the generic protocol", () => {
const server = royaleGame.createServer();
server.addPlayer(7);
const client = royaleGame.createClient();
const welcome = server.createSnapshot(7, 10);
client.initialize(7, welcome);
const packet = client.createInput({
forward: 1,
strafe: 0.25,
yaw: 0.7,
pitch: -0.2,
fire: false,
sprint: true,
reload: false,
}, 1);
const decoded = royaleGame.protocol.decodeClient(
royaleGame.protocol.encodeClient({ kind: "input", packet }),
);
assert.equal(decoded.kind, "input");
assert.deepEqual(decoded.packet, packet);
assert.equal(server.submitInput(7, decoded.packet).accepted, true);
server.step();
assert.equal(server.currentState.players.find((player) => player.id === 7).pitch, -0.2);
client.step();
client.reconcile(server.createSnapshot(7, 20));
assert.ok(client.currentState.players.some((player) => player.id === 7));
});
test("Royale pitch drives the authoritative three-dimensional hit ray", () => {
const server = royaleGame.createServer();
server.addPlayer(7);
server.addPlayer(8);
const authority = server.currentState;
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
const shooter = authority.players.find((player) => player.id === 7);
const target = authority.players.find((player) => player.id === 8);
let origin = null;
for (let x = -100; x <= 100 && !origin; x += 10) {
for (let z = -100; z <= 60; z += 10) {
if (
!collidesWithRoyaleMap(x, z) &&
!collidesWithRoyaleMap(x, z + 40) &&
hasRoyaleLineOfSight(x, z, x, z + 40)
) {
origin = { x, z };
break;
}
}
}
assert.ok(origin, "expected an unobstructed public-map firing lane");
Object.assign(shooter, { x: origin.x, z: origin.z, altitude: 0, yaw: 0 });
Object.assign(target, { x: origin.x, z: origin.z + 40, altitude: 20, health: 100, armor: 0 });
const pitch = Math.atan2(20, 40);
assert.equal(server.submitInput(7, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: {
forward: 0,
strafe: 0,
yaw: 0,
pitch,
fire: true,
sprint: false,
reload: false,
},
}).accepted, true);
const result = server.step();
assert.ok(result.events.some((event) => event.type === "damage" && event.targetId === 8));
assert.ok(target.health < 100);
});
test("Royale strafe-right matches the first-person camera's screen right", () => {
const server = royaleGame.createServer();
server.addPlayer(7);
const authority = server.currentState;
for (const bot of authority.players.filter((player) => player.bot)) bot.alive = false;
const player = authority.players.find((candidate) => candidate.id === 7);
Object.assign(player, { x: 0, z: 0, altitude: 20, yaw: 0 });
assert.equal(server.submitInput(7, {
sequence: 1,
targetTick: 1,
observedTick: 0,
input: {
forward: 0,
strafe: 1,
yaw: 0,
pitch: 0,
fire: false,
sprint: false,
reload: false,
},
}).accepted, true);
server.step();
assert.ok(player.velocityX < 0, "screen-right is world -X while facing world +Z");
});
function ROYAALE_MAP_SEED_FOR_TEST() {
return ROYALE_MAP_SEED;
}
function ROYAALE_CHUNK_SIZE_FOR_TEST() {
return ROYALE_CHUNK_SIZE;
}

View File

@@ -0,0 +1,282 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
BOT_COUNT,
BOT_ID_BASE,
Weapon,
shooterGame,
} from "../dist/index.js";
function runHeadlessMatch(ticks) {
const server = shooterGame.createServer();
const totals = {
shots: 0,
damageEvents: 0,
eliminations: 0,
respawns: 0,
};
for (let tick = 0; tick < ticks; tick += 1) {
const result = server.step();
for (const event of result.events) {
if (event.type === "shot") totals.shots += 1;
if (event.type === "damage") totals.damageEvents += 1;
if (event.type === "elimination") totals.eliminations += 1;
if (event.type === "respawn") totals.respawns += 1;
}
}
return {
...totals,
tick: server.tick,
scores: [...server.currentState.players.values()]
.map((player) => ({
id: player.id,
kills: player.kills,
deaths: player.deaths,
alive: player.alive,
x: Number(player.x.toFixed(5)),
z: Number(player.z.toFixed(5)),
}))
.sort((a, b) => a.id - b.id),
};
}
test("the complete shooter match simulates headlessly on the backend", () => {
const match = runHeadlessMatch(7_200);
assert.equal(match.tick, 7_200);
assert.equal(match.scores.length, BOT_COUNT);
assert.ok(match.shots > 700, `expected active combat, saw ${match.shots} shots`);
assert.ok(match.damageEvents > 50, `expected hits, saw ${match.damageEvents}`);
assert.ok(match.eliminations >= 10, `expected kills, saw ${match.eliminations}`);
assert.ok(match.respawns >= 8, `expected respawns, saw ${match.respawns}`);
assert.equal(
match.scores.reduce((sum, score) => sum + score.kills, 0),
match.eliminations,
);
assert.ok(match.scores.every((score) => Number.isFinite(score.x + score.z)));
});
test("backend simulation is deterministic", () => {
assert.deepEqual(runHeadlessMatch(2_400), runHeadlessMatch(2_400));
});
test("human hitscan damages the historical target without rewinding live positions", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
const shooter = server.currentState.players.get(1);
const target = server.currentState.players.get(BOT_ID_BASE);
for (const bot of server.currentState.players.values()) {
if (bot.id === BOT_ID_BASE || bot.id === 1) continue;
bot.alive = false;
bot.respawnTicks = 10_000;
}
shooter.x = -4;
shooter.z = -15;
shooter.yaw = Math.PI / 2;
shooter.pitch = 0;
shooter.spawnProtectionTicks = 0;
target.x = 0;
target.z = -15;
target.velocityX = 0;
target.velocityZ = 0;
target.inputForward = 0;
target.inputStrafe = 0;
target.spawnProtectionTicks = 0;
target.bot = null;
server.step();
target.z = -12.5;
const healthBefore = target.health;
server.submitInput(1, {
sequence: 1,
targetTick: 2,
observedTick: 1,
input: {
strafe: 0,
forward: 0,
yaw: Math.PI / 2,
pitch: 0,
fire: true,
sprint: false,
reload: false,
weapon: Weapon.PulseRifle,
},
});
const result = server.step();
assert.equal(target.z, -12.5, "the live target must not move backward");
assert.ok(target.health < healthBefore, "the rewound hitbox should take damage");
assert.ok(
result.events.some(
(event) => event.type === "damage" && event.targetId === BOT_ID_BASE,
),
);
const shot = result.events.find(
(event) => event.type === "shot" && event.sourceId === 1,
);
assert.ok(shot);
assert.ok(Math.abs(shot.endZ + 15) < 0.01);
});
test("shooter recordings seek through checkpoints with privacy intact", () => {
const server = shooterGame.createServer({ replaySeed: 99 });
server.addPlayer(1);
for (let tick = 0; tick < 420; tick += 1) server.step();
const recording = server.exportRecording();
assert.equal(recording.durationTicks, 420);
assert.deepEqual(
recording.checkpoints.map(({ tick }) => tick),
[0, 120, 240, 360],
);
const replay = shooterGame.createReplay(recording);
replay.seek(135);
const finalFrame = replay.seek(420);
assert.deepEqual(finalFrame.state, server.currentState);
const playerView = replay.viewAs(1).state;
assert.ok(playerView.players.has(1));
assert.equal("nextEventId" in playerView, false);
assert.ok(
[...playerView.players.values()].every((player) => !("bot" in player)),
);
});
test("a human elimination creates a bounded attacker-perspective killcam", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
let ticket = null;
for (let tick = 0; tick < 600 && !ticket; tick += 1) {
server.step();
ticket = server.drainReplayTickets(1)[0] ?? null;
}
assert.ok(ticket, "expected the standing human to receive a bot killcam");
assert.equal(ticket.requesterId, 1);
assert.ok(ticket.perspectiveId >= BOT_ID_BASE);
assert.equal(ticket.toTick, ticket.issuedAtTick);
assert.ok(ticket.frames.length > 60);
assert.ok(ticket.frames.length < 300);
assert.ok(
ticket.frames.every(({ state }) =>
[...state.players.values()].every(
(player) => !("bot" in player) && !("kills" in player),
),
),
);
});
test("snapshots and perception events cross the binary protocol", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
const snapshot = server.createSnapshot(1, 123.5);
const snapshotFrame = shooterGame.protocol.encodeServer({
kind: "snapshot",
snapshot,
});
const decodedSnapshot = shooterGame.protocol.decodeServer(snapshotFrame);
assert.equal(decodedSnapshot.kind, "snapshot");
assert.equal(decodedSnapshot.snapshot.state.players.get(1).ammo.length, 3);
const event = {
id: 99,
type: "shot",
weapon: Weapon.RailRifle,
sourceId: 1,
originX: 1,
originY: 1.58,
originZ: 2,
endX: 11,
endY: 1.4,
endZ: -4,
impact: "wall",
};
const eventFrame = shooterGame.protocol.encodeServer({
kind: "event",
tick: 50,
event,
});
const decodedEvent = shooterGame.protocol.decodeServer(eventFrame);
assert.equal(decodedEvent.kind, "event");
assert.equal(decodedEvent.tick, 50);
assert.deepEqual(
{
...decodedEvent.event,
originY: Number(decodedEvent.event.originY.toFixed(2)),
endY: Number(decodedEvent.event.endY.toFixed(2)),
},
event,
);
});
test("occluded combatants stay private while their gunfire becomes anonymous audio", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
const authority = server.currentState;
const viewer = authority.players.get(1);
const hiddenBot = authority.players.get(BOT_ID_BASE);
viewer.x = 0;
viewer.z = -6;
viewer.yaw = 0;
hiddenBot.x = 0;
hiddenBot.z = 6;
const snapshot = server.createSnapshot(1, 0);
assert.equal(snapshot.state.players.has(BOT_ID_BASE), false);
const perceptions = server.createPerceptions(1, [
{
id: 777,
type: "shot",
sourceId: BOT_ID_BASE,
weapon: Weapon.PulseRifle,
originX: hiddenBot.x,
originY: 1.58,
originZ: hiddenBot.z,
endX: hiddenBot.x,
endY: 1.58,
endZ: hiddenBot.z - 10,
impact: "wall",
},
]);
assert.equal(perceptions.length, 1);
assert.equal(perceptions[0].type, "sound");
assert.equal("sourceId" in perceptions[0], false);
assert.equal("originX" in perceptions[0], false);
});
test("visible opponents expose exact health but redact armor and inventory", () => {
const server = shooterGame.createServer();
server.addPlayer(1);
const viewer = server.currentState.players.get(1);
const opponent = server.currentState.players.get(BOT_ID_BASE);
viewer.x = -4;
viewer.z = -15;
viewer.yaw = Math.PI / 2;
opponent.x = 0;
opponent.z = -15;
opponent.health = 37;
opponent.armor = 19;
opponent.ammo[Weapon.PulseRifle].magazine = 3;
const replicated = server.createSnapshot(1, 0).state.players.get(BOT_ID_BASE);
assert.ok(replicated, "opponent should be visible in the clear corridor");
assert.equal(replicated.health, 37);
assert.equal(replicated.armor, 0);
assert.ok(
replicated.ammo.every(
(ammo) => ammo.magazine === 0 && ammo.reserve === 0,
),
);
assert.equal(replicated.x, opponent.x);
assert.equal(replicated.weapon, opponent.weapon);
viewer.yaw = -Math.PI / 2;
assert.equal(
server.createSnapshot(1, 0).state.players.has(BOT_ID_BASE),
false,
);
});

View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src"]
}

11
tsconfig.base.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
}
}