Host a dedicated server
w4-dedicated runs one match of the W4 simulation for other people. The server decides everything that happens; players send orders and get back only what they may see. It lists itself on the Games page through the online service, so people find it in the browser and the engine.
This page is for the person who runs a server. Players read Online play and You can always come back.
w4-dedicated --help(or-h) prints every flag with its default; the table below is the same list, and a test keeps the help in step with the argument parser, so a flag that is not listed does not exist. A flag with a wrong value stops the program with a one-line message.
The smallest server
w4-dedicated --map battle.w4m --listen 0.0.0.0:7777 --address example.org:7777This listens on every address, accepts any name as a player (development mode) and is not listed anywhere. A development server only accepts connections from the same computer; to let others in you must sign players in through an online service with --online.
A public server looks more like this:
w4-dedicated --map battle.w4m --listen 0.0.0.0:7777 --address example.org:7777 \
--title "Official: Battle" --online http://127.0.0.1:5179 \
--tls-cert fullchain.pem --tls-key privkey.pem \
--checkpoint-dir /var/w4/checkpoints --metrics-listen 127.0.0.1:7790 \
--drain-file /var/w4/dedicated.drain --allow-guestsThe host key comes from the environment variable AQUA4_HOST_KEY here (or --host-key KEY). Prefer the variable on a shared machine: a flag can be read by other users with ps.
Every flag
The map and the address
| Flag | Default | What it does |
|---|---|---|
--map <folder or file.w4m> |
none | The map to play. One of --map and --map-hash is needed. |
--map-hash <sha256> |
none | Fetch the map from the asset store of the online service by its checksum (needs --online). |
--listen <addr:port> |
127.0.0.1:7777 |
Where to accept players. The default is loopback, so a test run never opens a firewall prompt. A public server says 0.0.0.0:7777. |
--address <host:port> |
the --listen value |
The address other people connect to, shown on the Games page. Set it to the public name, because the default is the listen address (0.0.0.0:7777 is no use to anyone). |
--title <text> |
W4 server: <map name> |
The name of the game on the list. |
--max-players <n> |
the map's seats | The player count shown on the list (1 to 12). It does not change how many seats the map has. |
Who may play
| Flag | Default | What it does |
|---|---|---|
--online <url> (also --auth) |
none | The online service, for example http://127.0.0.1:5179. With it, game tokens are checked by the service, the game is listed (with a host key) and results are recorded. Without it any name is accepted and only the same computer may connect. |
--host-key <key> |
AQUA4_HOST_KEY |
Proves to the service that this is a server it trusts: needed to list the game and report results. |
--allow-guests |
off | With --online: a hello with an empty token and a name (3 to 20 letters, digits, space, _, -) plays as Name (local). |
--start-when <n> |
the map's seats | Players connected and ready before the match starts. |
--load-cap-seconds <s> |
60 | Start anyway this long after the first player said hello. |
--lobby |
off | A lobby before the game: players choose teams and say ready, the host starts. Players whose hello carries the same party tag (up to 32 letters, digits, - or _) are seated on one team when it has free places. |
--lobby-lock-teams |
off | With --lobby: players cannot change team themselves. |
--seat-grace-minutes <n> |
10 | Before the match has started, how long a seat stays reserved for an absent player. After it has started a seat is kept for good. |
--password <text> |
none, or AQUA4_PASSWORD |
Players must send this password in their hello. The listing shows a lock icon ("needs a password"); wrong guesses count like failed sign-ins. Use the variable on a shared computer, because a command line is visible to other users. |
--kick-slow-loaders |
off | When the load cap (--load-cap-seconds) runs out, remove seats that never said ready (they get load_timeout and may rejoin) provided somebody else is ready. Without it the game starts with them still loading. |
--lobby-autostart-secs <n> |
0 (off) | With --lobby: start by itself n seconds after every open slot holds a ready human, with a countdown message each second. |
--restart-eta-secs <n> |
20 | What clients are told to expect (server_restarting.eta_secs) when the server drains or is stopped; a stop (SIGTERM, service w4_dedicated stop) writes the final checkpoint first, so the match resumes after the restart and players rejoin their seats. |
--ranked-lobby |
off | With --lobby: report games of this lobby to the history as ranked: true when every seat is a W4 ID (no guests). A match claimed from the matchmaker is always reported ranked. |
--allow-native-scripts |
off | Let a map run its native script (a Go build for FreeBSD) instead of the WASM one. Never in ranked, tournament or listed games, so it cannot be combined with --online; only for maps whose content hash is in --native-allow; the WASM is always the fallback and the one replays and checkpoints use. Today no map carries a native build, so the server only logs why it runs the WASM (AL5, AL6). |
--spell-reveal-lead-ms <ms> |
150 | With interest.events: a shot or spell that happens just outside a player's vision is told to them early (the pad is 14 tiles a second times this, capped by --spell-reveal-max-tiles); the units it names stay hidden. 0 turns it off. |
--spell-reveal-max-tiles <n> |
4 | The most tiles ahead of a player's vision such an event is told. |
--order-rules <file> |
none | JSON with the upgrade rules (from, to, gold, seconds) and transport capacities (definition, capacity) that the upgrade and load orders need; without it those two orders are accepted and then ignored by the simulation. A replay carries the rules it ran with. |
--utility-rules <file> |
none | JSON with the utility (trinket) slot: per tool stealth, farsight, sweeper the max_charges and recharge_secs, plus swap_secs, swap_range and grant (the definitions, by uuid, whose units start with a tool). Without it no unit has a slot and wards are not gated by charges. Owners see their slots in the utility message. A replay carries the rules it ran with. |
--ward-rules <file> |
the sim's defaults | JSON with the ward numbers: yellow, pink and blue objects (sight, lifetime_secs or forever, limit, health, place_range, detect) and sweep_radius, sweep_secs, sweep_range; every field is optional and a bad number stops the server at start. A replay carries the rules it ran with. |
--native-allow <file> |
none | With --allow-native-scripts: the content hashes of the maps whose native script may run, one per line, # starts a comment. |
--rooms <n> |
off | Run n matches (1 to 32) of this map in one process. A hello with "room":"2" picks one; none means the first open room. Checkpoints go to DIR/r<n>. |
--region <code> |
none | The region shown on the games list so people can filter by ping, for example eu or us-east (1 to 16 of a-z, 0-9, -). |
--motd <text> |
none | A message of the day sent in every welcome (200 characters at most). |
--config <file> |
none | Read flags from a file of key = value lines (a flag without its dashes; strings in quotes; true or false for switches). The command line wins over the file. Useful for a service, because ps then shows no flags. |
Security of the connection
| Flag | Default | What it does |
|---|---|---|
--tls-cert <pem> and --tls-key <pem> |
off | TLS on the game connection. Give both or neither. The files are looked at again about once a minute, so a renewed certificate (Let's Encrypt, Caddy) needs no restart; a half-written file keeps the old certificate and prints a line. |
--quic-listen <port or host:port> |
off | QUIC (UDP) beside TCP, for players on bad connections. Needs the TLS flags. A bare port means 127.0.0.1; a public server says 0.0.0.0:7778 and the firewall must pass that UDP port. TCP stays the fallback for networks that block UDP. |
--quic-max-handshakes <n> |
64 | QUIC: handshakes in progress at once. Past it new ones are refused, so a flood from many addresses costs a bounded amount of CPU. Twelve players starting a match need twelve. |
--quic-idle-secs <n> |
15 | QUIC: a peer that goes silent is dropped after this long (up to about a third more, because of the keep-alive timing). Keep-alives go out every n/3 seconds, so a player who is only watching is not cut. |
--quic-retries-per-sec <n> |
2000 | QUIC: address-validation replies (Retry) the listener sends per second. Above it, further first packets are ignored. |
--quic-handshakes-per-ip <n> |
20 | QUIC: handshakes one validated address may start per second. This machine is exempt. |
--quic-window-kib <n> |
1024 | QUIC: receive window per connection in KiB (256 to 65536). The send window is four times it. This is the most memory one connection can pin, apart from the server's own queue per player. |
--max-conns <n> |
256 | Connections open at once, TCP and QUIC together. Each one costs two threads. Past it a new connection is told the server is busy. |
--max-conns-per-ip <n> |
8 | Connections open at once from one address (an IPv6 address counts by its /64; this machine is exempt). |
--max-bytes-per-sec <n> |
250000 | The byte budget per player that uses the compact binary format. On QUIC the rate also drops by half when the connection loses more than 2% of its packets, and climbs back when the line is clean. |
--total-bytes-per-sec <n> |
0 (off) | The byte budget for all players together; each gets an equal share, never above --max-bytes-per-sec. |
Keeping the match alive
| Flag | Default | What it does |
|---|---|---|
--checkpoint-dir <dir> |
off | Save the whole match to this folder. After a crash or restart the server resumes an unfinished match from the newest valid file and its players join again as usual. The newest three files are kept, each written in one atomic step, and one more is written when the server stops on Ctrl-C or SIGTERM. |
--checkpoint-secs <n> |
10 | Seconds of game time between two checkpoints. |
--standby |
off | With --checkpoint-dir: do not listen yet; wait until the running server's lease file in that folder is gone (it stopped gracefully) or 5 seconds old (it crashed), then take over its match from the newest checkpoint. Start the primary and the standby on the same folder; players rejoin as after a restart. Measured takeover after a kill: 4.6 to 4.8 s. There is no protection against two machines both thinking they are the primary: move the address with the takeover. |
--admin <acct:id or Name#Tag> |
none | Repeatable. Only a signed-in (verified) account counts, never a bare name; acct:<id> survives a rename. A signed-in player whose shown name is listed (for example Ann#1234) can type /stats in the game chat and gets one line from "server" back, for them alone: phase, game time, seats connected, spectators, steps, overruns and the worst step, skipped and replaced snapshots, clients dropped for stalling, panics, paused. Anyone else's /stats is ordinary chat. |
--drain-file <path> |
off | See "Restarting without ruining a match" below. |
--reconnect-secs <n> |
60 | How long a player who dropped shows as reconnecting to the others before they show as away. |
--tcp-idle-secs <n> |
5 | TCP/TLS keepalive: a player whose connection vanished without closing (cable pulled, router restarted) is cut about n + 6 seconds later instead of after minutes. 0 turns it off. QUIC has its own 15 s timeout. |
--pause-on-drop |
off | Pause the game while a player is reconnecting. |
--player-pause |
off | Let a seated player hold a started game with pause; the others can end it by vote (resume). |
--pause-secs <n> |
60 | With --player-pause: the longest one pause runs. |
--pauses-per-seat <n> |
2 | With --player-pause: how many pauses one seat may start. |
--pause-budget-secs <n> |
120 | With --pause-on-drop: how long one seat may hold the game up in all. After that its drops no longer pause anyone. |
--takeover idle, ai or none |
none |
With idle: when a player has been away longer than the reconnect window the others are told they abandoned the game. Their units stand idle. With ai: the same, and every 2 seconds a simple defender sends the absent seat's idle units at the nearest enemy that seat can see within 25 map units (attack-move). It uses only what that seat sees, its orders are ordinary recorded orders (the replay still verifies), and it stops the moment the player says hello again. It does not build, train or retreat. |
--rejoin-limit <n> |
8 | How many times one identity may rejoin within 10 seconds before it is told to wait. 0 turns the limit off (for load tests). |
--replay-keyframe-secs <n> |
0 (off) | Keep a state keyframe in each replay every n seconds of game time, so a replay player can jump to a time without playing from the start. A keyframe is a few megabytes at thousands of units; at most 8 MiB are kept per game. Each keyframe also carries a state hash, so a verifier can name the first step where a replay stops matching the game. |
--ranked-lobby |
off | With --lobby: a finished lobby game is reported to the online service as ranked when every seat is a W4 ID (guests make it unranked). Matches that came from the ranked queue are always reported ranked, with or without this flag. |
--allow-join-in-progress |
off | Strangers can claim a free seat of the RUNNING game (late_join). The seats nobody took when the match began are opened (on a spawn_at_join map they have no units until someone claims them). The joiner loads while the game runs and is seated at the next step; the join is recorded in the replay. Never together with --ranked-lobby, never in a matchmaker match, and a game with a late joiner is never ranked. |
| `--join-catch-up none | starting | ally_average` |
--join-until-minutes N, --join-max N |
none | No joins after N game minutes; at most N joins in one game. |
--join-replace-abandoned |
off | A seat whose player left for good (or was away longer than --reconnect-secs) can be taken over; its units stay. |
| `--join-share-explored on | off` | on |
--join-hold-secs N |
90 | How long a claimed seat is held while its joiner loads. |
--allow-native-scripts |
off | Lets a map run its native script instead of the WASM one; never for ranked, tournament or listed games (not with --online), only for maps in --native-allow, with the WASM as the fallback. No map has a native build yet, so for now it only logs why the WASM runs. |
--native-allow <file> |
none | The content hashes (one per line) of the maps whose native script may run. |
--lobby with --online and --host-key |
off | Also takes ranked matches: while the lobby is idle the server claims a matched group from the sign-in service (POST /v1/queue/claim) and holds the room for those accounts only. There is no separate matchmaking flag. |
--dev-reload |
off | Development only: restart (exit 0) when the map file changes and nobody is connected. |
--overlay |
off | With --metrics-listen: serve /overlay.json on the metrics address, the live clock, phase and per seat name, team, gold and kills, for a stream overlay (OBS browser source). It shows everything, fog or not: keep the address on this computer or delay your stream. |
--max-game-minutes <n> |
120 | A game still going after this long ends as a timeout, with no winner. |
--postgame-minutes <n> |
10 | How long the room stays open after a game, for chat and a vote to play again. |
--cpu <n> |
off | FreeBSD only: pin the process to core n (cpuset), to keep other services off the simulation core. |
--rtprio <0-31> |
off | FreeBSD only: run in the real-time scheduling class (rtprio); 0 is the best. Use with care on a small box: a busy loop would starve everything else. |
--pace-spin-us <us> |
0 | Spin during the last us microseconds (0 to 5000) of the wait for each 33 ms step, so steps start on time. Costs up to that much CPU a step. |
Watching
| Flag | Default | What it does |
|---|---|---|
--max-spectators <n> |
0 | How many people may watch without a seat. 0 turns spectating off. |
--spectator-delay-secs <n> |
0 | Show spectators the game this many seconds late, so watching cannot be used to see an opponent's army. |
--spectator-commands |
off | Let spectators ask for the command-stream feed (spectate_feed): the game's orders and a state hash every second instead of unit pictures, for clients that run the simulation themselves. Needs --spectator-view full; costs one state hash a second (about 2 ms at 30,000 units) and the order log in memory. |
--spectator-view full or team:N |
full |
Whether spectators see everything or only what team N sees. |
--ws-max-conns <n> |
128 | WebSocket gateway: connections open at once. Separate from --max-conns, so a crowd of watchers cannot use up the places players need. |
--ws-max-handshakes <n> |
32 | WebSocket gateway: connections still sending their HTTP request at once. Past it a new one is turned away at accept with a 503, before it costs a thread. |
--ws-frames-per-sec <n> |
50 | WebSocket gateway: frames of any kind one browser may send per second (a burst of twice that is saved up). Above it the connection is closed (code 1008). |
--ws-keepalive-secs <n> |
45 | WebSocket gateway: after this much quiet a ping goes out; three quiet rounds in a row end the connection. |
--ws-origin <host> |
any | WebSocket gateway, repeat for more than one host: only a browser on a page of this host may watch (scheme and port are ignored). A request with no Origin header, which is not a browser, always passes. On the public server use --ws-origin w4.nebel.sh. |
--ws-listen <port or host:port> |
off | A WebSocket gateway so people can watch in a browser (the website's /spectate page). A bare port means 127.0.0.1. See "Watching in the browser" below. Needs --max-spectators above 0. |
Records and observation
| Flag | Default | What it does |
|---|---|---|
--replay-dir <dir> |
off | Also write finished games as .w4r files into this folder. |
--replay-keep-days <n> |
0 (keep all) | With --replay-dir: once an hour (and at start) delete replay files not written for more than n days. A file with a .star file beside it (touch game-1-900.w4r.gz.star) is never deleted. Only *.w4r and *.w4r.gz are touched. |
--plain-replays |
off | Report replays uncompressed (the default is gzip). |
--replay-keyframe-secs <n> |
0 | Keep a state keyframe in replays every n seconds of game time, so a viewer can seek. 0 keeps none. |
--overlay |
off | Also answer GET /overlay.json on the metrics address: the clock, phase, seats, gold and statistics, updated once a second, for a stream overlay. Needs --metrics-listen. |
--metrics-listen <addr:port> |
off | A small responder for /metrics, /healthz, /readyz and /series. Use a loopback address: nothing on it is protected. |
Watching in the browser
With --ws-listen 7791 the server also accepts WebSocket connections on /watch. A browser sends one hello and then receives the same feed an engine spectator receives: one JSON message per text frame, five pictures a second, delayed by --spectator-delay-secs. Everything else is the normal spectator code: sign-in or guest rules, the --max-spectators limit, at most 4 viewers per address, the observer view.
The gateway speaks plain ws:// and should stay on loopback. Put your proxy in front for wss://; with Caddy, on the site's domain:
handle /watch* {
reverse_proxy 127.0.0.1:7791
}Trying it on your own computer. The Watch a game page takes the gateway's address from the page address: /spectate?ws=ws://localhost:7791/watch. The live website's security policy only lets a page open its own secure socket (wss://w4.nebel.sh), so the override works on a site you run yourself: bun run dev in site/ allows ws://localhost and ws://127.0.0.1, and so does a build made with AQUA4_CSP_LOCAL_WS=1 bun run build (for a local copy only; never deploy it). The normal production build never allows plain ws:// to a local address.
The gateway reads the viewer's address from the last X-Forwarded-For entry, but only when the connection comes from the same computer (the proxy); a stranger cannot choose its address. Without --online only local addresses may watch. A browser may send only the hello and ping; binary frames, fragments and unmasked frames close the connection. Each viewer costs the bandwidth of the feed (about 100 bytes a unit, five times a second), so size --max-spectators for your uplink.
Restarting without ruining a match
Two things protect a running match, and a good server uses both.
Checkpoints (--checkpoint-dir) make a crash survivable: after a restart the match continues from the last checkpoint, up to ten seconds of game time behind, and everybody who was in it joins again with their usual sign-in. The official server does this.
Draining (--drain-file) makes a planned restart polite. While the file exists, the server:
- takes its game off the list,
- refuses everybody who has no seat ("the server is restarting"),
- lets players who hold a seat come back,
- exits when the match is over and nobody is left, or at once if nobody ever played.
If the match is still going after an hour, the server writes a final checkpoint and exits anyway, so the match continues in the new process.
To update a server: build the new program, touch the drain file, wait for the old process to exit, and let the service manager start the new one (a stale drain file must be deleted at start; the example below does it). The file is plain on purpose: it works on Windows and on FreeBSD the same way, with no signals.
Run it as a service
systemd (/etc/systemd/system/w4-dedicated.service):
[Unit]
Description=W4 dedicated server
After=network-online.target
[Service]
User=w4
EnvironmentFile=/etc/w4/dedicated.env # AQUA4_HOST_KEY=...
ExecStartPre=/bin/rm -f /var/lib/w4/dedicated.drain
ExecStart=/opt/w4/w4-dedicated --map /var/lib/w4/maps/battle.w4m \
--listen 0.0.0.0:7777 --address example.org:7777 --online http://127.0.0.1:5179 \
--checkpoint-dir /var/lib/w4/checkpoints --metrics-listen 127.0.0.1:7790 \
--drain-file /var/lib/w4/dedicated.drain
Restart=always
RestartSec=2
LimitNOFILE=4096
[Install]
WantedBy=multi-user.targetFreeBSD (what the official server uses): a small run-dedicated.sh that deletes a stale drain file, creates the checkpoint folder and ends with exec w4-dedicated ... (it reads the host key from a file and passes it with --host-key), started by an rc service made with the spawn tool (daemon(8) restarts it if it exits). The shipped example is server/dedicated/run-dedicated.sh; freebsd-drain.sh touches the drain file and waits for the new process.
Stop the service with SIGTERM (not SIGKILL): it writes a last checkpoint and tells the players.
Metrics, health and the time series
With --metrics-listen 127.0.0.1:7790 the server answers plain GET requests on that address only:
| Path | Answer |
|---|---|
/metrics |
Prometheus text: step time histogram (w4_step_seconds, budget 33.3 ms), steps and overruns, connected clients, bytes in and out, snapshot sizes, queue depth, clients dropped for falling behind. |
/healthz |
ok while the process runs. |
/readyz |
200 once the server listens and the main loop has beaten within 5 seconds, else 503. Use it for a load balancer. |
/series |
JSON, one sample per minute for the last 24 hours: {"interval_s":60,"samples":[{"t","clients","steps","bytes_out","overruns"}]}. |
With the server's host key in the header X-W4-Host-Key (the key configured with --host-key; without a key configured these paths do not exist, a wrong or missing key is 401) the same address also serves the operator's room commands:
| Request | What it does |
|---|---|
GET /admin/rooms |
One object per room: clients, phase, steps, overruns, panics, restarts, last step time, heartbeat age. |
POST /admin/room/<room>/broadcast?text=... |
A chat line from "Server" to everyone in the room. |
POST /admin/room/<room>/kick?name=<display name> |
The player is told the operator removed them and is dropped; the seat stays reserved like after any leave. |
POST /admin/room/<room>/end |
End the running game as a draw (a timeout). |
A command is answered 202 (queued; the room applies it on its next tick), 400 for a bad request, 404 for an unknown room and 429 when 16 commands already wait. Every applied command leaves a log line. Only the rooms of a multi-room server (--rooms) take these. Keep the metrics address on loopback.
curl -X POST -H "X-W4-Host-Key: $KEY" "http://127.0.0.1:7790/admin/room/r1/end"The website's Server status page draws the last day from /series. Run the site on the same computer and set AQUA4_SERIES_URL=http://127.0.0.1:7790/series for it; without the variable the page says the series is not connected. A target worth watching: 99% of steps under 33.3 ms.
The online service has its own GET /v1/metrics, for administrators and for a caller on the same computer, and /v1/healthz and /v1/readyz.
Backups and restore
The server itself keeps nothing that must be backed up except the checkpoint folder (and only while a match is running). Accounts, games and results live in the online service's database, which writes a whole backup every six hours and keeps the newest eight (backups/w4-<time>.db in its data folder).
To restore: stop the service (it drains first), move the live w4.db, w4.db-wal and w4.db-shm aside, copy the backup in as w4.db, start the service, and check /v1/readyz and a sign-in. Never copy a backup over a running database. To rehearse without risk, copy a backup to a scratch folder and start a second service on another port against it.
Crashes
A panic anywhere in the server writes w4-dedicated-crash-<time>.txt (message, thread, backtrace) next to the program. A panic while the game is stepping does not end the process: the game is stopped, its players are told "the game hit a server error and was stopped", and a fresh game starts over (stderr shows PANIC in ...). A panic elsewhere stops the process; the service manager starts it again, and with checkpoints on the match goes on. Delete old crash files by hand.