Skip to content

Running a Server

The same executable that plays is the one that hosts. This page covers the server a client carries with it, what an operator can change while it runs, and the files that decide how it starts.

DigitalHeaven.Engine.Server contains the authoritative composition used by desktop and external hosts. Every host constructs GameModule through CompiledGameplay<T>, so no path copies gameplay or bypasses the network protocol. AOT hosts initialize the complete shared ECS component schema before the first world is created. Friflo’s NativeAOT registration API also supports Mono full-AOT hosts such as iOS; it does not mean this app uses .NET NativeAOT. Desktop retains reflection-based component discovery.

Singleplayer is an embedded server on loopback (see above). The listen server is the same embedded server with a real UDP socket bound alongside the loopback, so a player’s own windowed game can host friends without a separate dedicated process. Two archived server.* preferences govern it:

  • server.open (bool, default false) — when true, the windowed game also binds a UDP socket on server.port so network clients can join. false keeps it loopback-only: pure singleplayer, no port bound. It is a live toggle: the embedded composition always carries an openable listen socket, and the server binds or unbinds it on the next tick, so opening a server to friends mid-session costs nothing and closing it drops the remotes without disturbing the local host’s world. A port that cannot be bound sets server.open back to false and says so once, rather than retrying every tick.
  • server.maxPlayers (int, default 8, min 1) — how many sessions the server holds at once. On a listen server that count includes the local host, which connects first over loopback and counts as one: 4 admits the host plus three remotes; 1 is host-only, a closed server, and the local host is always admitted regardless of the limit. The default is eight so a fresh dedicated server takes a party without an operator having to find this setting first; a listen server still admits no remote at all until server.open is on. Unlike server.open it applies live — the admission gate reads the preference at each join, so raising it admits the very next arrival with no relaunch. A remote refused for capacity is disconnected before a session exists — it never handshakes, never loads the map and never appears in players — and its console prints the reason the server gave: server is full (4 players).

What an operator may change from a remote session

Section titled “What an operator may change from a remote session”

An operator — the host, or an identity listed in server.operators — carries CommandContext.HasWorldAuthority on the server, so a forwarded console line from its session is executed with the same authority the server console has: world rules such as world.gravity or world.noclipAllowed are settable rather than answered with is read-only for remote clients, and the pause menu’s Server Settings link is live rather than dimmed. A remote operator that picks a map does not load it locally: its client submits the console line map <barcode> and the server runs the switch, so there is one switch path and it belongs to the machine that owns the world.

The maps the pane offers are the server’s, not this machine’s. On opening Server Settings against a remote server, an operator’s client asks once for the server’s map catalog and mirrors it; the tiles’ pictures stream down the same pipeline on demand, keyed by content hash, and are held in memory for the session only. A map the server has and this machine has never heard of is therefore selectable, and a map only this machine has is not offered — which is the whole point, since the server is the machine that will run the switch. See Map Replication for the transfer itself. Nothing changes for a player on a server of its own: that catalog is computed locally, from the same code.

The settings the pane shows are the server’s too, and it saves none of them. Display follows the session; persistence follows the machine. On a remote session every value in Server Settings — the open switch, the player cap, forgiveness, and every world row from gravity to auto bunny hopping — is read off the wire (the snapshot’s WorldSettings, plus world.noclipAllowed from the authority message) rather than out of this machine’s preference store or its selected preset file. That is what makes the window representative the moment it opens, instead of reporting the local server this machine would have launched. Changing one forwards the console line and writes nothing locally: the client’s store is untouched and the preset file is never rewritten, so a session on somebody else’s server cannot quietly overwrite the settings saved for your own. Because a forwarded line takes a round trip, the pane keeps showing what you asked for until the next snapshot agrees — or until client.ui.remoteSettingsEcho seconds pass, after which a refused change goes back to the server’s own answer rather than lying about it.

The preset dropdown is hidden on a remote session for the same reason: a preset is a file on this machine, and applying one to somebody else’s server would mean pushing nine of your values at a world you are only a guest of. The rows below it stay, showing the server’s live numbers. The open switch also stays — disabled, as HostOnly requires, but now reading the server’s actual value instead of yours. Apply on a remote session changes the map alone. A player at the main menu, or one running its own server, sees exactly the behavior it always did: the local store and the local preset file, read and written.

