Updated

dot-server

A console-driven dedicated server for Godot — console variables and commands, RCON, admin permissions with immunity, bans, votes, hot-loadable modules, a hook for the query asset to fill, and switching games under live players.

What it is. The dedicated server framework: a headless Godot process that players connect to, with the things whoever runs it expects. That means a console (commands and settings called cvars), RCON (remote admin), permissions, moderation, votes, modules that add features, and switching between games while it runs.

When to use it. Your game is multiplayer and needs a server people can host. To run one without writing any code, use dotserve or TMC’s server tool (Hosting a server); this page is for building a game’s server on top of the framework. The server-side wiring every game repeats is already packaged in dot-game.

Requires dot-core. dot-auth, dot-cloud, dot-moderation, dot-server-query and dot-server-security are optional, discovered at run time, and imported by nothing.

var server := DotServer.new()
server.config = DotServerConfig.new()
server.config.hostname = "My Server"
server.config.port = 27015
server.config.rcon_password = "something-long"
add_child(server)
godot --headless --path . res://server.tscn -- \
    --sv-port 27015 --sv-hostname "My Server" +sv_maxplayers 24 +changelevel dm_arena

--sv-* sets configuration; +command runs a console command; DOT_SERVER_* environment variables work too, which is what a systemd unit or a container wants. Or skip all of it and use dotserve.

What it gives you

A real console. DotConVars with the flag semantics operators know — FLAG_CHEAT gated behind sv_cheats, FLAG_PROTECTED never printed anywhere including the log, FLAG_STARTUP_ONLY locked once listening, FLAG_ARCHIVE persisted by writeconfig. Config execution with search paths, a command buffer supporting wait, aliases, tab completion, and “did you mean” on typos.

RCON existing tools work with. The classic RCON wire protocol, so operators keep their clients and panels. Constant-time password comparison, per-address lockout, an optional allow-list, and a WebSocket variant so a browser admin panel can reach it.

A hook where server queries go. Answering them is dot-server-query’s job now — the whole of A2S for the twenty years of trackers, chat bots and uptime monitors that speak nothing else, and the dot query protocol beside it. This server holds one hook, attach_query_host, and names nothing in that addon: install it and add a DotQueryHost, or leave it out and the server simply answers nothing on the query port.

Permissions that fit real communities. String flags rather than fixed roles, because nobody agrees what a “moderator” is. Numeric immunity so admins cannot kick each other in a loop. Admins from a JSON file, from dot-auth’s site groups, or from your own source — all merged. slay and teleport are standard flags alongside cheats, because dot-moderation’s live tools use them in every game, and a flag every game shares is one a group file has to be able to name without an “unknown flag” warning at boot.

Moderation with a paper trail. Bans by account and by address, with durations (30m, 2h, 7d), mutes and gags, and an append-only JSONL audit log flushed per entry so a crash cannot lose the action somebody is asking about.

Moderation from inside the game. /ban, /kick, /banip, /unban, /mute, /gag, /banlist and /whois all run from chat with the speaker’s own permissions — same commands, same flags, same audit trail — because a moderator who has to alt-tab to a terminal moderates less. Name a player however you have them: #12, their name, part of their name, their username, their account id, ip:203.0.113.9 for everybody at an address, or @me.

A console on the server’s own terminal. DotStdinConsole reads commands typed where the server was started — but only when stdin is a terminal or a file. A pipe is read only when stdin_console_pipes is set (--sv-stdin-console-pipes, DOT_SERVER_STDIN_CONSOLE_PIPES; off by default).

A limit on how many clients one address may hold. sv_max_connections_per_ip, off by default. Neither a ban nor a connect rate limiter covers this: connections that arrive slowly, from nobody banned, still let one machine take every slot on a small server. Loopback is never limited, so it cannot lock you out of your own server.

Games that swap under live players. Announce, wait for everyone to download the new content, swap, re-spawn. A failed change restores the previous game rather than leaving the server empty.

A game’s id may name its owner — gamemann/game-arena — so a fork and its original, or two people’s games that share a name, sit side by side; a bare name still works wherever it is unambiguous. A game can also list other packs it depends on (a shared asset set), and the server fetches them before the change and sends every client the whole set in the same sync. server_dependencies are mounted on the server only and never sent to a client (bot data, rulesets), and maps are the game’s delivered maps as pinned <owner>/<map>@<version> keys, fetched when the server changes to one rather than at load. DotGameManager.current_server_dependencies() and current_maps() read them back; dot-map turns the second into a catalogue.

