Conventions

The rules every Dot addon obeys — no autoloads, DotResult for anything fallible, DotNodeRef instead of scene paths, layered configuration, and the browser constraints that decide half of them.

These hold in every addon. Breaking one is a breaking change across fifty addons and the five games built on them, so they are worth knowing before you write against any of them.

DotRegistry instead of autoloads

No addon ships an autoload, anywhere.

An autoload reserves a global identifier in every consuming project, fixes the initialisation order, and cannot be instantiated twice — which rules out running a server and a client in one process, or two servers in one editor session. Both of those are things this collection does routinely.

So components are Nodes you place yourself, and find each other by name:

DotRegistry.register(&"dot_server", self)

var cloud: Object = DotRegistry.get_service(&"dot_cloud_client")
if cloud != null:
    ...

Most managers register themselves on _enter_tree and unregister on _exit_tree, under a SERVICE constant, with a register_service flag and a service_scope for running two of something in one tree.

Static classes cannot declare signals, which is why DotLogBus and DotRegistryBus exist beside DotLog and DotRegistry.

The registry names that are seams

Three of them are contracts rather than lookups — one name, one method, neither addon naming the other:

dot_ban_source

Anything with check_admission(uid, address) -> DotResult. dot-server asks it on every join; dot-moderation publishes itself under it.

dot_mute_source

Anything with is_voice_muted(peer) -> bool. dot-voice’s router consults it before relaying a frame.

dot_cloud_client

Anything speaking ensure(content_id, version) / is_mounted(content_id, version). dot-map’s loader and dot-server’s game change both reach content through it.

The full list of service names is on the extension points page.

Nothing hardcodes a scene path

Any component that needs a node in the host project exports a DotNodeRef, which is an inspector-editable description of which node: relative, absolute, self, parent, a group, a registry service, the nearest ancestor or descendant of a type, or create-the-node-if-missing.

session.world_ref = DotNodeRef.of_path(^"../World")
spawner.world_ref = DotNodeRef.of_group(&"world_root")

The host decides, per instance, in the inspector. An addon that hardcoded $"../../World" would be an addon whose scene layout you have to adopt.

DotResult for anything fallible

Never null, never an Array pair, never a bare false.

var res := await manager.resolve(identity)
if not res.ok:
    if res.error.code == DotError.CODE_RATE_LIMITED:
        wait(res.error.retry_after)
    return
var profile: DotUserProfile = res.value

Callers branch on DotError.CODE_*, never on the message — the codes are stable and the messages are for people. res.wrap(...) adds context without discarding the cause, and reading .value on a failure raises rather than handing back a plausible default.

Configuration is layered, identically everywhere

exported defaults  <  JSON file  <  environment  <  command line

Every DotConfig subclass just declares @export properties; discovery is reflective, so a new setting needs no parsing code. Four hooks shape it:

env_prefix()String

e.g. "DOT_SERVER_". Return "" to disable environment overrides entirely — correct for a config describing content rather than deployment, where an ambient variable changing behaviour would be a surprise.

cli_prefix()String

e.g. "--sv-".

sensitive_keys()PackedStringArray

Properties that must never be settable from the environment or the command line. Both are readable by other processes on most systems and both end up in ps output and pasted bug reports.

validate()DotResult

Catches what reflection cannot: a port outside 1–65535, a tickrate of zero, a URL with no scheme. Called by load_layered().

The same sensitive_keys() is what stops DotSettingsPanel putting a secret on screen.

describe() on anything with runtime state

Every component with state answers describe() / describe_lines(), so a console command or a bug report can dump it. It is also where an addon says what it cannot do in the current configuration — dot-moderation reports that nothing is registered as dot_ban_source, so a ban is a row in a file that stops nobody, in the place an operator actually looks.

Everything is Dot-prefixed

class_name is global in Godot and these addons get installed side by side, so every class is Dot-prefixed. dot-2d uses Dot2D*, because an identifier cannot begin with a digit and Dot2D reads better than DotTwoD.

Ask about capabilities, not platforms

DotPlatform.has_threads(), not OS.get_name() == "Web". The mapping is not one-to-one — a web template can be built with threads, and a desktop build can be missing something you assumed.

Comments explain why

There are a lot of non-obvious trade-offs in this code, and the convention is that a comment says why the obvious alternative is wrong. Otherwise the next reader “simplifies” one back into a bug — which has happened often enough that the reasoning is treated as part of the source.

The browser decides half of this

Desktop, mobile and the browser from one codebase. Every one of these is encoded in the code rather than left to the reader:

Constraint Consequence
No UDP; ENetMultiplayerPeer absent from the web template DotTransportENet reaches ENet through ClassDB.instantiate and Object.call, never by name — a script that mentions the identifier fails to compile on web
One listening socket speaks one protocol A server with browser clients listens on WebSocket, and then all its clients do. DotTransportAuto.require_web_clients defaults to true for this reason
A browser tab cannot listen Web builds are clients only
No HTTPClient, only HTTPRequest DotHttp uses HTTPRequest unconditionally
No threads unless the template was built for them DotScheduler slices on the main thread inside a frame budget
user:// is an IndexedDB mirror needing explicit flushes every write path calls DotWeb.sync_filesystem()
Storage quota the user can refuse DotCloudStore awaits navigator.storage.estimate() and never assumes unlimited
CORS, with fetch() refusing to say why it failed content hosts need documented headers; same-origin avoids it
A mounted resource pack can never be unmounted, on any platform dot-cloud namespaces content by id/version so nothing ever needs replacing

That last one shaped dot-cloud more than anything else.

GDScript traps the collection has paid for

Worth knowing before you write a bridge, because none of these produces an error.

  • == binds tighter than as. a == [x] as Array[StringName] parses as (a == [x]) as Array[StringName] and fails to compile.
  • await x.f().ok binds the await to the property access, not the call, so the coroutine is never awaited. Assign to a variable first.
  • var x := f() where f returns Variant is a parse error under these projects’ warning settings. Write var x: Variant = f(). Array.duplicate() returns an untyped array, so indexing one is exactly this.
  • Lambdas capture locals by value. A counter incremented inside a signal handler stays zero outside it. Capture an Array and append.
  • A Dictionary is a reference. to_dictionary() handing out its own dictionary means a “copy” shares it, and an edit to one silently edits the other.
  • Array.sort() on a StringName does not sort lexicographically — Godot compares interned pointers, so two peers can order the same list differently.
  • Vector3 components are 32-bit while GDScript arithmetic is 64-bit. Feet resting on y = 0 compute as -2.4e-8.
  • String.is_valid_identifier() is false for a digit, because an identifier may not begin with one.
  • Godot refuses an RPC unless both ends declare the same set of @rpc methods, and routes by node path relative to the MultiplayerAPI root. One extra @rpc method on one side makes every RPC between them fail — the handshake included, whose only symptom is a timeout.

Running N coroutines and waiting

Both obvious spellings are wrong. The working pattern is bare statement calls plus a member counter, so a worker that never suspends is already accounted for:

_workers_finished = 0
for i in range(n):
    _worker(i)              # runs to its first await, or to completion
while _workers_finished < n:
    await _worker_done      # not entered at all if all finished

Every return path must increment the counter and emit.