Skip to main content

05 - Anti-Cheat Architecture: Server Authority & Behavioral Analysis

How to design anti-cheat: what the authoritative server can prevent, what statistics can only flag, and how to sanction without punishing the innocent.

What do you think about this article?

At a Glance

How to design anti-cheat: what the authoritative server can prevent, what statistics can only flag, and how to sanction without punishing the innocent.

The client is in the enemy's hands

In a competitive game the program running on the player's computer is, by definition, under the control of someone who might cheat: they can read its memory, tamper with its packets, replace its input with a program. Gabriel Gambetta says it in one line that holds up the whole anti-cheat architecture: don't trust the player, start from the worst assumption. For a backend infrastructure this means moving to the server whatever can be moved, where the player cannot reach, and treating the rest as a statistics problem.

The defense therefore has three layers, and each does a different thing. Server authority prevents: it makes impossible whatever the server can compute on its own. Statistical analysis flags: it lines up the improbable behaviors. Review and sanction decide, with caution, because being wrong means punishing an innocent person. Here you build the three layers with executed models (fixed seed, synthetic data). The authoritative server has already been described in the article on the anatomy of the game backend and lag compensation in the one on state synchronization.

What server authority can prevent and what it cannot

The authoritative server is not a magic potion. For each type of cheat it pays to ask which piece of information the cheater manipulates and whether the server can recompute it.

CheatWhat it manipulatesCan the server prevent it?How
Speed hack, teleportThe declared positionYesThe server checks that the movement is physically possible
Wall hack with data in memoryPositions of enemies that the client receives but should not seeYes, if you do not send themVisibility computed on the server
AimbotThe input itself (the aim)No: the input is validStatistical analysis of behavior
Duplication of items or currencySequences of parallel requestsYesIdempotent transactions and inventory checks
Packet tamperingThe content of the messagesPartlyValidation of schema and values; encryption of the channel

The aimbot row is why the following layers are needed: a program that moves the mouse for you produces input identical to that of a very good human, and no validity check tells them apart. The server cannot know who generated the input, it can only say whether it is plausible over time. For the rows where prevention works, instead, the principle is one: the client sends intentions ("I move from here", "I shoot in this direction") and the server recomputes the consequences, never accepting a result declared by the client.

Validating movement, shots and visibility on the server

The listing is a 2D model at 60 ticks per second with three checks. The first accepts a movement only if the implied speed does not exceed the maximum with a 10% tolerance for jitter. The second evaluates a shot: the client sends the aim direction and the tick at which it saw the world, not the target's position (if it sent it, the server would be accepting a fact from the client), and the server rebuilds the world at that tick with the aim ray. The third decides which enemies to send to a client: whoever is behind a wall is not sent, and there is nothing to read in memory.

Server checks: movement, shot with rewind, visibility - TypeScript
// Server-side validation: movement, shot with lag compensation, visibility. 2D model, 60 Hz tick.
interface V { readonly x: number; readonly y: number }
const TICK_S = 1 / 60, MAX_SPEED = 6, TOLERANCE = 1.1, MAX_REWIND_TICKS = 12, HIT_RADIUS = 1.5;

// 1. Movement: the client declares where it wants to go, the server accepts only what is physically possible
function acceptMove(from: V, wanted: V): V {
  const dist = Math.hypot(wanted.x - from.x, wanted.y - from.y);
  return dist / TICK_S <= MAX_SPEED * TOLERANCE ? wanted : from;     // otherwise it stays where it is
}
const legit = acceptMove({ x: 0, y: 0 }, { x: 0.105, y: 0 });        // 6.3 units/s: jitter allowed
const hack = acceptMove({ x: 0, y: 0 }, { x: 0.3, y: 0 });           // 18 units/s: three times the maximum
console.log("legitimate movement (6.3/s):", legit.x, "| speed hack (18/s):", hack.x);

// 2. Shot: the client sends direction and the tick at which it saw the world, NOT the target's position
const history = new Map<number, ReadonlyMap<string, V>>();
for (let tick = 0; tick <= 100; tick++) history.set(tick, new Map([["target", { x: 50, y: 8 * (tick - 90) }]]));
const serverTick = 100;

