Scoreboard & scoring¶
This page explains how points are assigned, how the scoreboard is built, and what admin controls are available.
Data model¶
Scoring is built on four database tables:
| Table | Purpose |
|---|---|
Submission |
Every flag attempt (correct or wrong). Stores the SHA-256 hash of the submitted flag, result ("correct" | "wrong"), IP, and user-agent. The raw flag is never stored. |
Solve |
One row per principal per challenge, created on the first correct submission. Has a unique constraint (principal_id, challenge_id) that prevents double-scoring races. Stores points_awarded at solve time. |
ScoreEvent |
Append-only ledger of all point changes. Each row has a delta (positive or negative) and a reason string. Manual admin adjustments also write here with reason="admin:<note>". |
TeamFlag |
Durable mirror of the Redis team_flags:<principal_id> hash. Stores the pre-derived per-team flag for each (principal, challenge) pair. Redis is the fast lookup; this table is the fallback. |
Principal.score_total is a denormalized integer that is incremented
atomically on each correct solve:
db.session.execute(
update(Principal)
.where(Principal.id == principal.id)
.values(score_total=Principal.score_total + challenge.points, last_solve_at=now)
)
Using a SQL UPDATE ... SET score_total = score_total + ? expression prevents
double-credit when two workers process submissions concurrently.
Scoring a flag submission¶
The submission pipeline runs in this order:
- Principal resolution — who is submitting (team or solo player)?
- Already-solved guard — if a
Solverow already exists for this principal and challenge, redirect immediately without re-processing. - Rate limit check — 3 wrong attempts within the window triggers a 30-second lockout (Redis sliding-window counter, not per-process). Correct submissions clear the counter.
- Flag lookup — the expected flag is fetched from Redis (
HGET team_flags:<principal_id> <challenge_id>), falling back to theTeamFlagtable, and finally re-derived on the fly if neither is available. - Constant-time comparison —
hmac.compare_digestprevents timing oracles. - On correct: insert
Submission(result="correct"), insertSolve, insertScoreEvent(delta=points, reason="challenge_solve"), updatePrincipal.score_totalandlast_solve_at, clear the wrong-attempt counter, write aCHALLENGE_SOLVEDaudit event. - On wrong: insert
Submission(result="wrong"), increment the wrong-attempt counter, run the flag-sharing anti-cheat scan (see anti-cheat.md).
Points model¶
Points are fixed per challenge and awarded in full on first solve. There is no decay curve or dynamic scoring formula in the current implementation.
Dynamic scoring status
The Challenge.flag_type column accepts "derived" and "dynamic", and
Challenge.is_dynamic gates container spawning — but neither field
activates a decay formula. All challenges award their full points value
to every solver regardless of how many teams have already solved the
challenge. A future release may introduce first-blood bonuses or solve-count
decay; it is not implemented now.
Manual point adjustments are possible from the admin dashboard (Admin →
Dashboard → Manual Score Adjust). Each adjustment writes a ScoreEvent with
reason="admin:<organizer note>" and updates Principal.score_total
directly.
Scoreboard page¶
The scoreboard is served at /scoreboard/ and has three routes:
| Route | Purpose |
|---|---|
GET /scoreboard/ |
Full page render. Supports ?filter=all (default), ?filter=team, ?filter=solo. |
GET /scoreboard/partial |
HTMX partial — renders only the leaderboard table fragment. Used by the auto-refresh hx-trigger="every 30s" on the page. |
GET /scoreboard/chart-data |
JSON endpoint. Returns rank-vs-score scatter plot data for Chart.js, cached for ~15 seconds. Accepts ?filter=. |
Sort order¶
Higher score wins. On a tie, the team that reached that score first (earlier
last_solve_at) ranks higher.
Chart data format¶
/scoreboard/chart-data returns:
{
"datasets": [
{
"label": "Teams",
"data": [
{"x": 300, "y": 1, "label": "TeamName", "kind": "team",
"rank": 1, "score": 300, "solves": 3,
"last_solve_at": "2025-06-06T14:30:00+00:00"}
],
"borderColor": "#bc8cff",
"backgroundColor": "rgba(188, 140, 255, .18)",
...
},
{
"label": "Solo Players",
"data": [...],
"borderColor": "#79c0ff",
...
}
],
"max_rank": 12
}
The payload is cached per (filter_kind, frozen) with a 15-second TTL.
Challenge visibility¶
A challenge with status: "hidden" is not shown on the player challenge list.
Importing or toggling a challenge to "visible" also calls
generate_flags_for_challenge, which pre-derives and stores flags for every
active principal so the Redis lookup is always warm.
Toggling visibility from the admin panel (Admin → Challenges → toggle) fires the same flag generation path.
Scoreboard freeze¶
Admins can freeze the scoreboard from Admin → Dashboard → Freeze Scoreboard. While frozen:
GET /scoreboard/renders the same principal list (queried from the DB at request time — the freeze flag does not snapshot the data, it only sets a cache keyscoreboard_frozen=True).- The freeze timestamp is displayed to players as "Frozen at HH:MM:SS UTC".
- The chart-data cache key includes the frozen state, so unfreezing immediately invalidates the cached scatter data.
The freeze state is stored in the Flask-Cachelib cache (timeout=0, meaning
it persists until the app restarts). It does not survive a server restart.
Wall of Shame¶
The scoreboard page queries the EventLog for entries of type
USER_AUTO_BANNED_CHEATING and renders up to 8 of them as a "Wall of Shame"
section. Each entry would display the username, principal name, and reason
(e.g. "Submitted another team's flag" or "Replayed a previously submitted
flag").
Wall is always empty in the current build
The event type USER_AUTO_BANNED_CHEATING is queried by the scoreboard
code but is never written anywhere in the application. The platform
does not auto-ban: anti-cheat detections write FLAG_SHARE_DETECTED or
FLAG_REPLAY_DETECTED events (CRITICAL severity), and manual bans write
USER_BAN_TOGGLED. Until the auto-ban path is implemented, the Wall of
Shame will remain empty on every event.
To surface cheating evidence to your audience, direct players to the
scoreboard and review FLAG_* events in the admin audit log instead.