- JavaScript 69.5%
- PHP 21.5%
- CSS 5.5%
- C# 1.6%
- HTML 1.1%
- Other 0.7%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
Some checks failed
Game and API image / ci-image (push) Successful in 2s
Game tests and build / build (push) Successful in 12s
Game and API image / test (push) Successful in 10s
Game and API image / image (push) Successful in 38s
Game and API image / deploy (push) Failing after 2s
Reviewed-on: #30 |
||
| .forgejo | ||
| admin | ||
| api | ||
| deploy | ||
| docs | ||
| public | ||
| reference | ||
| scripts | ||
| src | ||
| test | ||
| .dockerignore | ||
| .gitignore | ||
| editor.html | ||
| index.html | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| vite.config.js | ||
Escape-Room
Browser based 3D escape room of the CA02 classroom, levels served by room code from the game server, which checks every answer. See docs/SPEC.md, and docs/HANDOFF.md for how it all fits together, its history and next steps.
Running locally
npm install
npm run dev
Open the URL Vite prints. npm run build writes the static site to dist/.
Levels come from the API, so run it beside the game. In development /api is proxied to port 8000:
cd api && php artisan migrate && php artisan admin:user james && php artisan serve
npm run dev # in another terminal; /api goes to port 8000
The room model is public/models/ca02.glb. On load, every mesh name is logged to the browser console (collapsed group ca02.glb: N meshes). The current list is in docs/mesh-names.txt.
Admin
/admin/ (on the NAS, https://escape.mrjj.uk/admin/) is for teachers. Logins are made on the server with php artisan admin:user <name> (see deploy/README.md).
- Rooms lists every room: the ones shipped with the game and the API, and the ones saved in the admin. Edit opens a room in the level editor, Play opens it in the game, and Delete (or Revert, for a shipped room) removes what was saved. New room starts an empty one.
- In the editor, logged in, Save puts the room on the game server and players get it straight away. Saved rooms go to the API's private
levelsfolder (/data/levelson the NAS), ahead of the files shipped inapi/resources/levels. The server also keeps the editor's copy with the plain answers (in/data/admin/drafts, never served), so editing a room later shows its answers. - Models uploads room models. Pick a straight Blender export (.glb): it is compressed in the browser first (the same steps as
npm run optimize-model), checked against the stations of every room that uses it, then uploaded to/data/site/models. Uploading over a model keeps the old file, and Put back restores it. A room picks its model in the editor's Room model box ("model": "lab-2"in the file;ca02when missing).
Rooms and models saved in the admin live on the NAS, not in this repo. To ship one with the game, download it from the editor (or copy the .glb) into api/resources/levels or public/models and commit it.
Updating the room model
The room was rebuilt in Blender (by build_ca02.py, outside this repo) from photos of CA02: 13 m by 8.6 m, 2.7 m ceiling, in metres with the floor at y = 0. Export it to reference/models/ca02-room.glb (glTF Binary, include cameras and custom properties, +Y up), then run:
npm run optimize-model
That writes the compressed public/models/ca02.glb (meshopt geometry, WebP textures up to 1024 px, about 750 KB). It keeps the object hierarchy and names, which the stations rely on, so it does not flatten, join, instance or prune anything (pruning would drop the empty spawn_player and room_centre markers). It also keeps every vertex attribute, because the _screen planes need their UVs. The raw export stays out of public/ so it is never deployed. npm test checks the model has every name below and every station the levels use.
What the game looks for in the model:
| Object | What it is for |
|---|---|
pc_01 to pc_32, teacher_pc, display_left, display_right, display_front, prop_<name> (e.g. prop_whiteboard) |
Stations: an Empty named after the level key, with its meshes, a <name>_screen plane (one quad, UVs 0 to 1 over the screen) and a <name>_cam perspective camera as children. Clicking one tweens the view to the camera |
poster_<name>, <name>_poster (e.g. poster_door_01, aberdare_way_poster) |
Posters: one flat quad each, UVs 0 to 1 over the poster. A level can put its own picture on any of them. In ca02, poster_back_02 and poster_back_03 are hidden behind display_left and poster_back_01 is half behind it |
Floor |
Its top is the floor height, and its outline keeps players in the room |
col_* |
Invisible collision boxes. When there are any, players collide only with these and the floor (so they can push past chairs), and they are hidden |
spawn_player, room_centre |
Where players start, and what they face. Without them, players start inside door_main |
door_main |
The door. It swings open when a team escapes, on its edge away from the handle (its smallest part) and out of the room, by its open_angle_deg custom property (default 95) |
Names must not contain dots or spaces: three.js removes dots, so a leftover pc_07.001 would not be found. Use PNG, JPEG or WebP textures (no KTX2 or Draco, which the game has no decoders for) and no lights: the lighting is baked into the textures, and the game adds its own soft fill.
UNITS_PER_METRE in src/controls.js is 1 for this model. Every size and speed in the game is written in metres times it, so a model at another scale only needs that changed. docs/mesh-names.txt lists every mesh.
Performance
The room is about 20,000 triangles with 750 KB of textures, so it loads quickly and runs well on school laptops. Collision uses the 15 col_ boxes and the floor: about 180 triangles.
Controls
| Device | Move | Look | Fallback |
|---|---|---|---|
| Desktop | WASD or arrow keys | Click to lock the pointer, then the mouse. Esc releases it | Drag to look, click a spot to walk there (also used if pointer lock fails) |
| Touch | Left stick | Right stick | Drag on the upper screen to look, tap a spot to walk there |
Stations in the current level light up when you aim at them (crosshair, mouse cursor, or the middle of the screen on touch) within 4 m. Click, press E, tap them, or press the Use button on touch to zoom to the station. Esc or Back returns to the room.
Touch is detected with pointer: coarse or touch support; the sticks are hidden on desktop. Keyboard works on every device. Speeds, eye height and collision size are in SETTINGS at the top of src/controls.js.
Levels
Players enter a room code and the app asks the game server for /api/levels/<CODE> (on the site the game is on, or <VITE_API_BASE> when that is set at build time). The server sends the level without its answers. Open ?room=CA02 to prefill a code.
| Code | Level | Types | Time |
|---|---|---|---|
CA02 |
Lockdown in CA02: the full level. A briefing, then four groups of PCs (Data, Hardware, Networks, Units) that unlock in order, each giving one digit of the door code | all v1 types | 30 min |
TEXT1 |
Acronym hunt | text |
10 min |
QUIZ1 |
Hardware quiz | multichoice, single and multi |
10 min |
BITS1 |
Binary basics, ending with a 16-bit conversion | binary |
15 min |
CODE1 |
Code breakers: notes give digits, a hex keypad gives the last | clue, keypad |
10 min |
DEMO1 |
One of each, to try every type quickly | all v1 types | 15 min |
BIN42 |
The example from the spec | mixed | 30 min |
TERM1 |
Lost files: a hidden file and an old server log | terminal |
15 min |
PY1 |
Bug hunt: fix a loop, then fix input() maths checked with three inputs |
python |
20 min |
SQL1 |
Club records: a count, then a join | sql |
20 min |
LOGIC1 |
Power cut: a circuit with lit wires, then one without | logic |
15 min |
PHISH1 |
Spot the scam: a phishing email and a fake login page | phishing |
15 min |
CIPHER1 |
Secret messages: Caesar, Vigenere and substitution | cipher |
20 min |
ORDER1 |
Get it in order: development stages, then lines of Python | order |
10 min |
NET1 |
Network down: a wrong gateway, then a new laptop to plug in | network |
20 min |
IMAGE1 |
Picture this: a hidden message, photo details, and a click-the-warning screenshot | image |
15 min |
Y11CS |
System failure, a Year 11 (GCSE) room with a live leaderboard on the right TV. A whiteboard briefing, then four groups that each give a digit: Programming (order lines, fix a function, write a password checker), Data (denary to hex, a hidden backup folder, a SQL join), Logic (a dark circuit, a trace) and Security (a phishing email whose reward is the key for a Vigenere message, then a network to fix) | most types | 35 min |
CA02 also has a Welsh version, CA02.cy.json (see Welsh below). SRV01 is a short test level used by the API's tests. The files live in api/resources/levels.
The answers are in test/levels.test.js (and working solutions for PY1 and SQL1 in test/solutions), which checks every answer is accepted and every level can be escaped. Copy one of these files to start a new level.
Every level is checked against src/level/level.schema.json plus a few rules a schema cannot express (lockedBy names real stations with no loops, the exit rule points at real stations, there is a salt and an answer for each station, the roomCode matches the file name). Problems are listed on the start screen with the exact field, e.g. stations.pc_19: unknown field "hint" (did you mean "hints"?). Station, display or poster names that are not in the room model show as a warning before Start.
Add "$schema": "../../src/level/level.schema.json" to a level file to get autocomplete and checking in VS Code.
Level editor
editor.html builds a level without touching JSON. It needs an admin login: the server only serves it when you are logged in, and sends anyone else to the admin's login form. Open it from Edit or New room in the admin's room list.
- The room is shown from above with the ceiling cut away. Every PC, the teacher PC, the wall displays and the posters have a label; click one (or its label) to edit it. Stations in the level, and posters with a picture, are green.
- Level tab: room code, title, language, room model, intro, time limit, hint penalty, the exit station and what it needs, wall displays, posters, and a list of the stations.
- Station tab: pick a challenge type and fill its form. Type the plain answer (or tick the right options, tick the suspicious parts, or give a working Python program or SQL query). The editor works out the exact answer the game submits and saves only its hash, so plain answers never go in the file. Logic circuits are solved for you. Try it shows the real challenge and says whether an answer would be accepted; it checks the draft in this browser, which is the only place answers are ever checked outside the server, since the draft is never served to players.
- Problems (missing answers, schema errors, lockedBy loops and so on) are listed under the form as you go. The JSON tab shows the file.
- Save puts the level on the game server (see Admin) and All rooms goes back to the list.
- Room model picks the model the level is built for; the view switches to it.
- Download saves
CODE.json(orCODE.cy.jsonfor a Welsh level) as a copy to keep, or to put inapi/resources/levelsfor a room shipped with the game. Test play opens the game with the draft straight away, without publishing it; answers are checked against the draft in this browser, so it never counts for the leaderboard. - Open file or Load (a room code, or
CA02.cyfor a Welsh version) edits an existing level. Load fetches it from the game server, with its plain answers when the server has the editor's copy; otherwise answers are kept as they are and you only type the ones you want to change. - The level being edited, plain answers included, is kept in this browser until New is pressed.
Answers
Answers are checked on the game server, never in the browser. A level file carries a salt and a hashed answer for each station; the API removes them before players get the level, checks every guess itself, owns the clock and the solved list (so a refresh or the browser console cannot skip anything), limits guesses to 10 per station per minute, and keeps a leaderboard. The file is never served to players. Shipped levels live in api/resources/levels; rooms saved from the admin live in the API's levels folder on the NAS (see deploy/README.md). Both are read the same way.
answerHash is sha256(salt + normalised answer) as lowercase hex. normalise steps (trim, lower, upper, nospace) run in order; with no normalise list the answer is still trimmed. python and sql stations are checked against config.expectedOutputHash / config.expectedResultHash instead. To work out a hash:
npm run hash -- bin42-2026 173
npm run hash -- bin42-2026 "Paris" trim,lower
Easier, and the only way for python, sql, logic and phishing: hash-station reads the salt and normalise from the level file, works out the answer in the exact form the game submits, and with --write saves the hash in the file:
npm run hash-station -- api/resources/levels/PY1.json pc_05 my-solution.py --write
npm run hash-station -- api/resources/levels/SQL1.json pc_14 "SELECT name FROM ..." --write
npm run hash-station -- api/resources/levels/LOGIC1.json pc_20 --write
npm run hash-station -- api/resources/levels/PHISH1.json pc_28 url,spelling --write
npm run hash-station -- api/resources/levels/TEXT1.json pc_01 "central processing unit" --write
npm run hash-station -- api/resources/levels/ORDER1.json pc_08 --write
For order, write config.items in the right order first; --write saves the hash and stores them scrambled, so the file never shows the order. network stations have no hash (see below).
A Python solution is run with the station's input or tests in Pyodide, as in the game; a SQL one against the station's database. Python and SQL are judged on their output, not the code, so any program that prints the right thing passes.
Check a level before putting it live with npm run check-level -- path/to/LEVEL.json.
Challenge types (v1)
What the player does, and the exact text that gets hashed, which is what to pass to npm run hash:
| Type | Player | config |
Hashed answer |
|---|---|---|---|
text |
Types an answer | placeholder, maxLength |
What they typed, after normalise. Use ["trim", "lower"] to ignore case |
multichoice |
Picks one option, or ticks several with multi: true |
options (2 or more), multi |
The option's text, e.g. RAM. With multi, the ticked options in the order they are listed in options, joined with |, e.g. Keyboard|Mouse|Microphone |
binary |
Converts a number, with a tap-the-bits place value helper | from, to (binary, denary, hex), bits (default 8) |
The answer in to, in one fixed form: denary with no leading zeros (173), binary padded to bits digits (00101101), hex in capitals padded to bits / 4 digits (0F, DE). The player can type 101101, 0xde or de; it is converted before hashing. Input that is not a number in that base, or too big for bits, is explained without counting as a guess |
keypad |
Enters a code on a keypad or keyboard | length (checks itself when full), charset (default 0123456789) |
The characters entered, e.g. 149 |
clue |
Reads config.text (Markdown) and config.image, presses Got it |
text, image |
No answer. Got it marks it solved |
Challenge types (v2)
These get a wider panel. Python and SQL run in the browser; only what they print is sent to the server to be checked.
| Type | Player | config |
Hashed answer |
|---|---|---|---|
terminal |
Explores a fake file system with ls (-a, -l), cd, cat, pwd, clear and help, with Tab completion and ↑ for history, then types what they found |
fs (folder → names in it), files (path → text), allowedCommands, cwd, user, host, motd, placeholder, maxLength |
What they typed in the answer box, like text |
python |
Edits and runs Python (Pyodide). Run tries it; Check runs it and submits what it printed. Endless loops are stopped after timeLimitSeconds (default 5) |
starter, input (what input() reads, one line per call), tests (one input per run; Check runs them all), timeLimitSeconds, expectedOutputHash |
The output, with spaces at line ends and blank lines at the end removed. Input is shown as typed, e.g. Number: 4 then 8. With tests, each run's output joined by a --- line |
sql |
Writes a query against a SQLite database (sql.js) that is rebuilt for every run, with the tables listed | schema, seed (SQL text or a list of statements), starter, ignoreOrder, expectedResultHash |
The last query's rows, one line each, values joined with | (NULL for null), no column names. With ignoreOrder the rows are sorted first |
logic |
Flips switches on a drawn gate circuit (AND, OR, NOT, NAND, NOR, XOR, XNOR) until the bulb is right. With showLevels: false the wires stay dark until Check |
inputs (switch names, up to 8), gates (id → type and inputs, which is the wiring), output (only needed if more than one gate is unused), targetOutput (1 or 0, default 1), showLevels |
The switches as 1s and 0s in inputs order, e.g. 101. The level checker warns if more than one pattern works, as only the hashed one is accepted |
phishing |
Clicks the suspicious parts of a fake email (or web page, when url is set) and presses Check. Hovering a link shows where it really goes |
html, from, subject, url, hotspots (part → label), required (how many to flag) |
The flagged part names in alphabetical order joined with |, e.g. from|link. Parts are from, subject and url when set, and each element in html with data-hotspot="name". Mark some safe parts too, so hovering does not give the answer away |
The html is cleaned before it is shown: formatting tags, style, images and links are kept; scripts, frames and forms are removed, and links never go anywhere.
Pyodide (about 12 MB, served from the game's own site so school filters treat it like the game) starts loading when a level with a python station begins. sql.js (about 650 kB) loads when a sql station is opened.
Challenge types (v3)
| Type | Player | config |
Hashed answer |
|---|---|---|---|
cipher |
Decodes a message, with a cipher wheel (Caesar), the wheel plus their key lined up under the message (Vigenere) or a letter guessing table with letter counts (substitution). The tools never decode by themselves | cipher (caesar, vigenere, substitution), ciphertext, showTool (default true), placeholder, maxLength |
What they typed, like text. The editor encrypts a message for you; so does encrypt() in src/challenges/ciphers.js |
order |
Drags steps or lines of code into order (or uses the arrow buttons, or Alt + up and down) | items, shuffle (default true), code (show as code) |
The items in order, one per line. The file stores them scrambled (the editor and hash-station --write do it), and each team gets its own shuffle |
network |
Clicks devices on a network map, fixes the settings they are allowed to (IP address, prefix, default gateway), plugs in cables if allowed, and runs the pings, which explain why each one fails | devices (id: type pc, server, laptop, printer, switch or router; label; ip, prefix, gateway for hosts; ips like 10.0.0.1/24 for routers; edit; optional x, y from 0 to 100), links, targets (pings that must work), cables |
No hash. Many setups are right (any free address will do), so the game server runs the pings on the player's changes. Switches join a network; a router joins networks for hosts that use it as their gateway (one hop) |
image |
Looks for something in a picture with zoom, File info (metadata, like a photo's details) and pixel tools: brightness, contrast, invert, one colour, and one bit plane, which shows messages hidden in the lowest bits. With hotspots, clicks the right spot instead of typing |
src (a path under public/, e.g. images/photo.png, or a web address), alt, hotspots (id: x, y, w, h in percent), metadata, tools (zoom, info, filters, bits), placeholder |
With hotspots, the id of the one clicked (mark decoys too, as the boxes are in the file); otherwise what they typed |
npm run hide-message -- photo.png out.png "MEET AT 4" hides text in bit 0 of the blue channel of a PNG (--channel red, or --faint to hide it as a tiny brightness change that strong contrast shows). The picture looks the same; Blue only and Bit 0 only show the words. Use PNG: JPEG compression wipes the hidden bits. Pictures for levels go in public/images, and the level writes images/photo.png. scripts/make-example-images.js makes the pictures for IMAGE1.
An unknown type falls back to a plain answer box, so a level made for a newer version still loads.
Welsh
The game's own text (buttons, messages, the monitors in the room) is in English and Welsh. Players pick English or Cymraeg on the start screen; the choice is remembered, and a link with ?lang=cy (e.g. ?room=CA02&lang=cy) starts in Welsh. Without either, Welsh is picked when the browser's language is Welsh. The strings are in src/i18n/en.js and src/i18n/cy.js; npm test checks both have every string.
A level's own text (titles, prompts, hints, options) is written in the level file, so a Welsh version is a second file next to the English one: CA02.cy.json beside CA02.json, with the same room code inside and "language": "cy". Players who pick Cymraeg get it; everyone else gets CA02.json. Both share one room code and one leaderboard, so a class can play in either language. When a room has no version in the chosen language, the intro says so and the level loads in the language it has.
To make one, copy the English file to CODE.cy.json in the same folder, translate the text, set "language": "cy", and keep the salt. Answers that do not change (numbers, codes, CPU) keep their hashes; work out new ones for Welsh answers with npm run hash-station -- api/resources/levels/CODE.cy.json pc_20 llwybrydd --write. npm test checks a Welsh file has the same stations, locks, rewards and exit as its English one. The run remembers its language, so answers are always checked against the file the team is playing.
Play
Each station's monitor shows its state: the title (or the station's screen text or image) when open, a padlock while locked, a tick and any reward code once solved. A station is locked until everything in its lockedBy is solved, and the exit station until everything in exit.requires is. Zooming in opens the challenge panel.
- Clock: starts when the team presses Start. With
timeLimitMinutesit counts down, otherwise it counts up. It shows in the top left corner with how many ofexit.requiresare solved, and on any wall display set to{ "show": "timer" }(red in the last minute). - Hints: a station's
hintsare revealed one at a time under its challenge. Each addshintPenaltySecondsto the time, which also brings a time limit closer; when there is a penalty, the button asks for a second press first. - Fullscreen: with
focusPenaltySecondsset, the game goes fullscreen when the team presses Start. Leaving fullscreen or switching to another tab or window puts a card over the room and adds that many seconds, once per time away, counted by the server; the card's button goes back to fullscreen. The intro card warns the team first. Where the browser refuses fullscreen (iPhone) only the tab check applies. - Wall displays:
{ "show": "text", "text": "..." }draws the text on the display, and zooming in repeats it in the panel.{ "show": "leaderboard" }shows today's five fastest teams, refreshed every 30 seconds. - Posters:
"posters": { "poster_door_01": { "image": "images/clue.png" } }puts a picture on a poster, e.g. a clue the team needs.imageis a path underpublic/or a web address (the site must allow other sites to show it). The picture is fitted inside the poster on white paper, so nothing is cut off: portrait A4 shaped pictures (e.g. 1240 by 1754 pixels) fill it. Posters not listed keep their own picture, and a picture that will not load leaves the poster as it was, with a warning in the browser console. The editor shows the picture in the room and a preview under the box. - End: solving the exit station shows
exit.message, then the view turns to the classroom door and it swings open (it just opens, with reduced motion turned on), then the end screen with the time (penalties included), stations solved, hints used and times the team left the game. Running out of time ends the run with a Time's up screen. Play again clears the run. - Refresh: solved stations, hints and the clock are kept on the server, so a reload in the same tab never resets the time.
- Save: for a lesson that ends before the room does. The Save button by the clock asks first, then the server stops the clock, the room stops, and the browser downloads
CODE-Team.escaperoom, a small JSON file. Next lesson, Load a saved game on the start screen opens the file and shows the room's intro with the time left and stations solved; the clock starts again when the team presses Start. The run itself stays on the server and the file only holds its id (the rest is for a teacher to read), so editing the file cannot change the time or the solved list. While saved, no answers or hints can be taken. A lost file cannot be recovered, so the card after saving has a button to download it again.
npm test runs the validator, game logic, challenge and API client tests, and checks every level in api/resources/levels. cd api && php artisan test tests the API.
Deploying
CI runs on Forgejo Actions, from .forgejo/workflows. Pupils use escape.mrjj.uk, served with the API from one Docker image on the NAS (api/Dockerfile builds the game with VITE_API_BASE=/, meaning "the API is on this site"), so it needs no CORS and avoids github.io, which schools block. See deploy/README.md.
.forgejo/workflows/game.yml runs npm test and builds the game on every pull request and on main, so a broken level or build shows up before merge. It deploys nothing: the game is only served from the Docker image.
.forgejo/workflows/api.yml tests the API and builds the Docker image on changes to the game or api/, and on main pushes it to forge.mrjjones.co.uk/james/escape-room-api. The tests run inside a small CI image (.forgejo/ci/Dockerfile, PHP 8.3 and Composer) that the runner builds once and reuses until the Dockerfile changes. Pushing needs the repo secret REGISTRY_TOKEN (see deploy/README.md). Running it on TrueNAS behind Cloudflare Tunnel: deploy/README.md.