Wiring server.open and server.maxPlayers into WorldSettings is the append-a-field path described under replicated world settings — a byte and a ushort on the end of the block — and bumped NetProtocol.Version 70 → 71.

The server.* scope forwards too, and only to authority: an operator’s server.maxPlayers 8 or server.forgiveness 0.5 runs against the server’s own store, so nobody needs a seat at a dedicated server’s stdin to raise its cap. Everyone below that authority is refused the whole scope — reads included, because the listen port and the operator list are not a guest’s to enumerate — with 'server.maxPlayers' is a server setting; only an operator or the server console may read or change it. The manifest still advertises server.* to every client, exactly as it advertises a world-authoritative command to every client: what gates the change is the execution, not the advertisement. client.* remains the client’s own tuning and universe.* remains server-console only.

server.open, server.port and server.operators are the exceptions, and deliberately so: they carry PreferenceFlags.HostOnly, which only the server console and the embedded host client clear. Operator authority does not reach them, because a remote operator turning the listener off, moving its port, or rewriting the operator list would unbind or lock itself out of the very server it was configuring. The refusal says so, the Server Settings pane draws the open switch disabled on a remote session with a line explaining why, and everything else on the pane stays editable. HostOnly is not the inverse of RemoteReadOnly: an operator clears the second gate and never the first.

op and deop forward to the host alone. The two verbs rewrite that same operator roster, so the session that runs the server may grant and revoke from inside the game — which is the only way anybody is opped on a listen server, whose console is a window nobody types into. A dedicated server keeps using its stdin. Everyone below that authority, operators included, is refused with 'op' is the host's to run; operator authority does not reach it, because handing out admin over the wire is how an operator gives the server away — the same sentence shape a HostOnly preference refusal uses, from the same place. The rule the pair was written under is unchanged: a remote client never grants or revokes admin over the wire, and the host is not a remote client. The manifest advertises them to every client like every other authority-gated verb; what gates them is the execution.

Under the hood a CompositeServerTransport presents the loopback end and the UDP socket to a single NetServer — one poll loop, one session map. It namespaces each underlying transport’s peer ids into a disjoint band so their independently-issued handles never collide, and routes each reply back to the owning transport. The NetServer is unaware there is more than one transport. A remote peer joining a listen server that lacks its map pallet flows through the normal map-replication path (advert → hash-skip or chunked transfer → reload); only the loopback host is skipped by the map broadcast, because it reloads in-process.

The dedicated server (--headless) binds a bare UDP transport with no loopback, and it honors the same server.maxPlayers — one capacity rule, one admission gate, wired by both compositions. The only difference is what the number counts: a dedicated server has no local player, so a cap of 4 is four remote sessions rather than a host plus three. It logs the cap beside its port at boot (dedicated server listening on UDP port 27015, capacity 4 players (server.maxPlayers)), and because the default is 1, a dedicated server that should hold more than one player must set server.maxPlayers — in its preferences file, on the console, or from an --exec script.

Which map it runs. A dedicated server boots the bundled default map unless --map names another: dh --headless --port 27015 --map core:maps/logic-map.dh-map. The name is resolved through the same map registry and the same spelling ladder the windowed client’s map command uses — a full barcode, the barcode without its .dh-map extension, the pallet-relative path (maps/logic-map), or the bare file name (logic-map) as long as it is unambiguous — so a map named at launch and a map named on the console are named the same way. A name that matches nothing, or several maps, is a usage error: the server prints the reason and the usage text and exits 2, rather than quietly coming up on the default map and leaving everyone wondering which world they joined.

map on the server console changes the map live, under the players already standing in the old one:

map # what is loaded now
map logic-map # the bare file name is enough
map core:maps/logic-map.dh-map # switch to it
maps # every map this server can see