The game says which map it is on. A game calls DotGameManager.set_current_map("surf_mesa") whenever its map changes, and reported_map() is what A2S, the dot query protocol and the backbone report list as the map: the game’s own map when it named one, else the game’s display name rather than its content id. It is cleared when the game unloads, and reporting one drops the query cache so a server list does not go on showing the previous map. A game built on dot-game does it with report_map.

Cross-play, with an owner’s opt-out. Every client speaks the same WebSocket protocol, so browser, desktop and mobile players share a server by default. sv_allow_web, sv_allow_desktop and sv_allow_mobile close a platform: the client is refused before the password and authentication, with a sentence saying where to join from instead. The three are replicated into the A2S rules, which is how the site knows to hide a closed Play mode.

The signon state machine

CONNECTING → AUTHENTICATING → PROFILE → DOWNLOADING → LOADING → SPAWNED

Each stage has its own timeout, deliberately: a player stuck downloading has a different problem from one stuck authenticating, and an operator has to be able to tell which without guessing.

PROFILE is a stage a module can hold a session in, and refuse from, before anything spawns: a profile store or an avatar the player must have first. A module calls DotServer.hold_profile(session, holder), then release_profile(session, holder) when it is ready or refuse_profile(session, reason) to drop the player with a reason; DotServerConfig.profile_timeout_sec drops a session a holder never released. Nothing is added on the wire: a client waits through it inside its own authenticating phase, so every shipped client still joins.

What a client can ask for

  • Replicated cvars. A cvar registered as replicated reaches every client (DotEnvelope.CVARS). DotClientLink.replicated_cvar(name, fallback) reads one, replicated_cvars is the whole table and replicated_cvar_changed fires on a change. It is optional on the wire: an older client is sent nothing and keeps its defaults.
  • One scoreboard. DotClientLink.request_scoreboard() asks for a single board without subscribing to it. Rows carry key (DotServer.player_key(), a per-server salted digest of the account, so a client can keep a block list without learning accounts) and peer.
  • Joining a public host and port. A native WebSocket join to a bare public host:port tries wss:// first, because a listed server’s game port is a TLS port; DotClientLink.tls_connect_timeout_sec bounds that attempt before it falls back.
  • A WebSocket player and an ENet player in one match is the mixed_transports example.

The addon’s API level is 10 (LEVEL in dot_server_api.gd).

The wire: six RPCs, and everything else a kind

DotServer and DotClientLink declare exactly six @rpc methods — one per lane each way (reliable control, unreliable control, reliable event) — and nothing else in the addon declares any. Each carries a named kind and a Dictionary. The handshake, content sync, loading, heartbeats, visibility, notices and chat are all kinds: signon.challenge, content.sync, heartbeat, notice, chat.line and so on.

Why frozen. Godot checksums a node’s RPC method names and refuses every RPC between two nodes that disagree — and it says so as “The rpc node checksum failed”, after which the client sits in AUTHENTICATING until it times out. Nobody reads that as “version mismatch”. So every feature that used to arrive as a new RPC pair locked every shipped client out of every server built after it. With the surface frozen, a server can run ahead of its clients: the goal is that mismatched versions of the dot addons play together as long as the API agrees.

Each end advertises what it knows, and the server decides. The challenge carries the server’s kinds and which of them are required; the credentials answer with the client’s. A kind the peer does not know is simply not sent; one that arrives with no handler is dropped with a DEBUG line. Only the core kinds a join needs are required — notices, chat, ping, visibility and progress are all optional, so an older peer loses the feature and keeps the game. A required kind one end lacks refuses the join at the credentials, before the password is looked at, with a sentence: “This server needs a newer game client.” or “This server is older than this game client and cannot host it.” — as CODE_VERSION, so a shell can tell it from a kick.

Adding a feature is server.envelope.register(&"mything", handler, required) plus server.send_kind(peer, &"mything", {...}, lane), and the same on link.envelope. Payloads change only by adding a field, and handlers read through DotEnvelope.number / text / flag, which default what an older peer did not send. A change that cannot be an addition is a new kind.

signon_revision makes the freeze checkable. DotSignon.revision hashes the sorted @rpc names — the same declaration the engine checksums — and the signon_revision self-test fails on the commit that adds a seventh RPC, naming it and saying “add a kind”, rather than on the deployment.

A related fix: a disconnect reason sent from outside an RPC handler — a module’s kick, a game’s netcode refusing a client — used to be lost, because the client read the data and the close in one poll. The close now waits a quarter of a second after a flush, and kick carries the DotError it is for, so a client dropped for a mismatch reads CODE_VERSION, not a moderator’s kick.

Telling the HUD something that is not chat: DotNotice

