Ma'at is a distributed online judge. You write a solution in C++ or Python, submit it, and a few seconds later you see whether it was right. It is named after the Egyptian goddess of truth and justice.
The hard part of a judge is running code written by strangers without trusting it. Every submission here runs in its own throwaway Docker container with no network, a read-only filesystem and strict limits on CPU, memory and processes. The container is destroyed as soon as the run ends.
- Browse problems, search them by title and filter them by difficulty or topic
- Read the statement and write a solution in an in-browser editor with VS Code-style snippets and suggestions
- Submit C++17 or Python 3 code and watch the verdict arrive live, test by test, without refreshing
- Open each test your submission ran, up to the one it failed, to see the full input, the expected output, and what your code printed there
- Get one of seven verdicts: Accepted, Wrong Answer, Time Limit Exceeded, Memory Limit Exceeded, Runtime Error, Compile Error, or Judge Error if the judge itself fails
- Join timed contests with ICPC-style scoring and a leaderboard that updates as submissions are judged
- See which problems you have solved or tried, in every problem list
- Upload a profile picture, which is re-encoded before it is stored
- Delete your account. It is kept for 30 days in case you change your mind, then removed with all its data
- Run code on a problem's examples before submitting, and format it with one click, wrapped to the width of the editor
- Share accepted solutions, and read other people's once a problem is no longer in a running contest
- See each problem's topics, such as greedy or graphs, once no contest that uses it is still to come or running
- Follow your progress on your profile: problems solved by difficulty and your latest submissions
- Switch between dark and light mode
- Manage problems, hidden test cases, contests and users from an admin area. A super admin also manages the admins
┌──────────┐ HTTP / WebSocket ┌───────────┐
│ Frontend │ ◄──────────────────► │ Backend │
└──────────┘ │ (Express) │
└─────┬─────┘
saves submission │ enqueues job
┌──────────────────────┼──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ MongoDB │ │ Redis │ │ MinIO │
└────▲─────┘ │ queue + │ │ profile │
│ │ pub/sub │ │ pictures │
│ verdicts └────┬─────┘ └──────────┘
┌────┴─────┐ pulls jobs │
│ Workers │ ◄──────────────┘
└────┬─────┘
│ docker run, one per test
▼
┌─────────────┐
│ Sandbox │
└─────────────┘
- The backend validates the submission, saves it as
PENDINGand puts a job on a BullMQ queue. It answers right away with202 Accepted. - A worker takes the job, compiles the code if needed, then runs it against each test case in a fresh sandbox container.
- The worker compares the output with the expected answer, saves the verdict to MongoDB and publishes it on a Redis channel.
- The backend receives that message and pushes it to the browser over Socket.IO. If the socket drops, the frontend falls back to polling.
If no worker is running, submissions wait in the queue. /health reports how many workers are connected, and the problem page tells the user the judge is offline.
Because workers are stateless and only talk to the queue, more capacity is just more workers. Judging is CPU-bound, so throughput climbs with each worker until the host runs out of cores. load-tests shows where that happens.
sequenceDiagram
autonumber
participant B as Browser
participant A as Backend
participant M as MongoDB
participant R as Redis
participant W as Worker
participant S as Sandbox
Note over W: On startup: remove leftover sandboxes, measure container startup time, then wait on the queue
B->>A: POST /api/v1/submissions (through nginx)
A->>A: Rate limit, login check, validate language, size, problem and contest
A->>M: Save submission as PENDING
alt Redis is down
A->>M: Delete the submission again
A-->>B: 503, the judge is unavailable
else Queued
A->>R: Add a job holding only the submission ID to bull:judge:wait
A-->>B: 202 Accepted, plus whether any worker is connected
end
B->>A: Socket.IO submission:watch
A->>A: Check the user owns it, join the submission room
Note over B,A: The browser also polls GET /submissions/ID as a backup
W->>R: Take the next job, wait to active, and hold a lock on it
W->>M: Load the submission, problem and hidden tests
W->>M: Set status RUNNING
loop Compile (C++ only), then each test in order, stopping at the first failure
W->>S: docker run with no network, read-only, CPU, memory and process limits
S-->>W: Output, time, exit code
end
alt Judged
W->>M: Save verdict, per-test results, the failed test's output and judgedAt
opt Contest submission
W->>R: Lua script updates the user's score and the contest board sorted set
end
W->>R: PUBLISH the verdict on the verdicts channel
W->>R: Mark the job completed
else Failed twice (error, crash or stalled lock)
W->>M: Save SYSTEM_ERROR (Judge Error), not scored in contests
W->>R: PUBLISH the verdict on the verdicts channel
end
R-->>A: Verdict message
A-->>B: submission:verdict to the submission room
opt Contest submission
A-->>B: contest:update, and the page reloads the standings
end
Note over B,M: A verdict missed by the socket is picked up by polling, since MongoDB is written before the publish
| Part | Choice |
|---|---|
| Frontend | React, Vite, plain CSS, Monaco editor |
| API | Node.js, Express, Mongoose |
| Database | MongoDB |
| Queue and leaderboard | Redis, BullMQ, sorted sets |
| Live updates | Socket.IO |
| File storage | MinIO (S3 compatible) |
| Sandbox | Docker |
| Testing | Vitest, Supertest, Playwright, k6 |
| Deployment | Docker Compose |
| Folder | What it is |
|---|---|
| frontend | The React app |
| backend | The REST API and the Socket.IO server |
| worker | The judge: pulls jobs and runs code in sandboxes |
| load-tests | k6 scripts and the recorded benchmarks |
| infra | Setup scripts for supporting services such as MinIO |
You need Docker. Node.js 22 is only needed to run the tests or to work on one part outside Docker.
- Copy
.env.exampleto.envand fill in the values. - Build the two sandbox images:
docker compose --profile build build sandbox-cpp sandbox-python - Start everything:
docker compose up -d --build - Add the example problems, a practice contest and the super admin account:
docker compose exec -e SEED_ADMIN_EMAIL=you@example.com -e SEED_ADMIN_PASSWORD=a-long-password backend node scripts/seed.js - Open http://localhost:8080.
Every service in compose.yaml has a CPU and memory cap that together model a small server of about 4 vCPU and 3 GB. Due to limited resources, the load tests were run locally against this capped setup rather than on dedicated cloud machines, so the numbers in load-tests describe that small server. Sandboxes are started by the worker outside Compose and carry their own limits: one CPU each and the problem's memory limit.
Each folder has its own README with the details.
| Where | Command | What it covers |
|---|---|---|
backend |
npm test |
The API, against an in-memory MongoDB |
worker |
npm test |
Verdict rules and contest scoring (scoring needs Redis) |
worker |
npm run test:adversarial |
Hostile programs against the real sandbox (needs Docker) |
frontend |
npm test |
Components and helpers |
frontend |
npm run test:e2e |
The main user flows in a real browser, against the running stack |
Running other people's code is the main risk, so the design assumes every submission is hostile.
- Sandboxes run with no network, a read-only root filesystem, all Linux capabilities dropped, no privilege escalation, and limits on memory, CPU, process count and output size
- Commands are started with argument arrays rather than shell strings, so nothing in a submission can inject a command
- Hidden test data is only shown to the author of a submission, for the tests that submission ran, and never while a contest with the problem is still to come or running
- Users can only read their own submissions, and sockets can only join the room of a submission they own
- Uploaded images are decoded and rebuilt from raw pixels, so metadata and hidden payloads do not survive
- Requests are sanitized against NoSQL and XSS injection and checked against the allowed frontend origin
- Every IP has an hourly request budget, and logins, password checks, signups, reset emails, submissions and uploads have tighter limits of their own
- The site is served with a strict Content Security Policy and the usual security headers
Released under the terms of the LICENSE file.