Initial public release of PSX Adventure Engine

Browser reference stack for PSX-era third-person adventure: fixed cameras,
inventory puzzles, Box3D physics, host-authoritative P2P co-op, and a modular
character harness. Ships the Ashgrove Precinct Level 1 investigation demo with
a full cast and nine linked rooms.
This commit is contained in:
ryanfitzpatrickio
2026-07-31 06:32:43 -05:00
commit 8a96ede9f2
181 changed files with 25807 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@psx/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./skeleton": "./src/skeleton/canonical.ts",
"./types": "./src/types/index.ts",
"./schemas": "./src/schemas/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"typescript": "^5.9.3",
"vitest": "^4.1.10"
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./types/index.js";
export * from "./schemas/index.js";
export * from "./skeleton/canonical.js";
+28
View File
@@ -0,0 +1,28 @@
/**
* Minimal validation primitives.
*
* `shared` stays dependency-free so the browser bundle and the Hono server can
* both import it without agreeing on a validation library — worth the hand
* written predicates, since this code sits on two trust boundaries (cloud saves
* and peer messages).
*/
export type ValidationResult<T> = { ok: true; value: T } | { ok: false; errors: string[] };
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
export const isVec3 = (value: unknown): boolean =>
isRecord(value) &&
Number.isFinite(value["x"]) &&
Number.isFinite(value["y"]) &&
Number.isFinite(value["z"]);
export const isStringArray = (value: unknown): value is string[] =>
Array.isArray(value) && value.every((entry) => typeof entry === "string");
/** Rejects NaN and Infinity, which JSON.parse will happily produce from input. */
export const isFiniteNumber = (value: unknown): value is number => Number.isFinite(value);
export const ok = <T>(value: T): ValidationResult<T> => ({ ok: true, value });
export const fail = <T>(...errors: string[]): ValidationResult<T> => ({ ok: false, errors });
+3
View File
@@ -0,0 +1,3 @@
export * from "./common.js";
export * from "./save.js";
export * from "./net.js";
+179
View File
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import { PROTOCOL_VERSION } from "../types/net.js";
import { validateClientMessage, validateHostMessage, validateSignalMessage } from "./net.js";
/**
* These validators sit on the boundary between the host and an untrusted peer.
* They are expected to *sanitise*, not merely accept or reject, so the tests
* assert on the values that come out rather than only on the ok/fail verdict.
*/
describe("validateClientMessage", () => {
it("accepts well-formed input", () => {
const result = validateClientMessage({
t: "input",
tick: 12,
move: { x: 0.5, z: -1 },
run: true,
yaw: 1.57,
});
expect(result.ok).toBe(true);
expect(result.ok && result.value).toMatchObject({
t: "input",
tick: 12,
move: { x: 0.5, z: -1 },
run: true,
});
});
it("clamps an out-of-range movement vector instead of rejecting it", () => {
const result = validateClientMessage({
t: "input",
tick: 1,
move: { x: 1e9, z: -1e9 },
run: true,
yaw: 0,
});
expect(result.ok).toBe(true);
expect(result.ok && result.value).toMatchObject({ move: { x: 1, z: -1 } });
});
it("rejects NaN and Infinity, which survive JSON round trips", () => {
expect(
validateClientMessage({
t: "input",
tick: 1,
move: { x: Number.NaN, z: 0 },
run: false,
yaw: 0,
}).ok,
).toBe(false);
expect(
validateClientMessage({
t: "input",
tick: 1,
move: { x: 0, z: 0 },
run: false,
yaw: Number.POSITIVE_INFINITY,
}).ok,
).toBe(false);
});
it("coerces a non-boolean run flag rather than trusting it", () => {
const result = validateClientMessage({
t: "input",
tick: 1,
move: { x: 0, z: 0 },
run: "yes",
yaw: 0,
});
expect(result.ok && result.value).toMatchObject({ run: false });
});
it("floors a fractional tick", () => {
const result = validateClientMessage({
t: "input",
tick: 7.9,
move: { x: 0, z: 0 },
run: false,
yaw: 0,
});
expect(result.ok && result.value).toMatchObject({ tick: 7 });
});
it("caps chat length so one frame cannot exhaust host memory", () => {
const result = validateClientMessage({ t: "chat", text: "x".repeat(10_000) });
expect(result.ok).toBe(true);
expect(result.ok && (result.value as { text: string }).text.length).toBe(280);
});
it("rejects an over-long interactable id", () => {
expect(validateClientMessage({ t: "interact", interactableId: "x".repeat(500) }).ok).toBe(false);
});
it("rejects unknown message types", () => {
expect(validateClientMessage({ t: "shutdown" }).ok).toBe(false);
expect(validateClientMessage(null).ok).toBe(false);
expect(validateClientMessage([]).ok).toBe(false);
});
});
describe("validateSignalMessage", () => {
it("overwrites a claimed sender id", () => {
const result = validateSignalMessage({
t: "signal",
from: 99,
to: 1,
payload: { sdp: { type: "offer", sdp: "v=0" } },
});
// The relay stamps the true sender; a claimed `from` must not survive.
expect(result.ok && result.value).toMatchObject({ from: -1, to: 1 });
});
it("rejects a protocol version mismatch", () => {
const result = validateSignalMessage({
t: "hello",
token: "abc",
lobbyCode: "ABCDE",
protocol: PROTOCOL_VERSION + 1,
});
expect(result.ok).toBe(false);
expect(!result.ok && result.errors[0]).toContain("protocol mismatch");
});
it("accepts a valid hello", () => {
const result = validateSignalMessage({
t: "hello",
token: "abc",
lobbyCode: "ABCDE",
protocol: PROTOCOL_VERSION,
});
expect(result.ok).toBe(true);
});
});
describe("validateHostMessage", () => {
it("rejects a snapshot carrying more players than the lobby allows", () => {
const players = Array.from({ length: 9 }, (_, i) => ({
peerId: i,
p: { x: 0, y: 0, z: 0 },
yaw: 0,
spd: 0,
grounded: true,
}));
expect(validateHostMessage({ t: "snapshot", tick: 1, ack: {}, players }).ok).toBe(false);
});
it("rejects a snapshot with a non-finite position", () => {
const result = validateHostMessage({
t: "snapshot",
tick: 1,
ack: {},
players: [
{ peerId: 1, p: { x: Number.NaN, y: 0, z: 0 }, yaw: 0, spd: 0, grounded: true },
],
});
expect(result.ok).toBe(false);
});
it("accepts a well-formed snapshot", () => {
const result = validateHostMessage({
t: "snapshot",
tick: 4,
ack: { 1: 3 },
players: [{ peerId: 1, p: { x: 1, y: 0, z: 2 }, yaw: 0.4, spd: 1.2, grounded: true }],
});
expect(result.ok).toBe(true);
});
it("rejects malformed flag payloads", () => {
expect(validateHostMessage({ t: "flags", set: "everything", unset: [] }).ok).toBe(false);
expect(validateHostMessage({ t: "flags", set: [1, 2], unset: [] }).ok).toBe(false);
});
});
+178
View File
@@ -0,0 +1,178 @@
import type { ClientMessage, HostMessage, SignalMessage, SignalPayload } from "../types/net.js";
import { MAX_PLAYERS, PROTOCOL_VERSION } from "../types/net.js";
import { fail, isFiniteNumber, isRecord, isStringArray, ok, type ValidationResult } from "./common.js";
/**
* Peer message validation.
*
* Guests are untrusted. Every `ClientMessage` the host acts on passes through
* here first, and the validator *sanitises* rather than merely accepting:
* movement is clamped to the unit square so a peer cannot claim a 1e9 input
* vector and teleport, and identifiers are length-capped so a peer cannot
* exhaust host memory with a single frame.
*/
/** Nothing legitimate approaches these; they exist to bound hostile input. */
const MAX_ID_LENGTH = 128;
const MAX_CHAT_LENGTH = 280;
const clampUnit = (value: number): number => (value < -1 ? -1 : value > 1 ? 1 : value);
const isSaneId = (value: unknown): value is string =>
typeof value === "string" && value.length > 0 && value.length <= MAX_ID_LENGTH;
export function validateClientMessage(input: unknown): ValidationResult<ClientMessage> {
if (!isRecord(input)) return fail("message must be an object");
switch (input["t"]) {
case "input": {
const move = input["move"];
if (!isRecord(move) || !isFiniteNumber(move["x"]) || !isFiniteNumber(move["z"])) {
return fail("input.move must be {x, z} finite numbers");
}
if (!isFiniteNumber(input["tick"]) || input["tick"] < 0) {
return fail("input.tick must be a non-negative number");
}
if (!isFiniteNumber(input["yaw"])) return fail("input.yaw must be finite");
return ok({
t: "input",
tick: Math.floor(input["tick"]),
move: { x: clampUnit(move["x"]), z: clampUnit(move["z"]) },
run: input["run"] === true,
yaw: input["yaw"],
});
}
case "interact":
if (!isSaneId(input["interactableId"])) return fail("interact.interactableId invalid");
return ok({ t: "interact", interactableId: input["interactableId"] });
case "combine":
if (!isSaneId(input["itemA"]) || !isSaneId(input["itemB"])) {
return fail("combine requires two item ids");
}
return ok({ t: "combine", itemA: input["itemA"], itemB: input["itemB"] });
case "chat": {
const text = input["text"];
if (typeof text !== "string") return fail("chat.text must be a string");
return ok({ t: "chat", text: text.slice(0, MAX_CHAT_LENGTH) });
}
default:
return fail(`unknown client message type: ${String(input["t"])}`);
}
}
/**
* Guests validate host messages too. The host is authoritative over *game*
* state, not over the guest's memory safety, and a compromised host should not
* be able to crash the room.
*/
export function validateHostMessage(input: unknown): ValidationResult<HostMessage> {
if (!isRecord(input)) return fail("message must be an object");
switch (input["t"]) {
case "snapshot": {
if (!isFiniteNumber(input["tick"])) return fail("snapshot.tick must be finite");
if (!Array.isArray(input["players"])) return fail("snapshot.players must be an array");
if (input["players"].length > MAX_PLAYERS) return fail("snapshot.players exceeds MAX_PLAYERS");
for (const player of input["players"]) {
if (!isRecord(player) || !isFiniteNumber(player["peerId"])) {
return fail("snapshot.players entry missing peerId");
}
const position = player["p"];
if (
!isRecord(position) ||
!isFiniteNumber(position["x"]) ||
!isFiniteNumber(position["y"]) ||
!isFiniteNumber(position["z"])
) {
return fail("snapshot.players entry has an invalid position");
}
if (!isFiniteNumber(player["yaw"])) return fail("snapshot player yaw must be finite");
}
return ok(input as unknown as HostMessage);
}
case "flags":
if (!isStringArray(input["set"]) || !isStringArray(input["unset"])) {
return fail("flags.set and flags.unset must be string arrays");
}
return ok(input as unknown as HostMessage);
case "despawn":
if (!isStringArray(input["interactableIds"])) {
return fail("despawn.interactableIds must be a string array");
}
return ok(input as unknown as HostMessage);
case "inventory":
if (!isFiniteNumber(input["peerId"]) || !Array.isArray(input["items"])) {
return fail("inventory requires peerId and items[]");
}
return ok(input as unknown as HostMessage);
case "notice":
if (typeof input["text"] !== "string") return fail("notice.text must be a string");
return ok(input as unknown as HostMessage);
case "chat":
if (!isFiniteNumber(input["from"]) || typeof input["text"] !== "string") {
return fail("chat requires from and text");
}
return ok({ t: "chat", from: input["from"], text: input["text"].slice(0, MAX_CHAT_LENGTH) });
case "sync":
if (typeof input["roomId"] !== "string") return fail("sync.roomId must be a string");
if (!isStringArray(input["flags"]) || !isStringArray(input["despawned"])) {
return fail("sync.flags and sync.despawned must be string arrays");
}
return ok(input as unknown as HostMessage);
default:
return fail(`unknown host message type: ${String(input["t"])}`);
}
}
export function validateSignalMessage(input: unknown): ValidationResult<SignalMessage> {
if (!isRecord(input)) return fail("message must be an object");
switch (input["t"]) {
case "hello":
if (typeof input["token"] !== "string") return fail("hello.token must be a string");
if (!isSaneId(input["lobbyCode"])) return fail("hello.lobbyCode invalid");
if (input["protocol"] !== PROTOCOL_VERSION) {
return fail(
`protocol mismatch: peer speaks ${String(input["protocol"])}, server speaks ${PROTOCOL_VERSION}`,
);
}
return ok({
t: "hello",
token: input["token"],
lobbyCode: input["lobbyCode"],
protocol: PROTOCOL_VERSION,
});
case "signal": {
if (!isFiniteNumber(input["to"])) return fail("signal.to must be a peer id");
if (!isRecord(input["payload"])) return fail("signal.payload must be an object");
// `from` is assigned by the server from the authenticated socket, never
// taken from the sender — otherwise a peer could impersonate another.
return ok({
t: "signal",
from: -1,
to: input["to"],
payload: input["payload"] as SignalPayload,
});
}
case "ready":
return ok({ t: "ready", isReady: input["isReady"] === true });
default:
return fail(`unknown signal message type: ${String(input["t"])}`);
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { SaveData } from "../types/save.js";
import { SAVE_FORMAT_VERSION } from "../types/save.js";
import { isRecord, isVec3, type ValidationResult } from "./common.js";
export function validateSaveData(input: unknown): ValidationResult<SaveData> {
const errors: string[] = [];
if (!isRecord(input)) return { ok: false, errors: ["save must be an object"] };
if (input["version"] !== SAVE_FORMAT_VERSION) {
errors.push(
`unsupported save version ${String(input["version"])}, expected ${SAVE_FORMAT_VERSION}`,
);
}
if (typeof input["campaign"] !== "string") errors.push("campaign must be a string");
if (typeof input["playtimeSeconds"] !== "number") errors.push("playtimeSeconds must be a number");
if (!Array.isArray(input["flags"])) errors.push("flags must be an array");
if (!Array.isArray(input["consumedInteractables"])) {
errors.push("consumedInteractables must be an array");
}
const player = input["player"];
if (!isRecord(player)) {
errors.push("player must be an object");
} else {
if (typeof player["roomId"] !== "string") errors.push("player.roomId must be a string");
if (!isVec3(player["position"])) errors.push("player.position must be a Vec3");
if (typeof player["yaw"] !== "number") errors.push("player.yaw must be a number");
if (typeof player["health"] !== "number") errors.push("player.health must be a number");
const inventory = player["inventory"];
if (
!isRecord(inventory) ||
!Array.isArray(inventory["slots"]) ||
typeof inventory["capacity"] !== "number"
) {
errors.push("player.inventory must have slots[] and capacity");
}
}
return errors.length === 0
? { ok: true, value: input as unknown as SaveData }
: { ok: false, errors };
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Canonical skeleton — GDD §10.2.
*
* Mixamo-compatible core set. The harness builds joints under these names and
* binds skin weights to them, so every generated character plays the same
* procedural clips (and any future shared animation library).
*/
export const CANONICAL_BONES = [
"Hips",
"Spine",
"Spine1",
"Spine2",
"Neck",
"Head",
"LeftShoulder",
"LeftArm",
"LeftForeArm",
"LeftHand",
"RightShoulder",
"RightArm",
"RightForeArm",
"RightHand",
"LeftUpLeg",
"LeftLeg",
"LeftFoot",
"LeftToeBase",
"RightUpLeg",
"RightLeg",
"RightFoot",
"RightToeBase",
] as const;
export type CanonicalBone = (typeof CANONICAL_BONES)[number];
/** Parent of each bone; `null` marks the skeleton root. */
export const CANONICAL_HIERARCHY: Record<CanonicalBone, CanonicalBone | null> = {
Hips: null,
Spine: "Hips",
Spine1: "Spine",
Spine2: "Spine1",
Neck: "Spine2",
Head: "Neck",
LeftShoulder: "Spine2",
LeftArm: "LeftShoulder",
LeftForeArm: "LeftArm",
LeftHand: "LeftForeArm",
RightShoulder: "Spine2",
RightArm: "RightShoulder",
RightForeArm: "RightArm",
RightHand: "RightForeArm",
LeftUpLeg: "Hips",
LeftLeg: "LeftUpLeg",
LeftFoot: "LeftLeg",
LeftToeBase: "LeftFoot",
RightUpLeg: "Hips",
RightLeg: "RightUpLeg",
RightFoot: "RightLeg",
RightToeBase: "RightFoot",
};
/** Bones a rigger must produce for a clip to be playable at all. */
export const REQUIRED_BONES: readonly CanonicalBone[] = [
"Hips",
"Spine",
"Head",
"LeftArm",
"RightArm",
"LeftUpLeg",
"LeftLeg",
"LeftFoot",
"RightUpLeg",
"RightLeg",
"RightFoot",
];
const CANONICAL_SET: ReadonlySet<string> = new Set(CANONICAL_BONES);
export function isCanonicalBone(name: string): name is CanonicalBone {
return CANONICAL_SET.has(name);
}
/** Depth-first walk order, parents always before children. */
export function canonicalWalkOrder(): CanonicalBone[] {
const out: CanonicalBone[] = [];
const visit = (bone: CanonicalBone) => {
out.push(bone);
for (const child of CANONICAL_BONES) {
if (CANONICAL_HIERARCHY[child] === bone) visit(child);
}
};
visit("Hips");
return out;
}
/**
* Common aliases emitted by third-party riggers, normalised to canonical names.
* Mixamo's `mixamorig:` prefix is stripped before lookup.
*/
export const BONE_ALIASES: Record<string, CanonicalBone> = {
hip: "Hips",
pelvis: "Hips",
root: "Hips",
spine_01: "Spine",
spine_02: "Spine1",
spine_03: "Spine2",
chest: "Spine2",
upperchest: "Spine2",
head_end: "Head",
clavicle_l: "LeftShoulder",
clavicle_r: "RightShoulder",
upperarm_l: "LeftArm",
upperarm_r: "RightArm",
lowerarm_l: "LeftForeArm",
lowerarm_r: "RightForeArm",
hand_l: "LeftHand",
hand_r: "RightHand",
thigh_l: "LeftUpLeg",
thigh_r: "RightUpLeg",
calf_l: "LeftLeg",
calf_r: "RightLeg",
foot_l: "LeftFoot",
foot_r: "RightFoot",
ball_l: "LeftToeBase",
ball_r: "RightToeBase",
};
/** Best-effort mapping of an arbitrary rigger bone name onto the canonical set. */
export function normaliseBoneName(raw: string): CanonicalBone | null {
const stripped = raw.replace(/^mixamorig[:_]?/i, "").trim();
if (isCanonicalBone(stripped)) return stripped;
const key = stripped.toLowerCase().replace(/[\s.-]+/g, "_");
const alias = BONE_ALIASES[key];
if (alias) return alias;
// `LeftArm` vs `leftarm` vs `Left_Arm`
const squashed = key.replace(/_/g, "");
for (const bone of CANONICAL_BONES) {
if (bone.toLowerCase() === squashed) return bone;
}
return null;
}
+47
View File
@@ -0,0 +1,47 @@
import type { Quat, Vec3 } from "./math.js";
/**
* Collision filter categories.
*
* box3d-wasm exposes `{categoryBits, maskBits}` filters on shapes and raycasts.
* The character mover raycasts against WORLD|PROP only, so it never snags on
* sensors or on its own capsule.
*/
export const CollisionGroup = {
WORLD: 0x0001,
PLAYER: 0x0002,
ENEMY: 0x0004,
PROP: 0x0008,
TRIGGER: 0x0010,
CAMERA_ZONE: 0x0020,
} as const;
export type CollisionGroupName = keyof typeof CollisionGroup;
export interface CollisionFilter {
categoryBits: number;
maskBits: number;
}
/** Everything the character mover is allowed to stand on or bump into. */
export const MOVER_SOLID_MASK = CollisionGroup.WORLD | CollisionGroup.PROP;
/**
* Collider primitives.
*
* Deliberately limited to what box3d-wasm@0.2.0 binds: box, sphere, capsule and
* convex hull. Upstream Box3D has no triangle-mesh shape in this build, so room
* geometry is authored as a compound of these rather than as a trimesh (GDD §7
* assumes trimesh support that does not exist yet).
*/
export type ColliderDef =
| { kind: "box"; halfExtents: Vec3; offset?: Vec3; rotation?: Quat }
| { kind: "sphere"; radius: number; offset?: Vec3 }
| { kind: "capsule"; radius: number; height: number; offset?: Vec3 }
| { kind: "hull"; points: Vec3[]; offset?: Vec3 };
export interface ColliderMaterial {
friction?: number;
restitution?: number;
density?: number;
}
+6
View File
@@ -0,0 +1,6 @@
export * from "./math.js";
export * from "./collision.js";
export * from "./room.js";
export * from "./inventory.js";
export * from "./save.js";
export * from "./net.js";
+39
View File
@@ -0,0 +1,39 @@
/** Inventory + item definitions — GDD §6.3. */
export type ItemCategory = "key" | "consumable" | "weapon" | "ammo" | "document";
export interface ItemDef {
id: string;
name: string;
description: string;
category: ItemCategory;
/** Key items cannot be discarded and do not consume a normal slot. */
isKeyItem?: boolean;
stackable?: boolean;
maxStack?: number;
/** Icon colour, standing in for a sprite until the item pipeline lands. */
iconColor?: number;
}
export interface ItemStack {
itemId: string;
quantity: number;
}
/** Recipe for GDD §6.3 item combination. Order-independent. */
export interface CombinationDef {
inputs: [string, string];
output: ItemStack;
/** Consume the inputs, or keep them (e.g. a tool used on a component). */
consumes?: [boolean, boolean];
}
export interface InventoryState {
slots: (ItemStack | null)[];
capacity: number;
/**
* Key items are held outside the slot array — they must not compete for
* capacity, or a full inventory could soft-lock a puzzle.
*/
keyItems: string[];
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Plain-object math types.
*
* box3d-wasm passes vectors as `{x,y,z}` and quaternions as `{x,y,z,w}`, which
* is also what three.js `.set()` / `.copy()` accept, so these cross the physics
* boundary with no marshalling.
*/
export interface Vec3 {
x: number;
y: number;
z: number;
}
export interface Quat {
x: number;
y: number;
z: number;
w: number;
}
export interface Transform {
position: Vec3;
rotation: Quat;
}
export const VEC3_ZERO: Readonly<Vec3> = Object.freeze({ x: 0, y: 0, z: 0 });
export const VEC3_UP: Readonly<Vec3> = Object.freeze({ x: 0, y: 1, z: 0 });
export const QUAT_IDENTITY: Readonly<Quat> = Object.freeze({ x: 0, y: 0, z: 0, w: 1 });
export const vec3 = (x = 0, y = 0, z = 0): Vec3 => ({ x, y, z });
export const addVec3 = (a: Vec3, b: Vec3): Vec3 => ({
x: a.x + b.x,
y: a.y + b.y,
z: a.z + b.z,
});
export const subVec3 = (a: Vec3, b: Vec3): Vec3 => ({
x: a.x - b.x,
y: a.y - b.y,
z: a.z - b.z,
});
export const scaleVec3 = (v: Vec3, s: number): Vec3 => ({
x: v.x * s,
y: v.y * s,
z: v.z * s,
});
export const dotVec3 = (a: Vec3, b: Vec3): number => a.x * b.x + a.y * b.y + a.z * b.z;
export const lengthVec3 = (v: Vec3): number => Math.sqrt(dotVec3(v, v));
export function normaliseVec3(v: Vec3): Vec3 {
const len = lengthVec3(v);
return len > 1e-8 ? scaleVec3(v, 1 / len) : { x: 0, y: 0, z: 0 };
}
/** Yaw-only quaternion — the only rotation a tank-controlled character needs. */
export function quatFromYaw(yaw: number): Quat {
const half = yaw * 0.5;
return { x: 0, y: Math.sin(half), z: 0, w: Math.cos(half) };
}
export function yawFromQuat(q: Quat): number {
return Math.atan2(2 * (q.w * q.y + q.x * q.z), 1 - 2 * (q.y * q.y + q.z * q.z));
}
/** Shortest signed angular delta from `a` to `b`, in (-PI, PI]. */
export function angleDelta(a: number, b: number): number {
let d = (b - a) % (Math.PI * 2);
if (d > Math.PI) d -= Math.PI * 2;
if (d < -Math.PI) d += Math.PI * 2;
return d;
}
export const lerp = (a: number, b: number, t: number): number => a + (b - a) * t;
export const clamp = (v: number, min: number, max: number): number =>
v < min ? min : v > max ? max : v;
/**
* Frame-rate independent exponential smoothing.
* `smoothing` is the fraction of remaining distance left after one second.
*/
export const damp = (a: number, b: number, smoothing: number, dt: number): number =>
lerp(a, b, 1 - Math.pow(smoothing, dt));
+118
View File
@@ -0,0 +1,118 @@
import type { Vec3 } from "./math.js";
/**
* Network protocol — GDD §8.
*
* Two distinct channels:
*
* - **Signalling** (`SignalMessage`) rides the WebSocket to the Hono server.
* It exists only to exchange SDP and ICE candidates and to announce lobby
* membership. No gameplay ever crosses it.
*
* - **Game** (`ClientMessage` / `HostMessage`) rides WebRTC data channels
* directly between peers. Topology is a star centred on the host, not a
* mesh: the host is authoritative, so guest-to-guest links would carry
* nothing anyone is allowed to trust.
*
* Field names are short because these are serialised on every snapshot at
* `SNAPSHOT_HZ`, and JSON key overhead dominates a payload this small.
*/
export const PROTOCOL_VERSION = 1;
/** GDD §8: "1020 Hz (adventure pacing is forgiving)". */
export const SNAPSHOT_HZ = 15;
export const SNAPSHOT_INTERVAL = 1 / SNAPSHOT_HZ;
export const MAX_PLAYERS = 4;
export type PeerId = number;
export interface PeerInfo {
peerId: PeerId;
displayName: string;
isHost: boolean;
isReady: boolean;
}
// --- signalling -------------------------------------------------------------
/** Opaque WebRTC descriptors. Kept `unknown`-ish so we never re-type the spec. */
export interface SignalPayload {
sdp?: { type: "offer" | "answer"; sdp: string };
ice?: { candidate: string; sdpMid: string | null; sdpMLineIndex: number | null };
}
export type SignalMessage =
/** client → server, first frame after the socket opens */
| { t: "hello"; token: string; lobbyCode: string; protocol: number }
/** server → client, accepted */
| { t: "welcome"; peerId: PeerId; isHost: boolean; peers: PeerInfo[] }
/** server → all, membership changes */
| { t: "peer-joined"; peer: PeerInfo }
| { t: "peer-left"; peerId: PeerId }
| { t: "peer-updated"; peer: PeerInfo }
/** client → server → target client, relayed verbatim */
| { t: "signal"; from: PeerId; to: PeerId; payload: SignalPayload }
/** client → server */
| { t: "ready"; isReady: boolean }
/** server → all, host pressed start */
| { t: "start"; roomId: string; spawnPointId: string }
| { t: "error"; message: string };
// --- guest → host -----------------------------------------------------------
/**
* Guests send intent, never position. The host is free to reject any of it,
* which is the whole point of host-authoritative play (GDD §8).
*/
export type ClientMessage =
| {
t: "input";
/** Guest's local tick, echoed back in snapshots so it can reconcile. */
tick: number;
/** Desired movement on the XZ plane, each component in -1..1. */
move: { x: number; z: number };
run: boolean;
/** Facing the guest believes it has; the host treats it as advisory. */
yaw: number;
}
| { t: "interact"; interactableId: string }
| { t: "combine"; itemA: string; itemB: string }
| { t: "chat"; text: string };
// --- host → guests ----------------------------------------------------------
export interface PlayerSnapshot {
peerId: PeerId;
p: Vec3;
yaw: number;
/** Horizontal speed, so remote avatars animate without a second message. */
spd: number;
grounded: boolean;
}
export type HostMessage =
| {
t: "snapshot";
tick: number;
/** Last input tick the host consumed, per peer, for reconciliation. */
ack: Record<PeerId, number>;
players: PlayerSnapshot[];
}
/** Authoritative world state. Sent on change, not per tick. */
| { t: "flags"; set: string[]; unset: string[] }
| { t: "despawn"; interactableIds: string[] }
| { t: "inventory"; peerId: PeerId; items: Array<{ itemId: string; quantity: number }> }
| { t: "notice"; text: string; to?: PeerId }
| { t: "chat"; from: PeerId; text: string }
/** Full state for a peer that just joined mid-session. */
| {
t: "sync";
roomId: string;
flags: string[];
despawned: string[];
players: PlayerSnapshot[];
};
export type GameMessage = ClientMessage | HostMessage;
+91
View File
@@ -0,0 +1,91 @@
import type { ColliderDef, ColliderMaterial } from "./collision.js";
import type { Vec3 } from "./math.js";
/** A static piece of level geometry: one visual mesh + its collider compound. */
export interface StaticGeometryDef {
id: string;
/** Which builder in `client/src/world/geometry.ts` renders this piece. */
visual:
| { kind: "box"; size: Vec3; color: number; textureId?: string }
| { kind: "plane"; size: [number, number]; color: number; textureId?: string }
/** Rendered via ConvexGeometry so the mesh matches a hull collider exactly. */
| { kind: "hull"; points: Vec3[]; color: number }
| { kind: "none" };
position: Vec3;
/** Yaw in radians. Rooms are authored axis-aligned plus yaw; that is enough for the genre. */
yaw?: number;
colliders: ColliderDef[];
material?: ColliderMaterial;
/** Despawned once this flag is set — barred doors, collapsed debris, shutters. */
removeOnFlag?: string;
}
/**
* A fixed camera zone — GDD §6.1.
*
* The trigger is a box sensor. While the player is inside, the camera snaps to
* `camera` and looks at `lookAt` (or tracks the player if `trackPlayer` is set).
* Overlapping zones resolve by highest `priority`.
*/
export interface CameraZoneDef {
id: string;
trigger: { center: Vec3; halfExtents: Vec3 };
camera: {
position: Vec3;
lookAt: Vec3;
fov?: number;
/** Follow the player with a damped look-at instead of a fixed target. */
trackPlayer?: boolean;
/** How far the camera may pan while tracking, in world units. */
trackRadius?: number;
};
/** Hard cut (classic RE) or a timed blend (Silent Hill style). Seconds. */
blend?: { mode: "cut" } | { mode: "blend"; duration: number };
priority?: number;
}
export type InteractionKind =
| { kind: "examine"; text: string }
| { kind: "pickup"; itemId: string; quantity?: number }
| { kind: "door"; targetRoomId: string; spawnPointId: string; requiresItemId?: string }
| { kind: "useItem"; acceptsItemId: string; onUseFlag: string; text: string };
/** Something the player can walk up to and press Interact on. */
export interface InteractableDef {
id: string;
label: string;
position: Vec3;
/** Sensor half-extents defining the "you can reach this" volume. */
halfExtents: Vec3;
action: InteractionKind;
/** Only active while every listed flag is set (or unset, when prefixed with `!`). */
requiresFlags?: string[];
/** Set to false once consumed; persisted in the save. */
repeatable?: boolean;
visual?: { kind: "box"; size: Vec3; color: number } | { kind: "none" };
}
export interface SpawnPointDef {
id: string;
position: Vec3;
yaw: number;
}
export interface RoomDef {
id: string;
displayName: string;
/** Linear fog, the primary atmosphere tool (GDD §14). */
fog: { color: number; near: number; far: number };
ambientLight: { color: number; intensity: number };
lights: Array<{
kind: "point" | "directional";
color: number;
intensity: number;
position: Vec3;
distance?: number;
}>;
geometry: StaticGeometryDef[];
cameraZones: CameraZoneDef[];
interactables: InteractableDef[];
spawnPoints: SpawnPointDef[];
}
+46
View File
@@ -0,0 +1,46 @@
import type { InventoryState } from "./inventory.js";
import type { Vec3 } from "./math.js";
/**
* Save payload — serialised straight into the `saves.data` JSONB column
* (GDD §9). Versioned so migrations stay possible once campaigns ship.
*/
export const SAVE_FORMAT_VERSION = 1;
export interface PlayerSaveState {
roomId: string;
position: Vec3;
yaw: number;
health: number;
inventory: InventoryState;
}
export interface SaveData {
version: number;
campaign: string;
player: PlayerSaveState;
/** Puzzle/door/interaction flags. Presence in the set means "set". */
flags: string[];
/** Interactables already consumed (non-repeatable ones that fired). */
consumedInteractables: string[];
playtimeSeconds: number;
savedAt: string;
}
export function emptySave(campaign: string, spawnRoomId: string): SaveData {
return {
version: SAVE_FORMAT_VERSION,
campaign,
player: {
roomId: spawnRoomId,
position: { x: 0, y: 0, z: 0 },
yaw: 0,
health: 100,
inventory: { slots: Array(8).fill(null), capacity: 8, keyItems: [] },
},
flags: [],
consumedInteractables: [],
playtimeSeconds: 0,
savedAt: new Date().toISOString(),
};
}
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.base.json",
"include": ["src/**/*.ts"]
}