function rayHits(origin: V, angle: number, target: V): boolean {
  const dx = Math.cos(angle), dy = Math.sin(angle);
  const t = (target.x - origin.x) * dx + (target.y - origin.y) * dy;          // projection onto the ray
  if (t < 0) return false;
  return Math.hypot(origin.x + dx * t - target.x, origin.y + dy * t - target.y) <= HIT_RADIUS;
}
function validateShot(origin: V, angle: number, claimedViewTick: number): string {
  const rewind = Math.max(serverTick - MAX_REWIND_TICKS, Math.min(serverTick, claimedViewTick));   // the client does not choose the past
  const world = history.get(rewind)!;
  return [...world].some(([, p]) => rayHits(origin, angle, p)) ? `hit (rewind to tick ${rewind})` : `miss (rewind to tick ${rewind})`;
}
const shooter = { x: 0, y: 0 };
const seenAt = history.get(97)!.get("target")!;                    // the client saw the target 3 ticks ago
const angle = Math.atan2(seenAt.y - shooter.y, seenAt.x - shooter.x);
console.log("honest aim, view from 3 ticks ago   :", validateShot(shooter, angle, 97));
console.log("same aim evaluated in the present    :", validateShot(shooter, angle, 100));
const old = history.get(60)!.get("target")!;                        // where it was 40 ticks ago
console.log("cheater claims a tick from 40 ago    :", validateShot(shooter, Math.atan2(old.y - shooter.y, old.x - shooter.x), 60));

// 3. Visibility: do not send a client the position of those it cannot see (against the wall hack)
const wall = { x: 10, y0: -5, y1: 5 };                                 // vertical wall
const blocked = (a: V, b: V) => {
  if ((a.x - wall.x) * (b.x - wall.x) >= 0) return false;              // same side: no crossing
  const y = a.y + ((b.y - a.y) * (wall.x - a.x)) / (b.x - a.x);
  return y >= wall.y0 && y <= wall.y1;
};
const me = { x: 0, y: 0 };
const enemies: [string, V][] = [["e1", { x: 20, y: 0 }], ["e2", { x: 20, y: 30 }], ["e3", { x: 5, y: 3 }], ["e4", { x: 30, y: -2 }]];
const sent = enemies.filter(([, p]) => !blocked(me, p));
console.log(`positions sent to me: ${sent.map(([id]) => id).join(", ")} out of ${enemies.length}`);

// Output:
// legitimate movement (6.3/s): 0.105 | speed hack (18/s): 0
// honest aim, view from 3 ticks ago   : hit (rewind to tick 97)
// same aim evaluated in the present    : miss (rewind to tick 100)
// cheater claims a tick from 40 ago    : miss (rewind to tick 88)
// positions sent to me: e2, e3 out of 4

The output shows the expected behavior. The step of 6.3 units/s passes thanks to the tolerance and the one of 18 units/s does not, and the player stays where they were. The target moves sideways, so the "honest" aim built on what the player saw 3 ticks ago hits if the server rewinds to that tick (rewind to tick 97), and misses if the server evaluates in the present: this is the reason for lag compensation. The delicate point is the line with MAX_REWIND_TICKS: the client declares the tick, but the server limits it to 12 ticks (200 ms) back. If it accepted any tick, a cheater could shoot "in the past" where the target was convenient; in the third case of the output the declared tick is 60, the server brings it to 88 and the shot misses. The price is that a player with more than 200 ms of latency does not benefit from the full compensation. Finally, the visibility filter sends 2 enemies out of 4: the other two are behind the wall.

A note on scope. This model does not protect against vulnerabilities of the server itself (Gambetta excludes them from his series) and the visibility filter has costs: more computation per tick, and the risk that an enemy appears at the last moment from behind a corner if the filter is too aggressive. It is solved by extending the filter by a few tenths of a second of movement, just enough to make the appearance imperceptible.

Statistics: flagging improbable behavior without accusing anyone

For the aimbot you look at the distribution. A player's metric (here the share of headshots) is compared with that of the population, and whoever is far beyond is flagged. The classic threshold is "mean plus three standard deviations". The listing checks how it fares in practice, with a synthetic population of honest players (headshot share around 20%, deviation 6 points, asymmetric distribution as you would expect from a proportion) and of cheaters (around 60%). It repeats this in three scenarios, with 0.5%, 5% and 25% of cheaters, and compares two thresholds: the classic one and a robust one, which uses the median and the median absolute deviation (MAD) in place of the mean and standard deviation.

3-sigma threshold versus robust threshold, with precision - Python
import random
import statistics as st

def population(n_honest, n_cheat, seed=4):
    rng = random.Random(seed)
    honest = [rng.betavariate(8, 32) for _ in range(n_honest)]      # headshot share ~20% +-6%
    cheat = [rng.betavariate(30, 20) for _ in range(n_cheat)]       # ~60%
    return honest, cheat

def flag_z(values, k=3.0):
    m, s = st.fmean(values), st.pstdev(values)
    return [v > m + k * s for v in values]

def flag_mad(values, k=3.0):
    med = st.median(values)
    mad = st.median(abs(v - med) for v in values) * 1.4826           # scales the MAD like a standard deviation
    return [v > med + k * mad for v in values]

