# KZ Anti-Cheat
**Movement cheat detection for Counter-Strike 1.6 KZ servers**
Author: **illusion @ CodeStrike** · [codestrike.store](https://codestrike.store)
---
## What it detects
| Detector | Catches |
|---|---|
| `BUNNYHOP` | auto bunnyhop, jump scripts, `+jump` macro loops |
| `GROUNDSTRAFE` | GS and SGS scripts, including variants that never touch `+duck` |
| `STRAFE-STABILIZER` | air strafe optimisers / "perfect strafe" hacks |
| `PRESTRAFE-STABILIZER` | ground strafe optimisers (experimental, off by default) |
| `INPUT-ANOMALY` | speedhack and client clock manipulation |
---
## The idea
GoldSrc player movement is fully deterministic. Every tick the server receives a
usercmd — buttons, msec, forwardmove, sidemove, viewangles — and runs
`PM_Friction`, `PM_Accelerate`, `PM_AirAccelerate` and `PM_Jump` on it.
This plugin re-implements that math. For every single tick it knows not only
what the engine did, but **what the best possible input would have produced**.
`PM_AirAccelerate` adds `min(K, 30 − dot(v, wishdir)) · wishdir` to the velocity,
with `K = sv_airaccelerate · wishspeed · frametime`. Maximising that over the
freely chosen yaw has a closed form:
```
Δv_max = √(v² + wishspd² − max(0, wishspd − K)²) − v
```
Everything is derived from that and from the live server cvars. Nothing is a
magic number pulled out of the air.
### Why that matters for airaccelerate
The same cheat behaves completely differently depending on `sv_airaccelerate`,
so a single fixed threshold cannot work:
| | `sv_airaccelerate 10` | `sv_airaccelerate 100` |
|---|---|---|
| K at 250 u/s, 100 fps | 25 | 250 |
| optimal `dot(v, wishdir)` | 5 | 0 |
| view angle to hold | 88.9° | 90° |
| window for ≥0.90 efficiency | **±0.85°** | **±2.2°** |
A stabilizer written for a 100 AA server aims perpendicular, which is exactly
optimal there — and 1.1° wrong on a 10 AA server, where it then gains *less*
than a good human. Every threshold in this plugin is interpolated between its
10 AA and 100 AA value using the live cvar, so both are judged correctly.
The same principle applies to fps. All ground-strafe timings are measured in
**milliseconds of wall clock**, never in ticks, because 25 ticks is 250 ms at
100 fps and 27 ms at 940 fps.
---
## Installation
1. Copy `kz_anticheat.amxx` into `cstrike/addons/amxmodx/plugins/`
2. Add this line to `cstrike/addons/amxmodx/configs/plugins.ini`:
```
kz_anticheat.amxx
```
3. Change the map or restart the server.
On first load the plugin creates and executes:
```
addons/amxmodx/configs/CodeStrikePlugins/kz_anticheat.cfg
```
That file is generated **once** from the plugin's own defaults and is never
overwritten again. Delete it to regenerate. Everything is configured from
there — no source file ever needs to be edited or recompiled.
You will see a banner in the server console confirming the load, the movement
cvars it read, and the prestrafe ceiling it derived from them.
---
## First five minutes on a new server
```
kzac_physics
```
Run this before anything else. It prints the movement model the plugin
derived. **If the prestrafe ceiling it reports does not match what your players
actually reach (normally ~278 u/s), your movement cvars are not what the plugin
is reading, and nothing else will be right either.**
```
kzac_diag
```
Confirms the movement hooks are alive. `ticks processed` must climb while
anyone is moving.
---
## Admin commands
All require flag **`c`** (`ADMIN_KICK`).
AMXX flag letters: `a` immunity · `b` reservation · **`c` kick** · `d` ban ·
`e` slay · `f` map · `g` cvar · `l` rcon.
| Command | Purpose |
|---|---|
| `kzac_stats <target>` | every metric the plugin holds for one player |
| `kzac_watch <target\|off>` | live telemetry into your console |
| `kzac_physics` | the derived movement model and all active thresholds |
| `kzac_diag` | are the movement hooks firing, and which guard is discarding ticks |
| `kzac_reset <target>` | clear one player's accumulated evidence |
`kzac_stats` is the important one. Every detection can be verified by hand
against the numbers it prints, and each detector shows a **session peaks**
block — the most extreme values that player has produced. That is what you
calibrate against if you ever need to move a threshold.
---
## Configuration
### Announcements
```
kzac_announce_mode 1 // 0 = silent, 1 = admins only, 2 = everyone
kzac_announce_flag "c" // flag required to see them in mode 1
```
Mode **1 is the default on purpose**. The flag defaults to `c` (`ADMIN_KICK`),
the same one the admin commands require, so anyone who can investigate a
detection also sees it happen. A public accusation cannot be taken back,
and no anti-cheat is perfect. Console output and the log file always happen
regardless of this setting.
### Punishment
```
kzac_punish_bhop 0 // 0 = nothing, 1 = kick, 2 = ban
kzac_punish_gstrafe 0
kzac_punish_strafe 0
kzac_punish_prestrafe 0
kzac_punish_input 0
kzac_punish_hits 3 // detections of the SAME type before it fires
kzac_ban_minutes 0 // 0 = permanent
```
Each detector carries its own reason text, so a banned player is told what was
actually caught rather than a generic line:
```
kzac_reason_bhop "Bunnyhop Cheat Detected"
kzac_reason_gstrafe "Ground Strafe Cheat Detected"
kzac_reason_strafe "Strafe Stabilizer Cheat Detected"
kzac_reason_prestrafe "Prestrafe Stabilizer Cheat Detected"
kzac_reason_input "Speedhack Detected"
kzac_punish_reason "Movement cheat detected" // fallback
```
Leave one **empty** and it falls back to `kzac_punish_reason`. Clear all five
to use a single line for everything.
Punishments are **off by default**. Turn them on deliberately, and prefer to
start with `1` (kick) while you watch your own server's numbers.
They are set per detector on purpose. `BUNNYHOP` and `GROUNDSTRAFE` have the
widest measured separation between a hand and a script — those are the safest
to automate. `STRAFE-STABILIZER` has the thinnest margins.
### Ban command
The **entire command** is a cvar, so the plugin does not care which ban system
you run — write whatever yours expects, in whatever argument order it wants.
```
kzac_ban_cmd amx_ban %n %m %r // default
kzac_kick_cmd amx_kick %n %r // default
```
| | |
|---|---|
| `%m` | ban length in minutes, `0` = permanent |
| `%n` | player name — **already quoted for you** |
| `%r` | reason text — **already quoted for you** |
| `%u` | userid, use as `#%u` |
| `%s` | steamid |
| `%i` | ip address |
| `%N` `%R` | name and reason **unquoted**, if your ban system wants them raw |
**Check your own server first.** Type `amx_ban` with no arguments in the server
console and it prints the usage it expects. The argument order is **not** the
same across ban systems - some want the target first, some the minutes first.
Getting it wrong fails with `Client with that name or userid not found`.
```
amx_ban %n %m %r # target first (default)
amx_ban %m %n %r # minutes first
amx_ban #%u %m %r # by userid
amx_banip %s %m %r # by steamid
banid %m %s ; writeid ; kick # %u # engine level ban
```
Argument order is entirely yours — placeholders expand wherever you put them,
and several commands can be chained with `;`.
### If the punishment does not happen
The console always prints the exact command it ran:
```
[KZ-AC] executing: amx_kick "illusion" "Bunnyhop Cheat Detected"
```
If that line appears and the player is still there, the plugin did its job and
the command refused. The usual cause is that **`amx_kick` and `amx_ban` respect
AMXX immunity** - a player holding flag `a` (`ADMIN_IMMUNITY`), which server
owners normally give themselves, is silently ignored. Testing on your own
account therefore looks exactly like a broken plugin.
Run the same command by hand in the server console to confirm. The engine
commands ignore immunity completely:
```
kzac_kick_cmd kick # %u
kzac_ban_cmd banid %m %s ; writeid ; kick # %u
```
If the `executing:` line does **not** appear, check the console for:
```
[KZ-AC] illusion :: BUNNYHOP held - 1 of 3 detections needed (kzac_punish_hits)
```
Only announced detections count towards `kzac_punish_hits`; ones suppressed by
`kzac_cooldown` do not.
**Never put a quote character in these values yourself.** A cvar value ends at
the first quote the engine sees, so a template written with quotes around the
reason would be cut off there and the reason would silently vanish. That is
exactly why `%n` and `%r` arrive already quoted.
### Logging
```
kzac_log 1
kzac_log_file "kzac_detections.log"
```
Written to `addons/amxmodx/logs/`. Detections also always go to the standard
AMXX log via `log_amx`.
### Sensitivity
```
kzac_min_conf 80.0
```
Confidence needed before a detection is announced. Raise it to `90` to keep
only the strongest rules. Lower it and you will start seeing marginal calls.
```
kzac_immunity ""
```
Admin flags that are never reported or punished. Empty means nobody is immune.
---
## Testing that was done
Tested against **real cheats**, not simulations, on a live KZ server:
| Cheat | `sv_airaccelerate 10` | `sv_airaccelerate 100` |
|---|---|---|
| Auto bunnyhop | detected | detected |
| Bunnyhop script | detected | detected |
| GS script | detected | detected |
| SGS script | detected | detected |
| Strafe stabilizer | detected | detected |
Legitimate play was tested across the same range — manual bunnyhop with a
scroll wheel, manual GS and SGS, air strafing, prestrafe — at 100 fps and at
1000 fps, on both airaccelerate settings.
### Measured separation
Every active threshold sits between a value measured from real human play and a
value measured from a real cheat on the same server:
| Signal | Human | Threshold | Cheat |
|---|---|---|---|
| `+jump` presses per jump | 5.26 | 1.20 | 1.00 |
| perfect jumps in a row | 3 | 12 | 20–23 |
| perfect jump share | 26.6% | 80% | 100% |
| `+duck` presses per second | 13.3 | 35.0 | — |
| `+duck` gap variation | 0.70–0.88 | 0.10 | — |
| ground breaks on null moves | 3% (vs 9% chance) | 50% and 3× chance | 99% (vs 5%) |
| air strafe efficiency, 300-tick window | 0.583–0.65 | 0.85 | 0.909 |
| efficiency variation, 300-tick window | 0.172 | 0.075 | — |
### What a legitimate ground strafe actually looks like
Worth knowing, because it is the reason speed alone is never used as evidence:
```
GS spd 324.9->323.2 bound 324.5 | grnd 11 run 6 | z 36.03
GS spd 571.4->569.2 bound n/a | grnd 11 run 1 | z 36.03
+duck edges 102 | +duck held 102 ticks
```
A legitimate GS was measured at **571 u/s on a 278.7 u/s ceiling**. Every
`+duck` press lasted exactly one tick — that is a scroll wheel. On the ticks
where the player really is grounded, the resulting speed is always just *under*
the closed-form bound: the ground physics is untouched. The speed comes from
the one-tick air frames each duck transition creates.
So the groundstrafe detector never looks at speed. It looks at the input
stream: how fast `+duck` repeats, how regular it is, and whether ground contact
is being broken by commands that carry no time at all.
---
## No anti-cheat is perfect
This one is not either, and you should run it knowing that.
**False positives are possible.** During development six were found and fixed,
each from a different rule, and every one had the same root cause: a threshold
that looked sound in reasoning but had never been measured against human play
in that specific situation. Human movement statistics shift with airaccelerate,
with fps, and with what the player is doing — ground strafing, bunnyhopping and
air strafing produce genuinely different numbers from the same person.
Five rules were **disabled** after they produced false positives, and they ship
disabled. They are still in the config for anyone who wants to study them:
`kzac_st_err_aa10`, `kzac_st_err_aa100`, `kzac_st_err_sd`, `kzac_st_inband`.
What this means in practice:
- **Do not enable automatic bans on day one.** Run with warnings for a while
and read what comes out of your own server.
- **`kzac_stats` before you act.** Every detection prints the numbers behind
it. If they do not look extreme, treat the call as marginal.
- **The air strafe detector is the least certain of the three.** Its margins
are the thinnest. Warn or kick on it; leave banning to the bunnyhop and
groundstrafe detectors, which have far wider separation.
- **A cheat used partially is harder to catch.** A stabilizer that a player
fights with his own inputs gains less and looks more human. Detection
scales with the advantage being taken, which is a reasonable property but
not a complete one.
- **A cheat can be written to stay under any threshold** by deliberately being
worse than it could be. At that point it stops providing much advantage,
but it is honest to say the coverage is not absolute.
If a player disputes a detection, `kzac_stats` on them and compare against the
measured human values in the table above. The plugin gives you the evidence to
make that judgement — it does not ask you to trust it blindly.
---
## Files
```
kz_anticheat.sma main plugin
include/kzac_core.inc movement math, guards, reporting, punishment
include/kzac_bhop.inc bunnyhop detector
include/kzac_gstrafe.inc ground strafe detector
include/kzac_strafe.inc air strafe detector
include/kzac_config.inc config file generator
compiled/kz_anticheat.amxx ready to use
```
Only `kz_anticheat.amxx` is needed to run the plugin. The source is included so
you can audit or extend it.
To recompile, put the five `kzac_*.inc` files in your `scripting/include/`
folder and compile `kz_anticheat.sma` as usual. It builds clean on AMX Mod X
1.10 with **zero errors and zero warnings**.
---
## Security notes
**Player names are sanitised before they reach a console command.** A name
containing a quote or a semicolon would otherwise let a caught cheater execute
arbitrary server commands through the kick or ban template. Quotes, semicolons
and control characters are replaced with `_`.
**Punishments never run inside the movement pipeline.** They are queued and
fired from a task one frame later. Executing a kick synchronously from inside
a movement forward disconnects the player mid-callback and crashes the server -
this was found and fixed during testing.
---
## Requirements
- AMX Mod X 1.8.2 or newer (developed and compiled on 1.10)
- `fakemeta` module
- Counter-Strike 1.6 — works on HLDS, ReHLDS and ReGameDLL
No other plugin, module or dependency is required.
---
## Performance
One forward (`FM_CmdStart`) carries the whole pipeline. Per player per tick the
plugin does a handful of float operations and a few `pev` reads; server cvars
are cached and refreshed once per second rather than read per tick.
There is no per-frame entity scanning, no `set_task` loops per player, and no
string work on the hot path.
---
## Support
Bought from [codestrike.store](https://codestrike.store).
When reporting a problem, include the output of `kzac_stats <player>` and
`kzac_physics`. Those two dumps contain everything needed to tell a genuine
detection from a threshold that needs moving on your server.
---
*KZ Anti-Cheat — illusion @ CodeStrike*
---
## Detector maturity — read this before enabling punishments
Not all three detectors are equally proven, and the difference is large enough
that they should not be configured the same way.
### BUNNYHOP and GROUNDSTRAFE — production ready
Validated against **two independent players**, both airaccelerate settings, and
real cheats. Nothing came close to a threshold in either direction:
| Signal | Player A | Player B | Threshold | Cheat |
|---|---|---|---|---|
| `+jump` presses per jump | 5.26 | 5.30–6.19 | 1.20 | 1.00 |
| perfect jumps in a row | 3 | 2–4 | 12 | 20–23 |
| `+duck` per second | 13.3 | 9.0–20.2 | 35.0 | — |
| `+duck` gap variation | 0.70–0.88 | 1.03–1.16 | 0.10 | — |
| breaks on null moves | 3% | 0% | 50% | 99% |
These are safe to automate. `kzac_punish_bhop 2` and `kzac_punish_gstrafe 2`
are reasonable.
### STRAFE-STABILIZER — warn only
Nine false positives were found during development, each from a different rule.
Five rules had to be **disabled** because human and cheat distributions overlap:
| Disabled rule | Why |
|---|---|
| angle error | human 1.83°, cheat 1.34–2.20° — overlapping |
| angle error stddev | human range 0.69–5.47°, eight-fold spread |
| in-band share | it is a speed-dependent angular window, not a constant |
| turn lock | human floor 0.139 for one player, **0.026** for another |
| single jump efficiency | human 0.684–**0.956**, cheat 0.835–0.946 — *inverted* |
That last one is the important lesson. Over a single jump a player who simply
strafes that jump well reaches solver-grade efficiency, and the human values run
straight through and past the cheat values. The difference between a hand and a
machine only appears once **hundreds of ticks** are averaged.
What remains active are the two windowed rules over 300 clean air ticks —
efficiency mean and efficiency variation — which have never produced a false
positive across every session recorded. They catch a stabilizer reliably at high
`sv_airaccelerate`, where it holds 0.909 against a human ceiling of 0.65.
**At `sv_airaccelerate 10` a stabilizer tuned for 90° gains less than a good
human and is not reliably detected.** That is an honest limitation, not a
tuning problem: with the data available there is no measured signal that
separates the two in that case.
Recommended:
```
kzac_punish_bhop 2 // ban
kzac_punish_gstrafe 2 // ban
kzac_punish_strafe 0 // warn only, decide by hand
```
Use `kzac_stats` on anyone the strafe detector reports. The numbers behind the
verdict are all there, and a human admin comparing them against the tables above
is a better judge than any threshold currently is.