How a map may be spelled. Four forms, shortest first, and all four are accepted wherever a map is named — the console, the server console, --map: the bare file name (logic-map), the pallet-relative path (maps/logic-map), the barcode without its extension (core:maps/logic-map), and the full barcode (core:maps/logic-map.dh-map). Matching is case-insensitive. The bare name is the rung completion leads with, which is why typing log offers logic-map rather than nothing at all now that the core maps live under a folder — the same short-first ladder spawn uses, and the same ladder the resolver reads, so every spelling the panel offers is a spelling the command accepts.

When two maps share a name. A bare name several maps answer to is not dropped from the list and is not silently resolved to one of them. Completion offers every map that answers, each drawn under the shortest name that tells it apart — the folder where that is enough (maps/flat-map, test/flat-map), the pallet where it is not (core:arena, demo:arena) — with the qualifying part dimmed and the name itself lit, and each inserting a spelling that resolves to exactly one map. Typed by hand, the shared name is answered with the conflict rather than a guess: map: 'flat-map' is ambiguous (2 maps match):, listing the barcodes and pointing at the longer spellings, which keep working untouched.

It is the same map verb the windowed client carries, registered on the server console by the host composition (ServerOptions.RegisterHostCommands) with a server-side switch handler in place of the client’s — one spelling, one resolution ladder, one set of answers wherever it is typed. No world qualifier is needed on stdin: the server console is bound to the world it serves, so map is unqualified there exactly as it is in game.

The switch goes through AuthoritativeServer.RequestMapSwitch with MapSwitchReason.Switch — the same request the listen server’s map makes — so the expensive half (reading the pallet, cooking collision) runs on a worker while the old map keeps ticking, and only the commit lands on the tick thread. Connected clients follow it the ordinary way, through map replication: advert, hash-skip or chunked transfer, reload. Nothing about the dedicated case is a second delivery path.

The answer is map: switching to core:maps/logic-map.dh-map, present tense on purpose: the switch commits frames after the line is written, so claiming it had landed would be a claim the command cannot check. A name that matches nothing is answered (map: no map matching 'nope' (run 'maps' to list)) and the world is left exactly where it was.

Switching is authority-gated like say and players: an operator in game may forward map <barcode> to the server and it runs, while a guest is refused with the same sentence every world-authoritative family uses — map: requires world authority (admin or server console). Reading is not gated: a bare map answers anyone, because what is loaded is already on every player’s screen.

The server records demos. demo record [name] / demo stop / demo status on the server console write every pawn’s simulated input per tick plus a full world checkpoint every server.demo.checkpointTicks (300) to a .dh-demo under the user data demos\ directory; --demo <file> replays one headlessly through the real tick and checks every checkpoint. See Demos.

A server is a universe, and a universe holds worlds. One world is the ordinary case — the one the server boots with, the one every map command means — but the server keys its per-world state by WorldId rather than holding one of each, so a second world opened beside the first shares nothing with it: its own replicated entities, its own bone poses, its own mesh-renderer overrides, its own spawned-asset table, its own map. What they do share is the transport, the session table, and the revision counters, which are monotonic generation counts a client compares against its own cursor and so must never restart per world.

Four verbs on the server console drive it:

  • world.list — every world this server runs, with the map it holds and how many players are in it. The world the server booted with is marked (boot).
  • world.open <name> [mapBarcode] — opens another world beside the ones already running. Nothing is unloaded and nobody is moved. Name a map and the new world comes up holding it: each ServerWorld gets a gameplay host of its own, and the map is installed through the same live map switch the map verb uses. Without a barcode the world opens empty, which is still somewhere to move a pawn to.
  • world.close <name> — closes a world nothing is in. A world still holding a player or a spawned entity is refused rather than emptied, and the boot world is never closed.
  • world.move <player> <world> — moves one connected player into another loaded world. The pawn is re-created there at the pose it stood at, under the same wire id and the same ownership, and the entity state that dressed it — its bone poses, its mesh-renderer overrides, its avatar’s place in the asset table — moves with it. The world it left stops simulating it immediately; the world it joined starts on its next tick.

The wire id is deliberately kept rather than reallocated: the client already holds it from its spawn ack, and minting a new one would make a move read as a despawn and a join.

