Skip to content

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:

  1. Principal resolution — who is submitting (team or solo player)?
  2. Already-solved guard — if a Solve row already exists for this principal and challenge, redirect immediately without re-processing.
  3. 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.
  4. Flag lookup — the expected flag is fetched from Redis (HGET team_flags:<principal_id> <challenge_id>), falling back to the TeamFlag table, and finally re-derived on the fly if neither is available.
  5. Constant-time comparisonhmac.compare_digest prevents timing oracles.
  6. On correct: insert Submission(result="correct"), insert Solve, insert ScoreEvent(delta=points, reason="challenge_solve"), update Principal.score_total and last_solve_at, clear the wrong-attempt counter, write a CHALLENGE_SOLVED audit event.
  7. 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

ORDER BY score_total DESC, last_solve_at ASC NULLS LAST

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 key scoreboard_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.