dot-moderation holds bans, kicks, chat gags, voice mutes and warnings as one
durable record — stored, expiring, scoped and revocable, against a person or
against an address.
Requires dot-core. It publishes dot_mute_source and dot_ban_source, which
dot-voice’s router,
dot-chat’s router and
dot-server’s admission check consult without
any of them importing another.
Why this exists next to a server that already bans
dot-server has bans, kicks, admin flags with
immunity and an audit log. What it does not have is a mute that outlives a
session: DotClientSession.silence() sets a mute flag, a gag flag and an expiry on a session object,
and a session is destroyed when its player disconnects. So a muted player
reconnects and can talk again, which is the first thing anybody who has been
muted tries. Bans persisting and mutes not is not a design; it is an accident of
where the state happened to live.
The model
A DotPunishment is a kind, a subject, a reason, an issuer, a moment it was
issued and a moment it expires. BAN, KICK, VOICE_MUTE, GAG and WARN
are the same record with a different kind, because a server asks the same four
questions about each.
- The subject is an opaque durable key, never a peer id. A peer id is reused within minutes and means nothing after a disconnect.
- Expiry is answered on read, never swept on a timer. A server that was off overnight would otherwise come up enforcing yesterday’s mutes.
- A revoke keeps the record. The history is the point; it is what makes the next decision defensible.
- A kick and a warning are never “active”. They happened; they are not still happening.
- A subject is a person or a machine, and
DotPunishmentSubjectdecides how each is spelled:uid:backbone:abcandip:203.0.113.9, with the port and any decoration stripped. Opaque is right — but “opaque” and “however each caller felt like writing it” are not the same thing. A ban filed against1.2.3.4and a check for1.2.3.4:51234are two strings that never meet, and nothing errors.
Getting it enforced
Nothing here can refuse a connection: there is no session list and no socket. So
the manager publishes itself under dot_ban_source, and dot-server asks
it on every join:
check_admission(uid: String, address: String) -> DotResult
Same shape as the dot_mute_source name
dot-voice already looks up — one registry
name, one method, neither addon naming the other. With nothing registered, a
BAN recorded here is a row in a file that stops nobody, and
describe_lines() says so where an operator looks.
Storage
The table is defined once in DotPunishmentSchema and every backend holds the
same fourteen columns.
| Backend | Class | Status |
|---|---|---|
| JSON file | DotPunishmentStoreFile |
Works out of the box. Right for one server. |
| REST API | DotPunishmentStoreRest |
Works out of the box. Four endpoints; keep your own ban system. |
| SQLite | DotPunishmentStoreSql + DotSqlDriverSqlite |
Needs the godot-sqlite GDExtension. |
| PostgreSQL / MySQL | DotPunishmentStoreSql + DotSqlDriverGateway |
Needs dot-sql’s HTTP gateway (DotSqlDriverGateway) beside the database; no PostgreSQL or MySQL wire client ships. |
Be aware of what Godot does not have. There is no SQLite,
PostgreSQLClient or MySQL class in the engine, and neither wire protocol can
be spoken from GDScript without implementing an authentication handshake, a
binary format and a connection pool. That is a project, not a file, and a
half-finished one is a security problem rather than a missing feature.
So the SQL half does everything that can be done here — the DDL per dialect, bound parameters, row mapping, upserts, indexes — and hands execution to a dot-sql driver, held duck-typed so this addon does not depend on it. Putting the database behind HTTP is what most deployments should do anyway: a game server holding a Postgres password is a game server whose compromise is a compromise of the database.
Statements are always parameterised. A ban reason is typed by a moderator and quotes the offender’s name half the time, so it is exactly the string that must never reach SQL by concatenation.
The REST contract
GET {base}/punishments -> {"punishments": [ {...}, ... ]}
GET {base}/punishments?subject=uid:123 -> the same, filtered
PUT {base}/punishments/{id} <- one record -> {"ok": true}
DELETE {base}/punishments/{id} -> {"ok": true}
A record is the JSON DotPunishment.to_dictionary() produces, which is what the
file store writes. One format for every backend, so moving between them is one
line.
A read failure is never an empty list
An API outage that read as “nobody is banned” would readmit everyone silently. Every error path returns a failure and the manager keeps what it already had.
Mod tools
DotModTools is what a moderator does to a player standing in front of them:
teleport, bring, goto, send, and return_player.
return_player is what makes the rest safe to use. Without an undo, “bring” is
something a moderator hesitates to do mid-round, and a moderator who hesitates
does not moderate. Every move saves a position first, including a goto.
It knows nothing about your world: positions come from position_fn and moves
go through teleport_fn, both yours. Positions are Variant, so 2D is a
first-class user rather than something to work around.
The live tools: the community admin set
Noclip, god, buddha, freeze, slay, slap, respawn, health, speed, gravity, give, strip, rename, burn, blind and beacon — as a table of handlers the game fills in, one per ability:
tools.handlers[DotModTools.ACTION_GOD] = func(id: StringName, args: Dictionary) -> DotResult:
world.player(id).health.invulnerable = args["on"]
return DotResult.success(args["on"])
tools.unsupported_reasons[DotModTools.ACTION_NOCLIP] = "A 2D arena has no walls to pass through."
Why a table: what “god” means is the game’s business, but everything around
it is the same everywhere, and that is what the addon owns — the immunity check,
the audit record of what was done, who has which toggle on and who turned it on,
switching noclip and freeze off on a respawn while god and buddha carry over
(persist_on_respawn), forgetting all of it when the player leaves, and a timed
release (release_after) that checks it is still the same grant before lifting
it. An ability with no handler is refused with CODE_UNSUPPORTED and the game’s
reason, and describe_lines() lists what the game supports and what it refuses.
DotModToolCommands.install(host, tools, server) puts all of it on a dot-server
console — noclip, god, buddha, freeze, unfreeze, slay, slap,
respawn, rename, burn, blind, beacon, hp, speed, gravity,
give, strip, bring, goto, send, return and modtools — typable in
chat as !noclip, with player completion. Targets resolve through the server’s
own resolve_target, plus @me, @all, @others, and @alive, @dead and
@team:<name> where the game says who is alive and on which side. freeze and
blind take a number of seconds — a blind is a spell, not a state, and without a
duration it lasts “until somebody remembers to lift it”. The permission flags
are dot-server’s standard slay (handling a person), cheats (changing the
game) and teleport (moving people); permissions overrides any of them. It is
duck-typed, so this addon still does not depend on dot-server —
dot-game’s services layer builds and binds it for
you.
Live-tool records reach the store, against a person. Each action is filed through the manager with its evidence set before the store write — set after, a SQL or REST store never saw what was done — and against the manager’s own peer mapping rather than a session id that restarts every boot, so “an admin froze me” has an answer next week too.
Yourself is not a loophole
Acting on yourself skips the immunity check, which made respawn, slap,
send and return on yourself a free advantage. On somebody else they are
moderation; on yourself they need cheats. Numbers are bounded for the same
reason: an infinite duration was a timer that never fired, and a big enough slap
went past god.
Where a game plugs in
| To change | Where |
|---|---|
| Where punishments live | DotPunishmentStore subclass |
| Which database | DotSqlDriver subclass, or DotPunishmentStoreRest |
| Where a player is, and how to move them | DotModTools.position_fn / teleport_fn |
| What noclip, god, slay and the rest mean in your game | DotModTools.handlers, one callable per ability; unsupported_reasons for the ones you refuse |
| What the commands are called, and which flag each needs | DotModToolCommands.names / prefix / permissions / skip |
| Who counts as alive, and who is on which team | DotModToolCommands.alive_fn / team_fn |
| How a peer maps to a person | DotModerationManager.key_for_peer |
| Which servers a punishment covers | DotModerationManager.server_scope |
| Whether dot-server enforces these bans | DotModerationManager.register_ban_source |
| The longest punishment allowed | DotModerationManager.max_duration_sec |
| Whether equals may act on equals | DotModerationManager.equal_immunity_may_act |
Signals: punished, revoked, expired on the manager; acted, refused, toggled on DotModTools.
Zero immunity means "no immunity to respect", not "the highest rank"
A revoke path that required strictly greater immunity unconditionally meant 0
could not act on 0 — which is every ordinary unmute on a server that has not
configured immunity at all.
A server with no scope must still see its punishments
The unconfigured case is the only case a single-server community has, and it was the one that silently enforced nothing.
What is deliberately not here
Console commands for punishments (mute, gag and ban belong to
dot-server’s console, which already has permission checking and an audit
trail; only the live tools bring commands of their own), a second ban list to run alongside
DotBanManager, a connection limit per address (that is admission), kicking
anybody (there is no socket here), and an appeals workflow — evidence holds a
ticket number, and the workflow is a website.
