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-game uses a superRefine rule that requires either an access token or a deviceUUID.
  • Handlers (endpoints/**): one file per endpoint, promise-chained business logic.
  • Error logging (database/error/Write.ts): unexpected failures are stored in the errors table 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:

  1. Auth token (AUTH_KEY, default 15 min): embedded in an e-mail link after signup/login.
  2. Access token (ACCESS_KEY, default 60 d): issued when the auth token is redeemed at GET /account/auth; sent as Authorization header on all protected endpoints. Most endpoints return a fresh access token with each response (rolling renewal).
  3. Recovery token (RECOVERY_KEY, default 15 min): e-mailed for account recovery; redeeming it rotates the account’s internal auth_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_blacklist table): 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), otherwise Kritisches 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_definitions table), 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:

  1. Awake() configures the Input System UI module.
  2. Start() runs DeviceInit() (registers the headset, receives the game code), loads all interactables, and plays the intro.
  3. FixedUpdate() (tuned to 1 call/second) polls GET /game/get-game and forwards the result to GameStatusController.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/patientModelIds hold six patient roots (patient_apatient_f) that are toggled per game; PatientSpawner offers an alternative prefab-instantiation path (Assets/Models/Patients_Modells/Patient_A–E.prefab plus scene objects for all six patients).
  • RoomConfigurator adapts 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) from PatientFileDefaultCatalog.cs, with in-file error interactions — diagnosis/medication mismatch (error 28), allergy conflict (error 30, via WrongMedicationAllergyPopup), 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 Interactables root; InteractablesController collects them per patient root. An object’s numeric name prefix is its error ID (e.g. 15 ErrorContainer), extracted by regex in ErrorObject.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-mistake with 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, and DesktopButtonAdapter allow 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 from certificates/ (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.de is 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 with javascript-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 /docs on the main branch.

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_definitions table, frontend Data.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 (0105) 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 vr client 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


This site uses Just the Docs, a documentation theme for Jekyll.