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 =
| { kind: "input"; packet: InputPacket }
| { kind: "ping"; ping: PingPacket }
| { kind: "state-report"; report: ClientStateReport };
export type ServerWireMessage =
| { kind: "welcome"; playerId: PlayerId; snapshot: StateSnapshot }
| { kind: "snapshot"; snapshot: StateSnapshot }
| { 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 {
encodeClient(message: ClientWireMessage): ArrayBuffer;
decodeClient(payload: ArrayBuffer): ClientWireMessage;
encodeServer(message: ServerWireMessage): ArrayBuffer;
decodeServer(payload: ArrayBuffer): ServerWireMessage;
}
export function createBinaryProtocol(codecs: {
input: BinaryCodec;
state: BinaryCodec;
event?: BinaryCodec;
}): BinaryProtocol {
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");
}
}