More Projects
59 in total
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.
~900 users · 242 teams · 413 validated in the server log
Project Details
Creative Computing Society
2023
~900 users · 242 teams · 413 validated in the server log
Society · Web
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
.glbwith its own component tree, hint mesh layout and CSS module. - Per-team question randomisation through
randomUniqueArray, materialised asUserQuestiondocuments 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
setTimeoutagainst 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
Creative Computing Society
2023
~900 users · 242 teams · 413 validated in the server log
Society · Web