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 pipe nobody closes hangs the server on its way out
A read blocked on stdin cannot be cancelled, and on a pipe whose writer never
closes, Godot hangs inside its own exit. Every test suite that booted a server
under a CI runner’s pipe hung that way until the console learned to tell a pipe
from a terminal — so turn stdin_console_pipes on only when something really
writes to the pipe and will close it.
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_cvarsis the whole table andreplicated_cvar_changedfires 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 carrykey(DotServer.player_key(), a per-server salted digest of the account, so a client can keep a block list without learning accounts) andpeer. - Joining a public host and port. A native WebSocket join to a bare public
host:porttrieswss://first, because a listed server’s game port is a TLS port;DotClientLink.tls_connect_timeout_secbounds that attempt before it falls back. - A WebSocket player and an ENet player in one match is the
mixed_transportsexample.
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.
Shells exported before the freeze must be rebuilt once
This was the last coordinated protocol break (DotSignon.PROTOCOL is 2). A web
or native shell exported before it is sent kinds on RPCs it never declared and
ends at its own signon watchdog. Re-export every shell alongside the servers
once; after that, a new dot-server feature should not need it.
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— andmodule_unloadcleanly undoes all of it. Override_module_unloadonly for what the helpers did not cover: timers, files, sockets.A module that cannot work must fail in
_module_loadrather than load broken. A module that does not even compile — one extending a class nobody registered, say — comes back fromload_moduleas 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.okoff 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_commandandgame_changingare cancellable; a hook that cancels stops the remaining pre-hooks.declare(name, description)registers your own event so it shows up inevent_list.- DotQueryProvider subclass
_provider_name()and_contribute(snapshot)— how a game publishes its own state into a server query. The class belongs todot-server-query;DotModule.add_query_provideris 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. AFileStoreships.A failed
_loadmust not be treated as “no bans”.DotBanManagerkeeps 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.DotModerationManagerpublishes itself under it.- DotConsole.command / DotConVar
A command or a variable without a whole module.
DotConVar.validatoris aCallable;DotConCommand.completerdrives tab completion.- Admin sources
Anything with
lookup()andsource_name().DotAuthAdminSourceis the shipped one; all registered sources are merged.- DotGameDescriptor
What a game is: an id, a scene, a client scene, content ids, and a
cvarsblock 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, indot-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.
`client_spawn` carries `userid`, not `peer_id`
A module looking a session up by peer_id gets null every time and adds nobody,
silently, because a null session is a legitimate thing to find. Two separate
games shipped this and neither errored — in one, nobody could ever join a
dedicated server, for as long as the module had existed.
Nothing may be sent to a peer before it says it can receive
Signon finishes and then the client builds its scene. Everything sent in between lands on a node that does not exist and is lost, one “Node not found” per call.
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.