def report(name, flags, n_honest, n_cheat):
    fp = sum(flags[:n_honest]); tp = sum(flags[n_honest:])
    prec = tp / (tp + fp) if tp + fp else 0
    print(f"  {name:<24} honest flagged {fp:>4} of {n_honest} | cheaters caught {tp:>3} of {n_cheat} | precision {prec:5.1%}")

for n_honest, n_cheat in ((19900, 100), (19000, 1000), (15000, 5000)):
    h, c = population(n_honest, n_cheat)
    vals = h + c
    print(f"{n_cheat} cheaters out of {n_honest + n_cheat} ({n_cheat / (n_honest + n_cheat):.1%})")
    report("mean + 3 sigma threshold", flag_z(vals), n_honest, n_cheat)
    report("median + 3 MAD threshold", flag_mad(vals), n_honest, n_cheat)

# Output:
# 100 cheaters out of 20000 (0.5%)
#   mean + 3 sigma threshold honest flagged   36 of 19900 | cheaters caught 100 of 100 | precision 73.5%
#   median + 3 MAD threshold honest flagged   96 of 19900 | cheaters caught 100 of 100 | precision 51.0%
# 1000 cheaters out of 20000 (5.0%)
#   mean + 3 sigma threshold honest flagged    0 of 19000 | cheaters caught 799 of 1000 | precision 100.0%
#   median + 3 MAD threshold honest flagged   46 of 19000 | cheaters caught 996 of 1000 | precision 95.6%
# 5000 cheaters out of 20000 (25.0%)
#   mean + 3 sigma threshold honest flagged    0 of 15000 | cheaters caught   0 of 5000 | precision  0.0%
#   median + 3 MAD threshold honest flagged    0 of 15000 | cheaters caught 4197 of 5000 | precision 100.0%

The first scenario gives the most important lesson: even with a "three sigma" threshold, in a population with 0.5% of cheaters 36 innocents are flagged on top of the 100 guilty, so only 73.5% of those flagged is really a cheater. The honest players are many and a tiny fraction of them falls beyond the threshold anyway, because the distribution of the share is asymmetric. The other two scenarios show the opposite flaw: when cheaters become 5% the mean and the standard deviation are already inflated by them and the three-sigma threshold catches 799 out of 1000; at 25% it catches none (the masking phenomenon). The robust threshold catches almost all of them (996 and 4,197), but flags more honest players with few cheaters (96). No threshold wins in every scenario, and the numbers hold for this synthetic population, not for a real game. Two rules follow. A statistical flag is not proof: it is a reason to look more closely. And the threshold is tuned on your data, counting false positives, not by copying a "3".

Machine learning: one more signal, not a judge

Sophisticated cheats (an aimbot that imitates the tremor of a hand) escape a threshold on a single metric, and for those you use models that look at sequences of actions. A public example is AntiCheatPT (August 2025): a Transformer trained on Counter-Strike 2 gameplay data, with a labeled dataset of 795 matches released publicly (CS2CD). The results declared by the authors on the non-augmented test set are an accuracy of 89.17% and an AUC of 93.36%. I did not reproduce the model (there is no PyTorch on this machine) and those numbers hold for that dataset, not for your game.

Even if you obtained them, the arithmetic of the previous example stands: with 0.5% of cheaters, a classifier that wrongly flags 1% of the honest players flags 199 innocents for every 100 guilty (even assuming it catches them all). The design consequence is clear: the output of a model is a score that enters the evaluation pipeline, never an automatic verdict. A good model is a model you can explain to whoever handles the appeal.

From flag to sanction: much evidence, one person

The most delicate point of anti-cheat is deciding. The listing shows a policy that accumulates evidence over time: each flag loses weight with a half-life of 7 days, the evidence is combined with a probabilistic OR (1 − Π(1 − s·w)), no automatic sanction fires with fewer than three distinct matches, and the permanent ban is never automatic. The thresholds (0.50, 0.80, 0.95) are parameters that I declare and do not recommend: they are chosen by looking at the cases that human reviewers confirm or refute.

Accumulation of evidence with decay and action thresholds - Python
HALF_LIFE_DAYS = 7
MIN_MATCHES = 3                       # never automatic sanctions on fewer than 3 distinct matches

def combined(evidence):
    """evidence: list of (days_ago, score 0-1, match_id). Combines the evidence with a probabilistic OR."""
    p_innocent = 1.0
    for days_ago, score, _ in evidence:
        weight = 0.5 ** (days_ago / HALF_LIFE_DAYS)       # a piece of evidence halves its weight every 7 days
        p_innocent *= 1 - score * weight
    return 1 - p_innocent

