Updated

dot-core

The shared foundation for every Dot addon — results, capability detection, node references, layered configuration, frame-budgeted jobs, swappable transports, HTTP and logging.

What it is. The common layer under everything else, and the only hard dependency in the collection: a result type for operations that can fail, logging, configuration that layers file, environment and command line, transports, HTTP, paths and hashing.

When to use it. Always: copy it first, whatever else you take. You can also use it alone, as a set of small tools for a Godot project of any kind.

Smallest use. Return a DotResult instead of null, and branch on its code rather than its message:

func load_save(path: String) -> DotResult:
    if not FileAccess.file_exists(path):
        return DotResult.fail(DotError.CODE_IO, "no save at %s" % path)
    return DotResult.success(FileAccess.get_file_as_string(path))

var res := load_save("user://save.json")
if res.ok:
    print(res.value)
elif res.error.code == DotError.CODE_IO:
    start_new_game()

In more detail: It targets desktop, mobile and the browser from one codebase, and encodes the browser’s limitations rather than pretending they do not exist.

cp -r dot-core/addons/dot_core my-game/addons/
godot --headless --path . res://examples/capability_report.tscn

What is in it

DotResult / DotError The return type of everything fallible. Stable CODE_* values callers branch on; reading .value on a failure errors instead of handing back a default.
DotPlatform Cached capability detection — threads, UDP, listening, pack mounting, storage family, unique id. Ask about capabilities, not platform names.
DotNodeRef An inspector-editable description of which node: relative, absolute, group, registry, ancestor or descendant by type, or create-if-missing.
DotConfig Layered configuration: exported defaults < file < environment < command line, with reflective key discovery and secret-key protection.
DotRegistry / DotRegistryBus Name-based lookup, and the signals a static class cannot declare.
DotScheduler / DotJob Long work in slices. Worker threads where available, a per-frame time budget where not.
DotTransport Swappable multiplayer transport. DotTransportDual serves browsers over WebSocket and native clients over ENet in one server; DotTransportAuto picks one of the two for a client, and logs why.
DotHttp An HTTPRequest-based client with retries, jittered backoff, Retry-After, range-resumed downloads and connection pooling.
DotLog / DotLogBus / DotLogSink Levelled, channelled logging with structured fields, rotating files and UDP forwarding. dot-log is the sink layer for everything beyond a file.
DotPaths Path sanitisation that refuses traversal rather than cleaning it, atomic writes, and web filesystem flushing.
DotHash / DotHashJob SHA-256, HMAC, constant-time comparison, base64url, CSPRNG tokens.
DotRateLimiter A token bucket, used on every path a client can drive.
DotSemVer Version comparison, for content and engine minimums.
DotWeb The browser-only calls: filesystem sync, secure-context detection, storage estimates.
DotRandomStream / DotRandomTable / DotRandomSchedule Randomness a second machine can reproduce. See below.
DotContent The one place the family asks for downloadable content. See below.
DotAddonApi Each addon’s API level, and a pack’s requires.json checked against it. See below.
DotRenderWarmup Draws a scene’s meshes and materials once, out of sight, so their shaders compile under a loading screen rather than on the first shot. It removes most first-use stalls, not all (a light that appears later adds variants nobody drew). Finishes after timeout_sec even when nothing can be drawn (headless).
DotCollisionAudit audit(root) lists the visible geometry under a node that nothing collides with: the level pieces a player walks straight through.

Where a game plugs in

DotTransport subclass

_create_server, _create_client, _transport_name, _is_available, supports_web_clients, scheme. Availability is checked before use, so an unusable transport fails at boot with a reason rather than as a null peer three calls later.

DotJob subclass

_step (a bounded slice; return true if more remains), _progress, _setup, _teardown, is_thread_safe. Hash a chunk, not a file — the scheduler can stop calling _step, but it cannot interrupt one.

DotConfig subclass

env_prefix(), cli_prefix(), sensitive_keys(), validate(). Declare @export properties and the layering, the reflection and the settings screen all follow.

DotLogSink, or DotLog.add_sink(callable)

Where records go. The Callable form is one line; the class handles files, rotation and forwarding.

DotNodeRef on every component

The collection-wide answer to “which node”. Every addon’s node exports at least one.

DotResult is not optional politeness

var res := await cloud.acquire(url)
if not res.ok:
    if res.error.code == DotError.CODE_RATE_LIMITED:
        retry_after(res.error.retry_after)
    return
var scene_path: String = res.value

Branch on DotError.CODE_*; never on the message. res.wrap(...) adds context without discarding the cause, so a failure three layers down still says what it was doing at each level.

CODE_CONFLICT means two different things claim one identity — a pack wanting a uid another pack already holds, say. It is neither CODE_STATE (“not downloaded yet”) nor CODE_INTEGRITY (bytes that disagree with a hash), and it is not retryable: retrying produces the same conflict, so the caller has to change what it asks for.

DotScheduler and the browser

DotScheduler runs jobs on worker threads where the build has them and slices them on the main thread inside a frame budget where it does not — because a web export has no threads unless the template was built for them, and DotPlatform.has_threads() is the question to ask rather than OS.get_name().

Reproducible randomness

dot-core’s random/ module replaces RandomNumberGenerator with named streams whose draws are a pure function of (key, index). It shipped as a separate addon, dot-randomness, and folded in here: every other addon that wanted determinism had to take it, which made it a second hard dependency in a collection that has exactly one.

Other addons still find it through DotRegistry under dot_random_source and call stream(name), so anything with that method can stand in.

var world := DotRandomStream.new(seed)
var spread := world.stream(&"spread")
var loot := world.stream(&"loot")

