A self-hosted platform for running timed technical assessments.
Build a categorised question bank, assemble timed multiple-choice papers, release them to exactly the students who should sit them, and get scored results with a question-by-question breakdown the moment the clock stops.
- What it is
- Screenshots
- Quick start
- Feature tour
- Tech stack
- Architecture
- Database schema
- API reference
- Configuration
- Testing
- Deployment
- Troubleshooting
- Security
- Known limitations
- Project layout
- Contributing
- License
Many colleges, training institutes and small teams still run technical and aptitude assessments by hand: type the paper, print it, invigilate, collect, mark, tally into a spreadsheet, then work out who passed. Every step costs time and every step can lose a mark.
CodeJudge replaces that loop. It is a complete, self-hosted assessment system:
| For the department | A reusable question bank, a paper builder, batch-based assignment, automatic marking, and cohort analytics with CSV export. |
| For the candidate | A clean exam screen with a visible countdown, answers that save as you go, and a fully marked script returned the instant they submit. |
It is not proctoring software β there is no browser lockdown, webcam monitoring or tab-switch detection. It handles the assessment, not the invigilation.
- Zero install beyond Docker. No Java, Maven, MySQL or Node on your machine.
- Genuinely dependency-light. The HTTP server, JSON parser, connection pool and password hashing are all written against the JDK. The only backend dependency is the MySQL driver.
- No build step for the frontend. Plain HTML, CSS and vanilla JavaScript served from the
same jar. No webpack, no npm, no
node_modules. - Honest about its gaps. See Known limitations.
These are real renders from a running instance, captured by the browser test suite.
| Home | Features |
|---|---|
![]() |
![]() |
| Sign in | Sign up |
|---|---|
![]() |
![]() |
Admin overview
Per-test report β distribution, pass rate, question difficulty and ranked results
Docker Desktop (or Docker Engine + Compose v2). Nothing else.
git clone https://github.com/<your-username>/codejudge.git
cd codejudge
cp .env.example .env # edit the passwords before anything public
docker compose up --buildFirst boot takes a few minutes: Maven downloads dependencies inside the build container and MySQL initialises its schema. When it settles, open http://localhost:8080.
| Who | Where | Credentials |
|---|---|---|
| Staff / admin | http://localhost:8080/#/staff | ADMIN_EMAIL / ADMIN_PASSWORD from your .env |
| Candidates | http://localhost:8080/#/register | They register themselves |
Defaults are admin@codejudge.local / Admin@12345. Change these in .env before first
boot β the admin account is created once, from those values, against an empty database.
Editing .env afterwards does not change an existing admin's password.
- Sign in as staff β Questions β New category (four are seeded: Java, SQL, Aptitude, General Programming) β New question. Add a few.
- Tests β New test. Set the duration and pass mark.
- On the new test row β Questions β tick the ones you want β Save paper.
- Assign β Everyone, or pick batches and named candidates.
- Publish.
- Register a candidate in another browser, sit the paper, submit.
- Back in staff β Report on that test.
docker compose up -d --build # rebuild and start
docker compose logs -f app # follow application logs
docker compose restart app # restart just the app
docker compose down # stop (data survives)
docker compose down -v # stop and WIPE the database| Area | What you can do |
|---|---|
| Overview | Candidate / test / question / attempt counts, average-score gauge, latest submissions, top performers, quick actions. |
| Question bank | Create categories; add questions with four options, one correct answer and their own mark value; filter by category; edit and delete. |
| Tests | Create with title, description, duration (1β480 min) and pass mark (0β100%); attach questions from the bank with a running marks total; publish / unpublish; delete. |
| Batches | Group candidates into classes, sections or intakes. Searchable member picker with select-all. |
| Assignment | Per test, choose Everyone or Only who I choose (any mix of batches and named individuals), with a live count of who it reaches. |
| Candidates | Everyone registered, with join dates. |
| Reports | Papers sat, average, highest, lowest, average time, score distribution, pass-rate donut, per-question difficulty (hardest first), full ranked results, CSV export. |
| Area | What you get |
|---|---|
| Tests | Only the papers you are entitled to sit. Completed ones show your score; in-progress ones offer Resume. |
| Exam | One question at a time, an answer sheet showing what is left, a ring countdown that turns amber then red, and clear/change answers until you submit. |
| Result | Score dial, pass/fail verdict, rank, time taken, per-topic breakdown, and every question marked with your answer against the correct one. |
| My results | Every attempt, with a score-trend chart over time. |
| Leaderboard | Ranked by marks, then by time taken, with your own row highlighted. |
- The clock is server-side. Each attempt gets a deadline in the database and a scheduled task that force-submits it when that deadline passes β closing the tab does not stop it. Timers are re-armed on startup, so an app restart mid-exam does not lose the deadline.
- Answers save as you pick them, so a refresh or a dropped connection costs nothing.
- One attempt per candidate per test, enforced by a unique database constraint.
- Correct answers are never sent to the browser during an exam β the payload omits them entirely and they only appear once the attempt is submitted.
- Marking is a single transaction: every answer is compared to the key, per-question marks are written, and the attempt total is finalised atomically.
| Layer | Choice | Why |
|---|---|---|
| Language | Java 21 | The brief: Core Java. |
| HTTP | com.sun.net.httpserver (JDK built-in) |
No Spring, no servlet container. |
| Database | MySQL 8.4 via JDBC | Plain prepared statements, no ORM. |
| JSON | Hand-written parser/writer | Avoids Jackson/Gson for a Core Java project. |
| Pooling | Hand-written bounded pool | Avoids HikariCP; ~120 lines. |
| Passwords | PBKDF2WithHmacSHA256 (JDK) |
120k iterations, per-user salt. |
| Concurrency | ScheduledExecutorService |
Exam auto-submission and session sweeping. |
| Build | Maven (inside Docker) | Single fat jar. |
| Frontend | Vanilla JS + CSS, no build step | Served from the same jar. |
Only Maven dependency: com.mysql:mysql-connector-j.
Frontend libraries (CDN): Bootstrap Icons, AOS, animate.css, Swiper, ApexCharts. These are presentation only β see Known limitations for the air-gapped caveat.
If you are using this as a Core Java project, here is where each topic actually lives:
| Topic | Where |
|---|---|
| Collections Framework | service/*, DAO result mapping, SessionManager |
| Streams & lambdas | AnalyticsService, LeaderboardService, BatchService |
| Multithreading | worker/AttemptTimerRegistry, util/SessionManager sweeper |
| JDBC | every class in dao/, transactions in AttemptDao.finalizeAttempt |
| File handling | ReportService (CSV generation via java.io) |
| Exception handling | http/Response, http/Router error mapping, DAO SQLException paths |
| Layered architecture | model β dao β service β http |
βββββββββββββββββββββββββββββββββ
Browser ββββββββββββΊβ app (eclipse-temurin:21-jre) β
vanilla JS SPA β β
β http/Router ββ small router β
β service/ ββ business rulesβ
β dao/ ββ JDBC β
β worker/ ββ exam timers β
β json/, db/ ββ hand-rolled β
βββββββββββββββββ¬βββββββββββββββββ
β JDBC
βΌ
βββββββββββββββββββββββββββββββββ
β mysql:8.4 β
β db/init/*.sql on first boot β
β SchemaMigrator on every boot β
βββββββββββββββββββββββββββββββββ
Request flow: Router matches the path, enforces auth and role, calls a service,
which validates input and orchestrates one or more DAOs. Services throw Response for
expected failures (400/403/404/409); the router maps everything else to a 500 and logs it.
Two background threads:
AttemptTimerRegistryβ one scheduled task per in-progress attempt, firing at its deadline. Re-checks the wall clock before finalising, so a suspended/resumed host cannot end a paper early. Recovers all in-progress attempts on startup.SessionManagersweeper β clears idle sessions every five minutes.
Schema migration: db/init/*.sql only runs on a brand-new MySQL volume, so
db/SchemaMigrator applies additive changes on every boot instead. Both the pass-mark and
the assignment features were added to a live instance this way, with no data loss.
Cache busting: every asset URL carries a build id that changes on each deploy
(styles.css?v=β¦), and the server sends Cache-Control: no-cache, must-revalidate, so a
redeploy can never leave a browser on stale CSS or JS.
Eleven tables.
users βββ¬β< batch_members >ββ batches ββ< test_batches >βββ
β β
ββ< test_candidates >ββββββββββββββββββββββββββββββ€
β βΌ
ββ< attempts >ββ attempt_answers tests ββ< test_questions >ββ questions ββ> categories
| Table | Holds |
|---|---|
users |
Accounts, PBKDF2 hash + salt, role (ADMIN / CANDIDATE) |
categories |
Question categories |
questions |
Text, four options, correct option, marks, category |
tests |
Title, duration, pass %, audience (EVERYONE/ASSIGNED), published flag |
test_questions |
Which questions are on a paper, and in what order |
batches |
Cohorts |
batch_members |
Candidates in a cohort |
test_batches |
Papers assigned to whole cohorts |
test_candidates |
Papers assigned to named individuals |
attempts |
One per candidate per test β timings, status, score |
attempt_answers |
Per-question answer, correctness and marks awarded |
Key constraints: attempts is UNIQUE (test_id, user_id); attempt_answers is
UNIQUE (attempt_id, question_id); questions.correct_option is checked to be AβD.
JSON over HTTP. Authentication is an HttpOnly session cookie set at login (a bearer token
is also accepted via Authorization: Bearer <token>).
Access levels: π’ public Β· π΅ any signed-in user Β· π΄ admin only
| Method | Path | Purpose | |
|---|---|---|---|
| π’ | GET |
/api/health |
Liveness probe |
| π’ | POST |
/api/auth/register |
Create a candidate account |
| π’ | POST |
/api/auth/login |
Sign in, sets the session cookie |
| π΅ | POST |
/api/auth/logout |
Invalidate the session |
| π΅ | GET |
/api/me |
Current user |
| π΅ | GET |
/api/me/summary |
Tests taken, average, best, score trend |
| Method | Path | Purpose | |
|---|---|---|---|
| π΅ | GET |
/api/tests |
Papers this candidate may sit |
| π΅ | POST |
/api/tests/:id/start |
Start or resume an attempt |
| π΅ | GET |
/api/attempts/mine |
Attempt history |
| π΅ | GET |
/api/attempts/:id |
Live attempt state (for resume) |
| π΅ | POST |
/api/attempts/:id/answer |
Save or clear one answer |
| π΅ | POST |
/api/attempts/:id/submit |
Submit and mark (idempotent) |
| π΅ | GET |
/api/attempts/:id/review |
Full marked script |
| π΅ | GET |
/api/tests/:id/leaderboard |
Ranked results |
| Method | Path | |
|---|---|---|
| π΄ | GET POST |
/api/admin/categories |
| π΄ | DELETE |
/api/admin/categories/:id |
| π΄ | GET POST |
/api/admin/questions Β Β·Β ?categoryId= filter on GET |
| π΄ | PUT DELETE |
/api/admin/questions/:id |
| Method | Path | Purpose | |
|---|---|---|---|
| π΄ | GET POST |
/api/admin/tests |
List / create |
| π΄ | GET PUT DELETE |
/api/admin/tests/:id |
Read / update / delete |
| π΄ | GET PUT |
/api/admin/tests/:id/questions |
Read / set the paper |
| π΄ | PUT |
/api/admin/tests/:id/publish |
{ "published": true } |
| π΄ | GET PUT |
/api/admin/tests/:id/audience |
Who the paper is for |
| π΄ | GET |
/api/admin/tests/:id/analytics |
Full report data |
| π΄ | GET |
/api/admin/tests/:id/leaderboard |
Ranked results |
| π΄ | GET |
/api/admin/tests/:id/results.csv |
CSV download |
| Method | Path | |
|---|---|---|
| π΄ | GET |
/api/admin/overview |
| π΄ | GET |
/api/admin/candidates |
| π΄ | GET POST |
/api/admin/batches |
| π΄ | GET PUT DELETE |
/api/admin/batches/:id |
| π΄ | PUT |
/api/admin/batches/:id/members |
# sign in and keep the cookie
curl -c jar.txt -X POST http://localhost:8080/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@codejudge.local","password":"Admin@12345"}'
# add a question
curl -b jar.txt -X POST http://localhost:8080/api/admin/questions \
-H 'Content-Type: application/json' \
-d '{"categoryId":1,"text":"Which keyword prevents subclassing?",
"optionA":"static","optionB":"final","optionC":"sealed","optionD":"private",
"correctOption":"B","marks":2}'
# restrict a test to two batches
curl -b jar.txt -X PUT http://localhost:8080/api/admin/tests/1/audience \
-H 'Content-Type: application/json' \
-d '{"audience":"ASSIGNED","batchIds":[1,2],"candidateIds":[]}'Error shape β every failure returns {"error":"human readable message"} with a sensible
status: 400 validation, 401 not signed in, 403 wrong role or not your data,
404 missing, 409 conflict (duplicate email, already-submitted attempt, unassigned test).
All configuration is environment variables, so the same jar runs unchanged anywhere.
| Variable | Default | Notes |
|---|---|---|
DB_HOST |
localhost |
mysql inside Compose |
DB_PORT |
3306 |
|
DB_NAME |
codejudge |
|
DB_USER |
codejudge |
|
DB_PASSWORD |
codejudge_pw |
Change it. |
APP_PORT |
8080 |
Port the app listens on |
ADMIN_EMAIL |
admin@codejudge.local |
Bootstrap admin, first boot only |
ADMIN_PASSWORD |
Admin@12345 |
Change before first boot. |
Compose also reads MYSQL_ROOT_PASSWORD, MYSQL_DATABASE, MYSQL_USER and
MYSQL_PASSWORD from .env for the database container.
.envis gitignored..env.exampleis the template β keep real secrets out of the repo.
Everything runs inside containers; nothing is installed on your machine.
Start the app first (docker compose up -d).
| Suite | Checks | Covers |
|---|---|---|
test/e2e_test.py |
113 | Sign-up, sign-in, question bank, building and publishing tests, sitting papers, marking accuracy, the review report, analytics, CSV export, authorisation, sessions |
test/e2e_batches.py |
36 | Cohorts, assignment, and that restricted papers are refused server-side rather than merely hidden |
test/ui_quick.py |
β | Renders key screens in headless Chromium, captures screenshots, fails on console errors |
# API suites
docker run --rm -i --network codejudge_codejudge-net \
python:3.12-alpine python - < test/e2e_test.py
docker run --rm -i --network codejudge_codejudge-net \
python:3.12-alpine python - < test/e2e_batches.py# Browser suite (writes to test/shots/)
mkdir -p test/shots
MSYS_NO_PATHCONV=1 docker run --rm -i \
--network codejudge_codejudge-net \
-v "$(pwd)/test/shots:/shots" \
-v "$(pwd)/test/ui_quick.py:/ui_quick.py:ro" \
mcr.microsoft.com/playwright/python:v1.47.0-jammy \
bash -c "pip install -q playwright==1.47.0; python /ui_quick.py"MSYS_NO_PATHCONV=1 matters on Windows Git Bash β without it MSYS rewrites the
container-side paths and the run dies before it starts. Drop it on PowerShell, Linux or macOS.
Chart colours are checked computationally rather than by eye (lightness band, chroma floor, colourblind separation, contrast against the real surface):
| Role | Light (on #FFFFFF) |
Dark (on #131A2E) |
|---|---|---|
| Sequential ramp | #A69DFF β #3A31A8 |
#A69DFF β #5B4EDB |
| Correct / pass | #0E9F6E |
#23A578 |
| Incorrect / fail | #D93F35 |
#E4604E |
The red/green pair sits in the colourblind warn band, which is only acceptable with a second channel β so every use of those two ships an icon and a text label, never colour alone.
It is two ordinary containers, so it runs anywhere that takes a Dockerfile plus a MySQL
database. Set the same variables as .env.example; no code changes needed.
- Point the service at this repo; it builds from the
Dockerfile. - Add a managed MySQL instance.
- Set
DB_HOST,DB_PORT,DB_NAME,DB_USER,DB_PASSWORD,ADMIN_EMAIL,ADMIN_PASSWORD. SetAPP_PORTto whatever the platform expects (often$PORT). - Deploy. The schema creates itself on first boot.
On a managed database the
db/init/*.sqlmount will not run.SchemaMigratorcreates the newer tables, but the original ones come from those files β so for a fresh managed database, rundb/init/01-schema.sqlanddb/init/02-seed.sqlagainst it once before first boot.
git clone https://github.com/<your-username>/codejudge.git
cd codejudge && cp .env.example .env
# edit .env β set strong passwords
docker compose up -d --buildThen put Nginx or Caddy in front for TLS. Once you are on HTTPS, consider marking the session
cookie Secure in Main.setSessionCookie.
Comfortable on 1β2 GB RAM. The app container is a slim JRE with -XX:MaxRAMPercentage=75.
Free tiers work; expect a cold start after idle.
The UI looks out of date after an update
Assets are versioned per deployment, so a normal reload should pick up the new build. If you are still on the old one, hard-reload (Ctrl+Shift+R).
A candidate cannot see a test
Check three things: the test is published, it has at least one question, and β if its audience is Only who I choose β the candidate is in an assigned batch or named directly.
A test will not publish
A paper needs at least one question. A restricted paper also needs at least one batch or candidate assigned, otherwise publishing would reach nobody.
Everyone was signed out
The app restarted. Sessions live in memory by design; signing back in is all that is needed. In-progress exams are unaffected β their deadlines live in the database.
Port 8080 is already in use
Set APP_PORT=9090 in .env and docker compose up -d.
I forgot the admin password
The bootstrap admin is only created against an empty database. Either restore from a backup,
or docker compose down -v to wipe and start fresh with new .env values. There is no
in-app password reset yet.
Build fails downloading Maven dependencies
The build container needs internet on first run. Behind a proxy, pass it through in the
Dockerfile build stage.
What is in place
- Passwords β PBKDF2-HMAC-SHA256, per-user 16-byte random salt, 120,000 iterations, constant-time comparison. Never stored or logged in plaintext.
- Sessions β random 256-bit tokens held server-side, sent as
HttpOnly,SameSite=Laxcookies so page scripts cannot read them. Eight-hour idle timeout with a background sweeper. - Roles β every staff route is gated on the server, not merely hidden in the UI.
A candidate cannot list the question bank, reach analytics, or read another candidate's
attempt or script; those return
403whatever the browser asks. - Assignment β restricted papers are filtered out of the candidate's list and refused by the server on a direct link.
- Exam integrity β correct answers are withheld until submission; the deadline is enforced server-side; late answers are rejected; one attempt per candidate per test is a database constraint.
- Injection β every query is a parameterised prepared statement; no SQL is built from user input. All user text is HTML-escaped before rendering.
- Registration β self-registration always produces a candidate. There is no path to create an admin through the API.
Before exposing it publicly
- Set strong
ADMIN_PASSWORD,DB_PASSWORDandMYSQL_ROOT_PASSWORDin.env. - Put it behind HTTPS.
- Consider marking the session cookie
Secure(left off so plain-HTTP local dev works). - Do not publish the MySQL port; Compose already keeps it internal.
Found a vulnerability? Please open a private security advisory rather than a public issue.
Being straight about what this does not do, so nobody discovers it mid-exam:
Assessment
- Multiple choice only β four options, one correct answer. No coding questions, no essays, no multi-select, no negative marking.
- One attempt per candidate per test. For a resit, clone the test.
- No question randomisation or shuffling.
- No scheduled open/close windows; a paper is simply published or not.
Accounts
- No password reset or change in the UI.
- No two-factor authentication.
- No rate limiting on sign-in attempts.
- No bulk candidate import (CSV upload); candidates self-register.
- Admins can only be created by the bootstrap process.
Operational
- Sessions are in memory, so a restart signs everyone out and the app does not scale horizontally as-is.
- No audit log of staff actions.
- No email β no verification, no result notifications.
- Frontend libraries load from CDN. In an air-gapped deployment the icons, animations and
charts degrade, but the app still works; vendor them into
src/main/resources/web/if you need full offline operation.
Not proctoring
- No browser lockdown, webcam monitoring, screen recording or tab-switch detection.
Cosmetic
- Navigating away from the admin overview while the gauge is still animating logs a few
harmless
NaNSVG warnings from ApexCharts' teardown. Nothing renders incorrectly.
Coding questions with a sandboxed runner Β· question shuffling Β· scheduled exam windows Β· password reset via email Β· bulk candidate import Β· persistent sessions (Redis or a table) Β· audit logging Β· multi-select and negative marking.
codejudge/
βββ docker-compose.yml # app + mysql
βββ Dockerfile # multi-stage: Maven build β slim JRE
βββ pom.xml # one dependency: mysql-connector-j
βββ .env.example # copy to .env
β
βββ db/init/
β βββ 01-schema.sql # tables, constraints, indexes
β βββ 02-seed.sql # four starter categories
β
βββ docs/screenshots/ # images used by this README
β
βββ test/
β βββ e2e_test.py # 113 API checks
β βββ e2e_batches.py # 36 assignment checks
β βββ ui_quick.py # headless browser render
β βββ README.md # how to run them
β
βββ src/main/
βββ java/com/codejudge/
β βββ Main.java # bootstrap + all route registration
β βββ config/ # env-driven configuration
β βββ db/ # connection pool, schema migrator
β βββ json/ # hand-written JSON reader/writer
β βββ http/ # router, request, response, static files
β βββ model/ # plain data classes
β βββ dao/ # JDBC data access
β βββ service/ # business rules
β βββ util/ # passwords, sessions, ids
β βββ worker/ # exam auto-submission timers
β
βββ resources/web/ # the frontend, served from the jar
βββ index.html
βββ css/ # styles.css (app) Β· site.css (marketing + auth)
βββ js/ # api Β· ui Β· charts Β· site Β· views-* Β· app
37 Java files across 10 packages Β· 12 JavaScript files Β· no frontend build step.
Issues and pull requests are welcome.
# make your change, then:
docker compose up -d --build
docker run --rm -i --network codejudge_codejudge-net python:3.12-alpine python - < test/e2e_test.py
docker run --rm -i --network codejudge_codejudge-net python:3.12-alpine python - < test/e2e_batches.pyA few conventions worth matching:
- Keep the backend dependency-free beyond the JDBC driver.
- Business rules belong in
service/, SQL indao/, wiring inMain. - Throw
Response.error(status, "message")for expected failures; the message is shown to the user, so write it in plain language. - Add checks to the relevant suite for anything you change.
- The frontend has no build step β plain JS, and escape user text with
UI.esc().
MIT. See LICENSE.





