Raptus - 3D Escape-Room CTF
Completed
Society · Web

Raptus - 3D Escape-Room CTF

Creative Computing Society · 2023

Capture-the-flag competition staged as a set of 3D escape rooms in the browser. React Three Fiber renders the rooms over Three.js, a MERN backend holds the challenge state, and Socket.IO drives a live leaderboard. There is no proctoring anywhere in the system by design — no webcam, no tab-switch detection — because each team is served its own subset of challenges with the hint object randomly placed, which makes a shared answer worth nothing. Run online over Discord across two days with cryptic multi-stage challenges.

SCALE

~900 users · 242 teams · 413 validated in the server log

Built with
ReactReact
React Three Fiber
@react-three/drei
Three.jsThree.js
Node.jsNode.js
ExpressExpress
MongoDBMongoDB
Socket.IOSocket.IO
NodemailerNodemailer
Project Details

STATUS
Completed
ORGANISATION

Creative Computing Society

YEAR

2023

SCALE

~900 users · 242 teams · 413 validated in the server log

TYPE

Society · Web

TAGS
Game
CTF
Real-time
WebGL

A capture-the-flag event is normally a table of challenges with a text box next to each one. Raptus hid them. Four 3D rooms rendered in the browser, with the cryptographic questions and their hints placed somewhere inside the geometry, so before a team could solve anything they had to search the room and find it. It ran online over 21-22 July 2023, pulling students from colleges across the region into a Discord server for a 24-hour game. The repository is creative-computing-society/Raptus: 504 commits between 30 June and 22 July 2023, 240 of them his, which makes him the single largest contributor to it.

It is three trees in one repo. client/ is the game portal, server/ is the Express and Mongoose engine that owns the game state, and registration/ is a separate front end that handled sign-up before the game opened. Both front ends went out on Netlify; the server was self-hosted.

The anti-cheat is the level design

The interesting decision is that there is no proctoring anywhere in this system. No webcam, no tab-switch detection, no session recording. Cheating is made pointless instead of being watched for, and the mechanism is worth walking through because it is the thing the whole architecture is arranged around.

When a team first enters a round, RoundInitialController.initialAssign calls randomUniqueArray(roundQs, questions) against the question pool attached to that room. Each team draws its own random subset. The controller then walks that subset and, for every question, picks a random HintAddress between 1 and 12 that no other question in the set already holds, and writes a UserQuestion document keyed to the team carrying the question text, the answer, the hint and that address. The room's hint file on the client is a set of numbered meshes at fixed positions in space. BunkerHints.js declares twelve of them, the Control Room eight, the Jail five, the final room three, each an <Html> panel wrapped on a mesh at hand-tuned coordinates. Clicking hint slot seven POSTs {Round, HintAddress: 7} to /question/hint, and the server answers with whatever that team's UserQuestion at address seven says.

The consequence is that the hint for a given question sits at a different physical location in the 3D room for every team, and two teams do not have the same questions in the first place. A team that finds the answer behind the third pipe on the left cannot tell another team to look behind the third pipe on the left, because for that team there is a different question there, or nothing. Sharing a screenshot of a solved puzzle transfers no useful information about where anything is.

Underneath that sits ordinary server-side discipline. validateAnswer looks the question up by number, loads the round, refuses if round.Enabled is false, refuses if the current time is outside round.StartTime and round.EndTime, and compares the trimmed submission against the stored answer. Already-solved detection is a pair of array membership checks against the leaderboard's Solved and Unsolved lists rather than a trust in the client's idea of progress. Authentication is a SessionID cookie resolved through AuthenticationModel in middlewares/ProtectRouter.js, which hangs the user, the team and the leaderboard row off the request before any handler runs. Logging in deletes the previous session row for that user, so a credential in two browsers ends up with one live session.

Four rooms and four clocks

Each room is its own .glb and its own React component tree: underground_bunker-transformed.glb, controlRoom-transformed.glb, jailroom-transformed.glb and finalroomfinal-transformed.glb, loaded with useGLTF under client/src/Models/{Bunker, Control Room, Jail, XFinal}. The renderer is React Three Fiber rather than hand-written Three.js: @react-three/fiber 8.13 and @react-three/drei 9.70 over three 0.153, with @react-three/postprocessing supplying the Bloom pass on the Control Room and @react-three/rapier and detect-gpu pulled in alongside leva and r3f-perf as the physics and tuning kit. Two HDRI environments ship in public/: dikhololo_night_1k.hdr lights the bunker, pedestrian_overpass_1k.hdr sits beside it. The bunker scene is a Canvas at dpr={[1, 2]} with an environment map, a fog attachment, one ambient and one shadow-casting directional light, and orbit controls clamped to a small radius so the camera pivots inside the room instead of flying out of it. Before the scene mounts, a fake terminal types npm i start and a welcome line for six seconds, which is the loading screen doing double duty.

Rounds are scheduled, not polled. app.js reads the four Round documents at boot and hands their start and end timestamps to timers/RoundOneTimer.js through RoundFourTimer.js, each of which sets timeouts for the round opening, the round closing, and broadcast warnings at fifteen and five minutes before both. Round three is the one that bends the data model. timers/RoundThreeCollaborator.js takes the round-two leaderboard, keeps the top four teams per room, shuffles them with a Fisher-Yates pass, pairs them, and writes a CollaborativeLeaderboardModel document per pair with TeamID1, TeamID2, a hyphenated joint name and each side's carried-in points. Both teams get reassigned into the round-three room, every member's RoomID updated individually, and from that point validateAnswer scores against the pair document rather than either team's own row.

