Files
horizon/docs/server.md
T
Ludovic Marie f4858e8a25 Merge pull request #11186 from flexeykinDev/install-ps1-signature
🤖 Agent: install.ps1 now verifies the release signature with the release-keys.txt key, the same check install.sh already does.
2026-11-07 10:12:13 +01:00

1026 lines
80 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Hosting a Horizon server: design
Horizon's dedicated server should be the easiest and lightest Minecraft 26.3 server to host. It should be one
small native program with no Java and no Python, one command to install it, one config file, and a CLI, a web
panel and a control API to manage it. At the same view and simulation distance and with the same players, it
should beat vanilla, Paper and Fabric + Lithium on TPS/MSPT, RAM, CPU, chunk generation and start time.
Gameplay stays vanilla: there are no Paper-style rule changes unless the owner turns one on.
This page covers what the server does today (measured), the design of `horizon-server`, and what ships for the
first working version (2026-10-23). The owner's guide is [docs/hosting.md](hosting.md).
## 1. Today
### How it runs
The dedicated server is the game itself, run headless:
`horizon-client --resources <prepared> --dedicated [--nogui] [--port N] [--world NAME] [--universe DIR]
[--initSettings]`, started in the server folder (`crates/horizon-client/src/game/dedicated.rs`).
- **Startup.** It follows vanilla's `Main.main` and `DedicatedServer.initServer`. It reads `server.properties`
and writes it straight back with vanilla's defaults filled in (`horizon_client::server`, byte-exact with
vanilla's `Properties.store`), and refuses to start until `eula.txt` says `eula=true`. It then loads the
operator, whitelist and ban lists in vanilla's order and format, and opens `<universe>/<level-name>` with
the same `Game` as single player, minus the local player.
- **Network.** Players join through the play-state hub that Open to LAN uses (`crates/horizon-client/src/lan`).
Status ping, legacy ping, compression, login gate (bans, whitelist, IP bans), operator permission levels,
the player limit with `bypassesPlayerLimit`, MOTD and `server-icon.png` all follow `server.properties`.
- **Main loop.** It ticks at 20 TPS on one thread, with chunk generation and lighting on the worker pool.
Chunks stay loaded around every player (`lan_update_streaming`), or around the spawn while nobody is
online. It supports `pause-when-empty-seconds`, `player-idle-timeout` and the autosave.
- **Console (stdin).** `stop`, `save-all`, `save-on`/`save-off`, `list [uuids]`, `op`/`deop`,
`whitelist on|off|list|reload|add|remove`, `ban`, `ban-ip`, `pardon`, `pardon-ip`, `banlist`, `kick` and
`say` behave as in `DedicatedServer`. `tempban <player> <duration> [reason]` is Horizon's own: a ban that
ends by itself ([hosting](hosting.md#temporary-bans)). Every other command goes to the game's own dispatcher as the
server (permission level 4). The log follows vanilla's Log4j pattern, in `logs/latest.log` and on stdout.
- **Player names.** `usercache.json` in the universe folder works as vanilla's `CachedUserNameToIdResolver`.
Players are added when they join and kept for a month, and the file keeps the 1000 most recently used,
newest first. The console finds a player by name in it, in any case. A name it doesn't hold is asked of the
profile service (`getByName`). If the service doesn't know the name, an offline server uses the offline
player of the lower-case name; an online server answers "That player does not exist".
`tools/test_user_cache_parity.py` checks this against vanilla, with a mock profile service.
### Measured (2026-10-16, Mac mini M4 Pro, main at bd8dff20, release build)
The server ran in a scratch folder with `online-mode=false`, `level-seed=12345` and view and simulation
distance 10. The vanilla server was the pinned 26.3 `server.jar` on Java 25 with `-Xmx4G`.
| | Horizon (`horizon-client --dedicated`) | Vanilla 26.3 |
|---|---|---|
| Start, new world (wall clock, to `Done`) | 11.4 s (`Done (9.762s)`) | 12.5 s (`Done (2.066s)`, includes the bundler's first unpack) |
| Start, existing world (wall clock) | **5.8 s** (resources load in 1.6 s) | 10.7 s (`Done (0.446s)`: the JVM and DataFixerUpper take the rest) |
| RAM, idle with nobody online (RSS) | 1.40–1.51 GB | **0.93–0.97 GB** |
| CPU, idle with nobody online | 1.2 % of one core | **0.6 %** |
| `stop` to exit | 0.6 s | 0.6 s |
| What has to be installed | one 46.8 MB binary + 589 MB of prepared resources | Java 25 + the 62 MB jar |
| Crates linked | 384 (wgpu, winit, cpal, gilrs, arboard, naga…) | n/a |
On Linux the full game loads `libasound.so.2` (cpal) and `libudev.so.1` (gilrs) at startup. wgpu, winit
and the Wayland/X11 libraries are loaded only when a window opens. The server build (`horizon-engine`,
section 3) is the same program built without its `audio` and `controllers` features, so it needs neither
library, and a minimal server image runs it as it is.
The two clear losses are RAM and idle CPU. The dedicated server loads everything the client needs to draw
(the HUD atlas, item renderer, block-entity models, panorama, fog and music tables, entity models), and it
builds the meshing tables at startup. None of these are used without a window. Loading them is startup work,
not tick cost, so it is in scope here (see section 9).
### Joining it
- **Horizon client:** works. A headless Horizon client (`--server 127.0.0.1:PORT`, the harness mode) logged
in, received its 49 chunks at render distance 2, chatted, saw `/list`, and left. The server's log matched
vanilla's lines (`logged in with entity id`, `joined the game`, `[Not Secure] <Tester> …`, `lost connection:
Disconnected`, `left the game`).
- **Vanilla 26.3 client:** checked by `tools/test_dedicated_server_parity.py`. VanillaProtocolClient, a
headless client built from vanilla's own codecs, pings and joins Horizon's server and vanilla's from the
same world copy. It compares every login, configuration and play packet up to the first chunk batch, the
chunk bytes, chat, `/list`, failing commands, the console lines and the files left behind. Result on main:
see "Harnesses on main" below.
- **The real vanilla 26.3 client** (`tools/vanilla_client.py`, from the owner's own files, dev-only) joins
too. On 2026-10-18 it joined the server build of the game (`horizon-engine`, section 3) on the Mac mini:
`python3 tools/vanilla_client.py --name Vanilla --join localhost:PORT`. It logged in, drew the terrain
around spawn and stayed connected for four minutes, with no warning or error on the server. The tool
runs on Linux under vscreen and on macOS from the logged-in screen session, even a locked one, with vsync
off.
### What fails or is missing
| Area | Status |
|---|---|
| `online-mode=true` | **Works** (ServerLoginPacketListenerImpl): a 1024-bit RSA key pair at start ("Generating keypair"), the encryption request, the shared secret and challenge, AES/CFB8, and the session service's `hasJoined` check (with the address when `prevent-proxy-connections` is on), with vanilla's log lines and messages. `test_online_mode_parity.py` runs the same logins against vanilla's server and Horizon's with a mock session service. |
| Secure chat | **Works** (`SignedMessageChain`, `LastSeenMessagesValidator`, `MessageSignatureCache`): the chat session update and its profile key, checked against the services' `playerCertificateKeys`; signed chat, `say`, `me` and `msg`, relayed with their signatures and last-seen lists; `enforce-secure-profile` (with `online-mode`) refuses unsigned chat; and vanilla's log lines (`[Not Secure]`, the decoder's warnings, the kicks). Offline servers and players without a chat session chat unsigned, as on vanilla. `test_secure_chat_parity.py` runs the same signed and unsigned players against vanilla's server and Horizon's, with test keys and a mock services server. |
| Nether and End | **Works**: the server holds a level per dimension, and each player is in its own (portals, `execute in … tp`, respawn). `test_server_dimension_players_parity.py` checks it against vanilla. How it works, its cost and what's left: [server-levels.md](server-levels.md). |
| Refused logins | Logged as vanilla does: `Disconnecting <name> (/address): <reason>`, then `<name> (/address) lost connection: <reason>`, and the session errors (`Username '<name>' tried to join with an invalid session`, `Couldn't verify username because servers are unavailable`). |
| Gameplay for remote players | Checked against vanilla's server with a vanilla-codec client (#484, `tools/test_dedicated_server_parity.py`): a right click with an item on a block uses it once, a dead player's commands run, players on a team without friendly fire don't hurt each other, and a player's projectiles are theirs (their ender pearl takes them). A player's arrow, snowball or egg hits other players like vanilla's, refused by the `pvp` game rule and by teams without friendly fire (`tools/lan_e2e.py` `arrows`; also checked on a dedicated server with two protocol clients, #5785). The recipe book, status effects and block sync while digging (#500) work. Left: a mob a guest kills counts in the host's statistics and advancements, not the guest's (`mob_died` runs as the host). |
| Defaults that surprise owners | `white-list=true` is vanilla 26.3's default, so a fresh server refuses everyone until they are added. That matches vanilla, so the first-run flow (section 3) asks about the whitelist up front. |
| Management | Only stdin. There is no service, no backups, no status beyond `list`, no restart after a crash, and a server can't be managed while it runs in the background. |
| Packaging | The server is the full game client. Resources come from `horizon-prepare`, which needs a JDK and the official launcher's client install, both awkward on a headless VPS. |
| Tick times | The dedicated loop never recorded its tick times in the game clock (only the windowed loop did), so `/tick query` on a dedicated server reported 0 ms. Fixed in PR 2b, which the benchmark needs (section 9). |
| Scale | Measured against vanilla on equal work (section 9, `benchmarks/server-equal-work-2026-10-17/`): 20 TPS up to 50 bots, 9 to 11 TPS at 100, where vanilla's watchdog stops it. Horizon is ahead with few players and when exploring; with 25 to 50 players near spawn vanilla's tick is shorter. |
### Harnesses on main
On the tip of the code PRs (2a to 3, rebased on b4ad6538), `tools/parity.py dedicated_server online_mode
server_files server_player_combat server_player_damage` passed all five. That includes the vanilla protocol
client joining Horizon's server and vanilla's from the same world. The full `--changed origin/main` set
(309 harnesses, since the PRs touch the client crate) had passed 60 of 60 when it was stopped for disk
space; CI runs it on every pull request.
## 2. Architecture: a supervisor and an engine
```
owner ──► horizon-server (CLI, TUI) browser ──► web panel AI ──► MCP (stdio)
│ │ │
└────────── control API: HTTP + JSON on 127.0.0.1, token ────────┘
│
horizon-server run (the supervisor: no GPU, no game code)
server.toml ──► server.properties ──► │ ◄── stdout: the vanilla log
backups, updates, crash restart │ ──► stdin: console commands
│ ◄─► engine endpoint: status, TPS/MSPT, players
▼
the engine: horizon-client --dedicated (the game, headless)
```
`horizon-server` is a new crate and binary (`crates/horizon-server`). It is the only program an owner runs.
It depends only on the light crates (`horizon-core`, `horizon-protocol`, `horizon-world`, `horizon-bench`)
plus `serde`, `toml`, `toml_edit` and `zip`, which are already in the lockfile. It doesn't link wgpu, winit,
audio or controller crates. It runs the engine as a child process.
The supervisor/engine split stays even after the engine becomes headless, for these reasons:
- **Crash restart and diagnostics.** The supervisor outlives an engine crash. It keeps the crash report,
restarts the engine with backoff, and the panel stays up to say what happened.
- **Safe updates.** An update swaps the engine binary and restarts. If the new engine fails its start check,
the supervisor rolls back to the previous one.
- **Parity stays cheap.** The engine keeps reading vanilla's own files (`server.properties`, the four JSON
lists, `eula.txt`) exactly as `test_dedicated_server_parity.py` checks them today. The clean config is a
layer on top and never changes what the engine sees.
- **No conflicts.** The supervisor touches none of the game code that the tick and integrated-server work
(perf-server-1, perf-ticks-2) are changing.
**The engine today** is `horizon-client --dedicated`. It needs one small, additive change: a status endpoint.
With `--control-socket <dir>/.horizon/engine.json` (the existing control protocol: TCP on 127.0.0.1 plus a token, on
every OS), the dedicated loop answers `server-status` with TPS, MSPT (average, P50/P95/P99 from the tick
clock `/tick query` uses), players (name, UUID, address, ping, position), loaded chunks and uptime. It answers
`console <line>` with the command's output.
**A proxy moving a player between two servers** uses more requests on this endpoint (`lan/arrivals.rs`,
`game/dedicated/transfer.rs`). They are a built-in module, **off by default**, that the owner turns on in
`server.toml`:
```toml
[transfer]
enabled = false # true: take a proxy's player-* requests; needs Velocity forwarding (forwarding.secret)
expect_seconds = 10 # how long a player the proxy announced is waited for, when it names no time
max_expect_seconds = 60 # the longest the proxy may ask for
max_expected = 64 # players waited for at once
max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over
max_hold_seconds = 60 # the longest a player arriving from another server is held before it is dropped
```
The supervisor passes the section to the engine as `--transfers expect_seconds=10,...`, beside
`--control-socket`, and not as a `server.properties` key: that file stays vanilla's. The section has nothing to do
with vanilla's `accepts-transfers`, which is about the transfer packet a server sends a client. While it is off,
every `player-*` request and `transfer-status` are answered `{"error": "transfers are off on this server"}`, the
engine keeps no table and does nothing for transfers on a tick.
With it on, the engine refuses to start unless Velocity forwarding is on (`forwarding.secret`,
[velocity-forwarding.md](velocity-forwarding.md)). The proxy must be the only way in: a player who joined a
source server directly would find there the older copy of itself that a hand-over left behind, and items would
double.
**Who can ask.** The requests do not widen who can reach the engine: they use the control endpoint, a Unix socket
or TCP on 127.0.0.1 only (horizon-server starts it on TCP with a token). A proxy on another machine needs
something local to each engine to relay for it. The token is all or nothing: whoever can move players can also
run any console command.
Answers are one JSON line, `{"ok": true, ...}` or `{"error": "..."}`, and a refused request changes nothing. A
unique id is written as 32 hexadecimal digits with or without dashes in a request, and as lowercase text with
dashes in an answer (`123e4567-e89b-12d3-a456-426614174000`). A hand-over id is 32 lowercase hexadecimal digits.
`transfer-status` is what a proxy reads before it sends players to a server. It names no player and changes nothing:
```json
{"ok": true, "players": 12, "held": 1, "expected": 2, "frozen": 0, "online": 13, "max_players": 20, "full": false, "stopping": false}
```
`players` are the players placed in the world (a player a `moved` release took out is no longer counted), `held`
the logins held for a hand-over, `expected` the expectations still alive, and `frozen` the players this server is
handing over. `online` is what the player limit counts: the placed and the held players and the logins still
joining. `full` is `online` at `max_players` or above: an ordinary login, held or not, is then refused as
`multiplayer.disconnect.server_full`, unless the player is an operator with `bypassesPlayerLimit`. `player-expect`
counts the expected players too: it is refused once `online` and `expected` reach `max_players`. `stopping` is true
once the server has begun to stop, and no player is taken in any more. A proxy asks it often, so it writes nothing to
the log.
`player-expect <uuid> [seconds]` tells the target server that the player is about to log in: within
`expect_seconds` by default, `max_expect_seconds` at most (a longer time is cut to it), and at most
`max_expected` players are expected at once. The reply is `{"ok": true, "renewed": false, "ttl_seconds": 10}`;
asking again for the same player renews the time and says `"renewed": true`. It is refused for a missing or
malformed id, a time that is not a whole number of seconds above zero, a player already connected, a full table,
a full server (`{"error": "the server is full"}`) and a server that is stopping (`{"error": "the server is
stopping"}`). Each expectation holds a place until it is used up or runs out: once the places the player limit
counts and those of the other expected players reach `max-players`, a new one is refused as a login would be, so two
proxies sending players at once do not fill the server past its limit. As at login, an operator with
`bypassesPlayerLimit` is never refused as full. The places are held against other expectations only: an ordinary
login (one nobody expected) sees the vanilla limit alone, so it can take a place an expected player was counting on.
That player's login is then refused as `server_full`, and the proxy aborts the hand-over on the source, where the
player goes on as it was. `full` in `transfer-status` is likewise the vanilla condition alone. The player's next
login uses the expectation up as it starts, once the bans, the whitelist and the player limit let it in, so a login still in configuration when the time runs out (a resource pack prompt) keeps it. A
login that then fails before it reaches play (its connection drops in configuration, or the client refuses a
required pack) gives the expectation back with its own deadline, so the proxy's next attempt within that time is
still expected. An expectation no login used is reported in the log when its time runs out.
A login that used up an expectation is a *held* player. It gets the whole join sequence and the level around it
(chunks, block and light changes, entities, the level's broadcasts), and its acknowledgements are handled (teleport,
chunk batch, loaded, keep-alive, ping, tick end, client information), but it is not placed: no join message, no
player-list entry for the others, not among the entities they are shown, not simulated, and nothing else it sends is
acted on. The world is kept loaded, and generated if need be, around a held player as around a placed one, so a
server with nobody else on it sends a held player its chunks. The log says `<name> is held: it arrives from another
server` instead of the join line. A held player nobody takes over is dropped after `max_hold_seconds` (1,200 ticks
by default), with a warning, and its departure is logged as `<name> was dropped while held: <reason>`, with no "left
the game" line. `player-export` refuses a held player.
`player-drop <uuid>` forgets the player's expectation, and disconnects the held player without a save or a word to
anyone. The reply is `{"ok": true, "held": true, "expected": false}`: whether a held player was disconnected, and
whether an expectation was forgotten. It is refused for an id that is neither held nor expected, and leaves a placed
player alone.
`player-export <uuid> [freeze]` reads a connected player out of the source server. It first closes the player's open
menu as vanilla does when a menu closes, in the player's own level (the cursor stack, a crafting table's grid and
the inventory's own 2x2 grid go back into the inventory, a trade back to its villager; the client is told to close
it and is sent its inventory), then answers:
```json
{"ok": true, "record": {
"version": 1,
"data_version": 5023,
"handover": "9f2c4be0a1d34e6b8c0f1e2d3c4b5a69",
"uuid": "123e4567-e89b-12d3-a456-426614174000",
"name": "Alex",
"dimension": "minecraft:overworld",
"position": [10.5, 70.0, -3.25],
"rotation": [90.0, 15.0],
"velocity": [0.0, 0.0, 0.0],
"game_mode": 0,
"player": "<base64>",
"advancements": { },
"stats": {"stats": { }, "DataVersion": 5023}
}}
```
`player` is the base64 of the uncompressed named NBT (root name empty) of the player compound: what the server
writes to `players/data/<uuid>.dat` before it gzips it, taken from the connected player at that moment, with the
values mods attached to the player. `advancements` and `stats` are the JSON the server writes to
`players/advancements/<uuid>.json` and `players/stats/<uuid>.json`, or null on a server that keeps none.
`version` is the version of this record (1); `data_version` is the world data version of the server that made it
(5023 for 26.3). The other fields are a few of the compound's values, for a proxy that does not read NBT. While the
player is frozen, the reply also gives `"freeze_ticks_left"` beside the record: the ticks before the hand-over ends
by itself, so that a proxy does not commit on the target too late for its release to come in time.
How a server reads a record (the target's `player-import`):
- A record of another `version`, or of another `data_version` than the server's own, is refused.
- The NBT in `player` wins over the plain fields: they are copies of its values, for a proxy.
- Fields the reader does not know are ignored, so a later version 1 record may carry more.
**The hand-over.** `freeze` starts one: the record gets a `handover` id, which the later requests name, and the
player's own inputs (moves, clicks, chat, commands) stop being acted on, while what answers the server (teleport
confirmations, chunk batch acknowledgements, keep-alives, pings, tick ends, client information and chat
acknowledgements, and the messages a chat packet says the client has seen, in the order they came) still is. A frozen player is not
kicked for floating. Every later export of that player, with or without `freeze`, answers with the same record,
byte for byte, and does not restart the freeze: a proxy that lost the reply asks again. The hand-over ends after
`max_freeze_seconds` (counted in ticks, 600 by default) with a warning in the log, and the id is then forgotten.
Without `freeze` the export is a read: `handover` is null and the player goes on. The request is refused for a
player who is not connected, is held, has not finished joining, is leaving, is riding, is asleep or is dead.
While frozen, the player takes no damage (as vanilla's `Abilities.invulnerable`: the void and `/kill` still kill)
and picks nothing up (items, arrows, experience orbs), so its record stays what it is until the release. A player
who dies while frozen all the same ends the hand-over, with a warning in the log.
`player-release <uuid> <hand-over> moved [announce]` and `player-release <uuid> <hand-over> abort` end the
hand-over, on the source server. Both name the hand-over the export returned. `moved` takes the player out of the
world because another server has it now: the others see its entity and its player-list entry go, nothing is written
to its player file or its records (the other server has the player), the log and the `leave` event record the
departure with the reason `moved to another server`, and the player's connection is closed with a generic
disconnect. The "left the game" line is neither sent nor logged unless `announce` is added. Nothing is deleted: the
player's file here stays as it was. `abort` lifts the freeze, and the player goes on as if nothing had happened.
`moved` replies `{"ok": true, "released": "moved", "announced": false}` and `abort` replies
`{"ok": true, "released": "abort"}`. Both are refused for a player who is not in a hand-over, for another hand-over
than the player's current one, and for a player who died while frozen. Once the freeze has run out the hand-over is
over, and a late `moved` is refused: the player has played on since, and the record the other server has is older.
The proxy then fails the hand-over on the target (`player-drop`).
`player-import <uuid> <json>` applies a record to the held player `uuid` on the target server, whole or in parts,
and lets it into the world:
```json
{"record": { ...the record of player-export... }, "parts": ["inventory", "experience"], "announce": true, "place": true}
```
`parts` is optional (all of them by default), `announce` is optional (true by default) and `place` is optional (true
by default; see below). The parts, in the order the reply lists them, and what each sets:
| part | sets |
| --- | --- |
| `inventory` | the inventory, armour, off hand and selected slot |
| `ender_chest` | the ender chest |
| `experience` | the level, progress, total and seed |
| `health` | health and absorption, food, saturation, exhaustion and its timer, and the body's air, fire and freezing |
| `effects` | the active effects |
| `game_mode` | the game mode, flight and flight speed |
| `position` | where the player stands, how it looks, whether it is on the ground, its fall and motion, and the vehicle it rode |
| `spawn_point` | the respawn point |
| `recipe_book` | the recipe book |
| `advancements` | the advancements and the statistics |
A part that is not asked for keeps what the held player has, which is what this server loaded for it when it logged
in: its own file, or a new player's defaults. An imported inventory or ender chest replaces the held player's whole: a
slot the record leaves empty is empty afterwards. The keys of the compound that Horizon does not model, the values
mods attached to the player, and the last death, the post effects and the credits, are taken only when every part
is. Only the compound of the record is read: the fields above it (`position`, `rotation`, `velocity`, `dimension`)
are for whoever reads the record.
A record is refused, with `{"error": "..."}` and nothing changed, when its `version` is not 1, its `data_version` is
not this server's, its `uuid` is not the request's, it has no `handover` id (an export without `freeze`), its
compound is not valid base64 NBT, a part's name is unknown, or the player is not held or is leaving. It is refused
too when its values are ones no player can have: a position, rotation, motion, health, absorption, saturation,
experience progress or fall that is not a number, a position farther than 30,000,512 blocks from the origin (20,000,000
up or down), which vanilla's `Entity.load` would clamp, a health of zero or less, a health over 1,024 or an absorption
over 2,048 (the bounds of `max_health` and `max_absorption`), a food exhaustion over 40 (`FoodData.addExhaustion`'s)
or an effect amplifier outside 0 to 255. A stack's count out of vanilla's 1 to 99 is read as 1, as for any item. A record whose position is in
another dimension than the held player's world is refused when `position` is among the parts: the client is in one
dimension and only a login changes it.
The record's hand-over is bound to the held player at the import: a second import of the same record is taken (a
retry), one of another hand-over is refused, and `player-confirm` and `player-drop` name it.
**The commit point.** On success the player is sent what the import changed the way a join sends it (game mode,
abilities, held slot, a new position to confirm, the effects, with those it had from this server's own file removed,
inventory, recipe book and the player list as it now is). Then, unless `"place": false`, it is placed as
`player-confirm` places it: its player file, advancements and statistics are written to disk first, each to a new
file that is synced and then renamed into place (on Linux and macOS the folder is synced too), then it is placed: simulated, shown to the others and taking part from the next tick, when its first tick sends its
health, experience and advancements as a new player's does. Everyone gets its player-list entry, since everyone will
be shown its entity; with `announce` true they also get the join line. The log says `<name> placed: it has arrived
from another server`, and the join line when it is announced. The reply is `{"ok": true, "handover": "...", "parts":
[...], "placed": true, "announced": true, "recentered": false}` only once the file is written, so a proxy sends `player-release moved` to
the source after this reply: from then on a crash of the target, or a power loss, leaves the player's three files there
as it arrived. When one cannot be written, the reply is an error and the player stays held and imported, to be
confirmed again or dropped.
**A lost reply after the commit.** A `player-import` or `player-confirm` that names the hand-over of a player already
placed from it is answered `{"ok": true, "placed": true, ..., "retried": true}`, with nothing done again: a proxy that
lost the reply asks again and learns the commit happened.
**A refused `moved` after the commit.** The target then has the player placed and on disk, and nothing undoes that:
`player-drop` takes only a held player. It happens when the freeze ran out between the confirm and the release (the
proxy was late), or when an operator's `/kill` killed the frozen player (nothing else hurts it, and it does not
move). The proxy keeps its client on the target and disconnects the source's connection; the source then saves its
copy of the player as it is, so the two servers' files differ from that moment. To keep clear of the first case, the
proxy confirms only while the export's `freeze_ticks_left` leaves it time to release, and otherwise drops the arrival
and aborts.
**Where the player arrives.** Only the compound of the record is read, so to put the player somewhere other than the
record says (a proxy that knows where its client stands, which the frozen source may not have seen yet), the request
takes four optional fields, which win over the compound:
```json
{"record": {...}, "position": [300.5, 64.0, 300.5], "rotation": [90.0, 20.0], "on_ground": false, "velocity": [0.0, -0.08, 0.0]}
```
`position` is `[x, y, z]`, `rotation` is `[yaw, pitch]` in degrees, `on_ground` is a boolean and `velocity` is
`[x, y, z]` in blocks a tick. The pitch is taken as vanilla's `Entity.setXRot` takes it: modulo 360, then cut to ±90;
the yaw is kept as it is. A velocity component over 10 blocks a tick is taken as 0, as `Entity.load` takes a saved
`Motion`. The velocity is the server's: the arrival's teleport carries no motion, as a vanilla login's does not, so
the client starts still, and since a player's client moves itself, its first move replaces it. They belong to the `position` part: a
request that gives one and does not import that part is refused. A position that is not finite, or farther than the
record's own may be (30,000,512 blocks from the origin, 20,000,000 up or down), is refused, and so is a rotation or a
velocity that is not finite numbers (a rotation past what a float holds too). A record with a vehicle still to restore
(`RootVehicle`) puts the player back on it at the vehicle's own place, so a request that names a position for such a
record is refused. The border, the blocks and the chunks are not checked,
as a vanilla server does not check the place its player file names. The player is teleported to the position (the
client confirms a new teleport id), so an anticheat never sees the arrival as a move, however far it is from the
export.
**The view follows the imported position.** `recentered` says whether the import moved the player to another chunk
than the one its view was built around (only possible with the `position` part, and only once the player has been sent
chunks). When it did, the server moves the view at once, before the position the client confirms: it sends
`SetChunkCacheCenter` for the new chunk and forgets the chunks that are out of reach, as the next tick would have. The
chunks around the new place are **not** sent yet: they come one batch at a time with the next ticks, and the client
acknowledges each batch. Whoever reads the player's world (a proxy building a view of it) must therefore wait for new
chunk batches after a reply with `"recentered": true`, as it waited for the first ones; with `false` the chunks it
already has are the ones around the player.
**Importing in two steps, so that a hand-over can be cancelled.** With `"place": false` the record is applied and the
player's client is sent everything the import changed, but the player stays held: not placed, not simulated, not
shown, not announced, and nothing is written. The reply is `{"ok": true, "handover": "...", "parts": [...], "placed":
false, "announced": <what announce asked>}`. Then either:
- `player-confirm <uuid> <hand-over>` writes the player's files and places it as `place: true` would have, and
announces it if the import's `announce` said so. What the client did since the import was not acted on, so it is
sent its position to confirm again and its inventory. Reply `{"ok": true, "placed": true, "announced": true}`. It is
refused, with nothing changed, for a player who is not held, is leaving, whose record was not imported, or whose
record is of another hand-over.
- `player-drop <uuid> [hand-over]` cancels the arrival: the connection is closed and nothing is saved or said to
anyone (the player was never placed, so the world never knew it). Reply `{"ok": true, "held": true, "expected":
false}`. With a hand-over id, it must be the imported record's, so a late drop of an older hand-over leaves a newer
one alone; before any import there is no id to name, and the drop goes by the unique id alone (a late drop of
that kind can still cut a newer held player that was not imported yet, which loses nothing: the source still has
the player frozen, and the proxy aborts there). Once dropped, the player can be neither imported nor confirmed.
A proxy that fails after a successful `player-import` with `place: false`, and before it has sent its client the first
byte of the hand-over, calls `player-drop` and the target is as it was. A proxy that dies in between leaves a held
player that is dropped by itself after `max_hold_seconds`, with a warning in the log.
**A hand-over, in order.** A proxy moving a player from server A to server B, without a loading screen. Both run
with `[transfer]` on and behind the proxy's Velocity forwarding:
1. `player-expect <uuid>` on B: B holds the player's next login.
2. `player-export <uuid> freeze` on A: the record and its `handover` id, and A stops acting on the player's inputs.
A lost reply is asked again: the same record comes back.
3. The proxy logs the player in to B on its own connection: B sends the held player the world around the place B
loaded for it.
4. `player-import <uuid> <json>` on B with `"place": false` and, if the proxy knows where its client stands, the
request's `position`, `rotation`, `on_ground` and `velocity`. With `"recentered": true` the proxy waits for B's
next chunk batches.
5. The proxy switches its client over to B's connection. If anything fails before the client got the first byte of
it, `player-drop <uuid> <hand-over>` on B and `player-release <uuid> <hand-over> abort` on A, and the player goes on
on A as before.
6. `player-confirm <uuid> <hand-over>` on B: B writes the player's files, then places it. Only after its `ok`,
`player-release <uuid> <hand-over> moved` on A: the player is out of A's world, and A saves nothing of it.
Every step that is not taken in time is undone by itself: an expectation after `expect_seconds`, a held player after
`max_hold_seconds`, a freeze after `max_freeze_seconds`, each with a warning in the log. A `moved` that comes after
the freeze ran out is refused, so the proxy must confirm and release within `max_freeze_seconds` of the export.
**A server that stops.** From the `/stop` command (the console's, a player's or the control endpoint's) or a stop
signal on, `player-expect`, `player-import` and `player-confirm` are refused `{"error": "the server is stopping"}`: a
player must not arrive on a server that is about to close. The proxy drops the held player there and aborts on the
source. A retry after the commit is still answered, and `player-export`, `player-release` and `player-drop` stay
open: they are the way out.
**In the log.** Each `player-*` request writes one line with what became of it: `INFO` and what it did
(`player-import Alex (<uuid>): ok, parts inventory,experience, placed false, announced true, recentered false,
hand-over <id>`), or `WARN` and the reason it was refused (`player-export Alex (<uuid>): refused: that player is
riding`). The hand-over's id is in the lines of the export, the import, the confirm, the release and a named drop, so
the lines of one hand-over can be found on both servers. Only the ids, the name when the server knows it, and a few
words of the outcome are written: never the record or the request's body, which are large and are the player's. A
held player has its own lines instead of the join and leave ones: `<name> is held: it arrives from another server`,
`<name> placed: it has arrived from another server` and `<name> was dropped while held: <reason>`.
**The headless engine** comes after perf-ticks-2's work lands: Resources split into shared and per-game, the
game built in `Game::new`, the integrated server on its own thread. On that base:
1. The dedicated path skips everything only a window uses: the HUD atlas, item renderer, block-entity and
entity models, panorama, fog, music, and mesh tables (perf-ticks-2 already stops server-side meshing).
This is the RAM and start-time item.
2. The game moves into a library crate, with the window parts behind a `client` feature, so
`horizon-engine` links no wgpu or winit either. Sound and controllers are already optional (the
`audio` and `controllers` features, on by default): `horizon-engine` today is the game built without
them (section 3).
## 3. Packaging
**Binaries.** The release archives hold `horizon-client`, `horizon-ctl`, `horizon-prepare` and
`horizon-server`. On Linux the release also has `horizon-server-linux-x86_64.tar.gz`: `horizon-server`,
`horizon-prepare` and `horizon-engine`, the game built with `--no-default-features` (no sound, no
controllers), which links neither `libasound.so.2` nor `libudev.so.1`. `release.yml` builds it after the
client archive (`xtask package-server`) and fails if `tools/check_imports.py --server` finds either library;
CI lints the same build. `horizon-server` runs `horizon-engine` when it is beside it or on the PATH, else
`horizon-client`. Build it yourself with `cargo build --release -p horizon-client --no-default-features`
and copy `target/release/horizon-client` as `horizon-engine`. macOS and Windows have no such libraries to
avoid, so they keep the one archive.
**Game files: the owner's own copy, as with `prepare`.** Nothing of Mojang's is redistributed and nothing is
downloaded on the owner's behalf. `horizon-server` looks for prepared resources in this order:
1. `[engine] resources` in `server.toml`;
2. `resources/` in the server folder;
3. `.local/horizon-client/26.3` beside the engine or up to four folders above it: a release folder, the
installer's `game-files` (below), or a checkout.
If none is found, `init` says where it looked. Today the owner runs `horizon-prepare --prepare --server-jar
PATH` from the release folder (it needs a JDK and the client files, as for playing), or copies a folder
prepared on their own computer to the server (`scp -r`). Planned wrappers:
- **On this machine:** `horizon-server prepare --server-jar PATH` runs `horizon-prepare` on the owner's own
server jar plus their launcher install.
- **Prepared elsewhere:** `horizon-server import-resources DIR` copies a folder prepared on the owner's own
computer, for example by the launcher, to a headless machine. It first checks the folder: `horizon-resources.json`
is there and names Minecraft 26.3, the derived files match their checksums, and the font and texture folders
aren't empty; otherwise it lists what is wrong and changes nothing. It copies next to the destination and
renames the copy in, so a failure partway keeps the old copy. The destination is the first place the server
reads: `[engine] resources` when set, else `resources/` in the server folder when it exists or the server
wasn't installed by `install.sh`, else the installer's `game-files/horizon-client/26.3` (where the release's
`.local/horizon-client` points). It ends by saying to start or restart the server.
A server-only `prepare` (from `server.jar` alone, without the client jar or a JDK) is the follow-up that
removes the last Java step. It needs a list of which prepared files the server really reads, and that list
comes out of the headless-engine work.
**One-command install** (`packaging/server/`):
- Linux and macOS:
`curl -fsSL https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.sh | sh`
The script picks the release archive for the OS and architecture (on Linux x86_64 the server archive,
on macOS arm64 the game's), downloads it from GitHub Releases, and checks it against the release's signed
`SHA256SUMS` when OpenSSL 3 is installed, else against the SHA-256 GitHub publishes for each asset
(docs/security.md). It never asks for sudo. Options: `--version vX.Y.Z`, `--dir` (default
`~/.local/share/horizon`), `--bin` (default `~/.local/bin`), and `--archive FILE` to install an archive
already on the machine (a build of your own; no download and no checksum).
A Linux release from before the server archive installs the full game, which links ALSA's and udev's
libraries (`libasound.so.2`, `libudev.so.1`); minimal server images often lack them (Debian and Ubuntu:
`sudo apt install libasound2 libudev1`, `libasound2t64` on Ubuntu 24.04 and later). Then the script lists
any that are missing, `init` warns, and `run` stops at once with what to install, rather than restarting
a game that can't load.
- Windows:
`irm https://raw.githubusercontent.com/horizon-rust/horizon/main/packaging/server/install.ps1 | iex`
The script does the same into `%LOCALAPPDATA%\Horizon` (`-Version`, `-Dir`), with junctions instead of
links (no admin rights), and adds `bin` to the user's PATH. It finds OpenSSL 3 on the PATH or in Git for
Windows to check the signature.
- **Layout.** Each release goes to `<dir>/releases/<tag>` and `<dir>/current` points at the one in use, so an
update or a rollback is one link. `<bin>/horizon-server` and `<bin>/horizon-prepare` are two-line launchers
that run `current`. The prepared game files live in `<dir>/game-files/horizon-client` and each release's
`.local/horizon-client` links there, so updates keep them. The script then prints the next steps
(`horizon-prepare`, `horizon-server init`).
**Docker** (`packaging/server/Dockerfile`, built the same way as the relay's image): a multi-stage build that
ends on `debian:bookworm-slim` as user `horizon`, with the server build as `horizon-engine`, so the image needs
no ALSA or udev. It uses volume `/data` for the server folder, takes the owner's prepared game files as a read-only
mount on `/opt/horizon/.local/horizon-client/26.3`, exposes port `25565/tcp`, and keeps the control API and
the panel on 127.0.0.1 inside the container: `docker exec <name> horizon-server status|cmd|backup`.
On the first start, `HORIZON_EULA=true` and `HORIZON_WORLD`, `HORIZON_SEED`, `HORIZON_PORT`,
`HORIZON_WHITELIST`, `HORIZON_PLAYERS` and `HORIZON_OP` answer `init`'s questions; without the EULA answer
the container exits and says why. `docker stop` sends SIGTERM, which saves and stops cleanly (give it
`--stop-timeout 180`). The image is published only with Ludovic's OK (it's hosted infrastructure).
**Services.**
- `horizon-server service install` writes and enables a systemd unit (`--user`, or a system unit with
`--system`) on Linux, a launchd agent (`~/Library/LaunchAgents/dev.horizon.server.<name>.plist`) on
macOS, or a Scheduled Task at logon on Windows (a real Windows service needs a wrapper and comes later).
- Each runs `horizon-server run --dir <folder>` through the installer's launcher, so an update applies at the
next restart. The supervisor restarts the engine itself, and the service manager restarts the supervisor.
`service uninstall` stops and removes it and leaves the server folder alone.
- `service print` shows the unit or agent it would write, for owners who'd rather install it by hand.
**First run (`horizon-server init`, interactive on a terminal):**
1. **EULA.** It shows the link to https://aka.ms/MinecraftEULA and asks "Do you agree? [y/N]". Only a yes
writes `eula=true`.
2. **World.** It asks for the world name (`world`) and an optional seed. An existing world folder, or a
vanilla server folder, is detected and kept: its `server.properties` is imported (section 4).
3. **Port.** 25565 by default, checked free right away.
4. **Players.** It asks whether to use a whitelist (yes by default, as vanilla) and who to add first, then
offers to make the first player an operator.
5. **Game files.** It finds or explains the prepared resources (see above).
6. It writes `server.toml`, shows the panel address and the commands to start it in the background
(`horizon-server start`) or as a service.
Every question has a flag (`--eula`, `--world`, `--seed`, `--port`, `--whitelist`/`--no-whitelist`,
`--player NAME`, `--op NAME`, `--yes`) and an environment variable for Docker (`HORIZON_EULA`, …; flags win).
## 4. One config file: `server.toml`
The owner edits only `server.toml`. The supervisor writes `server.properties` from it before every engine
start, so the engine stays exactly vanilla. The file is short: typed sections for what owners change, and
a `[properties]` table that passes any other vanilla key through unchanged.
```toml
# Horizon server settings (docs/server.md). The server writes them into server.properties before
# every start: edit this file, not that one.
[server]
motd = "A Minecraft Server"
port = 25565
bind = "" # listen on every address, or only on this one (server-ip)
max_players = 20
online_mode = true # check players' Minecraft accounts (false: anyone joins under any name)
whitelist = true # only whitelisted players and operators can join
enforce_whitelist = false # kick online players taken off the whitelist
view_distance = 10
simulation_distance = 10
[world]
name = "world" # the world folder
seed = "" # empty: a random seed on the first start
game_mode = "survival" # survival, creative, adventure or spectator
difficulty = "easy" # peaceful, easy, normal or hard
hardcore = false
# Any other server.properties key, spelled as vanilla spells it, for example:
# spawn-protection = 16
[properties]
[engine]
path = "" # the game program; empty: the one installed beside horizon-server
resources = "" # the prepared game files; empty: resources/ here, else the usual places
restart_on_crash = true
[backups]
every = "6h" # automatic backups while the server runs: 30m, 6h, 1d...; "0" turns them off
keep = 12 # automatic backups kept (backups made by hand are never deleted)
[updates]
keep = 1 # other releases kept by updates, besides the one in use and the one rollback goes to
```
This is the file `init` writes today, and it also writes `[panel]` (`bind = "127.0.0.1:8130"` and
`allow_public = false`, section 6), `[api]`, `[transfer]` (off; a proxy's hand-overs, section 2) and `[discord]`. The defaults are vanilla 26.3's (a test checks
each one). Unknown keys are errors, as are a typed key set again under `[properties]` and values vanilla would
refuse. There is no `backups.before_update` setting: an update always backs up the world first (section 7).
- **Import.** `horizon-server init` in a folder with a `server.properties`, or `horizon-server import
server.properties`, reads it with vanilla's own parser (`ServerProperties`). Keys with typed fields go into
their sections; any other key that differs from vanilla's default goes into `[properties]`. Keys left at
their defaults are dropped, so the TOML stays short. The original file is kept as
`server.properties.imported`.
- **Changes made in game.** The engine writes `server.properties` itself, for example after `/whitelist on`.
After it stops, the supervisor compares the file with what it wrote and copies any changed keys back into
`server.toml`, so an in-game change survives the next start. Edits go through `toml_edit`, so the owner's
comments and order are kept.
- **Changes without opening the file.** `horizon-server config set KEY=VALUE...` (the launcher's Servers
page uses it) writes through `toml_edit`, checks the result as `server.toml` is checked, and writes nothing
if a value is wrong. On a running server it sends what vanilla changes live through its console:
`difficulty`, `defaultgamemode` and `whitelist on|off`; `server.motd` and `server.max_players` go to the
game as the management protocol's `serversettings/motd/set` and `serversettings/max_players/set` do, so
the server list and the next logins use them at once; the backup settings apply at once because the
supervisor reads them each time. Everything else waits for the next start, and the command says which. A
hot reload of the view distance needs engine support and comes later.
- `server.properties`, `ops.json`, `whitelist.json`, `banned-*.json` and `world/` keep vanilla's names and
formats. A vanilla server folder works as is, and so does going back to vanilla.
## 5. CLI and TUI
```
horizon-server init first run (section 3)
horizon-server run supervisor + engine in the foreground; the console on stdin
horizon-server start | stop | restart in the background (detached `run`); start also resumes a stopped supervisor
horizon-server status [--json] up?, uptime, TPS, MSPT, players, RAM, CPU, crashes
horizon-server cmd <command…> one console command, with its output
horizon-server console attach: the live log, and send commands (Ctrl-D detaches)
horizon-server logs [-f] [-n N]
horizon-server op|deop <player>
horizon-server whitelist on|off|list|add|remove [player]
horizon-server players [--json] who is online, operators, whitelist, bans
horizon-server kick|ban <player> [reason] pardon <player> ban-ip <address|player> [reason] pardon-ip <address>
horizon-server backup [NAME] backups restore <backup> [--yes]
horizon-server import <server.properties>
horizon-server config [--json] config set KEY=VALUE... [--json]
horizon-server service install|uninstall|print [--system]
horizon-server panel the panel's address and a one-time login link
horizon-server update [--check] [--version vX.Y.Z] [--timeout S] rollback [--timeout S]
horizon-server update --archive FILE --version NAME an archive you have (your own build)
horizon-server mcp MCP server on stdio (section 8)
horizon-server import-resources <dir> copy game files prepared elsewhere into place (checked first)
Planned:
horizon-server prepare --server-jar PATH
```
- Every command takes `--dir` (the server folder; default: the current folder).
- While the server runs, management commands go through the control API, so the engine applies them as
vanilla's console would and its lists stay right. While it's stopped, `op`, `deop`, `whitelist`, `ban`,
`pardon`, `ban-ip` and `pardon-ip` edit the vanilla files directly with the same code the engine uses
(`horizon_server_files::user_lists`).
- **The TUI** is `horizon-server console` (built, `console.rs`): the recent log and then the live one (the
control API's event stream), a one-line header (TPS, MSPT, players, RAM) that updates
every second, and a command line. It uses plain ANSI with no TUI framework, and falls back to plain lines
when stdout isn't a terminal.
## 6. Web panel
**Scope (MVP, PR 4):** status (phase, uptime, version, crashes), live TPS, MSPT, RAM and CPU graphs (the last
10 minutes, sampled once a second), players (list with ping, op, kick), the console (live log plus a command
line), and backups (list, back up now, restore with confirmation), with restart and stop. The panel lives in
the supervisor, so it is up exactly when the server is: starting a stopped server stays a command (or the
service manager's job). Whitelist and bans go through the console for now.
**Updates:** when a newer release is out, the panel says so with an Update button, which runs the same
path as `horizon-server update` (section 7) and then shows how it went.
**Later:** bans and IP bans, worlds, `server.properties` import, logs and crash reports, and modpacks (with
the modpack work).
**Tech:** one HTML page with plain JS and CSS (`crates/horizon-server/panel/`), embedded in the binary with
`include_str!`. No build step, no framework, no CDN. It uses Horizon's design system tokens (`horizon-theme`'s
colours, light and dark) and draws charts on a canvas. It polls the control API's routes once a second
(`status`, `metrics?since=`, `logs?since=`), served by the same small HTTP code on `std::net`, with no async
runtime or new dependency. Server-sent events can replace the polling later if a tab ever costs too much.
**Auth: local or tailnet only, never an open admin port.**
- **Bind.** The panel listens on `127.0.0.1:8130` by default. `panel.bind` can name a tailnet address
(100.64.0.0/10 or Tailscale's fd7a:115c:a1e0::/48) or another loopback address. Any other address, such as
0.0.0.0 or a public IP, is refused unless `panel.allow_public = true` is set, and even then the panel logs
a warning at every start.
- **Who may act.** The API token in `.horizon/api.json` (file mode 0600) is what the CLI and MCP use. Browsers
get a one-time login link from `horizon-server panel`: a code good for 10 minutes, swapped for an
`HttpOnly; SameSite=Strict` session cookie. Anyone who can read the server folder can manage the server,
and no one else can. Sessions end when the supervisor stops.
- **Protection.** The `Host` header must name the panel itself: its address, `localhost` on loopback, or a
tailnet name (a MagicDNS short name or `*.ts.net`). That stops DNS rebinding. Changes (POST, PATCH, DELETE) need
the `X-Horizon: 1` header, which stops CSRF. After 10 failed logins in a minute, logins are refused for a
while. The page is served with a strict Content-Security-Policy (nothing from elsewhere) and can't be
framed.
- **Remote access** goes over Tailscale/SSH (`ssh -L 8130:127.0.0.1:8130 host`), with no TLS or passwords to
manage. HTTPS comes for free with `tailscale serve`, and the doc shows how to set it up.
## 7. Backups, updates and crash restart
- **Backups.** The supervisor runs `save-off`, then `save-all flush`, then zips the world plus the vanilla
files and `server.toml` into `backups/<UTC time>-<name>.zip`, then runs `save-on`. Players stay online. Zip
works for everyone, Windows included. It runs on the `every` schedule, on demand and before an
update. It keeps the last `keep` automatic backups and never deletes one made by hand. A backup is written
to a temporary name and renamed when complete, so a crash never leaves a half-written backup that looks
finished. A stopped server is backed up straight from its files.
- **Restore.** `restore NAME` takes a file name, a unique prefix, or the name given to `backup` (the newest
with it). The server stops. The current world is moved to `backups/replaced-<time>/` (never deleted), the
backup is unpacked into a temporary folder and checked (`level.dat` is there), then moved in, and the server
starts again if it was running. One command, or one click with confirmation in the panel. `restore NAME
--dimension minecraft:the_nether` (API: `dimension` in the body; MCP: `dimension`) brings back only that
dimension's folder (`dimensions/<namespace>/<path>`), the same way, and leaves the other dimensions,
`level.dat` and the players as they are; the folder it replaces goes to `backups/replaced-<time>/`.
- **Stopped game.** A `stop` that reaches the game in game, over RCON or through `horizon-server cmd` leaves the
supervisor, control API and panel alive with no engine process; `stop` on the supervisor's own stdin (`run`, a
hosting panel's console) still ends both, and while the game is stopped that stdin takes `start` and `stop`.
`horizon-server start` or `POST /api/v1/start` starts the game again; `horizon-server stop` and the management
protocol's `server/stop` (as vanilla's, the process exits) still shut down both the game and the supervisor. File-backed player-list edits are safe during this stopped state.
- **Crash restart.** If the engine exits without being asked to stop, the supervisor keeps the last 200 log
lines and the engine's crash report (`crash-reports/`), restarts it after 5 s, and doubles the wait up to
5 minutes. It gives up after 5 crashes in 10 minutes, so it doesn't loop on a broken world, and shows the
reason in `status` and the panel.
- **Updates** (`crates/horizon-server/src/update.rs`), for a `horizon-server` installed by `install.sh` or
`install.ps1`. Releases ship often (release-flow.md), so updates stay opt-in: a running server looks for a
newer release a minute after it starts and then every 6 hours, and only says so, in its log, in `status`
and in the panel (`HORIZON_NO_UPDATE_CHECK=1` turns the check off). `update --check` asks GitHub once.
`update`:
1. downloads the current Nightly's archive for this computer (the only build Horizon ships since 2026-10-28;
GitHub's latest release while there is none; on Linux the server build; `--version` picks another: a
`vX.Y.Z` release, or a `nightly-<date>` of the last 7 days) and checks it against its SHA256SUMS, whose Ed25519
signature must check with a key built into `horizon-server` (docs/release-flow.md, "Signed checksums"),
signed for the Nightly channel: an unsigned or tampered build is refused. A Nightly installs as
`nightly-<date>-<commit>`, newer than every `vX.Y.Z`; another build of the same day is an update too;
2. unpacks it into `<dir>/releases/<tag>`, beside the others, with its game files linked to
`<dir>/game-files` as the installers do;
3. stops the game, backs up the world (`backups/<time>-before-<tag>.zip`), records the release in use in
`<dir>/previous` and points `<dir>/current` at the new one;
4. checks that the new game loads on this system (`--help`), then starts it;
5. waits until it has logged `Done` and answered a status request, for 300 s (`--timeout`).
If the new game doesn't load, stops, or isn't up in time, the supervisor goes back by itself: `current`
points at the release before, `previous` is left as it was, the world is put back from the backup (the
one the new game left is kept in `backups/replaced-<time>/`), and the game starts again on the old
release. `rollback` switches to `previous` by hand, through the same path (the world is backed up first
and kept as it is). The supervisor keeps running as it was: the new `horizon-server` itself takes over
the next time the server starts (`stop` and `start`, or the service's restart). A stopped server is
switched and its new game checked to load; it starts on the new release next time. One install serves
every server folder on the machine, so each one runs the new release from its next start. Once the new
game is up (or a stopped server is switched), old releases are deleted from `<dir>/releases/`: the one in
use, the one `previous` names (where `rollback` goes), the one the running `horizon-server` itself came
from, and the `updates.keep` newest of the others (1 by default) stay. A release that can't be deleted (a
program still running from it on Windows) is left for the next update.
## 8. Control API and MCP
- **Control API.** The control API is the panel's HTTP API, documented as stable (`/api/v1/...`). The
supervisor serves it on `127.0.0.1` at a port of the system's choosing, written with the token to
`.horizon/api.json`. It's plain HTTP/1.1 on `std::net` (`crates/horizon-server/src/http.rs`), no runtime.
- built (PR 2b, 2c): `GET status` (phase, uptime, crashes, RSS, CPU, and the engine's TPS, MSPT
average/P50/P95/P99, players, chunks), `GET logs?since=`, `POST command {line}` (returns the output),
`POST start`, `POST stop`, `POST restart`, `GET backups`, `POST backups {name}`, `POST restore {name}`, `POST update
{tag, timeout}` (without a tag: the newest release, downloaded first; `status` has an `update` object
that says how it went), `GET metrics?since=`;
- built (server-admin): `GET players` (online with address, ping, position and op level; ops, whitelist, bans, IP
bans), `POST players/{kick,ban,pardon,ban-ip,pardon-ip,op,deop}`, `POST whitelist {enabled}`,
`POST whitelist/{add,remove}` (list edits also work while the game is stopped; running edits use
vanilla's console command and answer `changed` and its lines),
`GET metrics/prometheus` (Prometheus text);
- built (server-admin): `GET config` (the token hidden), `PATCH config {changes}` (as `config set`;
not `engine.*`, `panel.*`, `api.*`, nor the RCON, query and management keys, so the API can't widen who
reaches the server or change what program runs), `DELETE backups?name=`, `GET backups/download?name=`
(the zip, streamed);
- built (server-admin): `GET events` (server-sent events: log lines, the phase, and the engine's typed join,
leave, kick, chat and death events, which the supervisor fetches with `server-events SINCE` on the
engine's control endpoint every 250 ms; JSON when the client doesn't ask for a stream).
- **Auth.** Every call needs `Authorization: Bearer <token from .horizon/api.json>` (file mode 0600), and the
`Host` header must name the address it listens on (as the panel's). `[api] bind` and `[api] token` fix the
address and the token for a program that runs elsewhere (a moderation bot); `bind` takes loopback or a
tailnet address unless `allow_public = true`, which drops the Host check and warns at every start.
- **MCP** (built, `mcp.rs`). `horizon-server mcp` is an MCP server on stdio, for Claude Code or any MCP client
run as the same OS user. It reads the token itself, and its tools map one to one onto the API: `status`,
`players`, `run_command`, `logs`, `events`, `kick`, `ban`, `pardon`, `ban_ip`, `pardon_ip`, `op`, `deop`,
`whitelist`, `list_backups`, `backup`, `delete_backup` and `restore` (both need `confirm: true`), `get_config`,
`set_config`, `start`, `restart` and `stop` (needs `confirm: true`). Tools that send someone away or can't be
undone carry MCP's `destructiveHint`. It's the same idea as the game's `horizon-ctl`
(docs/agent-control.md): an AI can run and diagnose a server, and every action it takes shows in the log.
- **RCON.** `enable-rcon`, `rcon.port` and `rcon.password` turn on vanilla's remote console, for the hosting panels,
Discord bridges and scripts written for it. The supervisor serves it (`rcon.rs`, std only like `http.rs`), so it
answers while the game restarts (a command then says the game isn't running), and it reads its settings as
`RconThread.create` does: no password or a port outside 1 to 65535 leaves it off with vanilla's warning. The
packets are `RconClient`'s: login (3), command (2), the output in packets of 4096 characters, `-1` for a refused
login, `Unknown request <hex>` for another type, `Error executing: <command> (<reason>)` for a command that failed.
A command goes to the game as an `rcon <line>` request on the control endpoint (`horizon-server cmd` sends
`console <line>`, the same console under the `Server` source), so the
output is what the console prints, its messages joined as `RconConsoleSource` joins them. Differences, each one
named in `rcon.rs`: the packets are read from the stream, the password is compared in constant time, a
connection that hasn't logged in within 30 seconds is closed and at most 16 are open. A command runs as
`RconConsoleSource` runs it: at level 4 from a source named `Rcon`, whose `say` reads `[Rcon] message` and whose
feedback reaches the operators in game as `[Rcon: message]` while `broadcast-rcon-to-ops` is on (the default), and
`send_command_feedback` is on; the reply to the client always carries the feedback, whatever those say.
- **GS4 query.** `enable-query` and `query.port` turn on vanilla's UDP query, which server lists, status pages and
monitoring tools read the MOTD, the version and the players from. The supervisor serves it (`query.rs`, std
only), reads its settings as `QueryThreadGs4.create` does (a port outside 1 to 65535 leaves it off with
vanilla's warning) and speaks `QueryThreadGs4`'s protocol: the handshake (type 9, a token below 2^24 as text),
the basic stat and the full stat (a request of 15 bytes), each only with the token. The players come from the
game's `server-status`, read at most once a second, and a full stat is kept 5 seconds, as in vanilla. Differences,
each one named in `query.rs`: nothing answers while the game isn't running, and a session id outside ASCII is
echoed as lossy UTF-8.
- **Vanilla's own management protocol.** Vanilla 26.3 has a server management protocol: JSON-RPC over a
WebSocket, set up by the `management-server-*` keys in server.properties, so tools written for vanilla can
manage a Horizon server too. The supervisor serves it (`management.rs`, the WebSocket in `websocket.rs`, std
only like `http.rs`; TLS, on by default as in vanilla, through rustls in `management_tls.rs`, from a PKCS12
keystore, or a self-signed one Horizon makes when none is set), so it answers while the game restarts
(`server/status` says `started: false` then); calls that change the game go through its console.
Authentication, errors and replies follow vanilla's `net.minecraft.server.jsonrpc` (`AuthenticationHandler`,
`Connection`, `methods/*Service`), and `rpc.discover` gives vanilla's OpenRPC document (`management_schema.rs`).
`tools/test_management_protocol_parity.py` sends vanilla's server and Horizon's the same messages over TLS
(one keystore for both) and compares the answers, and the document with the data generator's
`json-rpc-api-schema.json`.
- built: the WebSocket and its authentication, TLS, `rpc.discover`, `players`, `players/kick`,
`server/{status,save,stop,system_message}`, and the four lists (`allowlist/*`, `bans/*`, `ip_bans/*`,
`operators/*`), whose changes the game applies itself (its control endpoint's `management` request, in
`game/dedicated/management.rs`), so the source, expiry, log lines and kicks are vanilla's;
- notifications: the supervisor pushes them to every connected client as vanilla's `JsonRpcNotificationService`
does (a JSON-RPC request without an `id`, its one parameter in an array), through a queue and a thread so a slow
client never holds the game's output. Built: `server/started` (the game's `Done`) and `server/stopping`
(when the supervisor or the game's own `stop` begins the shutdown, once per run), `server/saving` and
`server/saved` (around every world save of the running game: `save-all`, the autosave, the pause when
empty; a save the game makes while it stops is after the last one the supervisor hears of), and
`players/joined` and `players/left` (from the game's typed events, within a quarter of a second), and
`operators/*`, `allowlist/*`, `bans/*` and `ip_bans/*` `added`/`removed`: each of the game's lists tells a
watcher what `add`, `remove` and `clear` do, as vanilla's `StoredUserList` tells its `NotificationService`,
so a change by the console, a command in the game or the protocol itself is told alike (`clear` once per
entry); the event stream carries them as `operators_added`, `bans_removed`... too;
- game rules: `gamerules` lists every rule the game has as a typed rule (`{key, type, value}`), `gamerules/update`
sets one (checked against its type and bounds with vanilla's error texts, and set, logged and told on every
update, as vanilla's does) and `notification/gamerules/updated` follows every change, by that method or by
`/gamerule` (`Game::set_game_rule`); the parity script compares both servers on all of it;
- `server/activity`: a login attempt (the game records `login_activity` in its login gate, as
`ServerActivityMonitor.reportLoginActivity` is called when a login starts), told at most once in thirty
seconds and lost otherwise, as the monitor clears its flag whatever it did; and `server/status`, the
heartbeat: every `status-heartbeat-interval` seconds (none at 0, the first after as many) the status
`server/status` answers, up or not, to whoever is connected;
- planned: the rest of `serversettings/*` and the world-upgrade notifications (after `WorldUpgrader`'s server
side).
## 9. Benchmark plan (bots against vanilla and Paper)
The goal is the published claim: "runs 50–100 players on hardware where vanilla struggles". It must be
reproducible by anyone, and show the same gameplay on both sides.
- **Servers:**
- Horizon (`horizon-server`);
- vanilla 26.3 (the owner's `server.jar`);
- Paper (a jar the owner downloads; Paper's defaults are kept, and noted as non-vanilla);
- optionally Fabric + Lithium (via the pinned `tools/benchmark-deps/mods.lock.json`).
Java servers run on Java 25 with `-Xms` = `-Xmx` (the same heap for all, 4 GB by default), and the heap is
reported.
- **Same conditions:**
- the same seed (`12345`) and a fresh world, plus a second pass on a pre-generated copy of the same world;
- `view-distance` 10 and `simulation-distance` 10;
- `online-mode=false` and the same `server.properties`;
- one server at a time on the same machine, with the bots on another machine on the LAN, or pinned to other
cores when they share one (the report records which).
- **Bots:** `horizon-bots` (PR 5), a small Rust load client on `horizon-protocol`. It handles handshake,
offline login and configuration, accepts teleports, answers keep-alives and pings, acknowledges chunk
batches as vanilla's client does, and sends movement at 20 Hz. The bots play in creative and fly 24 blocks
above their spawn point: they never touch terrain, so they need no copy of the world, and no server can
refuse a move for walking into a block. Each behaviour is deterministic per bot from a seed:
- `idle`: hovers above spawn;
- `walk`: flies to random points within 48 blocks of spawn at sprint speed (5.6 blocks/s);
- `explore`: flies straight outwards at creative flying speed (22 blocks/s), which forces chunk generation;
- `mixed`: 60 % walk, 20 % explore, 20 % idle.
- `fight`: survival bots (`gamemode=survival`, `force-gamemode=true`, `allow-flight=true`) that track the entities the server
sends, walk to the nearest living one and attack it every 5 ticks, vanilla's bare-hand cooldown; with `--pvp true` (the
default) they attack each other too, which needs `pvp=true` on the server. The report counts `attacks`.
A `build` behaviour (breaking and placing blocks) needs the bots to read terrain and comes later. Runs use
1, 10, 25, 50 and 100 bots, joining 1 per second. Bots are needed because 100 real vanilla clients can't run
on one machine.
- **Measured** (once a second, for 5 minutes after the last bot joins, after a 1-minute warm-up):
- TPS and MSPT (average, P50/P95/P99), from each server's own `/tick query` (vanilla's
`ServerTickRateManager`; Horizon implements the same command and clock), so every server measures itself
the same way;
- RSS and CPU (process tree, `horizon_bench::rss_bytes`/`cpu_seconds`);
- chunk generation: chunks per second while the `explore` bots run, and time until each new bot has its
full view loaded;
- start time: cold and warm, to `Done`;
- bot-side: chunk arrival, keep-alive round trip, and disconnects (a run with disconnects is reported as
failed).
- **Output.** `tools/server_bench.py` drives it: a fresh server per run from the same `server.properties`,
`Done`, the bots, a warm-up, then the measurement. It writes a `horizon-server-benchmark` JSON report (the
machine, commit, Java version, settings and every run) and prints a comparison table. Results are
published like the client benchmark, with the machine, the versions and the exact commands. TPS is derived
from `/tick query`'s average (20 while it fits in 50 ms), since the command reports times, not the rate.
- **First run (PR 5,** on a busy shared Mac mini, so the numbers are noisy). With 1 bot Horizon ticks in
3.5 ms against vanilla's 11.2 ms and uses 1.6 GB against 3.6 GB. At 10 and 25 `mixed` bots Horizon falls
to about 10 TPS where vanilla keeps 20, and at 50 both land at about 15 TPS. The work isn't equal yet:
- Vanilla pulls fast players back when it lags (`moved too quickly`), which Horizon never does, so
vanilla's `explore` bots generated less terrain.
- Vanilla's chunk sending yields under load.
- Horizon sent each nearby `walk` bot 1.5–4 times as many chunks.
Horizon sent 2–4 times the chunks per second in every multi-bot run.
- **Equal-work run (2026-10-17,** `benchmarks/server-equal-work-2026-10-17/`, on the ThinkPad with the bots
on the Mac mini). Horizon now checks moves and paces chunks as vanilla does (#586), the bots wait for the
chunk they stand in, an `--explore-speed` pass keeps both servers at the same rate, and vanilla runs with
`gamerule locator_bar false` (Horizon had no locator bar when this ran; since #692, merged the same day,
it sends one to vanilla clients). Results:
- Memory: Horizon's RSS is 1.9 to 2.6 times smaller in every run (vanilla's heap is fixed at 4 GB).
- Walking near spawn: Horizon's tick is shorter with 1 and 10 bots (5.5 against 18.3 ms, 11.5 against
16.8), and longer with 25 and 50 (20.7 against 16.0, 31.4 against 20.5). Both keep 20 TPS up to 50.
- Exploring at the same rate: Horizon's tick is about half of vanilla's (13.5 against 29.2 ms at 10 bots,
20.2 against 37.0 at 25).
- At 100 bots: vanilla's watchdog stops it in every pass; Horizon runs at 9 to 11 TPS.
Two server costs were found and fixed on the way: every chunk section was encoded once per player (#623),
and every packet woke its connection's writer thread (#649). Together they took the 50-bot walking tick
from 281 ms to 31 ms. At 25 walking bots most of what remains is the engine's streaming and block entity
ticks, both of which grow with players (the README has the profile).
- **Mixed bots, as installed (2026-10-18,** `benchmarks/server-mixed-2026-10-18/`, the first published
run for the working version). It used the v2026.10.18.15 release as `install.sh` installs it, against
vanilla 26.3, with 1, 10, 25 and 50 `mixed` bots and vanilla's default game rules on both (Horizon has
the locator bar now). The servers ran on the ThinkPad and the bots on the Mac mini. Results:
- Both kept 20 TPS in every run.
- Horizon's RSS was 3.7 to 5.2 times smaller (1.0 GB against 4.0 GB at 50).
- Horizon's average tick was shorter with 1, 10 and 25 bots (2.4 against 17.9 ms, 12.4 against 34.5,
24.0 against 31.6) and longer with 50 (35.2 against 29.7). Its P99 was lower at every count.
- Horizon also did more work: it kept up with the exploring bots, so it generated 1.9 to 2.9 times
the new terrain a second.
- **Mixed bots, equal work (2026-10-18,** `benchmarks/server-equal-mixed-2026-10-18/`). This is the same
run with the load made equal. The explore bots fly at 0.8 blocks a second, so every bot follows the same
path on both servers and none waits for terrain. A 300-second warm-up lets both finish the bots' first
views before the measurement. `server_bench.py` now prints a work table that compares each count's chunks
a second across servers and flags a run that isn't equal. Horizon ran as `horizon-engine`, the server
build, installed from its archive. Results, on the ThinkPad again (the desktop didn't wake):
- The work was equal at 1 and 10 bots. At 25 the chunks sent a second were equal (158.6 against 157.6),
and vanilla still sent 2.5 more first-time chunks a second while it caught up.
- Tick: 1.8 against 11.3 ms at 1 bot, 4.7 against 34.3 at 10, 11.7 against 50.5 at 25 (vanilla 19.8 TPS).
At 50 Horizon ran at 23.8 ms. Vanilla's watchdog stopped its server 30 s after the last bot joined (a
60-second tick). This is one pass, to repeat on a bigger machine.
- Horizon's RSS was 4.1 to 4.9 times smaller (1.03 GB at 50).
- Served in full, vanilla's walking bots got 5400 to 6100 chunks each, against 640 to 1100 in the first
run. That is why vanilla held 50 bots there and not here.
- **Four servers, equal work (2026-10-19,** `benchmarks/server-four-way-2026-10-19/`). The same load
against vanilla, Paper (build 205, its defaults except the connection throttle, which refused bots that
share an address) and vanilla on Fabric with Lithium. Horizon was release v2026.10.19, which spawns mobs
and ticks blocks around every player as vanilla does (#950, #931), so it now does vanilla's work away
from spawn too. Results, on the ThinkPad (the desktop didn't wake):
- The work was equal on all four at every count (chunks a second within 4 %).
- Horizon's RSS was 3.8 to 4.8 times smaller (0.72 to 1.11 GB). It started in 8.2 to 8.5 s, against
14.7 to 22.4 s for the Java servers. From 10 bots up it used the least CPU.
- Average tick: Paper was ahead of Horizon at every count (10.3 against 13.7 ms at 1 bot, 28.3 against
30.9 at 25, 46.9 against 47.8 at 50). Fabric + Lithium was ahead at 1 and 10 bots (12.5 and 20.2
against 13.7 and 22.9), behind at 25, and fell to 17.2 TPS at 50. Vanilla was slowest, and its
watchdog stopped it at 50.
- Horizon's P99 was the lowest from 10 bots up (73 ms at 50, against Paper's 110 and Fabric's 174), and
its worst single P99 the lowest at every count.
- **Ownership.** perf-server-1 owns making the tick faster. This work only measures it, files what it finds,
and fixes startup, RAM and packaging.
## 10. Working version for 2026-10-23
Goal: `horizon-server` installs with one command, starts from a clean config, serves Horizon and vanilla 26.3
clients, backs up and restores, has a CLI and a basic web panel, and has a first published benchmark against
vanilla.
**In:**
1. `horizon-server` crate and binary, `horizon-server-files` (the vanilla files, moved out of the client
unchanged), `server.toml` with import, the CLI (`init`, `run`, `start`, `stop`, `status`, `cmd`, `op`,
`whitelist`, `backup`, `backups`, `restore`, `logs`), crash restart, and the engine status endpoint.
2. `install.sh` and `install.ps1`, the Dockerfile, systemd and launchd units, `service install`, and the server
archive in the release.
3. The web panel MVP: status, live TPS/MSPT/RAM/CPU, players, console, backups and login links.
4. `horizon-bots` and `tools/server_bench.py`, with a first run against vanilla (1/10/25/50 bots, `mixed`)
published in `benchmarks/`: done, `benchmarks/server-mixed-2026-10-18/`, and with equal work
`benchmarks/server-equal-mixed-2026-10-18/` (section 9).
5. Small engine fixes found on the way: logging refused logins (vanilla's lines).
**Where it stands (2026-10-16):** items 1 to 4 are built and in review (section 11). What's left for the 23rd:
run the Dockerfile and `install.ps1` once on a machine that has Docker and on Windows; publish a release
that holds `horizon-server`, so the installers have something to install; a clean benchmark run on a quiet
machine, then published in `benchmarks/`; and item 5.
**Out (next):**
- the Nether and End for remote players;
- the headless engine and its RAM win;
- a server-only prepare without a JDK;
- a Windows service, hot reload, modpacks in the panel;
Online mode works, so a public server can check accounts. An offline server should still run only on a
LAN, a tailnet, or behind a whitelist.
## 11. Pull requests
In order, each one small and shippable alone:
1. **This design** (docs only).
2. **`horizon-server`**, stacked in three parts:
- a. The crate, the vanilla files moved out of the client unchanged (`horizon-server-files`),
`server.toml` with import, `init`, `run` (crash restart, clean stop on Ctrl-C and SIGTERM), and `op`
and `whitelist` for a stopped server: #551.
- b. The control API on 127.0.0.1 with its token, `start`, `stop`, `restart`, `status`, `cmd` and
`logs`, plus the engine status endpoint. That endpoint is about 30 additive lines in
`game/dedicated.rs` plus a new `game/dedicated/status.rs`. perf-ticks-2's branches touch
`dedicated.rs` elsewhere (`listen: true`), so the edits don't overlap. It also makes the dedicated loop
record its tick times, so `/tick query` reports them: #552.
- c. Backups, restore and automatic backups: #553.
3. **Install scripts, Docker and services** (`service install`), and `horizon-server` in the releases: #554.
4. **Web panel MVP:** #555.
5. **Bots and the first benchmark against vanilla:** #556.
Each PR is stacked on the one before. Merging them in order keeps every step shippable alone.