A moved client stays connected and changes worlds where it stands. The server sends it worldChanged, carrying the five things serverInfo already carries about a world — its id and name, the map’s barcode and name, both content hashes and the map generation — and the client runs the map through the same advert path a join and a live map switch already use: the loading box, the transfer, the hash-skip when the content is already on disk. There is one client-side map-load path and this is it; nothing about a move is a second one.

The snapshot header names its world, which is the one thing an older client cannot be taught. A client applies a snapshot only to the world it currently occupies; one addressed elsewhere — a packet still in flight across a move, or a server addressing the wrong world — is dropped and counted (NetClient.ForeignWorldSnapshots), with one warning per run of them. The count is an observation, not a diagnosis: both causes read the same.

Everything else the stage added is trailing and optional, so an older client reads it as absence rather than as garbage: a WorldId on the asset-table, entity-pose and mesh-renderer messages (defaulting to the world the client joined, which is what a server too old to send one means), and a world list on serverInfo — every named world the server runs, so a picker can exist later. The client keeps one revision cursor set per world, because the server’s revisions are universe-wide and world B’s asset table is not a delta on world A’s.

A world’s gameplay host is per-ServerWorld, but the console command table is filled once, by the boot world’s host: the registry is keyed by name, so a second registration of the same module would collide verb by verb. Later hosts run the same gameplay against their own world and share the one table.

server.forgiveness (float, default 0.8, 0–1) is one number for how much of a player’s own claimed movement this server accepts. 1.0 is fully authoritative — exactly the behavior described under Client/Server Clocking & Interpolation, with no claim on the wire and nothing adopted. 0.0 takes the client’s transform as law, the shape a co-op or social world wants. In between the server still simulates every player from inputs and adopts the client’s claimed transform only when it lands inside a tolerance of its own answer.

It is set three ways, all the same preference:

  • the console or an autoexec: server.forgiveness 0.5
  • the start flag --forgiveness <v>, applied after the autoexec and any --exec so a launch flag beats the profile
  • the Forgiveness slider in the server settings of the change-map / new-server panel, beside the open/close toggle and the player limit. Unlike those two rows, it is never disabled by a closed door — the value a host picks is the value the server runs the moment the door opens, so it is worth seeing and setting either way. The slider shows no number: what a host picks here is a posture, so the track is labeled at its ends — “Permissive” at 0, “Authoritative” at 1.

A closed local server does not correct its own host. An embedded server that has designated a host peer and has server.open off is alone on the machine: the only claim it can ever receive is its own player’s, and mistrusting that player only costs a rubber band on a machine that is already the authority. So the forgiveness such a server actually runs is 0 — the claim is adopted outright and no tolerance is consulted — whatever server.forgiveness says. The preference is never written: the effective value is resolved per tick, so opening the door restores the host’s chosen number on the very next tick and closing it drops back to 0, and the server says which posture it moved to on the net channel each time. A dedicated server designates no host, so a shut listen port changes nothing for it. The effective value is what rides the snapshot’s WorldSettings, so the client decides whether to attach a claim from the same number the server gates with; a host’s Server Settings pane still reads the preference off its own store, so the slider keeps showing what it was set to. The flag mask is the guard that survives: a claimed Noclip or Frozen is stripped at any forgiveness, 0 included.

The tolerance itself comes from three more preferences, so a server can widen or tighten the whole curve without touching the one number a host actually sets: server.forgivenessRadiusAt0 (default 512 m, the map-crossing bound), server.forgivenessSpeedAt0 (default 2048 m/s) and server.forgivenessCurve (default 6). Position radius and speed ceiling are both at0 × (1 − forgiveness)^curve — monotone, exactly zero at 1.0, the full bound at 0.0. At the 0.8 default that is about 3.3 cm and 0.13 m/s: ordinary drift and a few ms of clock skew pass, a meter-sized lie does not.

Forgiveness is player movement only. Physics props, logic entities and editor state stay server-owned at every setting. Per-player overrides (players.<target>.forgiveness, the shape the movement variables already use) are not implemented: the read path is a world default a sparse override could layer onto, but only the world value exists.

Preferences are addressed by their dotted path, either bare (client.fov reads, client.fov 90 writes) or through the script-sugar verbs get, set and reset, which are exactly equivalent for a single path.

