Denis Cercasin
Architecture
Table of contents
System overview
Room of Horror is a multi-component training system. A nursing trainee wears a VR headset (Meta Quest) and searches a virtual hospital room for care errors, while an instructor configures and monitors the session from a web browser. A central REST/WebSocket API coordinates both clients and persists all game data in PostgreSQL.
| Component | Path | Technology | Responsibility |
|---|---|---|---|
| VR application | room-of-horror-unity | Unity 2022.3.33f1, XR Interaction Toolkit 2.5.4, Oculus XR Plugin 4.2.0 | Renders the hospital room, spawns the selected patient, activates error objects, reports found errors and mistakes |
| Backend API | room-of-horror-api | Node.js, Express 4, TypeScript, Socket.IO 4.8, PostgreSQL (pg 8), Zod 4, JWT | Accounts, game lifecycle, patient profiles, error catalog, evaluation engine, real-time updates, e-mail auth |
| Web frontend | room-of-horror-frontend | React 18 (Create React App), TypeScript, react-router 6, axios, socket.io-client | Instructor UI: login, join game by code, patient selection, error configuration, live progress, evaluation report |
| 3D source models | room-of-horror-models | FBX + textures | Source assets for room props (bed, breathing tube, trolley, walker, …) |
| Media assets | room-of-horror-assets | PNG/MP4/XD | Logos, icons, trailer, tutorial video, Adobe XD design file |
flowchart LR
subgraph VR["Unity VR App (Meta Quest)"]
U[Main.cs game loop]
end
subgraph API["Backend API (Express + Socket.IO)"]
R[REST endpoints]
W[WebSocket server]
E[Evaluation engine]
end
subgraph FE["Web Frontend (React)"]
D[Instructor dashboard]
end
DB[(PostgreSQL)]
M[SMTP mail server]
U -- "HTTP polling (UnityWebRequest)" --> R
D -- "HTTPS (axios)" --> R
D <--> W
R --> DB
W --> DB
R --> E
R -- "auth links" --> M
Unity communicates with the API via plain HTTP requests only (polling once per second); the WebSocket layer is currently consumed by the frontend. The WebSocket event model already defines a vr client type for a future push-based Unity integration (see websocket/Events.ts).
Communication flow
The full happy path of one training session:
sequenceDiagram
participant VR as Unity VR App
participant API as Backend API
participant DB as PostgreSQL
participant FE as Web Frontend (Instructor)
VR->>API: POST /device/device-init (deviceUUID, errorPos)
API->>DB: create or load game (status LOBBY)
API-->>VR: gameCode
Note over VR: Lobby shows game code
FE->>API: POST /account/login → e-mail link → GET /account/auth
API-->>FE: accessToken (JWT)
FE->>API: POST /game/join-game?gameCode=...
API->>DB: link user_uuid to game
API-->>FE: joined (status SETUP)
FE->>API: Socket.IO joinGameRoom
API-->>FE: gameStateUpdated
FE->>API: PUT /game/edit-game?target=patientId&value=patient_d
FE->>API: PUT /game/edit-game?target=errors&value=[1,23,24]
FE->>API: PUT /game/edit-game?target=mistakes&value=3
FE->>API: PUT /game/edit-game?target=status&value=STARTED
Note over VR: 1 s polling detects STARTED
VR->>VR: activate patient model, configure room, enable error objects
loop Gameplay
VR->>API: POST /game/error-found?errorID=n
API-->>FE: Socket.IO errorFound + gameStateUpdated
VR->>API: POST /game/add-mistake?mistakeCor=[x,y,z]
API-->>FE: Socket.IO mistakeMade + gameStateUpdated
end
API->>DB: status ENDED (all errors found / too many mistakes / stop)
API->>API: EvaluationEngine.evaluate()
API->>DB: persist game_evaluations
API-->>FE: Socket.IO gameEnded
FE->>API: GET /game/get-game → evaluation report
Game status lifecycle (single source of truth in the games.game_status column):
stateDiagram-v2
[*] --> LOBBY : device-init (no user linked)
LOBBY --> SETUP : join-game (user linked)
SETUP --> STARTED : edit-game target=status
STARTED --> ENDED : all errors found
STARTED --> ENDED : max mistakes reached
STARTED --> ENDED : stop-game (VR) / edit-game (frontend)
ENDED --> STARTED : edit-game restarts (found errors + mistakes reset)
SETUP --> LOBBY : sign out (edit-game target=uuid)
Backend architecture
Request pipeline
Every REST request passes through the same middleware chain, registered in utils/Endpoints.ts:
Request → CORS check → (endpoint rate limiter) → Zod validation → handler → JSON response
- CORS (
Main.ts): only the two configured frontend origins are allowed; all other origins are rejected. - Rate limiting (
utils/RateLimiters.ts,express-rate-limit): sensitive account endpoints have individual limits (login, signup, recovery) plus a global fallback limiter. - Validation (
schemas/requests.ts,utils/validate.ts): every endpoint has a Zod schema validating query parameters and headers before the handler runs.GET /game/get-gameuses asuperRefinerule that requires either an access token or adeviceUUID. - Handlers (
endpoints/**): one file per endpoint, promise-chained business logic. - Error logging (
database/error/Write.ts): unexpected failures are stored in theerrorstable with a unique origin tag (e.g.edit_game_3), so production issues can be traced without console access.
Module layout
| Folder | Purpose |
|---|---|
endpoints/account | Passwordless auth: signup, login, auth-token exchange, session-ID exchange, account recovery, data retrieval |
endpoints/game | Game lifecycle: create, join, edit, get, error-found, add-mistake, stop |
endpoints/device | VR headset bootstrap: device-init, update-error-positions |
endpoints/patient | Patient profile list for the frontend |
database/ | PostgreSQL pool, table creation/migration (General.ts), per-domain read/write modules |
evaluation/ | Error catalog, scoring service, evaluation engine, rule-based feedback generator, persistence |
websocket/ | Socket.IO server, typed event contracts, game-state serialization |
schemas/ | Zod request schemas and regex-based field validators |
email/ | Nodemailer configuration and HTML mail templates (login, signup, recovery) |
utils/ | Config validation, JWT helpers, encryption, cooldowns, rate limiters, logging |
Authentication model
The system is passwordless. Three separate JWT secrets sign three token types:
- Auth token (
AUTH_KEY, default 15 min): embedded in an e-mail link after signup/login. - Access token (
ACCESS_KEY, default 60 d): issued when the auth token is redeemed atGET /account/auth; sent asAuthorizationheader on all protected endpoints. Most endpoints return a fresh access token with each response (rolling renewal). - Recovery token (
RECOVERY_KEY, default 15 min): e-mailed for account recovery; redeeming it rotates the account’s internalauth_uuid, which invalidates all previously issued tokens.
Additional mechanisms:
- Session-ID handover (
/account/use-session-id): after login, the browser polls with a short-lived one-time session ID so the tab that initiated the login is authenticated automatically once the e-mail link is clicked — even if the link is opened on another device. - Token blacklist (
token_blacklisttable): auth and recovery tokens are single-use. - Device whitelist (
WHITELISTED_DEVICE_UUIDS): optionally restricts which VR headsets may register games. - Encryption at rest (
utils/Encryption.ts, AES-256-CBC): game codes and device UUIDs are stored encrypted.
Evaluation engine
The evaluation subsystem (evaluation/) was rewritten in this project phase to support error weighting — a core stakeholder requirement (errors must be prioritized by severity, and the final report must reflect this).
flowchart LR
A[Game input:<br>selected + found error IDs,<br>mistakes, max mistakes] --> B[ScoringService]
C[ERROR_CATALOG<br>32 error definitions:<br>category, severityWeight 1-5, critical flag] --> B
B --> D[EvaluationEngine<br>performance level + pass/fail]
D --> E[RuleBasedFeedbackGenerator<br>strengths, priorities, next steps]
E --> F[EvaluationResult JSON]
F --> G[(game_evaluations)]
F --> H[Frontend EvaluationPanel]
Key rules (from EvaluationEngine.ts):
- Score = achieved severity weight / total selected severity weight (
weightedScoreRatio). - Performance levels (literal German values returned by the engine):
Exzellent(excellent, ≥ 0.92 and no high-severity miss),Gut(good, ≥ 0.8),Akzeptabel(acceptable, ≥ 0.65),Verbesserungsbedarf(needs improvement, ≥ 0.5), otherwiseKritisches Ergebnis(critical result). - Missing critical errors dominates the ratio: two or more missed critical errors is always a critical result; a session only counts as passed with zero missed critical errors and without exhausting the mistake budget.
- The engine is catalog-agnostic:
evaluateGameWithCatalog()accepts the DB-backed catalog (error_definitionstable), so weights can be tuned without redeploying code.
WebSocket layer
websocket/WebSocket.ts attaches a Socket.IO server to the same HTTP(S) server under <ORIGIN_PATH>/socket.io. Clients join a room per game (session:<game_uuid>) after authenticating with either an access token (frontend) or a device UUID (VR).
- Server → client events:
gameStateUpdated,errorFound,mistakeMade,gameStarted,gameEnded,playerJoined/Left,deviceConnected/Disconnected,reconnectRequired,error. - Client → server events:
joinGameRoom,leaveGameRoom,requestGameState,clientReady. - REST handlers trigger broadcasts after each state change, so the instructor dashboard updates in real time without polling.
The complete event payload contracts are documented in the Reference.
Frontend architecture
Single-page application bootstrapped with Create React App (react-scripts 5). No global state library is used — state lives in page components and is synchronized with the backend via services and the Socket.IO subscription.
| Layer | Files | Notes |
|---|---|---|
| Routing | src/App.tsx | react-router 6; AccessNeeded guards /dashboard and /settings; RedirectOnAccess bounces logged-in users away from /login and /signup |
| Pages | src/pages/* | Home, Login, Signup, Auth, Recovery, Dashboard, Settings, FAQ, Imprint, Terms, Privacy, Success, ErrorPage, Maintenance |
| Services | src/services/* | Thin axios wrappers per API endpoint (account.service, game.service, patient.service) plus gameSocket.service (Socket.IO client with auto-reconnect and re-join) |
| Domain data | src/utils/Data.ts | Error list (ID, German label, icon) mirroring the backend catalog; per-patient allowed error IDs |
| Game UI | src/components/GameInterface.tsx, RunningGameInterface.tsx, PatientSelection.tsx, JoinContainer.tsx | Setup flow: join by code → choose patient (targeted or random with preview) → select errors → set mistake budget → start |
| Evaluation UI | src/components/results/* | EvaluationPanel renders overview, category breakdown, critical-mistake section, and rule-based trainer feedback from the API’s evaluation payload |
| Accessibility | src/components/ColorVisionControl.tsx | Global color-vision switcher (standard, protanopia, deuteranopia, tritanopia, achromatopsia — German UI labels) persisted in web storage and applied via a data-color-vision attribute on <html> |
| Config | src/utils/Config.ts | Validates all REACT_APP_* variables at startup, mirroring the backend’s fail-fast config approach |
Unity architecture
Scene and game loop
The productive scene is Assets/Scenes/SampleScene.unity. The entry point is Main.cs:
Awake()configures the Input System UI module.Start()runsDeviceInit()(registers the headset, receives the game code), loads all interactables, and plays the intro.FixedUpdate()(tuned to 1 call/second) pollsGET /game/get-gameand forwards the result toGameStatusController.OnGetGameStatus().
GameStatusController is the central state machine. It reacts only to changes (status, game code, error set, patient model) and drives everything else:
| Status | Behavior |
|---|---|
LOBBY | Shows game code, fog wall active, default room (patient A) rendered, “new game” button |
SETUP | “Connected” message, tutorial screen, bell sound, teleport back to lobby if coming from a running game |
STARTED | Activates the selected patient prefab, configures the room (RoomConfigurator), loads the patient file, enables exactly the selected error objects, resets health bar, teleports the player into the room |
ENDED | Win screen (all errors found) or lose screen (mistake budget exhausted) with audio and overlay fade |
Patient and room system
GameStatusController.patientModels/patientModelIdshold six patient roots (patient_a…patient_f) that are toggled per game;PatientSpawneroffers an alternative prefab-instantiation path (Assets/Models/Patients_Modells/Patient_A–E.prefabplus scene objects for all six patients).RoomConfiguratoradapts the room to the patient: patient B gets a bed scaled too high (error 21), patient E a bed too narrow instead of a heavy-duty bed (error 25); defaults are restored on every reconfiguration.PatientFileUI(1,000+ lines) renders the interactive digital patient file on a clipboard: a multi-page record (master data, anamnesis, diagnosis, medication) fromPatientFileDefaultCatalog.cs, with in-file error interactions — diagnosis/medication mismatch (error 28), allergy conflict (error 30, viaWrongMedicationAllergyPopup), missing address (error 26) and translator decision (error 27) as multiple-choice popups where only the professionally correct answer solves the error.
Error object system
- Error objects live under an
Interactablesroot;InteractablesControllercollects them per patient root. An object’s numeric name prefix is its error ID (e.g.15 ErrorContainer), extracted by regex inErrorObject.cs. - On game start only backend-selected error IDs are activated. Correction components reconcile the inactive state:
MoveErrorContainer(moves object between error/corrected position),VisualStateCorrection(swaps visuals),TransformStateCorrection(scales/moves transforms) — so an unselected error is rendered in its correct state instead of disappearing. - Clicking a non-error surface calls
POST /game/add-mistakewith the hit coordinates; the health bar (mistake container) shrinks, and the backend ends the game when the budget is exhausted. GameStatusController.TryGetHighestPriorityRemainingError()uses the severity weights delivered by the API (gameErrorPriorities) to point hints at the most critical unresolved error first.
Input: VR and desktop
- VR: XR Interaction Toolkit ray interactors on both controllers (
ControllerInput.cs), trigger to select errors, teleportation with dedicated teleport rays and audio feedback. Rendering uses an always-on-top line shader so rays stay visible through geometry. - Desktop fallback:
DesktopMovement,MouseLook,DesktopClickInteractor, andDesktopButtonAdapterallow running and testing the full game loop with mouse/keyboard when no headset is connected (also used for development and demos).
Deployment
- The API serves HTTP in development and HTTPS in production (
USE_HTTPS=true,DEV_MODE=false) using PEM certificate files fromcertificates/(Main.ts). - The Unity build points at the production API
https://roomofhorror.de/api/by default (Env.cs); local development overrides this in the Inspector.roomofhorror.deis the project’s production domain — version 2.0 has not been deployed there yet (as of July 2026, the domain still serves the previous version). - The frontend production build (
npm run build) obfuscates the JS bundle withjavascript-obfuscator. - There is no CI/CD pipeline, no Dockerfile, and no GitHub Actions workflow in the repository; deployment is manual.
- This documentation is published via GitHub Pages, served from
/docson themainbranch.
Known limitations
- Unity uses 1 s HTTP polling instead of the WebSocket channel; state changes reach the headset with up to ~1 s delay.
- The error catalog exists in three places (backend
ErrorCatalog.ts/error_definitionstable, frontendData.ts, Unity object names). IDs must be kept consistent manually when adding errors. - SMTP configuration is required for the full login flow; local development relies on
DEV_MODE=true, which returns tokens directly in API responses. - Automated tests are not present in any component; verification is manual (see Definition of Done).
Information sources
- Repository code:
room-of-horror-api/Main.ts,utils/Endpoints.ts,utils/Config.ts,evaluation/*,websocket/*,database/General.ts;room-of-horror-frontend/src/App.tsx,src/services/*,src/utils/Data.ts;room-of-horror-unity/Assets/Scripts/*,Packages/manifest.json,ProjectSettings/ProjectVersion.txt - Historical baseline: the analysis documents (
01–05) written at project start - External references: see Architecture Sources
Missing information
- The production deployment procedure (server provisioning, process manager, certificate renewal) is not documented in the repository. This information is not available from the provided project.
Suggested improvements
- Migrate Unity from polling to the existing Socket.IO contract (a
vrclient type is already defined). - Generate the frontend error list and Unity ID mapping from the backend catalog to remove triple maintenance.
- Add a CI pipeline (build + lint for all three components) and container images for reproducible deployment.
Last build: 19 Aug 2026, 05:20+00:00