rCTF Docs
Overview

Scoring

Challenge scoring kinds in rCTF (decay and dynamic), plus a guide for publishing scores to dynamic challenges.

Every challenge in rCTF picks one of two scoring kinds. The kind controls how points are produced and how they reach the leaderboard. The default is decay, which is what most CTFs expect.

You set the scoring kind from the admin challenge editor (under /admin/challs) or directly via the admin challenge update API. The platform refuses to switch a challenge’s scoring kind while solves exist. Wipe the solves first, or pick the kind when you create the challenge.

Challenge types#

Decay#

The default. Every solver receives the same point value, and that value decreases as more teams solve the challenge. The math is controlled by the global scoring provider, which sees the challenge’s points.min and points.max along with the current solve count.

Challenge data - decay
{
"points": { "min": 100, "max": 500 },
"scoring": { "kind": "decay" }
}

Editing points.min or points.max on a decay challenge immediately re-prices every existing solver. Switching the scoring algorithm globally also re-prices every decay challenge on the next worker tick.

Scoring providers that read maxSolves (the largest non-banned solver count across the event) only see decay challenges. Dynamic-feed solver counts are excluded, so a high-volume dynamic challenge won’t skew the decay normalization across unrelated decay challenges.

Note (Default behavior)

Existing challenges that don’t set scoring at all are treated as decay. Upgrading from an earlier rCTF version doesn’t require touching any data.

Dynamic#

The points for a dynamic challenge come from an external scoring source that publishes per-team values over time. Every team can have a different score on the same challenge, and the scores can move up or down as the source publishes updates. King-of-the-hill, attack-defense, and any scoring that runs outside rCTF fit this shape.

Challenge data - dynamic
{
"scoring": {
"kind": "dynamic",
"source": {
"transport": "webhook",
"secret": "<shared-secret>"
}
}
}
FieldRequired whenPurpose
scoring.source.transportAlwaysMust be webhook.
scoring.source.secretAlwaysShared HMAC secret used for webhook auth.
Warning (Flag submissions are rejected)

Dynamic challenges ignore flag submissions. The external source owns the scoreboard. Leave the challenge’s flag field empty so the public challenge listing hides the submit form.

Dynamic scoring guide#

Dynamic challenges talk to rCTF through a webhook. Your scoring backend POSTs the current per-team scores whenever they change.

Warning (Stop the feed when the event ends)

Unlike flag submissions, the dynamic-scores endpoint has no built-in event-timing gate. The scoring backend can push both before start and after end. Cutting deliveries off cleanly at end is still the operator’s responsibility. Every late delivery (post-end as well as post-freeze) lands in the tally, so a backend that keeps publishing will keep moving the scoreboard around after the CTF is supposed to be over.

Team identifiers#

Every entry in the score list references an rCTF team ID, the same UUID that appears on the /users/:id page. The dynamic backend needs to know each team’s ID somehow. A few patterns tend to work.

  • Have admins paste each team’s rCTF ID into the dynamic service’s onboarding flow.
  • Wire a “Sign in with rCTF” button on the dynamic service through External apps and read the team ID from /api/v1/users/me once the user lands back authorized. This is usually the lowest-friction option when teams self-onboard.

Score entries for unknown team IDs are silently dropped. The challenge backend won’t tell you which team IDs failed to land, so log on your side and reconcile if it matters.

Payload shape#

The webhook uses this JSON body:

Payload
{
"scores": [
{ "userId": "11111111-2222-3333-4444-555555555555", "points": 1280 },
{ "userId": "66666666-7777-8888-9999-000000000000", "points": 940 }
]
}
FieldTypeRequiredPurpose
scoresarrayYesPer-team score entries.
scores[].userIdstringYesrCTF team UUID.
scores[].pointsintYesSigned integer. 0 clears that team’s score for the challenge. Negative values are accepted.

Each entry is an absolute setter for that team and challenge. Teams omitted from the payload keep their existing rCTF-side score.

Webhook#

Your backend POSTs to /api/v2/challs/:id/scores whenever a team’s score changes. rCTF persists the per-team values, emits the corresponding score-event rows so the historical graph stays correct, and wakes the leaderboard worker on the next pub/sub tick.

Endpoint#

POST /api/v2/challs/<challenge-id>/scores HTTP/1.1
Host: ctf.example.com
Content-Type: application/json
X-RCTF-Timestamp: <unix-milliseconds>
X-RCTF-Signature: sha256=<hex>
{"scores":[{"userId":"","points":1280}]}
HeaderDescription
X-RCTF-TimestampUnix milliseconds at signing time. Requests outside a five-minute skew window are rejected.
X-RCTF-Signaturesha256= followed by the lowercase hex HMAC-SHA256 of ${timestamp}.${raw_body} using the challenge’s secret.

The route accepts any IP and doesn’t require an authenticated rCTF user. Only the HMAC and the timestamp window stand between the open internet and upsertDynamicSolves, so the secret is the only thing protecting the scoreboard. Treat it like a password.

Responses#

StatusKindMeaning
200goodDynamicScoresBody has inserted, updated, deleted counts.
400badBodyPayload didn’t validate against the schema.
401badSignatureHMAC mismatch, timestamp outside the skew window, unknown challenge ID, or the challenge isn’t dynamic. The endpoint deliberately doesn’t distinguish these so it can’t be probed for which challenges accept the feed.

Reference publisher#

publish-scores.ts
import { createHmac } from 'node:crypto'
const RCTF_BASE_URL = 'https://ctf.example.com'
const CHALLENGE_ID = 'koth-pwn-2026'
const SECRET = process.env.RCTF_DYNAMIC_SECRET!
type ScoreEntry = { userId: string; points: number }
export async function publishScores(scores: ScoreEntry[]) {
const body = JSON.stringify({ scores })
const timestamp = Date.now().toString()
const signature =
'sha256=' + createHmac('sha256', SECRET).update(`${timestamp}.${body}`).digest('hex')
const res = await fetch(`${RCTF_BASE_URL}/api/v2/challs/${CHALLENGE_ID}/scores`, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-rctf-timestamp': timestamp,
'x-rctf-signature': signature,
},
body,
})
if (!res.ok) {
throw new Error(`rCTF rejected scores: ${res.status} ${await res.text()}`)
}
return (await res.json()) as {
data: { inserted: number; updated: number; deleted: number }
}
}
Warning (Sign the raw body, not the parsed body)

rCTF reads the raw request bytes for HMAC verification and only parses JSON after the signature matches. Sign the exact bytes you send. Adding whitespace, sorting keys, or letting a middleware re-serialize the body between signing and sending will cause verification to fail.

How scores reach the leaderboard#

For every challenge kind, the per-team points are stored in solves.points and an immutable row goes into score_events with the delta and a source tag (flag, decay-recompute, feed, ban, delete, or algo-change).

  • The leaderboard worker sums each team’s solves.points to produce ranks. Decay recomputes are debounced through Redis pub/sub so a burst of solves only re-prices the challenge once.
  • The leaderboard graph replays score_events chronologically so historical points reflect what the team had at that point in time, not what they have now.
  • Banning a team emits reversing events for every solve they had, and unbanning restores them. The leaderboard worker re-runs decay for every affected challenge.
  • Deleting a solve emits a single reversing event.

You don’t need to interact with score_events directly. It’s an internal audit log. The shape is documented above so the leaderboard’s behavior is predictable when you’re debugging discrepancies between the /scores page and the /scores/:id graph.

Esc

Start typing to search the docs.