DotServer.broadcast_notice(notice) and send_notice(session, notice) put a line, a countdown and a sound on a playing client’s HUD; DotClientLink.notice_received is where the client hears it. A DotNotice is a cue id (usually a dot-audio id), an optional line of text, an optional countdown the client runs itself, and an optional topic naming the HUD line it replaces — so “10… 9… 8…” is one line changing, not ten, and a notice carrying only a topic takes that line down.

Why it exists. Something the server runs — a vote for the next game, a restart warning — outlives the game it is shown over and names none of its classes, so a game’s own wire cannot carry it. Until DotNotice, such things reached players as chat scrollback only. It is generic on purpose: a cue, seconds, text and a topic cover all of those, where a vote-shaped message would just be the first of several.

The text is sanitised by chat’s rules. The same client draws both, so a notice refuses everything dot-chat refuses — control characters, zero-width and bidi-override characters — and to_wire bounds every field again, because the fields are public and a host that assigned text directly would otherwise send it raw. BBCode is deliberately not escaped on the wire: the shell draws a notice in a plain Label. DotNotice.bbcode_text() is the one-pass escape for a client drawing it as rich text.

Notices reach playing sessions only, on the event lane — never queued behind a content sync. A countdown of zero is still a countdown; “no countdown” is -1.

Where a game plugs in

DotModule subclass

The plugin surface. _module_name, _module_version, _module_description, _module_author, _module_load, _module_unload, _module_game_changed. Register through the helpers — add_command, add_cvar, hook_pre, hook_post, add_query_provider — and module_unload cleanly undoes all of it. Override _module_unload only for what the helpers did not cover: timers, files, sockets.

A module that cannot work must fail in _module_load rather than load broken. A module that does not even compile — one extending a class nobody registered, say — comes back from load_module as a failed result, not a script error: load() hands back a script whatever its parse said, and a caller that awaited it used to read .ok off null and abort too, so a health check printed “ok” over a module that never loaded.

DotEventBus.hook_pre / hook_post

React to, or veto, anything. player_chat, player_command and game_changing are cancellable; a hook that cancels stops the remaining pre-hooks. declare(name, description) registers your own event so it shows up in event_list.

DotQueryProvider subclass

_provider_name() and _contribute(snapshot) — how a game publishes its own state into a server query. The class belongs to dot-server-query; DotModule.add_query_provider is the module form and is duck-typed, so a module registering one on a server with no query host is a no-op rather than an error.

DotBanStore subclass

_store_name, _load, _put, _remove, _refresh, _writable. Point it at a shared database or an HTTP service and a group of servers shares one ban list. A FileStore ships.

A failed _load must not be treated as “no bans”. DotBanManager keeps whatever it already had and logs loudly, because silently starting with an empty list readmits everyone who was ever removed.

dot_ban_sourceregistry

Anything with check_admission(uid, address) -> DotResult, asked on every join. DotModerationManager publishes itself under it.

DotConsole.command / DotConVar

A command or a variable without a whole module. DotConVar.validator is a Callable; DotConCommand.completer drives tab completion.

Admin sources

Anything with lookup() and source_name(). DotAuthAdminSource is the shipped one; all registered sources are merged.

DotGameDescriptor

What a game is: an id, a scene, a client scene, content ids, and a cvars block applied when the game loads.

Signals

client_state_changed, client_spawned, client_disconnected, permissions_resolved, message_sent, message_blocked, event_fired, module_loaded, module_unloaded, game_changing, game_loaded, game_load_failed, cvar_changed, command_executed, command_refused, ban_added, ban_removed, action_recorded, vote_started, vote_ended. The query signals live on the query host, in dot-server-query.

Connecting a client

var link := DotClientLink.new()
add_child(link)
link.player_name = "Ada"
link.phase_changed.connect(func(_p, text): status.text = text)
link.spawned.connect(func(): ui.show_game())

await link.connect_to_server("wss://play.example.com/game")

The client downloads whatever content the current game needs (through dot-cloud), loads the scene the server names, and reports ready.

Optional, discovered at run time

dot-auth Real player identity, so bans mean something and site groups can grant permissions. Without it, everyone is a DotGuestIdentity.
dot-cloud Downloadable game content: the server sends each client the pack for the game it is changing to, plus any packs that game depends on, in one content.sync. Without it, your games ship inside the build. TMC’s server tool uses it for every game.
dot-moderation Bans, mutes and gags as durable records in a store shared between your servers. Registered as dot_ban_source, its bans are enforced at connect.
dot-server-query Answers A2S and DQP through the attach_query_host hook. Without it the server is unlistable — nothing can ask it anything.
dot-server-security Rules over chat, connections, authentication and RCON that escalate on their own, anti-cheat detectors, and external ban feeds chained onto dot_ban_source. Ships in dry run.

Pick one ban list. DotBanManager and DotModerationManager both work standalone, and a deployment should run one rather than both.