The verbs also understand a namespace — an interior node of the path tree, such as client.debug, which is not itself a preference but has preferences beneath it (the same branch autocomplete offers with a trailing dot):

  • get client.debug lists every preference beneath the namespace, one per line in the ordinary path = value - help format.
  • reset client.debug restores every preference beneath it to its default and prints a count plus the affected lines (reset client.debug: restored 10 preferences to defaults), eliding the list past twelve entries.
  • set client.debug 1 is an error: a namespace has no single value, so the console names a few leaves to use instead rather than guessing one.
  • A path that is neither a preference nor a namespace stays an error (Unknown preference: client.debugNope) — the console never silently no-ops.

Matching is on segment boundaries, so client.debug sweeps client.debug.colliderGizmo but never the sibling leaf client.debugSoundCues (see Debug Overlays for the whole family). The worlds.<slug> form works too, sweeping that world’s copies of the world.* rules.

A namespace reset is gated per leaf, exactly as resetting each leaf by name would be: a leaf that requires a world context the caller lacks, or that is cheat-gated with cheats off, is skipped and named with the same reason the single-path form would have given (skipped world.gravity: 'world.gravity' is a world preference; …), while the permitted leaves are still reset. The sweep can neither bypass a gate nor fail silently.

Archived preferences persist across launches, which is exactly wrong for a few sim-critical world rules: after tuning world.gravity (or any movement value) for a test, that value would silently carry into the next session. The fix is an autoexec config, run at startup after the saved profile loads, that re-asserts the sim defaults so every launch boots into sane values unless you deliberately override.

The server boot sequence is profile-then-autoexec: profile.toml loads first (restoring every saved preference), then the world autoexec runs and wins. The autoexec is a plain, user-editable cfg exec’d through the ordinary console, seeded on first run and never overwritten afterward:

  • File: <config>/world.autoexec.cfg (per-domain by name — only world.autoexec.cfg exists, with the naming leaving room for server.autoexec.cfg and client.autoexec.cfg). <config> is the engine config directory (%LocalAppData%\DigitalHeaven\Engine\config).
  • Covers: the persisted, non-cheat-gated world sim tunables — world.gravity and every world.* movement value (accelerate, airAccelerate, friction, stopSpeed, maxSpeed, sprintSpeed, crouchSpeed, jumpHeight, airCap, surfaceFriction, stepHeight, groundCheckFraction, groundStickFraction, moveEpsilonFraction, uncrouchSkinFraction, contactSkin, noclipSpeed, noclipAccelerate, noclipFriction, autoBunnyHopping, coyoteTime, slopeLimit, scaleMovement), plus the physics prop tunables (world.prop.friction, world.prop.groundProbe, world.prop.pushSpeed, world.prop.pushResponse, world.prop.pushReach). Every line is a reset <path>, never a frozen number — so a file generated months ago still boots into whatever the current engine default is, and a later retune reaches it. This is the whole point: writing world.sprintSpeed 8 into the file would pin a stale default forever.
  • Does not touch: any non-sim preference (client display/audio/crosshair, server.*, world.cheats) — those persist normally. world.timeScale is already transient (never saved) and cheat-gated, so it boots at 1.0 on its own; the autoexec documents it in a comment rather than setting it.
  • Making a tweak permanent: edit world.autoexec.cfg (change or delete a line). Replace a reset world.gravity line with the value form — world.gravity 12 — and that is what the world boots into; delete the line entirely and the saved profile value stands.

Two launch flags override the startup config (server modes only — a --connect client has no server to configure):

  • --noAutoexec — skip the built-in world autoexec entirely; the saved profile’s sim values are used as-is.
  • --exec <file> — execute an extra cfg after the autoexec. A custom cfg may itself exec world.autoexec and then apply overrides, or be paired with --noAutoexec to replace the built-in autoexec completely.

A command typed by a person answers with its automatic confirmation: reset world.gravity prints what it reset, because the person who typed it is owed a reply. A command replayed from a config file does not. Without that split, a twenty-line autoexec wrote twenty untagged confirmations into the middle of the startup log every launch.

