DOMINATION

Dedicated Server Hosting

Production layout and startup contract

Direct packaging, Docker, and systemd use the same staged layout:

/opt/domination/server/config/server.json
/opt/domination/server/config/release-maps.json
/opt/domination/server/src/*.js
/opt/domination/unity/Assets/StreamingAssets/data/maps/<approved-id>.json
/server-data/                         # the only mutable tree

server.json is required. Relative paths inside it resolve from the config file, never from the process working directory. release-maps.json is the versioned allow-list for built-in maps; every listed descriptor must exist, parse, match its ID, and provide at least two spawns. The server exits before listening if the config, manifest, map directory, any approved descriptor, or writable data tree is unavailable. Approved custom maps under /server-data/maps/approved are additive and cannot replace a built-in map ID.

The current catalog is 2026.07.12 with nine approved skirmish maps. Campaign and test descriptors are not advertised by production servers.

Validate a checkout

The server uses Node.js built-ins only (Node 20 or newer). From the repository root:

npm --prefix server run validate
SERVER_DATA_DIR="$(mktemp -d)" npm --prefix server run validate:deployment
npm --prefix server run test:deployment

validate:deployment loads the required config and complete release catalog without opening a socket. A healthy result includes:

"event":"deployment_validated" ... "releaseMapCount":9

Secure transport contract

Public and LAN runtime binds require native TLS 1.2 or newer. The server reads a PEM certificate chain and private key before it creates runtime directories or opens a socket, and fails closed if either is absent, unreadable, malformed, or mismatched:

DEDICATED_SERVER_HOST=0.0.0.0 \
TLS_CERT_PATH=/etc/domination/tls/fullchain.pem \
TLS_KEY_PATH=/etc/domination/tls/privkey.pem \
SERVER_DATA_DIR="$HOME/.local/share/domination-server" \
node "$(pwd)/server/src/server.js" \
  --config "$(pwd)/server/config/server.json"

The certificate SAN must contain the hostname players enter. Unity shipping clients use platform chain, lifetime, revocation, and hostname validation; players connect with tls://game.example.com:7777 (a bare hostname also defaults to TLS). This is TLS-wrapped newline JSON, not HTTP. The automated player harness has a separate exact-certificate pin that activates only for MP_AUTOTEST=host|join after the connected peer is confirmed as loopback; interactive and public connections cannot activate it.

Plaintext is a local development exception only. It requires an explicit flag on both processes and literal numeric loopback addresses—hostnames including localhost, LAN addresses, and public addresses are rejected:

ALLOW_INSECURE_LOOPBACK=1 DEDICATED_SERVER_HOST=127.0.0.1 \
node server/src/server.js --config "$PWD/server/config/server.json"

domination_ALLOW_INSECURE_LOOPBACK=1 \
MULTIPLAYER_SERVER_URL=tcp://127.0.0.1:7777 \
./unity/Build/domination/domination.x86_64

Never use account, lobby, or reconnect credentials in plaintext mode.

For a local server, pass the config explicitly. The absolute path keeps the invocation independent of the shell's current directory:

SERVER_DATA_DIR="$HOME/.local/share/domination-server" \
node "$(pwd)/server/src/server.js" \
  --config "$(pwd)/server/config/server.json"

The checked-in launcher resolves its own source path and accepts the same arguments:

server/domination-server --config "$(pwd)/server/config/server.json" --port 7777

Readiness

The TLS-authenticated ping response is safe for health monitoring and contains no secrets:

{
  "type": "pong",
  "ready": true,
  "configLoaded": true,
  "config": "server.json",
  "catalogVersion": "2026.07.12",
  "releaseMapCount": 9,
  "mapCount": 9
}

Run the shipped probe from the staged server package:

READINESS_HOST=127.0.0.1 READINESS_PORT=7777 \
READINESS_SERVER_NAME=game.example.com \
node /opt/domination/server/scripts/readiness-check.mjs

Public-CA certificates use Node's platform trust store. For a private CA or the checked-in localhost test fixture only, also set READINESS_TLS_CA_PATH=/path/to/ca.pem. The probe never disables certificate or hostname validation.

mapCount can be greater than releaseMapCount when approved custom maps are installed. Readiness requires at least one release map and persistenceState: "healthy", so custom content cannot hide a broken production map mount and unresolved result recovery cannot accept new matches.

Docker

Build from the repository root. The root .dockerignore sends only server runtime files and map descriptors; the packager copies only manifest-approved maps into the final image.

docker build -f server/Dockerfile -t domination-server:2026.07.12 .
docker volume create domination-server-data
docker run -d \
  --name domination-server \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid,size=16m \
  --cap-drop ALL \
  --security-opt no-new-privileges:true \
  -p 7777:7777/tcp \
  -v domination-server-data:/server-data \
  -v /srv/domination/tls:/certs:ro \
  -e TLS_CERT_PATH=/certs/fullchain.pem \
  -e TLS_KEY_PATH=/certs/privkey.pem \
  -e READINESS_SERVER_NAME=game.example.com \
  domination-server:2026.07.12

The image runs as the unprivileged node user. Config, code, manifest, and map descriptors live in the read-only image; only /server-data is writable. TLS material is a read-only bind mount. The built-in Docker health check runs the same authenticated readiness probe and the container fails closed when the certificate/key are absent:

Ensure the mounted certificate and private key are readable by container UID 1000 without making the private key world-readable (for example, owner/group 1000 with mode 0640). For a private CA, mount its PEM too and set READINESS_TLS_CA_PATH to the in-container path.

docker inspect --format '{{json .State.Health}}' domination-server
docker logs domination-server

For a host bind mount instead of the named volume, create it for container UID/GID 1000 first:

sudo install -d -o 1000 -g 1000 -m 0750 /srv/domination/server-data

Do not mount over /opt/domination; doing so removes the validated config or release catalog and startup will fail intentionally.

Linux/systemd

From a repository checkout, the installer creates the unprivileged domination account, stages the same manifest-selected runtime used by Docker, creates /server-data, installs the hardened unit, validates the deployment in ExecStartPre, and starts it:

sudo TLS_CERT_SOURCE=/etc/letsencrypt/live/game.example.com/fullchain.pem \
  TLS_KEY_SOURCE=/etc/letsencrypt/live/game.example.com/privkey.pem \
  TLS_SERVER_NAME=game.example.com \
  server/scripts/install-systemd.sh --start
sudo journalctl -u domination-server -f

To inspect before starting, omit --start, then run:

sudo systemctl enable --now domination-server
sudo systemctl status domination-server
sudo env READINESS_SERVER_NAME=game.example.com \
  node /opt/domination/server/scripts/readiness-check.mjs

The installer copies the supplied certificate and key to /etc/domination/tls, writes a root-owned service environment file, and runs catalog, key-pair, and certificate-hostname preflight before replacing or starting the unit. TLS_SERVER_NAME must be the hostname players use. For a private CA, also supply TLS_CA_SOURCE=/path/to/ca.pem; public-CA deployments use Node's platform trust store. Rerun the installer after certificate rotation. The unit treats /opt/domination and TLS material as read-only and grants writes only to /server-data. Never place the production config under /server-data; mutable state must not be able to replace the release catalog or server policy.

domination-server.service uses Type=simple: systemctl status reports process liveness, not application readiness. A server with retained recovery evidence deliberately keeps its diagnostic socket alive but answers ready:false. The installer runs the readiness probe when invoked with --start; production monitoring must run the same probe continuously and alert on a nonzero exit. Do not route players to an instance based on active (running) alone.

Configuration overrides

The supported deployment variables are:

DEDICATED_SERVER_HOST
DEDICATED_SERVER_PORT
SERVER_CONFIG                 # use an absolute production path
SERVER_DATA_DIR
MAP_SOURCE_DIR                # must contain every manifest-approved descriptor
MAP_MANIFEST                  # must be a valid, non-empty release manifest
GAME_VERSION
TLS_CERT_PATH                # PEM certificate/full chain
TLS_KEY_PATH                 # PEM private key; never log or commit
TLS_SERVER_NAME              # exact public certificate hostname for preflight
MAX_PENDING_TLS_HANDSHAKES   # default 64; 1-4096 pre-auth sockets
ALLOW_INSECURE_LOOPBACK      # local numeric-loopback tests only
RECONNECT_GRACE_SECONDS
DISCONNECT_OUTCOME
LOADING_TIMEOUT_SECONDS
MESSAGE_RATE_PER_SEC
MESSAGE_BURST
DESYNC_VOID_TICKS
LOBBY_CREATES_PER_WINDOW

Run --validate-only after any override and before restarting production. Catalog-only --validate-only does not open a listener; a real runtime start must additionally load the TLS material successfully. Use --validate-only --validate-transport with TLS_CERT_PATH and TLS_KEY_PATH to perform the exact systemd preflight without opening a listener.

Transport validation

The server suite covers fail-closed public startup, plaintext and hostname-based opt-in probes, hostname mismatch, malformed credentials, secret-free logs, and saturated/stalled pre-auth TLS handshakes:

npm --prefix server test

After deployment integration supplies the certificate paths and TLS-aware health probe, validate the exact public name and capture a login/lobby session:

openssl s_client -connect game.example.com:7777 \
  -servername game.example.com -verify_return_error </dev/null

The capture must contain TLS records and no readable passwords, bearer or reconnect tokens, lobby credentials, chat, commands, or match state.

Lockstep timing contract

Protocol 0.1 uses a fixed deterministic simulation rate of 10 Hz. The authoritative command lead defaults to 3 ticks and accepts only 1–20 ticks. server/config/server.json deliberately omits commandLeadTicks, retaining the code-owned default. Invalid timing stops startup before a socket opens.

The server sends the timing contract in every initial and pre-simulation loading reconnect config. Clients reject missing or incompatible values and calculate their complete simulation horizon as serverTick + commandLeadTicks - 1. Running-match reconnect is rejected until authoritative snapshot recovery is available; command history alone is not a state checkpoint.

Safe shutdown

Use SIGTERM/SIGINT:

sudo systemctl stop domination-server
docker stop domination-server

The server sends serverShutdown to connected clients and flushes its append-only logs under /server-data/logs before exiting.