def decide(evidence):
    n_matches = len({m for _, _, m in evidence})
    score = combined(evidence)
    if n_matches < MIN_MATCHES:
        return score, "monitoring only (insufficient evidence)"
    if score >= 0.95:
        return score, "24 h suspension + priority human review (a permanent ban is decided by a person)"
    if score >= 0.80:
        return score, "human review queue, no automatic sanction"
    if score >= 0.50:
        return score, "reinforced monitoring"
    return score, "log only"

cases = {
    "a single strong signal today": [(0, 0.9, "m1")],
    "5 signals of 0.6 in the last 5 days": [(d, 0.6, f"m{d}") for d in range(5)],
    "the same 5 signals from 40 days ago": [(d + 40, 0.6, f"m{d}") for d in range(5)],
    "10 signals of 0.9 in 3 days": [(d % 3, 0.9, f"m{d}") for d in range(10)],
    "10 signals of 0.9, all in the same match": [(0, 0.9, "m1") for _ in range(10)],
}
for name, ev in cases.items():
    score, action = decide(ev)
    print(f"{name:<42} score {score:.3f} -> {action}")

# Output:
# a single strong signal today               score 0.900 -> monitoring only (insufficient evidence)
# 5 signals of 0.6 in the last 5 days        score 0.969 -> 24 h suspension + priority human review (a permanent ban is decided by a person)
# the same 5 signals from 40 days ago        score 0.046 -> log only
# 10 signals of 0.9 in 3 days                score 1.000 -> 24 h suspension + priority human review (a permanent ban is decided by a person)
# 10 signals of 0.9, all in the same match   score 1.000 -> monitoring only (insufficient evidence)

The five cases show what the policy protects. A single strong signal (0.90) is not enough: repeated evidence across distinct matches is needed. Five recent signals of 0.6 give 0.969 and lead to suspension and review, while the same five from forty days ago weigh 0.046 and stay in the log: a player who changed peripheral, or had a terrible evening, does not carry a life sentence around. The fifth case (ten signals, but all in the same match) stays in monitoring, because the repetition of a single observation is not independent. I did not include the account-age multiplier seen in many pipelines: punishing new accounts more means hitting whoever has just arrived, and the right way to treat it is a prior to be calibrated and justified, not a fixed coefficient.

Replay and post-match analysis

Some cheats are not visible in real time and show up by reconstructing the match: the telemetry the server records allows heavier checks to be rerun on players already flagged by others, without the constraint of the tick budget. It is asynchronous work: the data of a selected player is read from a queue and the result reaches the review system as one more score, with the rest of the evidence. Match telemetry and its pipelines are in the article on game telemetry.

Mistakes that keep repeating

  • Sanctioning on a single indicator (for example a high kill/death ratio): a very good player has high numbers.
  • Ignoring latency in the checks: a player with high ping can look like a cheater if the tolerance is fixed.
  • Telling the cheater what gave them away: every precise answer is information for getting around the check.
  • Not offering an appeal: even the best system gets it wrong, and without an appeal the error is permanent.

The four items are the same mistake seen from different sides: treating a signal as proof. The single indicator is the weakest signal, latency is a confounding cause, the precise answer teaches evasion, and the appeal is the only way to find out that the indicator was wrong. If you have to pick just one to apply right away, pick the appeal: it is the one that turns your false positives into data with which to tune the thresholds.

What you can do now

You can tell apart what the server can stop (movement, hidden data, transactions) from what it can only flag (assisted aim), and you can write the checks that close the first three holes, including lag compensation with bounded rewind. You can measure a statistical threshold by counting false positives, not by trusting a number, and you can use a model as a signal in a pipeline where the last word belongs to a person. The next step is leaning on ready-made components: Open Match and Nakama.

Frequently asked questions

Is the authoritative server enough to stop cheaters?

It stops those who alter data the server can recompute: speed, position, inventory, hidden information. It does not stop those who automate legitimate input, like an aimbot: for that you need statistics and review.

Why not automatically ban whoever exceeds three sigma?

Because even among the honest players a small fraction exceeds that threshold: in the example with 0.5% of cheaters, one flagged player in four was innocent. A statistical threshold is a reason to look more closely, not proof.

How far back can lag compensation go?

As far as you decide, with a limit: the example uses 12 ticks, that is 200 ms at 60 Hz. Without a limit a cheater could declare a distant instant in which the target was convenient; with the limit, players with very high latency receive only partial compensation.

Sources

The three listings were run (Node.js 24 for the server, Python 3 for statistics and sanctions) and the outputs are the real ones; populations, targets and evidence are synthetic with a fixed seed. The Transformer model was not run (PyTorch is not available): its results are those declared by the authors.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects