Rules over events, punishments that escalate, and an answer when somebody asks why. It watches chat, connections, authentication, the remote console and the anti-cheat detectors, and everything it does is a JSON document an operator can read.
Requires dot-core and dot-server.
dot-moderation,
dot-chat and
dot-auth are optional and are named nowhere
in the source — all three are reached at run time.
var guard := DotSecurityManager.new()
server.add_child(guard)
var watch := DotSecurityWatch.new() # wires it to everything worth watching
server.add_child(watch)
It ships in dry run
Every rule counts, logs and fills in sec_status, and punishes nobody until you
say so. Run it for a week, read sec_status, then sv_security_dryrun 0.
A guard that punishes on install is a guard that gets uninstalled
On thresholds nobody chose, against a chat culture it has never seen, the first false positive is the last day it is enabled — and then the server has no guard at all.
The shape of a rule
This many of that event, from one subject, within this window — then take the next step on the ladder.
{
"id": "chat_flood",
"event": "chat.message",
"threshold": 6,
"window_sec": 10,
"scope": "uid",
"cooldown_sec": 15,
"steps": [
{"action": "warn", "message": "Slow down, please."},
{"action": "gag", "duration_sec": 300},
{"action": "gag", "duration_sec": 1800},
{"action": "kick"}
]
}
Six messages in ten seconds. First time a warning, then five minutes, then half
an hour, then out. Offences are remembered for offence_memory_sec, so somebody
who spammed this morning does not start again at “warn” tonight — and somebody
who spammed a fortnight ago does.
The ladder is why this is more than a rate limiter. A flat rule has one answer for a first offence and a fiftieth, so you end up choosing between punishing a new player for typing fast and letting a spammer run.
`cooldown_sec` is the setting you would not have thought of
Without it, a rule whose action does not stop the behaviour — a warning — trips again on the very next message and walks its whole ladder in one second, banning somebody over four lines.
What it watches
chat.message chat.duplicate chat.caps chat.link chat.refused chat.command |
text chat, from the server’s own path and from dot-chat’s router |
connect.attempt connect.rejected connect.churn |
connections, including the reconnect loop no single connection looks wrong in |
auth.failed auth.rejected |
authentication, when dot-auth is installed |
rcon.auth_failed rcon.command |
the remote console |
command.denied |
somebody probing for commands they do not hold |
cheat.* |
the anti-cheat detectors |
The vocabulary is open.
guard.report_session(&"arena.buy_menu_spam", session) is one line, and that
event gets the whole engine: windows, thresholds, ladders, exemptions, the
ledger, dry run and sec_why. A closed enum would have meant every game forking
this.
Scope: a person, or a connection
Count against a connection and you have counted nothing — a peer id dies with the socket, so “five messages in ten seconds” is dodged by reconnecting.
uid |
behaviour that is a person’s: chat, commands, votes. Follows them to a new address; does not punish the sibling on the same connection. |
address |
anything that happens before there is an identity, which is most of what an attack is: connection floods, wrong RCON passwords, authentication that never completed. |
both |
counts twice, separately — one rule catches “this account is spamming” and another “this address is spamming from a new account each time”. |
Anti-cheat, and the split that matters
Detections are ordinary events, so all the rule machinery applies to them. There are two kinds and conflating them is the mistake every home-grown anti-cheat makes.
- Impossible — the server re-simulated what the client claimed and the claim does not fit: moving further in a tick than the movement code can produce, firing faster than the weapon allows, claiming more simulated time than has elapsed. These are facts about arithmetic, and a rule may act on one.
- Suspicious — aim that snaps, aim too smooth to be a hand, firing inside human reaction time, a headshot rate three deviations out. Every one of these also describes a very good player on a very good day. They accumulate, they warn, they tell the admins.
The shipped behavioural rules never punish, and the detector refuses at boot to let you configure one that punishes on a single detection — because a community remembers a wrongly banned good player far longer than it remembers a cheat.
The strongest check is a re-simulation:
ac.movement_reference = func(session, from, velocity, command, delta) -> Vector3:
return my_controller.simulate(from, velocity, command, delta)
Given the command a client sent and the state it started from, deterministic
movement says exactly where it should have ended up; anything else is a claim
the server can reject.
dot-player-controller’s
first-person half is command-driven and deterministic precisely so a server can
reconcile it. Without one, the envelope thresholds are the fallback — and they
ship at 0, meaning off, because the honest way to set them is to read the
peaks off sec_ac_status after a week of your own game.
What it cannot do, stated plainly
There is no client-side component and there will not be one: a Godot game ships its script code to the player, so anything running on their machine can be read, patched or replayed. Memory scanning and driver-level attestation need a signed native anti-cheat and are not honestly buildable here.
It will not detect a well-written wallhack — nothing server-side reliably does. The mitigation for that is not detection, it is not sending the data: interest management, in dot-net. A client never told where the enemy is cannot draw a box around them.
External ban lists
Any number of endpoints, merged, cached, and consulted on every join.
{
"id": "network",
"url": "https://bans.example.net/api/v1/active",
"auth": "bearer",
"token_file": "user://cfg/network.token",
"list_path": "data.bans",
"on_failure": "keep_last"
}
- Auth: public, bearer (a static key or a JWT alike), basic, a custom header, a query parameter, or a timestamp-nonce-HMAC that sends nothing replayable.
- Formats: a bare array of strings, an array of objects with any of several field names, a wrapper object, or
{"uids": [...], "ips": [...]}. A blocklist you cannot point this at is one you will copy into a file by hand, and then it is stale. - IDs, addresses and CIDR ranges. A
/24is matched, not expanded — expanding a/16is sixty-five thousand entries for one line of somebody’s list. - Expiries are honoured. A list that publishes them and a consumer that ignores them is how somebody stays banned for a week after their day was up.
- It caches to disk, so a restart during an outage still enforces.
on_failureiskeep_last(default),ignore, orrefuse_all— fail open or fail closed, your call.
It chains rather than replaces. dot-moderation registers under the same
dot_ban_source name, and whichever readied second would otherwise silently win
— leaving a deployment with both enforcing exactly one. Both are asked, and both
must say yes.
Credentials come from a file
token_file, not the environment and not the command line. Both of those are
readable by other processes and both end up in ps output and in pasted bug
reports — the same rule
DotConfig.sensitive_keys applies everywhere else.
With and without dot-moderation
With it, a gag is a record: stored, expiring, revocable, surviving a reconnect and a restart, sitting in that addon’s listing beside the ones a human issued.
Without it, a gag is two booleans on a session object — real, felt, and lost when they reconnect. Both are supported, because refusing to act without dot-moderation would make this addon conditional on another optional one, and a five-minute gag is still worth having against the ninety-nine per cent who will not think to reconnect out of it.
Explaining yourself
] sec_why Someone
2 record(s) matching 'Someone':
2026-09-12T14:02:11 chat_flood account u-8813 offence 2: 7 x chat.message in 10s -> gag 5m
2026-09-12T14:39:40 chat_repeat account u-8813 offence 1: 3 x chat.duplicate in 30s -> warn
An automatic punishment nobody can explain gets lifted blindly or refused
blindly, and after the second time the operator turns the guard off.
sec_forget is the other half: clear somebody’s counters after lifting a
punishment by hand, or the next event escalates from where the ladder left off
and the person you just forgave is gagged again for one message.
Also sec_status, sec_rules, sec_rule <id>, sec_log, sec_enable /
sec_disable, sec_reload, sec_dump, sec_test (feed it synthetic events to
prove a rule fires), sec_bans, sec_bans_refresh, sec_bans_check,
sec_ac_status, sec_ac_dryrun — plus sv_security and sv_security_dryrun,
both live.
