Explore the Platform: slavecode.codes
Support on GitHub: github.com/phero20 (Give it a star)"Your code is your master. Serve it well."
That's the philosophy I built this around. Not just a tagline — a design constraint. Every system decision in SlaveCode comes back to that principle: the code should be judged fairly, the infrastructure should serve users well, and nothing should break under pressure.
SlaveCode is a competitive programming platform I built from the ground up. It is not a weekend project. It is a full production system with:
- 11,000+ coding problems across multiple difficulty levels
- 82+ language learning tracks in the Academy (from Go to Haskell to Gleam)
- Real-time multiplayer Arena for up to 50 players simultaneously
- 460+ company interview profiles (Google, Meta, Amazon, Apple, and more)
- 56-topic System Design curriculum with an AI-powered diagram workspace
- A global contest aggregator pulling from LeetCode, Codeforces, AtCoder, CodeChef
- A compiler playground available to guests with zero login required
This post is a full architecture walkthrough. Not "here's what I used" — but why every decision was made, what breaks if you get it wrong, and how it all fits together.
The Big Picture: A Multi-Cloud Architecture
Before diving into any single system, it's worth understanding the physical topology first. Where things run matters as much as how they run.
┌──────────────────────────────────────────────────────────────────────┐
│ VERCEL EDGE NETWORK │
│ Next.js 15 (App Router) — SSR + Static + CDN-distributed assets │
└───────────────────┬───────────────────────────────┬─────────────────┘
│ HTTPS REST │ WSS WebSockets
▼ ▼
┌────────────────────────────────────────────────────────────────────┐
│ GOOGLE CLOUD PLATFORM (Compute Engine VMs) │
│ │
│ ┌──────────────────┐ ┌───────────────────┐ ┌─────────────┐ │
│ │ Hono API Server │ │ Golang Arena Hub │ │ BullMQ │ │
│ │ (Bun runtime) │ │ (WebSocket mgr) │ │ Workers │ │
│ └──────────────────┘ └───────────────────┘ └─────────────┘ │
└──────────┬─────────────────────────────────────────────┬────────────┘
│ Redis Protocol │ HTTP POST
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────────┐
│ MANAGED DBaaS │ │ MICROSOFT AZURE (VMs) │
│ ┌──────────────────────┐│ │ ┌─────────────────────────┐│
│ │ Neon (Postgres) ││ │ │ Judge0 Code Sandbox ││
│ │ MongoDB Atlas ││ │ │ (Dockerized, isolated) ││
│ │ Aiven Valkey (Redis) ││ │ └─────────────────────────┘│
│ └──────────────────────┘│ └─────────────────────────────┘
└──────────────────────────┘
There are three intentional multi-cloud decisions here:
Why GCP for the API and Arena? Raw Compute Engine VMs give persistent connections, no cold starts, and no 10-second serverless timeout that would kill a long-running BullMQ worker mid-job. Vercel serverless functions cannot hold a BullMQ consumer — period.
Why Azure for Judge0? This is the most important security decision in the entire platform. The code execution sandbox runs on an entirely separate cloud provider (Azure), isolated from the primary databases (GCP). If a sophisticated attacker crafts a zero-day Docker escape exploit and compromises the Judge0 VM, they land on Azure — not inside the same VPC as the API, Postgres, or MongoDB. The "blast radius" is physically separated across two clouds.
Why Vercel for the frontend? Edge CDN distribution, automatic CI/CD on push, SSR support, and zero infrastructure management for static assets. It offloads a dimension of complexity I do not need to operate.
The Complete Tech Stack
| Layer | Technology | Why |
|---|---|---|
| Frontend | Next.js 15 (App Router), React, TailwindCSS, Zustand | App Router enables co-located server components for SEO + client islands for interactivity |
| Code Editor | Monaco Editor | Same engine as VS Code — mature, syntax-aware, extensible |
| Whiteboard | Tldraw (infinite canvas) | Best-in-class React-native infinite canvas for System Design diagrams |
| REST API | Hono on Bun | Hono is 2-3× faster than Express on Bun. Bun's native speed matters for hot paths |
| Dependency Injection | Awilix | Enables clean service-layer architecture at scale without tight coupling |
| Multiplayer Engine | Golang (Fiber, WebSockets) | Go goroutines handle tens of thousands of persistent WebSocket connections with far less overhead than Node |
| Background Jobs | BullMQ (Bun Workers) | Reliable Redis-backed queue with retries, delays, and priority scheduling |
| Relational DB | PostgreSQL (Neon serverless) | Serverless Postgres — scales to zero, no idle VM cost |
| Document DB | MongoDB Atlas (Mongoose) | Flexible schema for problems, test cases, submissions — highly nested JSON |
| In-Memory Store | Aiven Valkey (Redis fork) | Open-source Redis drop-in, handles cache + queue + pub/sub + arena state |
| Auth | Clerk | Cryptographic JWT edge verification, OAuth, webhook user sync |
| Code Execution | Judge0 (primary) + Wandbox (fallback) | Dockerized sandbox that compiles and runs untrusted code safely |
| AI Integration | Unified AI proxy (multiple providers) | Key rotation, load balancing, and automatic provider failover |
| ORM | Drizzle ORM (Postgres) + Mongoose (MongoDB) | Type-safe SQL generation for Postgres; Mongoose for Mongo document modeling |
| Image Storage | Cloudinary | Bug report screenshots, user avatars |
Database Strategy: Why Three Databases?
This is the question I get most often. The answer is that the three databases own fundamentally different shapes of data, and forcing one data model onto all three use cases would be an architectural mistake.
PostgreSQL (Neon) — The Social Graph & Analytics Engine
Postgres owns everything that requires joins, aggregations, and referential integrity:
-- A simplified view of what Postgres owns
users → user_stats (1:1)
users → user_activity (1:many — daily heatmap)
users → user_solved_problems (many:many with Mongo IDs as text FK)
users → follows (self-referential many:many)
users → solutions (community editorial posts)
categories → category_problems (hierarchical DSA roadmap nodes)
contests (aggregated from external APIs by a cron worker)
Notice user_solved_problems.problemId is a text column that stores a MongoDB ObjectId string. This is a cross-database foreign key — a deliberate trade-off. I get relational semantics for user stats without forcing the problem definitions into Postgres where they don't fit.
Drizzle ORM handles this with full type safety. The column is declared as text() but semantically treated as a foreign reference in the service layer.
MongoDB Atlas — The Content Engine
MongoDB owns everything that is large, nested, or schema-flexible:
- Problem definitions — title, description, constraints, hints, editorial, companies tagged, difficulty, language support flags. A single problem document can be 20KB+ of nested JSON.
-
Hidden test cases — hundreds of
{input, expectedOutput}pairs per problem that the judge evaluates against. - Submissions — the full execution log: source code, stdout per test, stderr, time and memory per test case, final verdict. This can easily reach 100KB per submission.
- Academy tracks — deeply nested exercise trees with descriptions, hints, and starter code per language.
- System Design workspaces — Tldraw serialized JSON documents. These are opaque blobs that Postgres has no business indexing.
Forcing this into Postgres would mean either (a) JSONB columns everywhere and losing schema enforcement, or (b) a schema migration nightmare every time a problem field changes.
Aiven Valkey (Redis) — Four Jobs, One Store
Redis is the most overloaded layer in the system. It does four distinct things:
| Role | Key Pattern | TTL | Owner |
|---|---|---|---|
| Job Queue | bull:submission-evaluation:* |
Until consumed | BullMQ Workers |
| Job Queue | bull:arena-cleanup:* |
Until consumed | BullMQ Workers |
| Job Queue | bull:contest-sync:* |
Until consumed | BullMQ Workers |
| Arena State | arena:room:{roomId} |
24 hours | Go Arena Hub |
| Arena State | arena:user_room:{userId} |
24 hours | Go Arena Hub |
| API Cache |
company:*, llm:*, diagram:*
|
24h–7 days | Hono API |
| Pub/Sub | arena:events:{roomId} |
N/A (channel) | API → Go |
| Pub/Sub | arena:match:started:{roomId} |
N/A (channel) | API → Go |
| Pub/Sub | arena:submission:{roomId} |
N/A (channel) | Worker → Go |
The most interesting part is that Redis decouples services that would otherwise need to know each other's IP addresses. The Bun API and the Golang Arena Hub never talk directly. They shout into Redis, and the other side listens.
The Submission Pipeline: Async Code Evaluation
This is the most complex workflow in the platform. When a user clicks "Submit", this is what actually happens:
Phase 1: Synchronous Fast Path (the API's job)
Client → POST /submissions/submit { sourceCode, languageId, problemId }
→ SubmissionService: Insert { status: "PENDING" } into MongoDB
→ BullMQ: Enqueue job { submissionId }
→ Response: 202 Accepted { submissionId }
The API responds in ~50ms. It does not wait for compilation. It cannot — compilation can take 1–30 seconds depending on the language and problem complexity.
The client immediately starts polling:
// Client-side polling — every 2 seconds
const poll = async (submissionId: string) => {
const result = await fetch(`/api/submissions/${submissionId}/status`);
if (result.status === "PENDING" || result.status === "RUNNING") {
setTimeout(() => poll(submissionId), 2000);
} else {
displayVerdict(result);
}
};
Phase 2: Asynchronous Execution (the worker's job)
BullMQ dequeues job
→ MongoDB: Fetch public + hidden test cases for problemId
→ ExecutionService.runFullSubmission(sourceCode, tests)
→ For each test case batch:
→ Judge0: POST { source_code, language_id, stdin }
→ Wait for Judge0 response: { stdout, stderr, time, memory, status }
→ Calculate overall verdict
This is where the Driver layer matters. Before sending code to Judge0, a language-specific wrapper is applied. For a Python problem, the driver injects the boilerplate that calls the user's function with the test input and captures stdout. This means users only write the algorithm — not the I/O scaffolding. The /driver directory contains these wrappers for Java, C++, C, Go, Rust, Python, and more.
Judge0 is the primary and definitive judge. It compares the program's stdout against the expected output for each hidden test case. For 95%+ of problems, this is all that runs.
For a small subset of problems where multiple valid outputs exist — such as questions asking for "any valid topological order" or "all valid parentheses combinations" — a pure string comparison would incorrectly reject correct answers. In those cases, an AI-based semantic check runs as a fallback, evaluating whether the user's output is logically equivalent even if the string differs. This is the exception, not the rule.
Phase 3: Finalization
Worker → MongoDB: Update submission { status, executionResults, finalVerdict }
Worker → Postgres (parallel): Update user_stats { totalSolved++, streak, points }
Worker → Redis Pub/Sub: Publish arena:submission:{roomId} (if arena match)
The stats update runs in parallel with the MongoDB save using Promise.all. It handles streak calculation (checking lastSolveDate against today's date), difficulty-specific counters (easySolved, mediumSolved, hardSolved), language distribution tracking via a jsonb languageCounts column, and ELO-style arena point deltas.
Arena: Real-Time Multiplayer at Scale
The Arena is the feature that required the most architectural thought. Coordinating 50 simultaneous players — each submitting code, getting verdicts, and watching a live leaderboard update — requires a completely different stack than the REST API.
Why Golang?
Node.js uses an event loop — it is single-threaded I/O multiplexed. For WebSockets, this means each message goes through the same event queue. Under high concurrency, this creates latency jitter. A single slow operation (like a heavy JSON parse) can cause perceptible delays for unrelated clients.
Go's concurrency model is fundamentally different: each WebSocket client gets its own goroutine (a lightweight green thread, ~2KB stack vs ~8MB for OS threads). The Go runtime scheduler multiplexes thousands of goroutines across available CPU cores. Communication between goroutines uses channels — typed, blocking data pipes that are inherently safe without mutex locks.
// Arena Hub (simplified) — each room is a Go goroutine cluster
type Hub struct {
rooms map[string]*Room
mu sync.RWMutex
redis *redis.Client
}
type Room struct {
id string
clients map[string]*Client // userId → websocket connection
pub chan []byte // outbound broadcast channel
}
// Each client has a dedicated read goroutine
func (c *Client) readPump() {
for {
_, message, err := c.conn.ReadMessage()
if err != nil { break }
c.hub.handleMessage(c, message)
}
}
Redis as the State Store (Lua Atomic Scripts)
The arena room state lives in Redis, not in Go memory. This is a critical design decision: if the Go server restarts (deploy, crash, OOM), the match state is not lost. Redis is the source of truth.
But Redis is also shared between the Bun API (which handles join requests) and the Go Hub (which manages live connections). Two processes writing to the same key creates race conditions. The solution: embedded Lua scripts that execute atomically on the Redis server itself.
-- arena_join.lua (Lua runs atomically in Redis — no concurrent modification)
local roomKey = KEYS[1]
local userRoomKey = KEYS[2]
local userId = ARGV[1]
local maxPlayers = tonumber(ARGV[2])
local roomJson = redis.call("GET", roomKey)
if not roomJson then
return redis.error_reply("ROOM_NOT_FOUND")
end
local room = cjson.decode(roomJson)
-- Atomic check: is room full?
if #room.players >= maxPlayers then
return redis.error_reply("ROOM_FULL")
end
-- Atomic check: is user already in a room?
local existingRoom = redis.call("GET", userRoomKey)
if existingRoom then
return redis.error_reply("ALREADY_IN_ROOM")
end
-- Both checks passed — atomically update
table.insert(room.players, userId)
redis.call("SET", roomKey, cjson.encode(room), "EX", 86400)
redis.call("SET", userRoomKey, KEYS[1], "EX", 86400)
return redis.status_reply("OK")
Without this Lua script, two simultaneous join requests could both pass the "room full" check and push the room over capacity. The Lua script ensures this is compare-and-swap in a single atomic operation — guaranteed by Redis's single-threaded command processing.
The Pub/Sub Bridge
The Bun workers (which handle code evaluation) and the Go Hub (which manages WebSocket clients) need to communicate. But they run on different processes. The bridge is Redis Pub/Sub:
BullMQ Worker evaluates submission
→ Publishes to Redis channel: "arena:submission:{roomId}"
→ Payload: { userId, verdict, testsPassed, totalTests, score }
Go Hub subscribes to "arena:submission:*"
→ Receives message
→ Finds the correct Room by roomId
→ Broadcasts scoreboard update to all connected clients in that room
This design means the Bun API server and the Go Arena Hub are completely decoupled. Neither knows the other's hostname or IP. They only share knowledge of the Redis channel name format. This makes independent scaling and deployment of each service trivial.
The Arena State Machine
An Arena match transitions through these states, managed via Redis + Go:
[*] → WAITING (Host creates room, others join via PIN)
WAITING → LOBBY (Host toggles lobby mode for readiness checks)
LOBBY → WAITING (Host toggles back)
WAITING/LOBBY → PLAYING (Host starts match — requires ≥ 2 players)
PLAYING → FINISHED (20-minute timer expires OR host aborts)
The match timer is managed via a BullMQ delayed job: when the host starts a match, a job is enqueued with a delay of matchDurationMs. When that job fires, the Arena Worker publishes a "match:ended" event to Redis Pub/Sub, which the Go Hub receives and broadcasts to all clients.
System Design: AI-Powered Learning Canvas
The System Design section is more than static documentation. It is a 56-topic curriculum backed by an interactive canvas and AI assistant.
Each topic (e.g., "Load Balancer", "Message Queue", "CDN") has:
- Structured content — a deep-dive explanation with diagrams and real-world examples
- A Tldraw workspace — an infinite canvas where users sketch their architecture diagrams
- An AI chat assistant — context-aware of the current topic being studied
- AI diagram generation — describe a system in prose, get a Tldraw diagram back
The AI tutor prompt is aware of the current system design topic. If the user is on the "Caching" topic and asks "where does Redis fit?", the AI knows the context and responds with topic-specific advice, not generic information.
The diagram generation pipeline:
User: "Draw a system design for a URL shortener"
→ API: POST /systemdesign/workspaces/{id}/generate
→ AI: Generate Tldraw JSON schema from description
→ Parse + validate the returned JSON
→ Merge into the existing workspace document in MongoDB
→ Return updated Tldraw state to the client
AI responses and diagram outputs are cached in Redis (7-day TTL) so identical prompts never cost a second API call — useful when many users study the same topics concurrently.
The Codebase: 70+ Interconnected Classes
The backend (/api) uses Awilix for dependency injection — a container-based DI system similar to what you'd see in NestJS, but lighter and built for Hono. Every service is registered in the container and injected into controllers via the constructor.
// container.ts
container.register({
// Repositories
problemRepository: asClass(ProblemRepository).singleton(),
submissionRepository: asClass(SubmissionRepository).singleton(),
// Services
executionService: asClass(ExecutionService).singleton(),
submissionService: asClass(SubmissionService).singleton(),
// Controllers
submissionController: asClass(SubmissionController).singleton(),
});
The service layer boundary is strict:
- Controllers — handle HTTP routing, input validation, auth middleware
- Services — contain all business logic, orchestrate between repositories and external APIs
- Repositories — contain all database queries, return typed model objects
The codebase has 4,765 indexed nodes and 9,732 dependency edges across all packages. The submission domain alone chains: SubmissionController → SubmissionService → ExecutionService → DriverJudgeExecutionService → LLMService.
Feature Scope at a Glance
| Feature | What It Does |
|---|---|
| Problems | 11,000+ coding problems with hidden test cases, Judge0 execution, solution voting |
| Academy | 82+ language tracks (Go, Rust, Haskell, Gleam...) with progressive exercise-based learning |
| Roadmap | Interactive DSA roadmap — from Arrays to Tries — with problem links at each node |
| Arena | Real-time multiplayer coding battles, up to 50 players, live leaderboard via Go + Redis |
| System Design | 56-topic curriculum, Tldraw canvas, AI chat tutor, AI diagram generation |
| Companies | 460+ company profiles with tagged interview questions |
| Contests | Unified calendar: LeetCode, Codeforces, AtCoder, CodeChef — auto-synced via cron |
| Compilers | Guest-accessible Monaco playground, full Judge0 execution, built-in Tldraw scratchpad |
| Leaderboards | ELO-style arena rating, problem solve streaks, language-specific stats |
| Social | Follow users, post solutions with voting, GitHub/LinkedIn/LeetCode profile links |
| Admin | Internal dashboard for problem creation, category management, and content moderation |
What I Would Do Differently
1. WebSocket for submission status instead of polling. The client polling every 2 seconds works, but it creates unnecessary API load at scale. A Redis Pub/Sub → Server-Sent Events (SSE) channel would push the verdict to the client the moment it's ready, with zero polling overhead.
2. Read replicas for MongoDB. All submission reads go to the primary. Under high read load, a read replica for the GET /submissions/{id}/status endpoint would reduce primary pressure significantly.
3. Separate Redis instances by concern. All four Redis roles (cache, queue, state, pub/sub) share one Valkey instance. Under heavy arena load, the pub/sub throughput could starve the BullMQ queue processing. Separating pub/sub into a dedicated instance is the right move at scale.
4. gRPC instead of Redis Pub/Sub for Go↔Node communication. Redis Pub/Sub is "fire and forget" — there's no acknowledgment. If the Go Hub restarts and misses a message, that leaderboard update is lost. A gRPC streaming connection with delivery guarantees would be more robust.
The Repository Structure
slavecode/
├── /api → Hono API (Bun, Awilix, Drizzle, Mongoose, BullMQ)
├── /arena → Go WebSocket Hub (Fiber, goroutines, Redis Pub/Sub)
├── /web → Next.js 15 frontend (Monaco, Tldraw, Zustand)
├── /admin → Internal admin portal (Next.js)
├── /driver → Language-specific code wrappers (Java, C++, Go, Rust...)
├── /cloud → Azure VM provisioner + health checks
├── /infra → Docker Compose + Dockerfiles
├── /scripts → DB seeding, Drizzle migrations, Docker utilities
├── /testings → Integration tests (network, DB pool, endpoints)
└── /docs → Full UML diagrams, ERDs, architecture docs
Conclusion
Building SlaveCode taught me that distributed systems are not about choosing the right technology — they're about understanding the data flow and failure modes first, and then choosing the technology that maps cleanly to those constraints.
The Judge0 sandbox is on Azure because blast radius matters. The Arena is in Go because goroutines genuinely outperform an event loop for persistent WebSocket connections at scale. Redis does four jobs because it is the right tool for all four of those jobs at this scale.
Every piece is there because the alternative broke something.
SlaveCode is live at slavecode.codes. Find me on GitHub at @phero20. Your code is your master. Serve it well.
Top comments (0)