Built for the Coding Club, BITS Pilani. Adopted by 500+ users across live contests.
The source repositories are private (club-owned). This document is a technical writeup of the system I designed and built: the architecture, the engineering decisions, the trade-offs, and the measurements.
- What It Is
- System Architecture
- The Life of a Submission
- Deep Dive — The Remote Code Execution Engine (Go)
- Deep Dive — Matchmaking
- Deep Dive — Real-Time Duel State
- Scoring and Leaderboards
- Contest Lifecycle and Durability
- Data Model
- Reliability and Failure Handling
- Observability
- Load Testing and Results
- Technology Choices and Trade-Offs
- Testing Strategy
- Known Limitations and Roadmap
- Repository Layout
Code Duel is a head-to-head competitive programming platform. Two participants are matched against each other, receive the same algorithmic problem calibrated to their combined skill level, and race to solve it. The first correct submission wins the duel; the winner gains rating points weighted by opponent strength, solve speed, and win streak. Contests run for a fixed duration, during which participants duel repeatedly and climb a live leaderboard.
Everything that makes that experience feel instant is the hard part:
| Requirement | Engineering consequence |
|---|---|
| Untrusted code must run safely | Every submission executes in a locked-down, single-use Docker sandbox |
| Verdicts must feel immediate | Container startup is removed from the critical path via a pre-warmed pool |
| Bursty traffic (everyone submits at once) | Queue-backed, demand-proportional autoscaling of the sandbox pool |
| Duels are live and stateful | WebSocket state machine with reconnection, forfeit timers, and draw resolution |
| Two players can't be double-booked | Atomic Redis claim + idempotency token on match creation |
| A contest can't lose scores if a process dies | Redis is the hot path; PostgreSQL is checkpointed continuously |
Four independently deployable services, four languages judged (C++, Java, Python, PyPy), all coordinated through Redis.
flowchart TB
subgraph client["Client"]
FE["Next.js 15 Frontend<br/>React · Monaco editor · Socket.IO client"]
end
subgraph core["Core Services"]
BE["Backend — Node.js / Express / TypeScript<br/>REST API · Socket.IO · better-auth"]
MM["Matchmaking Service — Node.js / TypeScript<br/>independent pairing loop"]
RCE["RCE Engine — Go<br/>sandbox pool · autoscaler · judge"]
ADMIN["Admin Portal — Django<br/>unmanaged models over the same schema"]
end
subgraph data["State"]
REDIS[("Redis Stack<br/>queues · JSON state · sorted sets")]
PG[("PostgreSQL<br/>Drizzle ORM · source of truth")]
end
subgraph sandbox["Sandboxing"]
DOCKER["Docker Engine"]
C1["sandbox: cpp"]
C2["sandbox: java"]
C3["sandbox: python"]
C4["sandbox: pypy"]
end
FE <-->|"WebSocket + REST"| BE
BE --> PG
BE <--> REDIS
MM <--> REDIS
MM -->|"POST /api/v1/match-found"| BE
BE -->|"LPUSH submissionsQueue:{lang}"| REDIS
RCE -->|"BRPOP"| REDIS
RCE --> DOCKER
DOCKER --> C1 & C2 & C3 & C4
RCE -->|"POST result callback"| BE
ADMIN --> PG
The single most important boundary is the RCE engine. Node.js is single-threaded; orchestrating Docker lifecycles and marshalling compile output on the same event loop that serves every live WebSocket would turn one heavy C++ compile into a latency spike for every connected player. Moving execution into a separate Go process means:
- Blast radius containment — the service that touches the Docker socket and runs untrusted code is not the service that holds user sessions.
- The right concurrency model — one goroutine per job, bounded by a semaphore, is a natural fit for orchestrating hundreds of concurrent container lifecycles.
- Independent scaling — the judge is CPU- and memory-bound; the API is I/O-bound. They scale on different axes.
The matchmaking service is separated for a different reason: it is a periodic workload (a loop that wakes on an interval and pairs everyone waiting), not a request/response workload. Keeping it out of the API server means the pairing loop can be tuned, restarted, or scoped to a single contest without touching live connections.
Services communicate asynchronously through Redis rather than direct HTTP. The backend pushes a job and returns immediately; the engine pulls when it has capacity. That gives natural backpressure: a submission burst grows a queue instead of overwhelming a thread pool, and the queue length is itself the autoscaling signal.
sequenceDiagram
autonumber
actor P as Player
participant FE as Frontend
participant BE as Backend (Socket.IO)
participant R as Redis
participant RCE as RCE Engine (Go)
participant SB as Sandbox Container
P->>FE: Write solution, hit Submit
FE->>BE: socket.emit("submitCode", {duelId, problemId, code, language})
BE->>R: Validate duel is in_progress
BE->>R: Check cooldown:submit:{userId}
BE->>BE: Reject duplicate of last submission
BE->>R: SET cooldown (EX 10)
BE-->>FE: duelUpdate { status: "JUDGING" } (broadcast to both players)
BE->>R: LPUSH submissionsQueue:{language}
BE->>BE: INSERT into submissions (PostgreSQL)
RCE->>R: BRPOP submissionsQueue:{language} (5s timeout)
RCE->>RCE: Validate size limits, compute time/memory budget
RCE->>RCE: Acquire semaphore slot, mark job in-flight
RCE->>SB: Take pre-warmed container from pool
RCE->>SB: docker exec /app/runner --stdin ← JSON payload
SB->>SB: Compile → run every hidden test case → check output
SB-->>RCE: Verdict JSON on stdout
RCE->>SB: Destroy container (deferred), replenish pool async
RCE->>BE: POST /api/v1/submission/result (retry w/ backoff)
BE->>R: Update duel state, streaks, ZINCRBY leaderboard
BE->>PG: Persist verdict + duel outcome
BE-->>FE: submissionStatus + duelStatusChange + leaderboardUpdate
FE-->>P: Verdict, victory/defeat modal, updated rank
There are three separate queue families per language, so that a quick "Run against sample cases" never queues behind a full submission judgement:
| Queue | Purpose | Result callback |
|---|---|---|
submissionsQueue:{lang} |
Real submission, all hidden tests, decides the duel | /api/v1/submission/result |
runQueue:{lang} |
"Run" against visible sample cases | /api/v1/run/result |
runQueueCustom:{lang} |
Run against user-supplied custom input | /api/v1/run/result |
Four languages × three queues = 12 concurrent BRPOP goroutines, each an independent consumer.
This is the heart of the project and where most of the engineering went.
Every submission runs in a single-use container that is destroyed immediately afterwards. Containers are never reused between submissions, so no state — files, processes, memory — can leak from one user's code to another's.
Each sandbox is created with:
hostConfig := &container.HostConfig{
Resources: container.Resources{
Memory: baseMemory, // hard cgroup memory ceiling
NanoCPUs: cpuNanoCPUs, // 0.5 core — keeps CFS scheduling predictable
PidsLimit: &pidsLimit, // 512 — fork-bomb protection
Ulimits: []*container.Ulimit{{Name: "fsize", Soft: ..., Hard: ...}},
},
ReadonlyRootfs: true, // immutable root filesystem
CapDrop: []string{"ALL"}, // every Linux capability dropped
SecurityOpt: []string{"no-new-privileges"},
NetworkMode: "none", // no network namespace at all
Tmpfs: map[string]string{
"/workspace": "rw,exec,nosuid,size=256m,uid=1000,gid=1000",
"/tmp": "rw,exec,nosuid,size=128m,mode=1777",
},
}Layered on top of that:
- The container image creates a non-root
rceuser(uid 1000) and runs as it. - The only writable space is a size-capped in-memory tmpfs that vanishes with the container.
- Runtime memory is enforced twice — by the cgroup at the container level, and inside the
container by
ulimit-style address-space caps (and-Xmxfor the JVM), so an over-allocating program gets a cleanMEMORY_LIMIT_EXCEEDEDverdict rather than being OOM-killed opaquely. - Payload guards run before a container is ever acquired: max code size (256 KB), max test-case count, max captured output.
The engine deliberately supports two runner transports, selected by config:
| Mode | Mechanism | Why |
|---|---|---|
exec (default) |
docker exec /app/runner --stdin, payload on stdin, verdict on stdout, streams demuxed with stdcopy |
Sandbox needs no network namespace at all; portable and stable across hosts |
http |
Runner is an HTTP server on a random mapped host port; engine POSTs the payload | Lower per-job overhead under heavy Linux load |
exec is the production default because it lets the sandbox run with NetworkMode: "none" — the
strongest isolation posture — at a small latency cost.
Creating and starting a container is hundreds of milliseconds. Paying that on every submission is the single largest avoidable cost in a judge. So the engine keeps a pool of already-running, already-idle containers per language.
The pool is, at its core, a buffered channel per language — the channel is the pool, which makes "wait for a free sandbox" a single blocking receive with no custom condition-variable logic:
type ContainerPool struct {
containers map[string]chan ContainerInfo // language → ready sandboxes
currentSizes map[string]int
targetSizes map[string]int
createMus map[string]*sync.Mutex // per-language creation lock
...
}
func (p *ContainerPool) GetContainer(ctx context.Context, lang string) (ContainerInfo, error) {
select {
case info := <-p.containers[lang]:
return info, nil
case <-ctx.Done():
return ContainerInfo{}, ctx.Err()
}
}flowchart LR
subgraph pool["Warm pool — one buffered channel per language"]
direction LR
W1["warm"] --- W2["warm"] --- W3["warm"] --- W4["warm"]
end
JOB["Job arrives"] -->|"GetContainer — no cold start"| W1
W1 --> EXEC["exec runner<br/>compile · run tests · check"]
EXEC --> DESTROY["Destroy container<br/>(deferred, always runs)"]
DESTROY --> REPL["Replenish asynchronously<br/>off the critical path"]
REPL --> pool
Two properties make this work:
- Destroy-and-replenish is asynchronous. A
deferin the executor fires the container teardown and pool refill in a background goroutine, so the user's verdict is not waiting on Docker cleanup. - Creation is parallel across languages, serialized within one. A per-language mutex avoids hammering the Docker API with concurrent creates of the same image (a source of daemon-level contention), while all four languages still warm in parallel at startup.
Result: container startup leaves the critical path entirely — measured ~65% reduction in end-to-end evaluation latency versus creating a container per submission.
A fixed pool is wrong in both directions: too small and a submission burst queues behind cold starts; too large and idle containers waste the memory the judge needs to actually run code.
So a scaling manager runs on a 2-second tick and re-sizes each language's pool from live demand:
flowchart TD
T["Tick every 2s"] --> IF["In-flight jobs per language<br/>(atomic counters)"]
T --> QL["Queue depth per language<br/>(Redis LLEN × 3 queues)"]
IF --> D["langDemand = in-flight + queued"]
QL --> D
D --> P["proportion = langDemand / totalDemand"]
P --> TGT["target = baseSize + proportion × scalingCapacity<br/>clamped to [baseSize, maxSize]"]
TGT --> ADJ{"target vs current"}
ADJ -->|"higher"| UP["ScaleUp — capped at ±8 per cycle"]
ADJ -->|"lower"| DOWN["ScaleDown — capped at ±8 per cycle"]
ADJ -->|"equal"| HOLD["Hold"]
UP --> T
DOWN --> T
HOLD --> T
Three details that matter:
- Queue length alone is a bad signal.
BRPOPdrains the queue instantly — jobs move from "queued" to "in-flight" the moment a consumer is free, so a purely queue-based autoscaler sees zero demand while the machine is fully saturated. I added an in-flight tracker (a map ofatomic.Int64per language, incremented when a job takes a semaphore slot and decremented on completion) so demand isqueued + in-flight. This was a real bug found under load, not a theoretical one. - Capacity is allocated proportionally, not equally. If 90% of demand is C++, C++ gets the capacity. Java doesn't sit on 20 idle JVM containers during a C++-heavy problem.
- Adjustment is rate-limited to ±8 containers per cycle, so the pool ramps instead of thrashing.
With container_pool_base_size: 4 and container_pool_max_size: 20 per language, that is
5× burst capacity per language — a steady-state footprint of 16 warm containers that expands to
80 under load, and decays back when the burst passes.
The maximum concurrency is not hardcoded. On boot, the engine reads physical host RAM and derives a safe ceiling:
availableBytes := totalRAMBytes - 2*GiB // reserve for OS, Redis, Go runtime, other services
const workingMemoryPerContainer = 100 * MiB // ~2× observed peak (≈50 MiB during C++ compile)
safeMaxConcurrent := availableBytes / workingMemoryPerContainer
cfg.App.Server.MaxConcurrentJobs = safeMaxConcurrentThe subtlety worth calling out: the Docker --memory flag (1 GiB) is a security ceiling for
runaway processes, not a capacity-planning number. Sizing the pool by that limit would produce a
concurrency of ~4 on a 6 GB box. Sizing it by observed working set (~5 MiB idle, ~50 MiB peak
during compilation, budgeted at 100 MiB) produces the real safe number. The config file acts as a
floor and a ceiling around the computed value, so an operator who knows their workload can still
override it.
The computed value feeds a semaphore that bounds concurrent jobs; everything past it waits in Redis rather than in RAM.
A problem tagged "2 second limit" means nothing unless you know how fast the judge is. Running the same contest on a laptop and on a cloud VM would produce different verdicts for identical code.
On startup the engine calibrates itself: for each language it runs a fixed CPU-bound benchmark with a known reference time, and derives a scaling factor:
factor := measuredTime / bench.ReferenceTime
calibratedFactor := factor * 1.1 // 10% safety bufferEvery job's per-test-case limit then becomes problemTimeLimit × languageFactor. A host 1.5× slower
than the reference scales every limit by ~1.65×. Calibration failure falls back to a conservative
1.5. Time limits become a property of the problem, not of the machine.
Note that the factor is per language: the Python interpreter and an -O2 C++ binary are affected
very differently by the same slower host, so a single global multiplier would be unfair to one of
them.
Correctness for problems with multiple valid answers (any valid ordering, floating-point tolerance,
"output any shortest path") requires a special checker — a testlib.h-style C++ program that
validates a submission's output against the input and the reference answer. Compiling that checker
on every submission is pure waste, because the checker never changes for a given problem.
Two layers of avoidance:
- Pre-compiled checker binaries are bind-mounted read-only at
/checkers/{problemId}. If the binary exists, the runner copies it in and skips compilation entirely. The mount is optional and auto-detected from the engine's own container inspection, with graceful degradation: if the bind mount can't be created, the pool disables it and keeps serving. - Content-addressed compile cache on a shared Docker volume, keyed by
SHA-256(checkerCode), so a checker compiled once by any container is reused by all subsequent ones. The cache volume is labelledrce-persist=trueand explicitly excluded from the periodic prune, so cleanup never evicts it.
User code is deliberately not cached — it changes on every submission, so a cache lookup would be a guaranteed miss plus overhead.
The in-container runner produces one of a closed set of verdicts, distinguishing them carefully:
stateDiagram-v2
[*] --> Compile
Compile --> COMPILE_ERROR: non-zero exit
Compile --> RunTests: ok
RunTests --> TIME_LIMIT_EXCEEDED: context deadline
RunTests --> MEMORY_LIMIT_EXCEEDED: SIGKILL / SIGXCPU / OutOfMemoryError / bad_alloc
RunTests --> RUNTIME_ERROR: SIGSEGV or non-zero exit
RunTests --> Check: clean exit
Check --> WRONG_ANSWER: checker rejects
Check --> RunTests: more tests
Check --> SUCCESS: all tests pass
Signal-level inspection matters here: a process killed by SIGKILL under an address-space limit is
a memory failure, while SIGSEGV is a runtime failure, and the two must not be reported as the
same thing to a competitor debugging under time pressure. Java is special-cased (-Xmx injection,
OutOfMemoryError string matching) because the JVM manages its own heap and dies differently.
The engine runs in two modes per job: submit short-circuits on the first failure (fast rejection),
while run executes every visible case and returns per-test-case detail so the player can see
exactly which sample broke.
Results POST back to the backend over a shared, connection-pooled HTTP client (50 idle connections, 20 per host) — a fresh client per job would burn a TCP handshake on every verdict.
Delivery retries with exponential backoff (3 attempts, 1s → 2s) and, importantly, discriminates
error classes: a 5xx is retried, a 4xx is not, because a malformed payload will be malformed
on the next attempt too. All result fields are byte-truncated before transmission so a program
printing 100 MB of output can't blow up the callback or the database row.
Users waiting for a duel live in a Redis sorted set per contest, matchmaking:{contestId},
scored by join timestamp — so the set is inherently ordered by wait time and FIFO fairness comes for
free. Profile data (rating, score, streak, previous opponent, active duel) lives alongside as Redis
JSON documents.
Every cycle, the service reads all contest queues, hydrates and validates each user (with a Zod schema — malformed or stale entries are dropped rather than crashing the loop), and pairs them:
flowchart TD
START["Cycle begins"] --> CHECK{"Contest still running?"}
CHECK -->|"no"| STOP["Idle"]
CHECK -->|"yes"| SCAN["Read all matchmaking:{contestId} sorted sets"]
SCAN --> HYDRATE["Hydrate + Zod-validate each user<br/>evict anyone already in an in_progress duel"]
HYDRATE --> ORDER["Order by queue join time (longest wait first)"]
ORDER --> LOOP["For each unclaimed user"]
LOOP --> WAIT{"Waited ≥ betterMatchWaitTime?"}
WAIT -->|"no"| SKIP["Leave in queue — a better opponent may arrive"]
WAIT -->|"yes"| CRIT{"Early contest?"}
CRIT -->|"yes"| RATING["Pair on closest rating"]
CRIT -->|"no"| SCORE["Pair on closest contest score"]
RATING --> CAND["Rank candidates by criterion distance"]
SCORE --> CAND
CAND --> REMATCH{"Best candidate is<br/>the immediately previous opponent?"}
REMATCH -->|"no"| TOKEN["Build deterministic match token"]
REMATCH -->|"yes"| HELD{"Waited ≥ rematchWaitTime?"}
HELD -->|"no"| EXCLUDE["Exclude — keep looking for a fresh opponent"]
HELD -->|"yes"| TOKEN
TOKEN --> POST["POST match to backend"]
POST --> OUT{"Outcome"}
OUT -->|"created"| CLAIM["Claim both users, ZREM from queue"]
OUT -->|"duplicate"| CLAIM
OUT -->|"skipped_busy"| RELEASE["Claim only the genuinely busy users"]
Three mechanisms, each solving a distinct fairness problem:
1. Repeat-opponent avoidance with a graceful fallback. Facing the same person three times in a
row makes a contest feel broken. The matcher tracks each user's prevOpponentId and maintains two
candidate rankings simultaneously — a strict best match excluding immediate rematches, and a
fallback best match allowing them. The strict match always wins if one exists. The fallback only
becomes eligible once the pair has waited past rematchWaitTime. Nobody is starved for the sake
of variety — variety is preferred, not enforced at any cost.
2. Shifting match criteria. Early in a contest, nobody has a meaningful contest score, so pairing uses global rating. After a configurable window, it switches to live contest score, which by then reflects actual in-contest performance. Ties on the primary criterion break on the other one.
3. A deliberate wait window. A user is not matched the instant they queue. betterMatchWaitTime
holds them briefly so the matcher can consider people who arrive moments later. Trading a couple of
seconds of wait for a materially closer opponent is the right call in a rating-based system.
The dangerous scenario: two pairing cycles overlap, or a retry fires after a timeout, and the same two users get placed into two different duels. That corrupts the leaderboard and strands a player in a ghost match.
Three layers of defence:
Deterministic idempotency token. The matcher builds a token from the sorted user IDs plus both queue-join timestamps, so the same logical pairing always produces the same token regardless of which user is "first":
private buildMatchToken(contestId, firstUser, secondUser): string {
if (firstUser.id.localeCompare(secondUser.id) <= 0) {
return `${contestId}:${firstUser.id}:${secondUser.id}:${firstUser.queueJoinTime}:${secondUser.queueJoinTime}`;
}
return `${contestId}:${secondUser.id}:${firstUser.id}:${secondUser.queueJoinTime}:${firstUser.queueJoinTime}`;
}Atomic claim in Redis. The backend converts that token into a single-winner lock:
const lockAcquired = await redisClient.set(matchTokenKey, '1', { NX: true, EX: 120 });
if (lockAcquired !== 'OK') {
return ApiResponse(res, 200, 'Duplicate match ignored', { outcome: 'duplicate' });
}SET NX EX is a single atomic Redis operation — no read-then-write race window. The TTL means a
crashed request can't wedge a pair permanently, and the lock is explicitly released on any downstream
failure so a transient error doesn't block a legitimate retry.
In-process guard + busy check. The matcher additionally holds an in-flight token set (so a slow
backend call doesn't get re-attempted within the same cycle), and the backend re-verifies both users
are not in an in_progress duel before committing. The response is a typed three-way outcome —
created / duplicate / skipped_busy — and each is handled differently: created and duplicate
both claim the pair, while skipped_busy returns only the genuinely-busy users to a claimed state and
frees the other to match with someone else in the same cycle.
Once paired, the duel needs a problem that is fair to both and new to both. The backend:
- Averages the two players' live contest scores, normalizes to the nearest 100, and clamps to the available problem-rating band (800–1600).
- Fetches all problems at that rating.
- Subtracts the union of both players'
usedProblems:{contestId}:{userId}Redis sets. - Picks randomly from what remains, falling back to the full candidate list if the players have exhausted the band.
- Records the choice into both players' used-sets in a single pipelined
MULTI.
Duel duration is then derived from the problem's rating (15 / 18 / 21 minutes) — harder problems get more clock.
All live duel state is held in Redis (fast, shared across processes) and continuously checkpointed
to PostgreSQL (durable). WebSocket connections are authenticated at handshake time through
better-auth middleware — an unauthenticated socket never reaches a handler.
stateDiagram-v2
[*] --> in_progress: match created, both sockets joined duel room
in_progress --> ended: a correct submission lands
in_progress --> forfeited: a player disconnects and the 30s grace expires
in_progress --> drawn: clock expires, or both players back out
in_progress --> abandoned: both players disconnect simultaneously
ended --> [*]
forfeited --> [*]
drawn --> [*]
abandoned --> [*]
A player closing their laptop mid-duel must not instantly hand their opponent a win (they may be on flaky campus Wi-Fi), but must also not let them stall the opponent indefinitely.
sequenceDiagram
participant A as Player A
participant BE as Backend
participant R as Redis
participant B as Player B
A--xBE: socket disconnect
BE->>R: HSET disconnectMap[A] = disconnectToken (timestamp)
BE->>R: If A was queued but not duelling, ZREM from matchmaking
BE->>R: Is B also in disconnectMap?
alt Both disconnected
BE->>R: duel.status = "abandoned"
BE-->>B: duelStatusChange { abandoned }
else Only A disconnected
BE-->>B: opponentDisconnected { willForfeitAt: now + 30s }
Note over BE: 30-second forfeit timer armed
alt A reconnects in time
A->>BE: reconnect (new socket)
BE->>R: HDEL disconnectMap[A] ← invalidates the stored token
BE-->>A: reconnectedToDuel { problem, opponent, remaining time }
BE-->>B: opponentReconnected
Note over BE: Timer fires, sees token mismatch, exits as a no-op
else Grace expires
BE->>R: Re-read duel — still in_progress?
BE->>BE: endDuel(reason: forfeit) — Redis + PostgreSQL + leaderboard
BE-->>B: opponentLeft { you win, +score, streak }
end
end
The mechanism I'm most pleased with is the disconnect token. A naive setTimeout forfeit timer
is riddled with races: the player reconnects and disconnects again, the opponent solves the problem
during the grace window, the duel ends some other way — and a stale timer fires and forfeits someone
who is actively playing.
So each disconnect writes a timestamp token into disconnectMap. When the timer fires 30 seconds
later it re-reads the token and compares. Reconnection deletes the key; a newer disconnect
overwrites it. Either way the stale timer's comparison fails and it exits harmlessly. On top of that,
the timer re-reads the duel from Redis before acting, so a duel that ended by any other means during
the window is left alone — and if the opponent had also already forfeited, it resolves as a draw
rather than awarding a win to someone who left.
Reconnecting is not just "rejoin the room". The connection handler resolves whatever happened while
the player was away and replays the correct state: rejoin the duel room and restore the problem,
opponent identity and remaining clock if still in_progress; or report a win, loss, draw,
abandonment, or forfeit that concluded during the absence — then clear the stale references so the
player can queue again cleanly. Handlers are registered synchronously before any async work in
the connection path, so events fired immediately on connect are never dropped.
- Per-user cooldown (
cooldown:submit:{userId}, 10s TTL) to stop submit-spamming the judge. Notably, the cooldown is set after all validation passes, so a rejected duplicate doesn't burn the user's next legitimate submission window. - Duplicate detection — resubmitting byte-identical code to the last submission is rejected without ever reaching the judge.
- Duel ownership check — the submitted
duelIdmust match the user's active duel in Redis.
Duel scoring is Elo-derived with competitive-programming adjustments:
expected = 1 / (1 + 10^((loserRating − winnerRating) / 400))
baseScore = MIN_WIN_SCORE (20) + ELO_K (20) × (1 − expected)
streakBonus = min(streak × 3, 15)
timeBonus = round((1 − timeTaken / maxTime) × 10)
total = max(baseScore, baseScore + streakBonus + timeBonus − penalties)
- Upset-weighted — beating a stronger opponent pays more, exactly as Elo intends.
- Speed-rewarded — solving in a third of the clock earns most of the time bonus.
- Streak-rewarded but capped — 3 points per consecutive win, capped at 15, so a hot streak is worth chasing without running away with the contest.
- Floored — the final
max(baseScore, total)guarantees bonuses and penalties can never make a win worth less than the base Elo award.
Live leaderboards are Redis sorted sets (leaderboard:{contestId}), so a score update is a
single ZINCRBY and a rank lookup is a single ZREVRANK — both O(log N), no scan, no recompute.
Rendering the top-N batches user profile lookups into one JSON.MGET round-trip rather than N
individual gets, and every player receives their own rank alongside the top-N even when they're
ranked #400.
Redis is the hot path; PostgreSQL is the source of truth. Cron-driven schedulers move contests through their lifecycle and keep the two in sync.
stateDiagram-v2
[*] --> not_started
not_started --> preloading: T-2min — scheduler claims the contest
preloading --> preloaded: participants, problems, leaderboard warmed into Redis
preloading --> not_started: preload failed — released for retry
preloaded --> in_progress: start time reached
in_progress --> in_progress: periodic checkpoint of scores + active duels → PostgreSQL
in_progress --> ended: duration elapsed
ended --> backed_up: final leaderboard flushed to PostgreSQL
backed_up --> [*]
Preloading is the interesting piece. Cold Redis at the moment 500 users hit "join" would mean a stampede of database reads on the most latency-sensitive minute of the event. Instead, a scheduler running every 30 seconds finds contests starting within the next two minutes and warms all participant profiles, problems, and the leaderboard into Redis before the doors open.
The status field doubles as a distributed lock: a scheduler transitions not_started → preloading before doing the work, so a second instance won't duplicate it, and failure resets the
status to not_started so the next tick retries rather than leaving the contest stuck.
Checkpointing runs continuously during the contest — leaderboard scores and in-progress duel state are flushed to PostgreSQL — so a Redis failure mid-contest costs one checkpoint interval, not the entire contest.
erDiagram
USER ||--o{ SUBMISSION : "submits"
USER ||--o{ DUEL : "plays as A"
USER ||--o{ DUEL : "plays as B"
USER ||--o{ SESSION : "authenticates"
CONTEST ||--o{ DUEL : "contains"
PROBLEM ||--o{ DUEL : "assigned to"
PROBLEM ||--o{ SUBMISSION : "judged against"
DUEL ||--o{ SUBMISSION : "receives"
USER {
text id PK
text name
text email UK
int rating
json scores "per-contest scores"
int_array contests "registered contest ids"
enum role "participant | moderator | admin"
}
CONTEST {
serial id PK
text contest_name UK
timestamp start_time
int duration "minutes"
enum status "not_started → … → backed_up"
}
PROBLEM {
serial id PK
text problem_statement
text_array visible_input "samples shown to players"
text_array complete_input "hidden judge tests"
text_array complete_output
int rating "800–1600"
text checker_code "testlib special judge"
int time_limit
int memory_limit
}
DUEL {
serial id PK
int contest_id FK
int problem_id FK
text player_a_id FK
text player_b_id FK
enum status
text winner_id FK
timestamp duel_start_time
int duration
}
SUBMISSION {
serial id PK
int problem_id FK
text user_id FK
int duel_id FK
enum language
text code
text verdict
int test_cases_passed
int total_test_cases
timestamp time_of_submission
}
Visible and hidden test cases are stored as separate PostgreSQL arrays on the problem, so the "Run" path can never accidentally leak hidden tests — the two sets are fetched by different code paths with different column selections.
| Key | Type | Purpose |
|---|---|---|
matchmaking:{contestId} |
Sorted set | Waiting players, scored by join time |
{userId} |
JSON | Live profile: rating, score, streak, duelId, prevOpponentId |
duel:{duelId} |
JSON | Duel status, players, wrong-answer counts, start time, duration |
leaderboard:{contestId} |
Sorted set | Live rankings |
usedProblems:{contestId}:{userId} |
Set | Problem-repeat avoidance |
userSocketMap / disconnectMap |
Hash | Socket routing and disconnect tokens |
matchToken:{contestId}:{token} |
String (NX, EX 120) | Match-creation idempotency lock |
cooldown:submit:{userId} |
String (EX 10) | Submission rate limit |
submissionsQueue / runQueue / runQueueCustom :{lang} |
List | Judge job queues |
| Failure | Handling |
|---|---|
| Redis unavailable at startup | Both Node services retry with exponential backoff and jitter; log output is throttled so a long outage doesn't flood logs |
| Redis drops mid-run | Reconnect strategy with capped backoff; the matchmaking loop no-ops on a not-ready client instead of crashing; the judge tolerates 5 consecutive errors before backing off |
| Backend unreachable from the judge | 3 attempts, exponential backoff, 4xx not retried (won't succeed), shared pooled HTTP client |
| Backend unreachable from the matchmaker | 4 attempts with jittered backoff; 429 and 5xx retried, other 4xx not |
| A sandbox hangs | Layered budgets: per-test-case deadline → whole-container timeout (compile + checker + per-test budget + overhead) → executor context deadline |
| Container leaked by a crash | Periodic prune of stopped containers, dangling volumes, and dangling images every 30 min — with the compile-cache volume explicitly excluded by label |
| Process killed mid-contest | Graceful shutdown: signal handler cancels polling, in-flight jobs drain via WaitGroup, the entire warm pool is torn down |
| Checker bind-mount unavailable | Detected at container-create time, disabled at runtime with a warning, judging continues with runtime-compiled checkers |
| Duplicate match creation | Deterministic token + SET NX EX atomic claim + busy re-check |
| Stale forfeit timer | Disconnect-token comparison plus duel re-read before acting |
| Malformed user data in Redis | Zod validation at the boundary; invalid entries are dropped, not crashed on |
RCE engine exposes /healthz, which is a real health check rather than a static 200: it pings
the Docker daemon and Redis, and returns live pool statistics (current vs target size per language),
queue depths for all 12 queues, and in-flight job counts. The same endpoint doubles as the operator's
window into autoscaler behaviour.
Backend exposes Prometheus metrics at /metrics via prom-client:
- HTTP request rate, error rate, and duration histograms (RED metrics)
duel_outcomes_totalby outcome,duel_duration_mshistogramsuser_rating_delta,user_streak_distributiondisconnect_events_totalby resolution (reconnected / forfeited / abandoned) anddisconnect_timeout_ms— this is what told me the 30-second grace window was tuned correctlymatchmaking_queue_sizegauge per contest
Both services emit structured logs (slog JSON in Go) with job, user, duel, and problem IDs on every
line, so a single submission can be traced across services.
Admin portal — a Django app mapped onto the same PostgreSQL schema with unmanaged models, giving organizers a UI to author problems, schedule contests, and inspect duels and submissions without touching the database directly. Django's admin was the right call here specifically because it doesn't own the schema: Drizzle owns migrations, and Django gets a mature CRUD UI for free.
Judging systems fail in ways unit tests don't catch, so the engine ships with a purpose-built load
harness (rce/tests/) that:
- Generates 200+ Codeforces-style jobs (100 problems × AC and WA submissions) across all four languages, including deliberate TLE, MLE, runtime-error, and compile-error cases to verify every verdict path under load.
- Pushes them into the real Redis queues so the whole pipeline is exercised end to end, not mocked.
- Samples
docker statsevery 2 seconds during the run to record peak memory per container — this is what produced the ~50 MiB working-set figure that the memory-aware admission control is built on. - Reports throughput (jobs/sec) and P50 / P90 / P99 execution latency, split by verdict class.
Headline results:
| Metric | Result |
|---|---|
| Sustained submissions per run | 1000+ |
| Throughput | 15+ jobs/sec |
| Evaluation latency vs cold-start-per-job | ~65% reduction (pre-warmed pool) |
| Burst capacity | 5× (4 → 20 containers per language, 16 → 80 total) |
| Verdict correctness | All classes (AC / WA / TLE / MLE / RE / CE) verified under concurrent load |
The load harness is also what surfaced the two most valuable bugs in the project: the in-flight tracking gap in the autoscaler (queue-length-only scaling reading zero demand on a saturated machine), and a memory-accounting error where pool sizing was derived from the Docker security limit rather than observed working set.
| Layer | Choice | Why | Trade-off accepted |
|---|---|---|---|
| Judge | Go | Goroutines + channels map directly onto "N concurrent container lifecycles"; predictable latency; first-class Docker SDK | A second language in the stack |
| API / real-time | Node.js + TypeScript + Socket.IO | Excellent for many idle-but-live WebSocket connections; shared types with the frontend | Single-threaded — which is precisely why judging was moved out |
| Broker | Redis lists (BRPOP) |
Matchmaking already required Redis; blocking pop is a zero-overhead consumer; queue depth is a free autoscaling signal | Weaker durability than Kafka/RabbitMQ — mitigated by AOF, addressed properly in the roadmap |
| Hot state | Redis JSON + sorted sets | Leaderboards are literally a sorted set; O(log N) rank queries; shared across processes | Requires disciplined checkpointing to PostgreSQL |
| Persistence | PostgreSQL + Drizzle ORM | Relational data with real foreign keys; typed queries; versioned migrations | — |
| Sandbox | Docker + cgroups + seccomp defaults | Namespace and cgroup isolation without a hypervisor; universally deployable | Weaker than a microVM — gVisor/Firecracker is on the roadmap |
| Frontend | Next.js 15 + React 19 + Tailwind | App Router, server components, Monaco editor integration | — |
| Auth | better-auth | Shared session validation across REST and the WebSocket handshake | — |
| Admin | Django admin (unmanaged models) | Full CRUD UI for organizers at near-zero cost, without owning the schema | Schema drift risk — mitigated by keeping models unmanaged and Drizzle authoritative |
- Unit tests (Vitest) across backend controllers, services, middleware, socket handlers, and utilities — including the tricky paths: forfeit resolution, soft back-out, reconnection, duel-end state transitions, and leaderboard broadcast.
- Contract tests on the matchmaking controller, pinning the three-way
created/duplicate/skipped_busyresponse shape that the matchmaking service depends on — so the two services can't silently drift apart. - Integration tests for auth, contest, duel, and leaderboard flows against real Redis and PostgreSQL behaviour.
- E2E tests (Playwright) for auth, contest entry, and full duel flows through the browser.
- Load tests (Go) for the judge, as described above.
- Pre-commit hooks running lint and format on push across the TypeScript services.
I'd rather state these plainly than pretend they don't exist.
Current limitations
- Single-node judge. The RCE engine scales up (memory-aware concurrency, autoscaling pool) but not out. Multiple engine instances would work today — they'd simply consume from the same Redis queues — but there's no coordinated capacity view across nodes.
- Queue durability. Redis lists have no acknowledgement semantics. A job popped by an engine that then crashes is lost. Redis Streams with consumer groups, or a dedicated broker, would fix this.
- Docker socket exposure. The engine mounts
/var/run/docker.sock. That's a meaningful privilege concentration, mitigated by running the engine on a dedicated node with a minimal surface. - Backend horizontal scaling requires a Socket.IO Redis adapter to fan events across instances.
Roadmap
- Redis Streams with consumer groups and a dead-letter queue for at-least-once judging.
- Socket.IO Redis adapter, enabling multiple backend instances.
- gVisor or Firecracker microVM sandboxes to replace runc, shrinking container-escape risk.
- Rootless Docker or a socket proxy to eliminate the privileged mount.
- Distributed tracing (OpenTelemetry) across the submit → queue → judge → callback path.
- Multi-node judge orchestration with a shared capacity view.
code-duel/
├── backend/ Node.js · Express · Socket.IO · Drizzle · better-auth
│ └── src/
│ ├── controllers/ matchmaking, submission, run, leaderboard, contest, user
│ ├── socket/ handlers (queue, submit, run, disconnect, back-out, timeout)
│ │ └── utils/ duel-end resolution: forfeit / draw / abandon
│ ├── services/ contest lifecycle, cron schedulers
│ ├── metrics/ Prometheus collectors (HTTP, duel, user, disconnect)
│ ├── models/ Drizzle schema
│ └── tests/ unit · integration · e2e
│
├── rce/ Go execution engine
│ ├── main.go queue polling, job budgeting, calibration, health server
│ ├── executor/
│ │ ├── pool.go pre-warmed container pool, sandbox hardening
│ │ ├── scaling_manager.go demand-proportional autoscaler
│ │ ├── inflight_tracker.go atomic per-language in-flight counters
│ │ └── docker_executor.go exec/HTTP dispatch, cleanup, pruning
│ ├── dockerfiles/ per-language images + the in-container runner
│ ├── benchmarks.go platform calibration workloads
│ └── tests/ load harness, verdict matrix, log parsing
│
├── matchmaking-service/ Node.js pairing loop
│ └── src/ matchMaker · redisConf · backendClient · schemas
│
├── frontend/ Next.js 15 · React 19 · Tailwind · Monaco
│ └── src/ duel arena, lobby, leaderboard, profile, socket store
│
├── admin-portal/ Django admin over the same PostgreSQL schema
└── Makefile orchestrates all four services
| Contribution | Detail |
|---|---|
| Platform | 1v1 competitive coding platform for the BITS Pilani Coding Club, adopted by 500+ users |
| Sandboxed judge | Go execution engine with pre-warmed, single-use Docker sandboxes — read-only rootfs, all capabilities dropped, no network, PID/memory/CPU/file-size capped, non-root, tmpfs-only writes — cutting evaluation latency ~65% |
| Autoscaling | Demand-proportional pool scaling from in-flight + queued load, giving 5× burst capacity and sustaining 1000+ submissions at 15+ jobs/sec |
| Matchmaking | Atomic Redis pairing with deterministic idempotency tokens, SET NX EX claims, and fairness-aware selection that avoids repeat opponents without starving anyone |
| Real-time state | Socket.IO duel lifecycle with token-guarded disconnect/forfeit resolution, full reconnection recovery, and Redis-backed live leaderboards |
| Fairness engineering | Per-language platform calibration making time limits hardware-independent; rating-banded, repeat-free problem selection |
| Operations | Prometheus metrics, deep health checks, contest preloading and continuous checkpointing, and a purpose-built load harness that found real bugs |