874b6f20c2
Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
626 lines
46 KiB
Markdown
626 lines
46 KiB
Markdown
# 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, also at
|
||
https://playhorizon.dev/server.
|
||
|
||
## 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`. 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). The recipe book, status effects and block sync while digging (#500) work. Left: a player's projectiles don't hit other players (no bow PvP between guests), and 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), loaded chunks and uptime. It answers
|
||
`console <line>` with the command's output.
|
||
|
||
**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.
|
||
|
||
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 SHA-256 GitHub
|
||
publishes for each asset. 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.
|
||
- **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
|
||
later 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 (PR 4 adds `[panel] bind = "127.0.0.1:8130"` and `allow_public =
|
||
false`, section 6). 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. Planned: `backups.before_update`
|
||
(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`; 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
|
||
real hot reload (view distance, MOTD) 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`); each waits for the game
|
||
horizon-server status [--json] up?, uptime, TPS, MSPT, players, RAM, CPU, crashes
|
||
horizon-server cmd <command…> one console command, with its output
|
||
horizon-server logs [-f] [-n N]
|
||
horizon-server op|deop <player>
|
||
horizon-server whitelist on|off|list|add|remove [player]
|
||
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)
|
||
|
||
Planned:
|
||
horizon-server console attach: the live log, and send commands (Ctrl-D detaches)
|
||
horizon-server ban|pardon <player> [reason] ban-ip|pardon-ip <address> (`cmd ban …` works now)
|
||
horizon-server import-resources <dir> prepare --server-jar PATH
|
||
horizon-server mcp MCP server on stdio (section 8)
|
||
```
|
||
|
||
- 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, they edit the vanilla files directly
|
||
with the same code the engine uses (`horizon_server_files::user_lists`).
|
||
- **The TUI** is `horizon-server console`: the log, 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 are POST only and 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 and on demand (later also 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.
|
||
- **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 newest stable release's archive for this computer (on Linux the server build; `--version`
|
||
picks another, a beta included) and checks it against the release's SHA256SUMS, whose Ed25519 signature
|
||
must check with a key built into `horizon-server` (docs/release-flow.md, "Signed checksums"): an unsigned
|
||
or tampered release is refused;
|
||
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 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);
|
||
- planned: `GET metrics?since=` (the panel's graphs), `GET events` (SSE: log lines and metrics),
|
||
`GET`/`PATCH config` (server.toml fields, validated).
|
||
- **Auth.** Every call needs `Authorization: Bearer <token from .horizon/api.json>` (file mode 0600), and the
|
||
`Host` header must be the loopback address it listens on.
|
||
- **MCP.** `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`, `backup`, `list_backups`, `restore` (needs `confirm: true`), `start`, `stop`,
|
||
`restart`, `get_config` and `set_config`. 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.
|
||
- **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. Horizon's engine already parses
|
||
those keys but doesn't serve the protocol. Serving it later, on the engine or the supervisor, would let tools
|
||
written for vanilla manage a Horizon server too. That is not part of the MVP.
|
||
|
||
## 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.
|
||
|
||
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 has no locator bar yet). 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.
|