Scoring Formula
In Jeopardy CTFs, dynamic scoring is used to infer the difficulty of a challenge based on the number of teams that can solve it. This scoring formula applies the same concept to A/D.
In effect, each round is treated as a Jeopardy CTF with the following challenges:
- For each flag you capture, you receive ATK points based on the number of teams that capture that flag.
- For each service and each flag store, you receive DEF points for each actively exploiting team that did not capture your flag, weighted by how difficult that team's exploit was to defend against, inferred (via the dynamic scoring formula) from how few teams managed to defend against it.
Additionally, you gain a fixed amount of SLA points per flag
store, split evenly across its flags that are still valid (submittable for
points). You earn the share of each such flag that is retrievable from the
service, as long as the checker status is SUCCESS
or RECOVERING.
Checker Status
The checker returns one of the following results for each service:
SUCCESSif all flags could be successfully deployed and retrieved, and functionality checks were successful.RECOVERINGif all checks for the current round succeed, but at least one flag from the past 4 rounds is missing.MUMBLEif any functionality checks for the current round failed.OFFLINEif the checker failed to establish a connection to the service.TIMEOUTif a service did not answer within its timeout.CRASHEDif a checker task failed for an unknown reason.REVOKEDif a checker task didnt not produce a result in time.
If you see CRASHED or REVOKED for only your own service,
please notify us with context in a ticket.
Implementation
The formula may be evaluated against real CTF data using our simulator, whose implementation has been tested to match the gameserver.
In the following sections, we will reference code from the simulator to aid the explanation of different components of the scoring formula.
Dynamic Scoring
NFITS chose the following dynamic scoring formula for the ECSC 2026 Jeopardy CTF, so we base the A/D dynamic scoring on it. Using the same formula for both contests means points map to skill in the same way across the two scoreboards, which is what makes merging them fair. We scale the value range to make it more AD-friendly.1
The formula determines the value of each challenge by anchoring it at two
exact fixed points: (1, max_points) and (teams, min_points).
The value of a challenge is exactly max_points when only one team solves it
and exactly min_points when every team solves it.
With the default
alpha = 0.705, the value drops steeply for the first few solves and then
flattens out, so that rare exploits remain clearly the most valuable.
Attack Points
You earn ATK points for every flag you capture. Each flag's value comes from the dynamic scoring formula: the fewer teams that capture it, the more it is worth. Every round the gameserver recounts how many teams have submitted each still-valid flag and recalculates its value, so a flag is worth less the more often it is stolen. When a flag's value decreases, so do the scores of the teams that captured it in earlier rounds, to match its reduced worth.
On top of each flag's value, an attacker also receives a bonus equal to the DEF points a team would earn from defending against that attack with perfect uptime. This ensures an attack never earns its targets more DEF points than it earns the attacker.
def attack_points_flagstore(put_round: int, live_round: int, attackers: int,
teams: int, captured: set[int]):
max_round = min(live_round, put_round + flag_rounds_valid)
points = defense_points_attack(len(captured), attackers, teams,
put_round, max_round, lambda _: True)
for flag in captured:
captures = flag_captures[flag]
points += attack_points_capture(captures, teams)
return points
Defense Points
You earn DEF points for every attacker you successfully defend against. The value of defending a flag is set by the dynamic scoring formula from how many teams held off that same attacker: the fewer teams that managed to defend, the harder the exploit was to defend against, and the more each successful defense is worth.
These points are scaled up by the maximum number of victims, so that defending stays roughly as rewarding as attacking. We divide by the number of active attackers, but this cancels out roughly with the number of attackers you were actually able to defend against.
A flag's defense points are spread evenly across all rounds it must stay
retrievable, and a round only pays out if the flag was actually available.
The flag_ok parameter is a per-team, per-flag predicate: the service
must be SUCCESS
or RECOVERING that round and retrieving the
flag must have succeeded.
This stops teams from deleting their own flags to dodge attacks: if a flag is
not at risk, defending it earns nothing.
def defense_points_attack(victims: int, attackers: int, teams: int,
put_round: int, live_round: int, flag_ok):
max_points = defense_points_attack_max(victims, attackers, teams)
rounds_retrievable = sum(flag_ok(r) for r in range(put_round, live_round))
return max_points * rounds_retrievable / flag_rounds_valid
The total defense points per flag store per round are calculated by summing over every active attack we are not a victim of. As an exception, the NOP team does not gain defense points.
def defense_points_flagstore(put_round: int, live_round: int, team: str,
teams: int, service: str, flagstore: int):
max_round = min(live_round, put_round + flag_rounds_valid)
points = 0
attackers = victim_map[put_round, service, flagstore]
for attacker, captures in attackers.items():
victims = {flag_owner[flag] for flag in captures}
if team in victims or attacker == team:
continue
flag_ok = flag_ok_fn(put_round, team, service, flagstore)
points += defense_points_attack(len(victims), len(attackers),
teams, put_round, max_round, flag_ok)
return points
SLA Points
You earn SLA points each round for keeping your services healthy and their
flags retrievable. A service in SUCCESS earns the
full reward (sla_scale * max_points for each of its flag stores) each round,
while a RECOVERING service earns a partial reward,
based on the fraction of flags in play that are actually retrievable.
Any other checker status earns nothing.
At game start, no flags have been deployed yet, and thus fewer flags are in play
to be checked. SLA is scaled to compensate for these missing flags, such that
one round of downtime always costs at least sla_scale * max_points per flagstore,
and more if flags were not able to be placed and/or remain unretrievable.
This necessarily increases the lifetime value of early game flags.
Total Points
The component points are the previously defined scores summed over every flag store of every service, for every round played so far. A team's total score is the sum of all three components; the scoreboard displays them separately as ATK , DEF , and SLA .
Final Scores
The final team scores are calculated at the end of the game by subtracting the NOP team score from each team's total score. Given that it earns neither attack nor defense points, the NOP team represents a team that only managed to keep its services up, without exploiting anyone or defending against any exploits. We thereby treat its score as a baseline of points which did not require any effort by teams to be earned.
It is highly unlikely for a playing team to earn fewer points than NOP.
Insights
- A flag is worth more the fewer teams capture it, so attackers are rewarded for pulling off harder exploits.
- Defense works the same way: the fewer teams that fend off an attack, the more each successful defense against it is worth.
- Defending a flag store never earns more than the attacker gains from that attack.
- Attacking every team but one effectively hands that team the defense points, so it pays to attack as widely as possible.
- Reducing an attacker's attack points is realistically never worth the cost of downtime for the victim.
- The NOP team earns neither attack points nor defense points.
FAQ
Why is our team losing defense/attack points?
Teams may appear to lose defense or attack points when the value of the attacks they defended against or the flags they submitted decreases. This calculation is retroactive, as flags may be submitted up to 4 rounds after the round in which they are deployed.
Why can the defense points be non-zero in a round where our service status is neither SUCCESS nor RECOVERING?
Most likely, a team was attacking your service before it went down and submitted (at least some of) those flags in the round before it went down. These flags are only considered in the next round, and you are then awarded defense points for defending against this exploit from the previous round retroactively. Crucially, you do not gain defense points for any flag stores not retrievable in the round in which your service was down.
-
For the A/D we scale the original jeopardy
max_valueandmin_valuedown by a factor of 100 to prevent the scores from getting unwieldy, but this does not affect the final ranking and does not devalue the A/D points since only linear operations are applied to the jeopardy formula. The scaling cancels out when the aggregated scores are calculated by normalization. ↩