The boundary is exec itself — not the reset verb — so nothing about typing at the console changes. Inside a file:

  • Suppressed: the automatic confirmation of a read or a write. A bare path, get, set and reset all apply silently, including a namespace reset’s summary block.
  • Never suppressed: problems. An unknown name, a refused (cheat-gated or world-less) write, a value the preference rejected — each still prints, because silencing a cfg’s chatter must never silence its mistakes. A leaf a namespace reset had to skip prints on its own line rather than under the summary that was suppressed.
  • Never suppressed: echo, help and find. These are deliberate requests for output, not confirmations — echo is how a cfg says something on purpose.

Each file that runs writes exactly one properly tagged, colorized log line on the console channel, naming the file and what it did:

[20:57:33.412] [INF] [console] exec world.autoexec.cfg: 21 command(s) applied

When something failed, the line is a warning instead and names the split — the failing lines themselves have already printed their own messages:

[20:57:33.412] [WRN] [console] exec mytweaks.cfg: 3 of 4 command(s) applied, 1 failed

Comments and blank lines are not commands and are not counted. A nested exec logs its own line and is not double-counted into its parent, whose exec line already counted as one command of its own. A missing file and an over-deep nesting chain each log a warning too, so a startup exec that never ran is visible rather than silent.

The launch window’s Preset dropdown reads presets\<name>.cfg from the same config directory the autoexec lives in (%LocalAppData%\DigitalHeaven\Engine\config\presets\). A preset is a plain console-line file, and it is additive: only the rows it actually changes are live lines. Every other row is written out commented, so the file still shows the whole surface without setting it:

// Bhop — launch preset. Hold jump to keep hopping.
// Commented rows are left alone; uncomment one to have this preset set it.
world.autoBunnyHopping true
// world.gravity 15.24
// world.maxSpeed 5.08
...

This is the point of the format. When every row was live, presets undid each other — choosing Low gravity turned bunny hopping off because its file said world.autoBunnyHopping 0, and choosing Bhop put the gravity back. Now Low gravity changes the gravity and nothing else. Editing a commented row in the window brings that line back to life where it already sits, so the file keeps its authored order. Bools are written true/false; the console parses 0/1, true/false, yes/no and on/off alike.

The list is live while the window is open. It reads the same pallet watcher map hot reload runs on, so a pallet built, copied in or deleted is listed or gone once the watcher settles (watchDebounceMs), with no reopening; a pallet caught half-written is not announced until the write that finishes it. The engine’s own maps are listed whatever the workspace holds. The list is only computed while the window is up, and opening it opens on the current set.

The chooser rechecks the selected barcode against the current map catalog when committing. If an import or catalog refresh removes that map, Start Game / Apply is unavailable and the chooser stays open with a reselection message. Select an available map to continue; a missing selection never silently becomes a default-map launch. The separate explicit Quick Launch action retains its default-map policy.

Three ship with the engine — Default (which pins nothing at all), Bhop and Low gravity — and are seeded into the folder on first run only when missing: a shipped file you edit is simply your copy from then on, and a new file you drop beside them is a new entry in the dropdown. The one exception is a file still byte-identical to a body that preset shipped with earlier — nobody chose that, so it is rewritten to the current shape. The window’s settings rows (world.autoBunnyHopping, world.gravity, world.maxSpeed, world.sprintSpeed, world.jumpHeight, world.coyoteTime, world.noclipSpeed, world.maxJumps, world.noclipAllowed) are the preset’s editable surface: committing a row rewrites that one line in place through the preference’s own parser, so the file holds the clamped, formatted value. A row the file omits reads as the preference’s declared default.

Presets are applied as console lines, never as saved profile state: Start Game forwards every line of the chosen preset to the embedded server the frame the world lands (the server is built on the chosen map, and only a running server can take world.* lines), and Apply from the pause menu forwards them live, with a row edit reaching the server the moment it commits. The chosen preset’s name persists as client.launch.preset; starred and recently played maps persist beside it as client.launch.favorites and client.launch.recent (native TOML arrays of barcodes, the recent list capped at LaunchPreferences.RecentCap; its head is the map the launch window opens on); client.launch.played keeps the time each map last landed in play, which the table’s Last played column reads.