Sockets carry the pressure

Two client hooks open two Socket.IO channels. leaderboardSocket.js listens for leaderboard-update, which sockets/LeaderBoardUpdated.js emits on every accepted answer, so the standings are a broadcast rather than several hundred clients asking the same question every few seconds. gameRoomSocket.js carries notification, question-solved and round, fed by sockets/Notification.js, QuestionSolved.js, RoundStartEnd.js, TeamConnectionStatus.js and UserSockets.js. Teams are Socket.IO rooms keyed by team id, so a solve notifies that team's members and nobody else, and a collaborative solve notifies both halves of the pair. app.js sets io.sockets.setMaxListeners(500), which is the concrete headroom the server was configured for.

In an escape-room CTF the leaderboard is not decoration, it is the pressure. A team that cannot see the gap closing has no reason to hurry.

Where it came from, and how people got in

Raptus grew out of Incursion-Registration-Form, the earlier society repo where he wrote 97 of 169 commits and where each room still shipped as its own separate Create React App build. The consolidation into one portal is what the Raptus repo is. The lineage is still visible in the files: the Raptus README reads, in its entirety, # Incursion-Registration-Form, and the server's package name is incursion-registration-form. The package author line names the team as Pariansh Mahajan, Akarsh Srivastava, Saransh Gupta and Pancham Agarwal.

Registration is a team-first flow. createTeam mints an eight-character invite code with crypto.randomBytes(4), creates the team, validates and creates the leader, opens a leaderboard row, and mails the leader a join link at /register/:Code. If any step throws, the handler deletes the team, the user and the leaderboard row it had already created, which is a hand-written compensating rollback rather than a transaction. Mongo duplicate-key errors are caught and mapped to readable messages for team name, code, email, roll number, phone number and Discord ID. UserValidator.js rejects a signup without a Discord ID and the model marks it unique, which is the hard evidence that the event was run over Discord. config/MailConfig.js is roughly 3,000 lines of hand-written transactional HTML for the team-created, member-invite and verification mails, sent through Nodemailer.

What was built

  • Four 3D escape rooms in React Three Fiber, each a separate .glb with its own component tree, hint mesh layout and CSS module.
  • Per-team question randomisation through randomUniqueArray, materialised as UserQuestion documents so a team's paper is state rather than a function recomputed on each request.
  • Per-team hint placement, with the hint for a question landing on a different one of the room's numbered slots for every team.
  • Four scheduled rounds driven by setTimeout against stored start and end timestamps, with fifteen-minute and five-minute warnings broadcast before each boundary.
  • A collaborative round that pairs the top teams off the round-two board into a shared room and scores the pair as one entity.
  • Live leaderboard pushed over Socket.IO on every solve, with setMaxListeners(500) for headroom.
  • Cookie session auth resolved server-side through AuthenticationModel, with the previous session invalidated at login.
  • Team registration and invite codes, with a compensating rollback across three collections and duplicate-key errors translated into readable failures.
  • Roughly 3,000 lines of transactional mail through Nodemailer, plus an admin surface of hand-built HTML pages for entering questions, rooms and rounds.
  • Coupon codes generated with crypto.randomBytes(6) and redeemed at login for five points, capped so a team cannot farm them.
  • Steganographic easter eggs shipped as static pages, including one whose visible text reads "This is a CLOAKED message" over a zero-width Unicode payload.

What the logs record

The server's own logs.txt runs 9,220 lines from 9 July to 21 July 2023. Counted directly, it records 242 teams created and 413 users validated during the registration window. His resume puts the platform at around 900 users; the log figures are the subset the server itself wrote down between those two dates, and they are the numbers I can point at in the file.

There is also evidence the game was hot-patched while it ran. A second working copy of the server sits beside the committed one with no .git directory and eleven files diverging, all of them in round logic, leaderboard prefetch and auth. That is what event-day looks like.

Honest limitations

Room assignment in initialAssign is a module-level count variable incremented per team and wrapped modulo the room list. It is a round-robin that lives in one process's memory, so it distributes teams evenly only as long as there is exactly one process, and it resets on restart. The collaborative scoring path writes board.Team1Points and board.Team2Points while the schema declares Points1 and Points2, so under Mongoose's default strict mode those increments go nowhere and the pair's split is not actually recorded, even though the joint Points total is. formRandomPairs loops a hardcoded twelve entries regardless of how many teams it was handed and falls back to the first team when it runs off the end, which can pair a team with itself. The client/src/config.js shipped in the tree points at localhost:3001 with the production api.raptus.ccstiet.com line commented out above it. CORS origins are a hardcoded list of three localhost addresses. RoundOneController.post is a scratchpad of one-off migration helpers that parse the log file back into Mongo, and several of them are left wired into a live route. None of that showed at the event, and all of it is what a 24-hour game built in three weeks looks like from the inside.

The rooms outlived the event. A month later, when the society set up its stall at the freshers' fair, the Raptus rooms were among the projects put on a screen to show incoming students what the society builds. A CTF scoreboard would not have survived that trip.

Project Details

STATUS
Completed
ORGANISATION

Creative Computing Society

YEAR

2023

SCALE

~900 users · 242 teams · 413 validated in the server log

TYPE

Society · Web

TAGS
Game
CTF
Real-time
WebGL