Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
50 KiB
The dedicated server: reference
halo --server runs the game as a host with no window, sound or player of
its own: other machines join it on the LAN, by its invite link or from the
server browser, as they join any host. port/linux/README.md, "Dedicated
server", says how to run one; this document is the reference for what it
takes and answers: the command line, the exit codes, the commands, the
HTTP API, the remote console (RCON) over WebSocket, the events, the log and
the files. docs/master_protocol.md is the master servers' protocol, which
the server publishes to and the server browser reads.
The code is port/linux/src/server_*.c (the platform side: the mode, the
standard input, the log, the rotation's grammar, the HTTP and WebSocket
server, the master servers' publisher) and port/linux/game/server_*.c
(the game side: the director that plays one game after another, the
commands, the gametypes, the status). The Android and web builds have no
dedicated server: they compile those files to nothing, but for
server_json.c (the JSON reader, which the server browser's list of master
servers uses in every build) and server_rotation.c (the rotation's
grammar and the gametype rules), which every build compiles.
Panels and fleets
A game panel (Pterodactyl, PufferPanel, MCSManager: deploy/) runs one
server and reads its standard output; deploy/systemd and deploy/docker
run servers on one machine. For many servers on many machines, opence-fleet
(github.com/OpenCommunityEdition/opence-fleet)
is one control plane over them all: an agent on each machine runs the
servers and drives each through this document's API, as the owner from
the machine itself (server.web_owner_password, below). What it relies on
is named in the status's and the RCON's capabilities: owner_web (the
owner's role over the web, from loopback), ban_address (sv_ban_address,
POST /api/bans), stop (sv_stop, POST /api/stop, the drain event),
event_addresses (addresses in the player events) and identity (the
status's pid, instance...).
Command line
| Argument | What it does |
|---|---|
--server |
Dedicated server mode (as HALO_SERVER=1 or server.enabled = true) |
--config <path> |
The settings file; default config.toml beside the executable. HALO_CONFIG_FILE, when it is set, wins over --config |
--version |
Prints OpenCE build-<n> (network version <v>) and exits 0 |
--keygen |
Writes a new publisher key (server.publisher_key_file) unless one exists, prints its public key and key id, exits 0; exits 3 when the key cannot be made, read or written |
--sign <text> |
Prints the publisher key's Ed25519 signature of the text, in hex (an application's proof of key), exits 0; exits 3 without a text or a usable key |
--probe [<invite>] |
A look at a server, for a list site that shows its state: joins nothing and never starts a server (whatever --server, HALO_SERVER or server.enabled say). It finds the invite's game over its tunnel (without an invite, the LAN's first) and prints it as one line of JSON, the game of the invite's host alone (never another game that answers), and exits 0; when nothing answers in 30 seconds it prints {"ok":false,"error":"no game answered in 30 seconds"} and exits 1. Internet play is as set (network.online, on by default), which an invite needs |
--help |
Prints these, exits 0 |
--version, --help, --keygen, --sign and --probe write no settings: the
settings file is read, never completed or made. --probe wins over
--server, HALO_SERVER and server.enabled: a probe never writes to
config.toml.
A probe's line:
{"ok":true,"name":"OpenCE Server","map":"bloodgulch","gametype":"ctf","players":5,"max_players":16,
"teams":true,"in_progress":true,"open":true,"dedicated":true,"network_version":38,"kind":0}
gametype is the game engine's name (ctf, slayer, oddball, king,
race, or none), kind the OpenCE gametype's kind the host sends (0 for
none), open whether it takes players now.
The release has a halo-server script beside halo, which runs
halo --server with its arguments.
Exit codes and signals
| Code | Meaning |
|---|---|
| 0 | Stopped: quit, sv_stop (now, or at the end of its drain), SIGTERM or SIGINT |
| 1 | A fatal error in the game (a halt, a map that failed to load); the last error lines were logged. The process exits within 5 seconds of the error, even when what runs at the exit (a hook, a listing's withdrawal) does not return |
| 2 | No usable game data: maps/ui.map, or every rotation map, missing or not a cache file; nothing was bound |
| 3 | Bad server configuration: no rotation entry that can be played (and server.rotation_fallback off), server.web_port past 65535, a web port the server may not bind (a port below 1024 without the right to it) or whose server.web_bind is an address the machine does not have, or a web password (admin, moderator or viewer) shorter than 8 characters with the web port bound beyond loopback (on loopback, a warning), or the owner's (server.web_owner_password) shorter than 8 wherever it is bound; --keygen or --sign failing. An entry that cannot be played (a bad gametype file among them) is left out with a warning: the server stops only when none is left |
| 4 | A port in use: the tunnel's UDP port, or the web port (another program listens on it) |
| 5 | Restart asked for (sv_restart, now or at the end of its drain, or an update installed: server.auto_update = "install" installs a new release once the server has been empty for 10 minutes, on a thread of its own: the server goes on meanwhile, and stops with 5 once it is empty again (a player who joined during the install plays on). An install that fails is tried again at the next check, 6 hours on) |
| 128 + n | Stopped by signal n: a second SIGTERM or SIGINT, or the shutdown not done 5 seconds after the first |
SIGTERM and SIGINT stop the server: a game in progress goes to its
postgame, every machine is told, the listings are withdrawn, and it exits 0.
SIGHUP reloads the server's settings, the rotation and the gametype files
(sv_reload). SIGUSR1 prints the status on standard output. SIGPIPE and
SIGTTIN are ignored.
Standard output
One line per event, for the operator and the panel:
2026-10-19T18:03:11Z INFO server: ready (tunnel 2302/udp, web 127.0.0.1:8080, listing public)
2026-10-19T18:03:40Z INFO player: Milenko joined (slot 0, machine 1)
2026-10-19T18:04:02Z INFO game: started: CTF on bloodgulch, 2 players
The fields are the time (UTC), the level (INFO, WARN, ERROR), the
category (server, lobby, game, player, chat, moderation, net,
master, console, health) and the text. server: ready is printed once,
when the lobby is open and, for a public server, its listing has been
published (or 10 seconds on, as the brokers are other people's): it is the
line a panel waits for. With server.log_format = "json" each line is an
object instead, and the line a panel waits for holds "msg":"ready (:
{"time":"2026-10-19T18:03:11Z","level":"info","cat":"server","msg":"ready (tunnel 2302/udp, ...)"}
Every line is also kept in the log the web's viewers read (/api/log,
sv_log, the RCON's log events), but for these, which go to standard
output alone: the web password a first start makes, the invite (a
private server's lets anyone in; /api/status gives it to admins), and
the owner's console, the commands typed on standard input or read from the
init file and what they answered (a password's command is shown as (a password's command) there too).
A health line comes once a minute: health: players=3 frame_ms_max=4.1 frame_ms_avg=1.20 cpu=2.1% rss=96MB (the longest and the mean frame of
the last minute, in milliseconds). Standard error keeps the platform
layer's halo-linux: lines, and debug.txt everything, as for a player's
game.
Commands
The same commands come from standard input, server.init_file (run once at
the start, a command a line, #, ; and // starting comments), the telnet
console (debug.telnet_console, loopback), the RCON, POST /api/command
and the web page. Each runs on the game's thread between frames. Every
command has a role; a caller may run it if their role is that one or above:
viewer < moderator < admin < owner (standard input, the
init file and the telnet console are the owner's: whoever holds the
process; so is a sign-in to the web or the RCON with
server.web_owner_password from the machine itself: "Signing in"). When
standard input ends and server.console_exit_on_eof is on, its last line
runs, whether or not a line's end ended it, and then the server stops.
A <player> is a slot number (sv_players lists them) or the start of a
name; a name that begins more than one player's is refused. A <duration>
is a number and a unit (45s, 30m, 2h, 7d, 1w), a bare number of
minutes, or forever; ten years at most. A word in quotes ("two words")
is one word.
A command's text (a notice, a reason, sv_set's value, a name) is kept to
printable ASCII: control characters (a line's end, a tab) and other bytes
outside it are dropped, as are the quotes around the whole text, and
standard input's and the init file's lines are cleaned so before they run.
The web is stricter where it takes a value as it is: PUT /api/settings
refuses a value with a control character (schema), and nothing is changed
for that setting. A command's reply is sent whole, however many lines.
Commands that are not the server's (the game's own console commands, the
scripts' globals and functions) need the owner role: from the web or the
RCON they are refused (not a command of the server's) but to the owner's
session. The server's
commands come first, so kick and ban are always the server's below, not
the game's console commands of those names.
| Command | Role | What it does |
|---|---|---|
help [command] |
viewer | The commands, or one command's use |
sv_status [--json] |
viewer | The server's state, map, gametype, players, uptime, games played, network version, listing and next game |
sv_players [--json] |
viewer | Each player's slot, name, team, score, kills, deaths, assists, ping and machine; with the owner role or server.log_addresses, their address and hardware id |
sv_rotation (sv_mapcycle), sv_maps, sv_gametypes, sv_version, sv_log [n] |
viewer | Lists: the rotation, the maps there are, the gametypes, the build, the log's last n lines |
sv_bans (sv_banlist), sv_settings |
admin | Lists: the bans of bans.txt, the settings sv_set changes |
sv_masters, sv_publisher |
viewer | The master servers and how each answered; the publisher key's id and public key (never its seed) |
say <text> (sv_say) |
moderator | A notice on every machine's screen |
tell <player> <text> |
moderator | A notice on one player's machine |
kick <player> (sv_kick) |
moderator | Drops the player's machine; it may join again |
ban <player> [<duration>] [<reason>] (sv_ban) |
moderator (a day at most), admin | Drops the player's machine and bans its address and hardware id, for the duration (else for ever). A moderator must give a duration of a day or less: a ban for ever, or longer, is refused |
unban <number|address|hardware id> (sv_unban) |
admin | Takes a ban out of bans.txt; the machine may join again at once, by the LAN or the internet |
sv_ban_address <address|hardware id> [<duration>] [<reason>] |
admin | Bans an IPv4 address (a.b.c.d) or a hardware id (up to 32 hex digits), its machine here or not: its line in bans.txt as ban writes it (the other part none), so that sv_bans, unban and GET /api/bans see it, and a machine present of that address or hardware id is dropped as ban drops it (player_left's reason banned). Answers ban <n> added (n: its number in sv_bans) |
mute <player> [<minutes>], unmute <player> |
moderator | The host's mute of a player's chat and voice for everyone |
sv_map <map> [<gametype>], sv_map <entry> |
moderator | Plays that game (or a rotation entry, map:gametype+rule=value; a co-op one too), below |
sv_map_next (sv_next) |
moderator | Ends the game without its scores and plays the rotation's next |
sv_end_game |
moderator | Ends the game: its scores, then the next |
sv_rotation_set <entries>, sv_rotation_add <entry>, sv_rotation_del <n>, sv_rotation_begin [<n>] (sv_mapcycle_add, _del, _begin) |
admin | Changes the rotation (written to server.rotation, 4096 characters at most) |
sv_set <setting> <value> |
admin | Changes one of the server's settings, now where it can be, and in config.toml (below); a setting its environment variable sets is refused. A value is 256 characters at most |
sv_name <name>, sv_maxplayers <n>, sv_password [<password>], sv_public <0|1>, sv_motd <text> |
admin | sv_set of server.name, server.max_players, server.password, server.public, server.motd |
sv_reload |
admin | Reads config.toml's server settings, the rotation and the gametype files again (as SIGHUP) |
sv_stop [now|game|empty] [<notice>], sv_stop cancel |
owner | Stops the server (exit code 0): now (the default) as quit does; game once the game being played is over, empty once nobody plays (a drain, below); cancel the drain waiting. The notice is said to every machine first |
sv_restart [now|game|empty] [<notice>], sv_restart cancel |
owner | As sv_stop, with exit code 5 (restart me) |
quit (exit) |
owner | Stops the server (exit code 0) |
sv_map's game is played as soon as it can be, and its answer says when:
next: <entry> (now) in the lobby before its start was sent (the lobby
takes the entry, and the rotation's entry it replaced is played after it),
(now: this game ends) while a game is played (it ends at once, without
its scores), and (once this game is over) once the lobby's start was sent
or while a map loads (it is the next game). sv_map of the game being
played plays it again. The rotation goes on after it.
A drain (sv_stop or sv_restart with game or empty, POST /api/stop) is a stop that waits: the listings are withdrawn as sv_public 0 withdraws them (a new invite, the masters told), without the setting
changing; new machines are refused as while a map loads (the game is not
open: the LAN's search shows it closed, a join by the invite is told the
game is not open); then the server stops. With game it stops once no
game is played nor about to be: at once in the lobby with nobody in it, or
with no countdown running (fewer players than server.min_players);
otherwise once the game played (or about to start) is over and its
postgame shown. With empty it stops once nobody is on the server (at once
if nobody is now). sv_stop cancel (or sv_restart cancel) ends the
drain: the server is listed again as server.public says and takes new
players. A drain event says each (after: now, game, empty or
cancel), and the stop that ends a drain has the shutdown reason
drained. /api/status's drain is the drain waiting, or null.
A co-op entry (<level>:coop) is ended as a game is: by sv_end_game,
sv_map_next, sv_map, server.empty_seconds and server.idle_minutes.
It has no scores: the server goes back to the lobby at once (no postgame),
and a level won ends it with game_ended's reason score.
sv_set (and the settings of the web page, PUT /api/settings) changes
these settings, which sv_settings and GET /api/settings list:
server.name, server.max_players, server.min_players,
server.password, server.public, server.rotation_fallback,
server.lobby_seconds, server.postgame_seconds, server.empty_seconds,
server.idle_minutes, server.team_start, server.motd,
server.frame_rate, server.web_password,
server.web_moderator_password, server.log_format,
server.log_addresses, server.status_file, server.auto_update,
server.coop_difficulty, server.region, server.master_publish,
server.master_publish_address, server.metrics, and network.text_chat,
network.voice_lobby, network.voice_mode, network.voice_kbps,
network.votekick, network.votekick_minutes,
network.votekick_ban_minutes, network.map_share,
network.map_share_upload, network.public_address,
network.coop_friendly_fire, network.coop_player_collisions,
network.coop_ally_friendly_fire and network.shield_effects. Of them,
server.status_file, server.auto_update and server.log_addresses need
the owner role: from the web's admin, sv_set of one of them (POST /api/command) answers 403 forbidden, and PUT /api/settings leaves it
out, in its errors with the code forbidden and the message <setting> needs the owner role (the server's console); the owner's session changes
them both ways. A web password is empty or
8 characters or more. A value outside a setting's choices or range is
refused (400 failed from sv_set, schema in PUT /api/settings's
errors), with <setting> takes a whole number from <min> to <max> (or
from <min>) or <setting> is one of <a>|<b>|<c>; a choice may be given
in either case, and is kept as the table spells it:
| Setting | Choices or range |
|---|---|
server.max_players |
2 to 127 |
server.min_players |
1 to 16 |
server.lobby_seconds, server.postgame_seconds |
0 to 300 |
server.empty_seconds, server.idle_minutes |
0 or more |
server.frame_rate |
30 to 120 |
server.team_start |
one, both, skip |
server.log_format |
text, json |
server.auto_update |
off, check, install |
server.coop_difficulty |
easy, normal, hard, impossible |
network.text_chat |
on, links, off |
network.voice_mode |
off, team_proximity, team_enemy_proximity, team_global, team_global_enemy_proximity |
network.voice_kbps |
8 to 64 |
network.votekick_minutes |
0 to 60 |
network.votekick_ban_minutes |
1 to 1440 |
network.map_share_upload |
16 to 20480 |
network.coop_friendly_fire |
off, on, shields_only, explosives_only |
network.shield_effects |
stock, glow, light_show, overshield, glow_overshield, light_show_overshield, players |
A dedicated server's text chat is its network.text_chat's, listed or
not: on by default (lines with links refused), links, or off, live
(sv_set network.text_chat off). A game a player hosts has no text chat
while it is listed in the server browser; a dedicated server is the
exception, and the players who join it from the browser's dedicated servers
have the chat as it says, as long as a master server lists it (the
masters' word that it is an approved operator's; a listing's own flag is
not enough, so a dedicated server listed by the brokers alone, before its
operator is approved, has none for them).
The settings sv_set does not change (server.web_viewer_password,
server.web_owner_password, server.web_port, server.web_bind, server.web_trusted_proxy, the
paths, the master servers' files...) are changed in config.toml or their
environment variables: sv_reload reads the file's server.* settings
again, and the web port and its address are bound once, at the start. The
rotation is changed by the sv_rotation_* commands.
HTTP
The server listens on server.web_port (8080; 0 for none) at
server.web_bind (127.0.0.1, an IPv4 address). Bound beyond loopback,
every web password that is set (admin, moderator, viewer) must have 8
characters or more, else the server exits with code 3; the owner's
(server.web_owner_password) must have 8 or more wherever the port is
bound. The page (/), the API
(/api/...), the RCON (/ws), /healthz and /metrics share the port.
Requests are HTTP/1.1 (or 1.0), GET, HEAD, POST, PUT or DELETE
(HEAD is answered as GET is, without the body, and counts as a read,
as a GET; /api/login takes POST alone), a head of 8 KB at most whose
lines end with CRLF (a bare LF, a CR inside a line, or more after the empty
line that ends it: 400 bad_request) and a body of 64 KB at most, sent within 10 seconds of the
connection's opening or of its last answer (a connection that sends
nothing is closed then too). Transfer-Encoding and Expect are refused.
24 connections are taken at once; when they are all taken, the oldest that
waits for a request (no RCON, no request being answered) is closed for a
new one. A client that does not read its answers is read no further once
256 KB of them wait, and is closed past 2 MB.
Every request but /healthz counts against its address's allowance, the
page's files, /metrics, a sign-in and the RCON's upgrades (/ws) too: 60
a minute, or 600 with a session (429 rate_limited past it, with
Retry-After: 60). Behind server.web_trusted_proxy, a request's address
is the last entry of its X-Forwarded-For (the one the proxy appended: an
IPv4 address, a.b.c.d or a.b.c.d:port, or an IPv6 one, v6, [v6] or
[v6]:port), and its X-Forwarded-Host and X-Forwarded-Proto are
believed, whether or not it sends X-Forwarded-For; from any other
address they are ignored. IPv6 clients are counted by their /64: one
host's addresses share its allowance and its lockouts. Every answer is JSON
(Content-Type: application/json) unless said, with
Cache-Control: no-store, X-Content-Type-Options: nosniff,
Referrer-Policy: same-origin (under no-referrer a browser would send
the page's own changes with Origin: null) and
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'.
Signing in
POST /api/login with {"password":"…"} answers
{"ok":true,"token":"<64 hex>","role":"admin","csrf":"<32 hex>","expires":<unix seconds>}
and sets the cookie opence_session=<token>; HttpOnly; SameSite=Strict; Path=/, with Secure when the request came through
server.web_trusted_proxy with X-Forwarded-Proto: https (never from
another address, whatever it says). The admin password
(server.web_password) gives the admin role, the moderator password
(server.web_moderator_password) the moderator role, the viewer password
(server.web_viewer_password) the viewer role; {"by":"…"} beside the
password names who signed in, in the log. A session lasts 12 hours, or 30
minutes without a request (the RCON's messages do not count: a page left
open asks GET /api/session now and then); 16 are kept at most, of every
role.
The owner's password (server.web_owner_password, empty by default: none)
gives the owner role, for a supervisor on the server's own machine (a
fleet's agent, a panel's daemon): what standard input may do, over the
API and the RCON (quit, sv_stop, sv_restart, POST /api/stop, the
owner's settings by sv_set and PUT /api/settings, the game's own
console commands by POST /api/command, the players' addresses and
hardware ids in /api/players and the events). It signs in only when the
connection's own peer address is in 127.0.0.0/8 and is not the trusted
proxy's: never by X-Forwarded-For, and never through
server.web_trusted_proxy, even one on the machine itself. From any other
address the right owner's password is refused with 403 forbidden (the
RCON's auth: {"code":"forbidden"}, and the connection closes 1008),
and counts toward the address's lockout as a wrong password does. An
owner's session is used from the machine itself alone, as it was begun
(from elsewhere its token is no session: 401, the RCON's auth with it
denied). A password that is also another role's gives the owner role
from the machine itself and the other role from elsewhere: give the owner
a password of its own.
A script passes Authorization: Bearer <token>. The page passes the
cookie, and on every request that changes something the header
X-CSRF-Token: <csrf>; the Origin of such a request (and of the
WebSocket's) must be the server's own. An Origin: null is taken only
when the browser's Sec-Fetch-Site says same-origin.
POST /api/logout ends the session (with the X-CSRF-Token by the
cookie; GET answers 405). GET /api/session answers
{"ok":true,"role":…,"csrf":…,"expires":…}, or 401.
When server.web_password, server.web_moderator_password,
server.web_viewer_password or server.web_owner_password changes
(sv_set and the settings change the first two; sv_reload reads any of
them from config.toml), the sessions of its role end and that role's RCON
connections close (1008, the password changed).
Five wrong passwords from an address (an IPv6 one's /64) in 10 minutes
lock it out for 10 minutes (429), and no more than 30 passwords a minute
are checked in all. /api/login takes POST alone (any other method:
405).
Errors
{"ok":false,"error":{"code":"forbidden","message":"needs the admin role"}}
| Status | Codes |
|---|---|
| 400 | bad_request, schema (a field missing or out of range), failed (the command ran and failed: its lines in lines), rejected (rotation entries: rejected[]), invalid (a gametype file: line) |
| 401 | denied (no session, or a wrong password) |
| 403 | forbidden (the role; the owner's password from beyond the machine), csrf, origin |
| 404 | not_found (also a command that is not one, a player or a ban that is not there) |
| 405 | method |
| 409 | pinned (a setting its environment variable sets), changed (a player's action whose slot holds another player now: the current name in name, nothing done) |
| 413 | too_large |
| 426 | bad_request (the WebSocket's version is not 13) |
| 429 | rate_limited, locked (with Retry-After) |
| 500 | internal (out of memory, a file that cannot be written) |
| 503 | loading (the game did not answer in 20 seconds, as while a map loads; with Retry-After: 5), starting, busy (8 RCON connections already; with Retry-After: 5) |
Endpoints
| Method, path | Role | Body → answer |
|---|---|---|
GET /healthz |
none | 200 {"state":"lobby"} (or loading, playing, postgame) while the server hosts and its frames run; 503 with {"state":"starting"}, {"state":"stalled"} (no frame for a minute) or the other state otherwise |
GET /metrics |
none (server.metrics) |
Prometheus text |
GET /api/status |
viewer | The status (below) |
GET /api/players |
viewer | {"ok":true,"players":[<player>…]} (with address and hwid for the owner, or every role with server.log_addresses) |
POST /api/players/{slot}/kick |
moderator | {"name":"<the player's name, checked>"} → {"ok":true,"lines":[…]}, or 409 changed when the slot holds another player |
POST /api/players/{slot}/ban |
moderator | {"name":…,"duration":"1d","reason":"…"} (no duration: for ever, which a moderator's ban cannot be: 400 failed) |
POST /api/players/{slot}/mute |
moderator | {"name":…,"minutes":10} |
POST /api/players/{slot}/unmute |
moderator | {"name":…} |
POST /api/players/{slot}/tell |
moderator | {"name":…,"text":"…"} |
POST /api/say |
moderator | {"text":"…"} |
GET /api/rotation |
viewer | {"ok":true,"index":0,"entries":[<entry>…],"next":<entry>} |
PUT /api/rotation |
admin | {"entries":["bloodgulch:ctf",…]} → the rotation, or 400 rejected with "error":{…,"rejected":[{"index":1,"entry":"…","reason":"…"}]} and nothing changed |
POST /api/rotation/next |
moderator | {}: sv_map_next |
POST /api/rotation/play |
moderator | {"map":"…","gametype":"…"}: sv_map (a map or gametype that cannot be played: 400 failed) |
GET /api/gametypes |
viewer | {"ok":true,"builtin":["slayer",…],"schema":{"fields":[<field>…]},"files":[{"name":…,"base":…,"description":…,"error":null}]} (builtin: the Xbox's gametypes, then OpenCE's; a file that cannot be read has base and description empty and error saying why) |
GET /api/gametypes/{name} |
viewer | {"ok":true,"name":…,"text":"<the TOML>"} |
PUT /api/gametypes/{name} |
admin | {"text":"<TOML>"} → {"ok":true}, or 400 invalid with line and message (a name is a–z, 0–9, _ and -, 32 at most) |
DELETE /api/gametypes/{name} |
admin | {"ok":true} |
GET /api/maps |
viewer | {"ok":true,"maps":[{"name":"bloodgulch","level":"levels\\test\\bloodgulch\\bloodgulch","kind":"xbox","title":"Blood Gulch"}…]} (kind: xbox or custom; title is the map's display name, else its name) |
GET /api/bans |
admin | {"ok":true,"bans":[{"n":0,"when":"2026-10-19 18:00:00","ip":"…","hwid":"…","players":"…","reason":"…","until":<unix>|null}…]} |
POST /api/bans |
admin | {"ip":"203.0.113.5","hwid":"…","names":"…","duration":"1d","reason":"…"} (ip or hwid, or both; the rest may be left out: no duration is for ever) → {"ok":true,"n":<the new ban's number in GET /api/bans>}: sv_ban_address, a machine present of the address or the hardware id dropped; 400 schema for an address that is not IPv4, a hardware id that is not hex, a duration that is not one |
DELETE /api/bans/{n} |
admin | {"ok":true} |
GET /api/settings |
admin | {"ok":true,"settings":[<setting>…]} |
PUT /api/settings |
admin | {"server.name":"…","server.max_players":16,"server.public":true,…} (a value as JSON, a boolean, a number or a string, or its text: "true", "16"; null empties it) → {"ok":true,"applied":["server.name"],"errors":[{"name":…,"code":…,"message":…}]}; each setting is changed or refused on its own |
POST /api/command |
the command's | {"line":"sv_status"} (511 characters at most) → {"ok":true,"lines":["…"]}, or a refusal (failed, forbidden, not_found, pinned) with the command's lines |
POST /api/stop |
owner | {"after":"now|game|empty","notice":"…","restart":false} (notice and restart may be left out) or {"cancel":true} → {"ok":true,"lines":["…"]}: sv_stop (sv_restart with restart) after notice, or sv_stop cancel (400 failed with no drain waiting); 400 schema for another after |
GET /api/log?after=<n>&limit=<m> |
viewer | {"ok":true,"lines":[{"n":12,"time":<ms>,"level":"info","cat":"game","text":"…"}…],"next":12,"missed":0} (limit 1 to 1024, 256 by default; the chat's lines for admins only) |
GET /api/events?after=<seq> |
viewer | {"ok":true,"events":[<event>…],"next":<seq>,"missed":0} (player_joined and player_left with address and hwid for the owner, or every role with server.log_addresses) |
GET /ws |
The RCON (below) | |
GET /, /app.js, /app.css, /icon.svg |
none | The page (server.web_ui) |
next is the after of the next request: the last line's n (or event's
seq) read. An after past the latest (as after a restart) reads from the
start. missed counts lines or events that left the ring (2048 lines, 512
events) before they were asked for.
The status
{"ok":true,"time":1760900000123,"name":"OpenCE Server","state":"playing",
"build":"build-206","network_version":38,"uptime":3600,"games_hosted":4,
"map":"bloodgulch","level":"levels\\test\\bloodgulch\\bloodgulch","gametype":"ctf","variant":"CTF","teams":true,
"players":5,"max_players":16,"machines":5,"countdown":-1,"game_seconds":312,"team_scores":[2,1],
"listing":{"online":true,"public":true,"password":false,"invite":"halo://join/…"},
"rotation":{"index":0,"count":3,"next":{"text":"sidewinder:team_slayer","map":"sidewinder","gametype":"team_slayer"}},
"frame_ms_max":4.1,"frame_ms_avg":1.2,"tick_ms_avg":1.2,
"masters":[{"url":"https://master1.example.org","listed":true,"probe":"ok","code":"","message":"","updated":1760899990}],
"update":{"available":false,"build":""},
"drain":null,
"pid":4021,"started":1760896400,"instance":"eu-1","region":"eu","tunnel_port":2302,
"publisher_key_id":"3f9a2c7d1e5b8a04","web_port":8080,
"capabilities":["events","status","rotation","settings","bans","owner_web","ban_address","stop","event_addresses","identity"]}
state is waiting (the menus settling), hosting (making the game),
lobby, loading, playing, postgame or stopping. countdown is the
lobby's seconds left, -1 when it is not counting. team_scores is null
in a game without teams. frame_ms_max and frame_ms_avg are the longest
and the mean frame of the last minute (tick_ms_avg is the mean again, for
older readers). invite is given to admins only (empty for others). An entry of the rotation is
{"text":"sidewinder:team_slayer+score=50","map":"sidewinder","gametype":"team_slayer","rules":{"score":50},"rejected":null}
(rejected is why it cannot be played, else null). drain is the
drain waiting (sv_stop game...), {"after":"game","restart":false,"since":<unix seconds>}, else null. pid is the server's process, started when it
started (seconds since 1970), instance the name the master servers know
it by (server.instance, else the slug of server.name they are given),
region server.region, tunnel_port the tunnel's UDP port
(network.tunnel_port; 0 when network.online is off), publisher_key_id
the publisher key's id (16 hex digits; empty without a key: sv_publisher)
and web_port server.web_port. capabilities is the RCON's hello's. The rotation's
index (in the status and GET /api/rotation) is the entry being played,
-1 while an sv_map game is played or before the first lobby; next is
the entry after the one played last (the first while none has been).
A player is
{"slot":0,"name":"Milenko","team":0,"score":3,"kills":3,"deaths":1,"assists":0,"ping":42,"machine":1,"joined":1760899000,"muted":false};
with the owner role, or server.log_addresses, also
"address":"203.0.113.5","hwid":"…". team is 0 (red), 1 (blue) or -1
(no teams); ping is in milliseconds, null when it is not known yet.
A setting is
{"name":"server.name","type":"string","value":"OpenCE Server","default":"OpenCE Server","comment":"…","env":"HALO_SERVER_NAME","pinned":false,"secret":false,"choices":null,"min":null,"max":null};
type is boolean, integer, real or string, and value and
default are of that type in JSON (true, 16, "OpenCE Server"); a
reader should also take their text ("true", "16"), which older servers
sent. choices lists a word setting's words, min and max bound a
number's, each null where there is none (the table above). env is
null for a setting no environment variable sets. A secret one (the
passwords) has value and default empty and "set":true when it is
set. pinned is a setting
its environment variable (env) sets: it cannot be changed.
A gametype schema field is
{"key":"score_to_win","alias":"score","table":"universal","engine":"all","type":"integer","min":0,"max":999,"choices":null,"default":null,"description":"…"}
(table: the gametype file's table it goes in, universal, options,
ctf, slayer, oddball, king or race; engine: all, or the game
engine whose table it is; type: integer, boolean or choice).
The RCON
GET /ws with Upgrade: websocket and the subprotocol opence-rcon-1.
Every frame is text holding one JSON object. The server speaks first:
→ {"type":"hello","protocol":1,"server":"OpenCE Server","build":"build-206","network_version":38,
"capabilities":["events","status","rotation","settings","bans","owner_web","ban_address","stop","event_addresses","identity"]}
← {"type":"auth","password":"…","by":"paul"} or {"type":"auth","token":"<session token>"}
→ {"type":"auth","ok":true,"role":"admin"}
→ {"type":"auth","ok":false,"error":{"code":"denied"}} and the connection closes (1008)
← {"type":"command","id":1,"line":"kick Milenko"} or {"type":"command","id":1,"command":"say","args":["two words"]}
→ {"type":"reply","id":1,"ok":true,"lines":["Milenko kicked by the host"]}
→ {"type":"reply","id":2,"ok":false,"lines":["…why…"]} (the command ran and failed)
→ {"type":"reply","id":3,"ok":false,"lines":[],"error":{"code":"busy","message":"…"}} (not run)
← {"type":"subscribe","events":["player","chat","game","moderation","status","log","master"],"after":1830}
→ {"type":"event","seq":1831,"time":1760900000123,"event":"player_joined","slot":3,"name":"Milenko",…,"replay":true}
→ {"type":"event","seq":1831,"time":…,"event":"status","status":{…the status…}} (at the subscribe, then every 2 s)
→ {"type":"event","time":…,"event":"log","n":5120,"level":"info","cat":"game","text":"…"} (no seq: n is its sequence)
← {"type":"ping"}
→ {"type":"pong"}
→ {"type":"overflow"} and the connection closes (1013): the client read too slowly
→ {"type":"error","id":…,"error":{"code":"…","message":"…"}} (an unknown type, a malformed message)
A connection signed in with the page's cookie (on the same origin) needs no
auth message: its hello is followed by {"type":"auth","ok":true,…}.
The capabilities say what the server has beyond the first release's
(events, status, rotation, settings, bans): owner_web,
ban_address, stop, event_addresses and identity ("Panels and
fleets"); a client checks for one before it uses it. The owner's password
signs in from the server's own machine alone ("Signing in").
A command is a line, or a command and its args, which the server
joins with spaces, quoting an argument that holds a space or is empty (511
characters in all at most). Ids are the client's (a number or a string),
echoed in the reply; each command gets exactly one reply. A connection
runs one command at a time: a command sent before the last one's reply is
not run. A reply whose command ran has ok and the command's lines, and
no error, whether it succeeded or failed; one that was not run has ok
false, no lines and an error: denied (not signed in), rate_limited
(32 commands a second, in bursts of 64), bad_request (no command, or too
long), busy (another command's reply is awaited) or loading (the game
did not answer in 20 seconds; the command may still have run, and is never
sent again by the server). after replays the events still in the ring
since that sequence number, marked "replay":true. A connection holds 1024
messages for the client at most; 8 connections are taken at once (a ninth
upgrade answers 503 busy), and each upgrade counts against its address's
allowance (above). The
event groups are player (player_joined, player_left, map_share), chat
(chat), game (lobby_opened, countdown, game_started,
game_ended, postgame, score, kill), moderation (kick, ban,
unban, mute, unmute, speed_hack), status (status), log
(log, each log line as {"n","level","cat","text"}), master
(master), and server (server_started, reloaded, warning,
drain, shutdown), which every subscriber gets.
The log's lines of the chat category go to the chat group, the others
to log.
status and log events go to their subscribers as they come and are
not kept in the ring: after does not replay them (/api/log reads the
log's own ring). A status event's seq is the latest event's (no new
number: the same one comes again until another event), and a log event
has no seq: its n, the log line's number and /api/log's cursor, is
its sequence. A client keeps one cursor for the events (seq, of the
events of the ring) and another for the log's lines (n); a client that
reconnects with after may get events it had, and drops those whose seq
it has seen (a status event is never dropped so).
Events
Every event has seq (rising, never reused while the server runs; a
status event has the latest one's, a log event none: above), time
(milliseconds since the epoch) and event:
| Event | Fields |
|---|---|
server_started |
name, build, network_version, pid, started, instance, region, tunnel_port, publisher_key_id, web_port (as the status's), rotation[] (the entries' texts) |
lobby_opened |
map, gametype, variant (the gametype's name as the game shows it), teams, index (the rotation's entry) |
countdown |
seconds (-1: paused) |
game_started |
map, gametype, variant, teams, team_scores[] (team games), players[] |
game_ended |
map, gametype, variant, teams, reason (score, time, idle, empty, command, next), duration (seconds), team_scores[] (team games), players[] |
postgame |
seconds |
player_joined |
slot, name, team, machine; address, hwid for the owner, or every role with server.log_addresses |
player_left |
slot, name, reason (left; kicked and banned when the server's kick, ban or sv_ban_address dropped the machine, banned for a speed hack's drop too); address, hwid (as it joined) as player_joined's |
chat |
channel (everyone, team, vehicle), slot, name, text (admins only; the lines the host sends on) |
kill |
killer, victim (slots; -1 for none), weapon (the killer's weapon's or the killing object's tag name, or null), friendly_fire |
score |
map, gametype, variant, teams, team_scores[] (team games), players[] (once a second at most, on a change) |
kick, ban, unban, mute, unmute |
name, by, reason?, until? |
speed_hack |
machine, names, rate, ahead_ticks, action (kicked, or unverified: its datagrams alone said so, and it stays) |
map_share |
machine, map, state (asked, downloading at each tenth, downloaded or failed once for each request), progress (0 to 100: a percentage already) |
warning |
text (a newer release, with server.auto_update) |
reloaded |
rotation_changed, rejected[] ({"entry","reason"} of each entry left out) |
master |
url, listed, probe, code, message |
drain |
after (now, game, empty, cancel), restart (exit code 5), notice (empty for none) |
shutdown |
reason (quit, sv_stop, sv_restart, drained: a drain's end, signal <n>, update installed, nothing to play, debug.exit_after), code (the exit code) |
Players in events are as in /api/players for the viewer role (no
addresses), but for player_joined and player_left, which carry the
player's address and hardware id ("" when not known) to the owner, and to
every role when server.log_addresses is on (/api/players' rule), in the
RCON's events, their replay and GET /api/events alike. variant is the gametype's name as the game shows it (Co-op
in a co-op entry).
Bans
A ban is a line of bans.txt (in paths.logs, else the data root). A
timed ban is kept out by its line's until= alone, and ends then. A ban
for ever, and a machine dropped for cheating, also keep the machine out
for the rest of the run by its address, its real address or its hardware
id. sv_ban_address and POST /api/bans write such a line for an address
or a hardware id with no player of it needed (the part not given is
none). unban takes the line out and readmits the machine by
the line's address or hardware id, whether it joins on the LAN or by
internet play.
The rotation
server.rotation (and sv_rotation_set, PUT /api/rotation) is a list of
entries, map[:gametype[+rule=value...]], separated by commas, semicolons
or new lines; a line that begins with # is a comment (to its line's end,
commas and all). The text is 4096 characters at most (the entries past it
are left out, with a warning), an entry 255 characters and the list 64
entries. config.toml keeps a string setting of up to 4096 characters, so
a rotation that long is saved whole and read back after a restart.
- The map is a multiplayer map's name (
bloodgulch: the Xbox'smaps/<name>.map, else a Custom Edition map ofcustom_maps),custom_maps\<name>(a Custom Edition map;/is taken for\), or a level's path (levels\test\bloodgulch\bloodgulch). A campaign level is played as co-op only:<level>:coop, atserver.coop_difficulty. - The gametype (
slayerwhen there is none) is a built-in's name (slayer,team_slayer,slayer_pro,elimination,phantoms,endurance,rockets,snipers,stalker,ctf,ironctf,ctf_pro,invasion,oddball,team_oddball,reverse_tag,accumulation,juggernaut,king,team_king,king_pro,crazy_king,race,team_race,rally,team_rally, and Halo PC's namesffa,ffa_slayer,teamslayer,tslayer,koth,team_koth,iron_ctf,teamrace,teamoddball), an OpenCE gametype's (swat,infection,gun_game,hardcore,hardcore_ctf,hardcore_oddball,hardcore_king), a gametype file's name (gametypes/<name>.toml), orfile=<name>(a gametype a player's game saved, below). A gametype's name is letters, digits,_,-and., in any case. - A rule is a gametype rule's name or its short name (
score,time,weapons,ff...;GET /api/gametypes's schema lists them), with a value it takes; a rule of another game engine than the gametype's is refused. 48 rules at most; a rule given twice is the last.
An entry that cannot be played (a map that is not there, a bad gametype
file, a rule out of range) is left out with a warning, at the start and at
sv_reload; sv_rotation_set and PUT /api/rotation change nothing when
one of their entries cannot be played.
A change of the rotation (sv_rotation_*, PUT /api/rotation,
sv_reload) keeps its place: the next game is the entry after the one
played last, found again in the new list (the first entry only when none
has been played yet). sv_reload takes the rotation again when its text
changed, and also when only its entries' rejections changed (a map or a
gametype file added or taken out since).
server.team_start decides how a team game starts. one (the default)
starts it with the players of one team. both waits, saying nothing,
for a player on each team. skip, when one player waits for a team game
that sv_map did not ask for, plays the rotation's next entry without
teams instead (if it has one); it is checked when the lobby opens and
again when its countdown would begin. With no entry left, the server
stops with exit code 3 (2 when no rotation map is there), or plays
bloodgulch:slayer with server.rotation_fallback.
Gametype files
A gametype file, gametypes/<name>.toml (the name a–z, 0–9, _
and -), is a built-in or OpenCE gametype (base) and rules over it:
base = "team_king" # the gametype it starts from
name = "Moving Hill" # the name the game shows: 11 characters of printable ASCII
[universal]
score_to_win = 3
[options]
time_limit = 15
friendly_fire = "off"
[king]
moving_hill = true
Each rule goes in its table (the schema's table): [universal],
[options], and one game engine's, [ctf], [slayer], [oddball],
[king] or [race]. A file gives each rule its full name
(vehicle_set, team_vehicles, score_to_win): the short names
(vehicles, score) are the rotation's alone. A file is 64 KB at most.
deploy/gametypes has examples. The server reads the gametypes folder
beside config.toml if there is one, else the data root's, and writes the
web page's files to it.
A gametype a player's game saved is played with file=<name>: copy the
saved gametype, as <name>.gametype, into the same gametypes folder
(beside config.toml if there is one, else the data root's).
The listing
A public server (server.public, with network.online and
network.public_lobby) is published to each master server of
masters.txt (docs/master_protocol.md). A change that a list shows (the
listing made public or private, a player joining or leaving, the name, the
map) is published within 5 seconds. Made private, the server makes a new
invite and sends the masters a closed publish, which takes it off their
lists;
network.public_lobby = false stops the publishing too. A master's
Retry-After is kept (a day at most). key_revoked and
publisher_revoked stop the publishing to that master for the run;
unknown_key, publisher_pending, application_denied and
listing_taken are logged once a minute while they last.
Files
| File | What |
|---|---|
config.toml |
Every setting (beside the executable; HALO_CONFIG_FILE, else --config, names another) |
init.txt (server.init_file) |
Commands run at the start, in the data root unless a full path |
gametypes/<name>.toml |
Gametype files: the gametypes folder beside config.toml if there is one, else the data root's |
gametypes/<name>.gametype |
Gametypes saved by a player's game (file=<name> in the rotation): the same gametypes folder |
publisher.key |
The publisher key for the master servers (beside config.toml). On Windows, a server's config.toml and publisher.key are given an access list of their owner alone (the entries inherited for Administrators and SYSTEM taken out) |
masters.txt |
The master servers (server.masters_file: beside config.toml, else beside the executable); one that names none leaves the official master, built in |
bans.txt, cheaters.txt, debug.txt |
In paths.logs, else the data root |
status.json (server.status_file) |
The status, written every 2 seconds |
A password's file (HALO_SERVER_WEB_PASSWORD_FILE, HALO_SERVER_WEB_OWNER_PASSWORD_FILE, HALO_SERVER_WEB_MODERATOR_PASSWORD_FILE, HALO_SERVER_WEB_VIEWER_PASSWORD_FILE, HALO_SERVER_PASSWORD_FILE) |
A container's secret: when the plain variable is not set, the file's first line (its CR and LF off) is the setting's value, which is then pinned as the plain variable's would be (sv_set and PUT /api/settings refuse it, 409 pinned). Read once, at the start; a file that cannot be read is logged, and the setting is config.toml's |
Releases
Each release has SHA256SUMS (the SHA-256 of each of its files, as
sha256sum writes them: <hex> <name>, written by tools/ci_build.py sums) and SHA256SUMS.sig: the Ed25519 signature of that file's exact
bytes by the project's release key, in 128 lowercase hex digits and a
newline (the form opence-master-server's cmd/release-sign writes). The
public key is deploy/release-key.pub (64 hex digits:
d04e8b101007d1641cace8b95fcca76f7f44709f6885c3d031b52394dbdf9376).
tools/release_sign.sh verify SHA256SUMS checks the signature (OpenSSL 3),
and sha256sum -c --ignore-missing SHA256SUMS the files downloaded. The
build workflow signs with the RELEASE_SIGNING_KEY secret (the key's seed,
64 hex digits) and checks the signature against deploy/release-key.pub
before it publishes the release.