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.
17 KiB
PSX Adventure Engine
Reference Project / Game Design Document Version 0.1 — July 2026
1. Vision
A modern, browser-first reference engine and project template for creating authentic PSX-era third-person survival-horror and action-adventure games in the style of:
- Resident Evil 1 & 2
- Parasite Eve
- Dino Crisis
- Silent Hill (classic)
The engine prioritizes:
- Fixed or limited cameras
- Tank or camera-relative controls
- Inventory, item combination, and puzzle gameplay
- Atmospheric low-poly aesthetics
- True peer-to-peer multiplayer
- Rapid content creation via photo-to-character pipelines
It is designed as a clean, fully-typed foundation that teams can fork and extend into complete games (or other genres later).
2. Core Pillars
-
Authentic PSX Feel Low-poly geometry, affine texture mapping, dithering, color quantization, fixed cameras, optional tank controls, atmospheric fog and limited draw distance.
-
Content Velocity Powerful img2mesh + common-skeleton harness so characters and items can be generated from reference photographs and immediately animated.
-
Multiplayer-Native Host-authoritative P2P WebRTC for 2–4 player co-op out of the box.
-
Cloud Persistence Neon-backed accounts, cloud saves, lobby system, and shared campaign progress.
-
Clean Architecture Strict separation between runtime engine, backend services, and content-creation harnesses.
3. Goals & Non-Goals
Goals
- Provide a production-quality reference "engine of sorts" for the target genre.
- Support both classic fixed-camera and freer camera styles.
- Deliver true browser P2P multiplayer with host-authoritative simulation.
- Enable rapid character and item creation from reference photos.
- Keep everything TypeScript-first and highly modular.
Non-Goals
- Competing with Unity or Unreal as a general-purpose engine.
- High-fidelity modern PBR graphics or complex soft-body physics.
- Dedicated-server authoritative multiplayer (we stay pure P2P + lightweight signaling).
- Mobile-first design (desktop + modern browsers are the primary target).
4. Technical Stack
| Layer | Technology |
|---|---|
| Runtime | Three.js (vanilla or React Three Fiber) + TypeScript + Vite |
| Physics | Box3D (via box3d-wasm) |
| Networking | PeerJS (or simple-peer) + custom WebSocket signaling |
| Backend | Hono + Neon Postgres |
| Asset Pipeline | Node-based tools + optional local web UI |
| Build / Monorepo | pnpm / Turborepo or simple Vite workspaces |
5. Monorepo Structure
/
├── client/ # Three.js game runtime
│ ├── src/
│ │ ├── engine/ # core loop, scene manager
│ │ ├── systems/ # Camera, Player, Inventory, Interaction, Multiplayer…
│ │ ├── post/ # PSX visual pipeline
│ │ └── ...
│ └── public/
│
├── server/ # Hono API + WebSocket signaling
│ ├── routes/ # auth, saves, lobbies
│ ├── ws/ # SDP/ICE exchange
│ └── db/ # Neon + Drizzle/Prisma
│
├── shared/ # Shared types & definitions
│ ├── skeleton/ # canonical skeleton
│ ├── types/
│ └── schemas/
│
├── tools/ # Content creation harness
│ ├── src/
│ │ ├── img2mesh/
│ │ ├── rig/
│ │ ├── anim/
│ │ ├── items/
│ │ ├── postprocess/
│ │ └── export/
│ └── web/ # optional local UI
│
└── assets/ # Generated GLBs + animation clips
6. Core Runtime Systems
6.1 CameraManager
- Fixed camera zones using trigger volumes (classic RE style).
- Smooth or hard cuts between cameras.
- Optional free / follow / rail cameras for Silent Hill style.
- Camera-relative movement so "up" always feels correct after a cut.
6.2 PlayerController
- Classic tank controls (toggleable).
- Modern camera-relative movement.
- Quick-turn, aim, interact, item use.
- Uses Box3D character mover under the hood.
6.3 InventorySystem
- Classic RE-style list or RE4-style grid.
- Item examination, combination, and key-item usage.
- Fully serializable to JSON for cloud saves.
6.4 Interaction & Puzzle Framework
- Examine, pick up, use item on object, door/key systems.
- Trigger volumes and state machines for puzzles.
- Sensors in Box3D for detection.
6.5 Combat & AI (baseline)
- Simple melee and limited-ammo ranged combat.
- Basic enemy behaviors suitable for the genre.
- Host simulates all AI and damage.
6.6 Multiplayer Layer
See section 8.
7. Physics — Box3D
Box3D (Erin Catto, 2026) is the primary physics engine.
Usage guidelines:
- Player → Capsule + character mover
- Rooms / static geometry → Triangle meshes or static compounds
- Triggers (cameras, items, doors, puzzles) → Sensors
- Dynamic props / enemies → Capsules or convex hulls
- Determinism is leveraged for host-authoritative multiplayer
Integration via box3d-wasm. Colliders and sensors are auto-generated or defined in asset metadata.
8. Multiplayer Design
- Transport: PeerJS (or simple-peer) data channels after WebSocket signaling.
- Model: Host-authoritative.
- Host (usually lobby creator) owns world simulation, AI, inventory truth, door/puzzle states.
- Clients send high-level actions / inputs.
- Host validates and broadcasts state or deltas.
- Update rate: 10–20 Hz (adventure pacing is forgiving).
- Interpolation + simple prediction on clients.
- STUN (Google public) by default; TURN optional for reliability.
- Shared campaign progress is written to Neon on checkpoints or session end.
9. Backend & Persistence (Neon)
Core Tables
users (
id UUID PRIMARY KEY,
username TEXT UNIQUE,
email TEXT UNIQUE,
display_name TEXT,
created_at, last_login
)
saves (
id UUID PRIMARY KEY,
user_id UUID REFERENCES users,
slot INT,
campaign TEXT,
title TEXT,
data JSONB NOT NULL, -- inventory, flags, positions, puzzle states…
playtime_seconds INT,
updated_at,
UNIQUE(user_id, slot, campaign)
)
lobbies (
id UUID PRIMARY KEY,
code TEXT UNIQUE, -- short room code
host_id UUID,
status TEXT, -- waiting / starting / in_game / closed
max_players INT DEFAULT 4,
settings JSONB,
created_at, expires_at
)
lobby_players (
lobby_id, user_id,
peer_id INT,
is_ready BOOLEAN,
PRIMARY KEY (lobby_id, user_id)
)
JSONB is used heavily for flexible save data and lobby settings.
10. Asset Pipeline & Harness
10.1 Prioritization
-
Primary (Characters) — shipped path Photo (Grok
/imagineor file) → silhouette measure → code-drawn low-poly mesh on the canonical skeleton → automatic skin weights → procedural clips → GLB. Geometry is constructed in code from measured proportions; we do not use Meshy or other third-party mesh APIs for the default harness. -
Secondary (Props / Stylized) Same measurement → code-drawn approach, simplified for non-humanoids.
10.2 Canonical Skeleton
Mixamo-compatible core set:
Hips
Spine → Spine1 → Spine2 → Neck → Head
LeftShoulder → LeftArm → LeftForeArm → LeftHand
RightShoulder → RightArm → RightForeArm → RightHand
LeftUpLeg → LeftLeg → LeftFoot → LeftToeBase
RightUpLeg → RightLeg → RightFoot → RightToeBase
Defined in shared/skeleton/canonical.ts. All auto-riggers map into these names.
10.3 Tools Folder Structure
tools/
├── src/
│ ├── grok/ # Grok CLI bridge (image_gen / image_edit)
│ ├── image/ # decode, segment, measure reference photos
│ ├── mesh/ # code-drawn humanoid from measurements
│ ├── rig/ # canonical skeleton + automatic skin weights
│ ├── anim/ # procedural Idle / Walk / Run / QuickTurn
│ ├── export/ # hand-rolled GLB writer
│ ├── pipeline.ts # end-to-end photo → GLB
│ └── cli.ts # `pnpm harness`
└── fixtures/ # reference images for tests
10.4 Key Interfaces (TypeScript)
interface Img2MeshRequest {
image: Buffer | string;
style?: "realistic" | "lowpoly" | "psx";
provider?: "meshy" | "triposr" | "img2threejs";
}
interface RigResult {
glbPath: string;
boneMap: Record<string, string>;
}
interface CharacterHarness {
fromPhoto(image: Buffer | string, options?: Img2MeshRequest): Promise<{
mesh: RigResult;
defaultPose?: AnimationClipResult;
}>;
generateAnimation(from: "pose" | "video" | "procedural", input: any): Promise<AnimationClipResult>;
}
11. PSX Visual Pipeline
- Vertex snapping
- Affine texture mapping
- Ordered dithering
- Color depth reduction
- Low-resolution render target + nearest-neighbor upscale
- Optional CRT / scanlines / fog
- Implemented via material
onBeforeCompilepatches + EffectComposer passes
12. Demo Content (First Playable)
Ashgrove Precinct — Level 1: a procedural crime-investigation loop inside a classic fixed-camera survival-horror building. Traverse rooms, question living NPCs, gather evidence, and open locked wings with inventory puzzles.
- 9 connected rooms with fixed cameras (lobby, squad hall, office, interview, lounge, corridor, records, break room, evidence vault)
- One dedicated room per ambient cast member + player Detective body
- Question / examine dialogue on every NPC (Reyes, Ellis, Voss, Kane, Hale, Marrow)
- Inventory + key items + crank/valve + brass/vault key progression
- Evidence pickups (statement, badge case, photograph, journal)
- 2-player co-op lobby
- Cloud save / load
- Harness-driven cast GLBs with Idle / Walk / Slumped poses
13. Roadmap / Milestones
Milestone 1 – Core Runtime
- CameraManager + PlayerController (tank + camera-relative)
- Basic room system + Box3D integration
- Inventory + Interaction
Milestone 2 – Multiplayer
- PeerJS lobby + host-authoritative movement & interactions
- Neon auth + cloud saves
Milestone 3 – Asset Harness
- Grok CLI bridge for reference generation (
image_gen) - Silhouette analysis → measurements + palette
- Code-drawn mesh on canonical skeleton + automatic skin weights
- Procedural locomotion clips + GLB export into the runtime
Milestone 4 – First Playable Demo
- Complete small campaign
- Full 2-player co-op
- Documentation & example workflows
Later
- Expanded animation library
- More advanced AI & combat
- Additional camera modes
- Modding / user-generated content hooks
14. Style Guide (PSX Aesthetic Rules)
- Prefer low polygon counts and limited texture resolution.
- Embrace affine distortion and vertex jitter where it serves atmosphere.
- Use strong silhouettes and readable shapes over fine detail.
- Fog, limited draw distance, and carefully placed lights are primary tools for tension.
- UI should feel period-appropriate (simple, slightly chunky, limited color palette).
Appendix A — Canonical Skeleton Definition
Full TypeScript definition lives in shared/skeleton/canonical.ts (see section 10.2).
Appendix B — Neon Schema
Full SQL is maintained in server/db/schema.sql (see section 9).
Appendix C — Key Interfaces
See section 10.4 and the tools/src/**/types.ts files.
Appendix D — Implementation Addendum (Milestone 1, July 2026)
Recorded as systems were implemented, per the note below that this is a living document. Nothing here changes the design intent; it records where the intent met the current state of the dependencies.
D.1 Box3D capability gaps
box3d-wasm@0.2.0 (published 2026-07-02) binds less of upstream Box3D than
§6.2 and §7 assume. Verified by enumerating the embind prototypes on the built
wasm module:
| Assumed in this document | Actual | Resolution |
|---|---|---|
| Box3D character mover (§6.2, §7) | Not bound | Hand-rolled collide-and-slide capsule mover, client/src/systems/CharacterMover.ts |
| Triangle-mesh room collision (§7) | No trimesh shape; only box, sphere, capsule, hull | Rooms are static compounds of boxes and convex hulls. ColliderDef in shared is deliberately narrowed to the four supported primitives so the asset pipeline cannot emit collision the runtime can't build |
| Capsule sweeps for the mover | No shape cast or overlap query; castRayClosest and shape.rayCast only |
A fan of ray probes (three heights × three lateral offsets) approximates the sweep |
Confirmed working and load-bearing: raycasts with filters, sensor begin/end
events keyed by userData, kinematic bodies via setTargetTransform, convex
hulls, contact and body events.
The package ships no TypeScript declarations. client/src/types/box3d-wasm.d.ts
declares the API as observed at runtime rather than as documented — the README
describes a slightly larger surface than is actually bound.
D.2 Determinism claim in §7
§7 states determinism is leveraged for host-authoritative multiplayer. These are independent properties: host-authoritative simulation requires only that one peer owns the state and broadcasts it, which is what §8 actually describes. Determinism is a requirement of lockstep networking, which this project is not using. Milestone 2 should not be designed around a determinism guarantee that a 0.2.0 wasm build has not demonstrated.
D.3 Userdata tag collisions
box3d-wasm keeps separate auto-increment counters for body and shape
userData tags, so an auto-assigned body tag and shape tag can be numerically
equal. Since sensor and contact events reference shape tags, PhysicsWorld
assigns every tag from a single counter of its own rather than relying on the
auto-assigned values.
D.4 Vite worker format
The threaded ("deluxe") physics build spawns emscripten pthread workers whose
entry uses top-level await. Vite bundles workers as iife by default, which
cannot express that, and the production build fails. worker: { format: "es" }
in client/vite.config.ts is required, not optional.
D.5 Affine texture mapping technique
GLSL ES 3.00 has no noperspective interpolation qualifier, so §11's affine
mapping cannot be requested directly. It is instead produced arithmetically:
the vertex shader emits uv * w and w as varyings and the fragment shader
divides them. The interpolator's own 1/w factors cancel, leaving
screen-linear UVs. The patch is applied only to materials that carry a map,
since without one three.js may not declare the uv attribute at all.
D.6 WebRTC transport: neither PeerJS nor simple-peer
§4 and §8 name "PeerJS (or simple-peer) + custom WebSocket signaling". Those two halves are in tension, and neither library fits:
- simple-peer depends on
bufferandreadable-stream, Node polyfills that are a recurring source of Vite build breakage. - PeerJS is built around its own broker protocol. Using it would replace the custom WebSocket signalling §4 calls for, not complement it.
What both libraries would have provided is a data channel following an SDP
exchange, which is a thin wrapper over RTCPeerConnection. client/src/net/ PeerLink.ts is that wrapper, with no dependencies, and it matches §8's
description exactly: data channels established after WebSocket signalling.
Each link carries two channels rather than one, because gameplay traffic has
two delivery profiles: state (unreliable, unordered) for position snapshots,
which are worthless once superseded, and event (reliable, ordered) for flags,
despawns and inventory, where a single drop desynchronises the world.
D.7 Co-op inventory is shared, not per-player
§6.3 and §8 do not say whether each peer carries their own inventory. In this implementation the party shares one, held by the host. It matches how the genre's co-op modes work, and it keeps a single authoritative copy rather than four that can disagree. Guests display a read-only mirror pushed by the host.
D.8 Host reach checks for remote interactions
Interaction reach is detected with Box3D sensors, but sensors only ever track
the local player's capsule. When a guest asks to interact, the host therefore
re-derives proximity from that guest's authoritative mover position before
running the interaction (Game.handleGuestInteract). Without this a guest could
open any door in the room by naming its id.
D.9 Key items and inventory capacity
§6.3 does not say whether key items consume inventory slots. They do not, in
this implementation: a full inventory would otherwise be able to soft-lock a
puzzle. They are held outside the slot array and serialised separately in
InventoryState.keyItems.
This document is the living design and technical specification for the PSX Adventure Engine reference project. It will be updated as systems are implemented and lessons are learned.