spread.at(tick)             # the same number on every machine, for ever
loot.next_range_i(1, 6)     # and drawing it does not move `spread`

at() advances nothing, so a client can know what the server will do on a tick it has not simulated, and a replay can be scrubbed backwards.

A single shared generator is mutable state every caller advances, which is three bugs in a networked game:

  • Two peers that draw in a different order diverge. A server that rolls weapon spread and then a loot drop, and a client that predicts the shot and never sees the loot, are one draw apart for the rest of the session — and the visible symptom is that the shooting stops matching, which points at the netcode.
  • Adding a feature changes the past. One extra draw shifts every later number, so yesterday’s replay plays back differently and a shared seed stops producing the world it was shared for.
  • A stream nobody can resume. A receiving peer has to adopt an index, not allocate one.

A seed is not a secret

It is meant to be shareable, and a client can compute every number a server will. Anything that must be hidden from a player belongs behind a value the client does not have.

Tables

DotRandomTable draws ids and knows nothing about what they are, so a loot table validates and draws on a server without the content.

WITH_REPLACEMENT Independent draws. The honest one, and the one that makes a player who has opened forty crates conclude the game is broken.
WITHOUT_REPLACEMENT A shuffled deck, reshuffled when empty. What a rotation and a card game want; not a loot table, because a player can count it.
PITY The weight climbs on a miss and resets on a hit, cutting off the long tail.

Events in ticks

var s := DotRandomSchedule.new()
s.mean_interval_ticks = 64 * 30     # about every thirty seconds at 64 Hz
s.min_gap_ticks = 64 * 8            # never two in eight seconds
s.max_gap_ticks = 64 * 90           # and never a gap longer than ninety

if s.fires_at(stream, tick, last_fired_tick):
    ...

fires_at is pure, so it answers for tick N without having run ticks 0..N-1 — which is what lets a client anticipate a server, a replay scrub, and a restarted server land on the schedule it was already running. A stateful countdown can do none of those, and its failure mode latches: a due event dropped while a cooldown runs, and nothing offered again.

Downloadable content: DotContent

Every addon that lets a developer deliver something — an avatar part, a prop, an NPC, a vehicle, an item, a map — declares a content_id. DotContent is the one shared caller that fetches it: client(), available(), ensure(), ensure_all() and resolve(), duck-typed through DotRegistry, so no addon gains a dependency on dot-cloud and a build that ships all its content still works.

Addon API levels: DotAddonApi

A delivered pack’s scripts compile against the host’s addons. A pack built against a newer dot-net than the client or server has does not fail with a sentence — it fails to parse, mid-load, with an identifier “not declared in the current scope”. DotAddonApi turns that into “This game needs dot-net API level 3 or newer; this server has level 2.”, asked before a single script in the pack loads (dot-cloud asks between download and mount).

  • Every addon may carry addons/<addon>/<addon>_api.gd with const LEVEL and const OLDEST. Bump LEVEL when you add anything a game could call; raise OLDEST to it when you remove or change something, because every pack built before no longer compiles. An addon with no api file is level 1.
  • A pack states what it needs in requires.json at its root — {"format": 1, "addons": {"dot_net": 3}} — and nobody writes it by hand: addons/dot_core/tools/dot_requires.gd derives it from the game’s own files at the levels of the checkout that built it, and dot-ci writes it into the pack. A game that commits its own has it audited instead.

An API level, not the release version

A developer checkout’s plugin.cfg says 0.1.0 whatever it holds, an export does not carry plugin.cfg at all, and a tag moves for fixes that change no API — so “needs 0.4.1” would refuse a 0.4.0 host that runs the pack perfectly. A level committed in the addon’s own source is the same number everywhere, because it is code.

Transports

  • A browser has no UDP, and ENetMultiplayerPeer is absent from the web template — so DotTransportENet reaches ENet through ClassDB.instantiate and Object.call, never by name. A script that so much as mentions the identifier fails to compile on a web export.
  • One listening socket speaks one protocol, so a server for both audiences listens twice. DotTransportDual puts WebSocket on TCP and ENet on UDP (the same port number by default) behind one MultiplayerPeer, so browsers and native clients play in the same match and nothing above the transport knows which a peer came in on. It is dot-server’s default. If this build has no ENet, or the UDP port is taken, it serves WebSocket only and logs a warning rather than failing to start.
  • A TLS hello at a plain WebSocket listener is closed at once. A native client tries wss:// first at a public host:port; without a screen, every wrong guess against a server with no certificate cost the client its TLS timeout. DotTcpScreen reads the first byte, closes anything that is not an HTTP request, and relays the rest to the WebSocket server on loopback. It is skipped for a listener bound to loopback (a TLS terminator’s backend) and costs a little script time per busy link.
  • The UDP port can be shared with the query protocols. A2S and DQP answer on the game port too, and ENet cannot share a socket with them, so DotUdpDemux owns the port: a query goes to its handler and everything else is relayed to ENet on loopback, with the client’s real address kept for bans and rate limits.
  • A client uses one transport. DotTransportAuto picks WebSocket in a browser and when require_web_clients is set (the default, for projects that have not decided); dot-server’s client link tries ENet first on a native build and falls back to WebSocket.

`DotWeb.is_secure_context()` is not "the page is HTTPS"

A browser treats http://localhost and http://127.0.0.1 as trustworthy origins, so it is true on a plain HTTP page — while mixed-content blocking, the rule that actually decides whether ws:// is allowed, exempts exactly those origins. Upgrading on the wrong question is why nothing here had ever loaded in a browser.