Millennium Admin Guide - Help

1. Ways to run a world

SetupGood forHow
Host GameA few friends, now. Nothing to install beyond the game.Click Host Game on the sign-on screen. Your computer runs the world (saved to saves/world.db, backed up hourly) and you're its admin.
Dedicated serverA world that's always on: a club, a community, a public server.Docker, or a systemd service on Linux (below). SQLite or PostgreSQL.
ClusterMore players than one process should carry.Several zone servers plus a world hub, sharing one Postgres (below).

SQLite or PostgreSQL?

  • SQLite is one file and nothing else to run. It's fine for one server and backs itself up automatically.
  • PostgreSQL is for clusters, for very busy worlds, or if you already run it.

Both are tested on every change, including a crash-and-restore test.

2. Before you open the doors

  1. Set a signing key. Run millennium-server --generate-key and put the result in network.private_key, or in the MILLENNIUM_PRIVATE_KEY environment variable. Without it the server uses an all-zero development key, warns at startup, and anyone could forge a login.
  2. Name your admins in [chat] admins. These are account names, not character names.
  3. Open port 5888 for TCP and UDP. TCP is the login, UDP is the game. Players need both.
  4. Check your backups. SQLite backs itself up. For Postgres, enable the backup timer and try a restore once.
  5. Turn on the status page ([metrics] bind = "127.0.0.1:9188"), and keep it private.
  6. Change the default Postgres password if you use the bundled docker-compose.yml.
  7. Optional: your own blocked words, the length of a day, and an event on the calendar.

3. Installing a server

Docker (SQLite in a volume)

git clone https://github.com/rsantacroce/millennium.git && cd millennium
docker build -t millennium-server .
docker run -d --name millennium --restart unless-stopped \
  -p 5888:5888/tcp -p 5888:5888/udp -v millennium:/data \
  -e MILLENNIUM_PRIVATE_KEY=$(openssl rand -hex 32) millennium-server

Everything lives in the volume:

  • world.db: the world.
  • backups/: the automatic backups.
  • tls/: the login certificate. Keep it: if it changes, players get an "identity changed" warning.

Map the port to the same number on the host: clients are sent to port 5888 of the address they logged in on.

Docker with Postgres

echo "MILLENNIUM_PRIVATE_KEY=$(openssl rand -hex 32)" > .env
docker compose --profile server up -d --build

systemd (Linux)

cargo build --release -p server
sudo install -m 755 target/release/millennium-server /usr/local/bin/
sudo useradd --system --home-dir /var/lib/millennium --create-home millennium
sudo mkdir -p /etc/millennium && sudo cp config/server.toml /etc/millennium/
sudo cp deploy/millennium-server.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now millennium-server

Put secrets (MILLENNIUM_PRIVATE_KEY, DATABASE_URL) in /etc/millennium/env, readable only by root.

The unit restarts the server if it crashes, only lets it write to /var/lib/millennium, and stops it with SIGINT: the clean shutdown that saves everyone online first.

Step by step, with every detail: docs/deploy.md.

4. Every setting

All in config/server.toml (or /etc/millennium/server.toml). Every key is optional: a missing key takes the default shown.

Environment variables override the file:

  • DATABASE_URL
  • MILLENNIUM_PRIVATE_KEY
  • MILLENNIUM_CLUSTER_SECRET
  • RUST_LOG

Top level

KeyDefaultWhat it does
log_filterinfoLog levels, e.g. info,server=debug. RUST_LOG overrides it.
multi_threadedfalseRun the simulation on several threads. Off is faster for this game (see Capacity).

[network]

KeyDefaultWhat it does
bind0.0.0.0:5888The game (UDP).
auth_bind0.0.0.0:5888The login service (TLS over TCP). Clients assume the same port number as the game.
tls_dirsaves/tlsWhere the login certificate is created on first start. Back it up.
private_key(empty: development key)64 hex characters that sign connect tokens. Set it for any real server.
max_clients64Players connected at once. See Capacity before raising it a lot.

[database]

KeyDefaultWhat it does
url(empty: in memory)sqlite://saves/world.db, postgres://user:pass@host:5432/db, or empty for a world that's forgotten on restart (testing only).
max_connections5Postgres connection pool size.
save_interval_secs10How often positions are written. Everything else (items, cash, quests...) is saved the moment it changes.
backup_dirsaves/backupsSQLite only: where automatic backups go.
backup_interval_minutes60SQLite only: a backup at startup and then this often (0 = off).
backup_keep48SQLite only: how many backups to keep.

[chat]

KeyDefaultWhat it does
admins[]Account names with admin powers (see Being an admin).
burst, per_second5, 1Chat rate limit: bursts of 5 messages, then 1 per second.
strikes_before_mute, auto_mute_secs5, 60Hitting the limit 5 times in a row means an automatic 60 s mute.
history_global, history_whispers50, 50Lines of global chat and whispers replayed at login.
blocked_words[]Whole words replaced in chat. Case doesn't matter, and d4rn and daaarn are caught too.

[world]

KeyDefaultWhat it does
day_minutes40Real minutes per game day. Clients get it when they log in.
clock_shift_hours0Moves the clock (12 = start at night). Use the same value on every server of a cluster.
y2k_event_at(none)Schedule the Y2K event: midnight as an RFC 3339 time, e.g. "1999-12-31T23:00:00Z".
y2k_countdown_minutes10How long before that midnight the countdown starts.
y2k_minutes30How long the city has to fix the bug.

[metrics]

KeyDefaultWhat it does
bind(empty: off)Address for the status page, /metrics and /health. Keep it private (127.0.0.1 or firewalled).
log_every_secs60A status line in the log this often (0 = never).

[cluster] and [hub]

Only for running the world on several servers: see Bigger worlds.

5. Being an admin

Add your account name to [chat] admins and restart the server. With Host Game, the host's account is the admin automatically. Admin commands are typed in the chat box.

CommandWhat it does
/adminThe admin window: everyone on this server (with Kick, Mute and Ban buttons) and the open player reports. It refreshes itself every 10 seconds.
/mute name [minutes], /unmute nameNo chat for that long (default 10). The account stays muted across logins.
/mod kick name [reason]Disconnect them now. They can log back in.
/mod ban name [hours|perm] [reason]Disconnect them and refuse the account. The default is 24 hours; perm means for good. They see the reason and the end time when they try to log in.
/mod unban nameLift a ban.
/mod reports, /mod resolve idList open reports in chat; close one.
/mod resetcode nameA new password recovery code for a player who lost theirs (see accounts).
/mod event y2k [minutes] [goal], /mod event stopStart or call off the New Year's Eve event (see events).
/give item [n], /givecash n, /givexp skill n, /givecar modelFor testing: give yourself items, cash, XP or a car. Item ids are on the asset list.

Kicks, bans, recovery codes and events go in the server log with the admin's name, so there's a record if you ever need one.

6. Reports, mutes and bans

  • Players report with /report name what happened (once every 30 seconds each). The report saves that player's last 10 lines of chat, as the server saw them, so you see what was actually said, not just the complaint.
  • Admins who are online get a notice right away. Open /admin and rest the mouse on a report to read the chat it came with.
  • A suggested ladder:
    1. a word in a whisper;
    2. a mute (/mute name 30);
    3. a kick;
    4. a ban that grows: 24 hours, then a week (/mod ban name 168 reason), then permanent.
    Always give a reason: the player sees it.
  • Close the report when it's dealt with (Resolve, or /mod resolve id).
  • Bans are on the account, so making a new character doesn't get around them. A banned account can't be deleted either, so the record stays.
  • Automatic protection: flooding chat earns a 60-second mute without you lifting a finger. Blocked words are filtered on the server, so a modified client can't skip the filter.

7. Helping players with accounts

  • Forgotten password: accounts have no email address. Every account gets a recovery code (like K7QM-2XVD-PR9T), shown once when it's created, and the player resets the password themselves with Forgot password? on the sign-on screen.
  • Lost the code too? Check they really own the account (ask about their characters, their crew, what's in their house). Then /mod resetcode name gives you a fresh code to pass on privately, for example by whisper. It replaces their old one. You never see or set passwords.
  • Changing a password: Change password on the sign-on screen (the current password is needed).
  • Deleting an account: players do it themselves with Delete account, which needs the password and typing DELETE. It's immediate and permanent:
    • its characters, items, cash and house go;
    • bidders on its auctions are refunded through e-Bid claims;
    • a crew it led passes to the longest-standing member.
    If they're online they're logged out first.
  • Too many failed logins (10 within 10 minutes from one address) lock that address out for a few minutes. It clears by itself.

8. Running events

Y2K: New Year's Eve 1999

  • Start it now: /mod event y2k gives a 10-minute countdown and a goal based on how many are online at midnight. /mod event y2k 2 60 gives a 2-minute countdown and a goal of 60.
  • Or schedule it: set y2k_event_at in [world]. The countdown starts y2k_countdown_minutes before.
  • What players do:
    • fix glitched terminals (Screwdriver Set, 5 points);
    • restart Metro generators (Wrench, 8);
    • bring hot cocoa to stranded commuters (3);
    • stomp Y2K Bugs (1).
  • Choosing a goal: the default is 30 plus 15 per player online at midnight, meant to be comfortably reachable. Raise it for a challenge (most jobs mean walking across town), or lower it for a short, casual event.
  • If they make it before y2k_minutes runs out, there's a party with fireworks. Everyone who helped and is still online gets the "I Survived Y2K" T-shirt, a floppy disk for their house and City Services reputation. If not, helpers keep the floppy.
  • Call it off with /mod event stop.
  • Tips:
    • Announce it in global chat beforehand.
    • Remind people to bring a Screwdriver Set and a Wrench.
    • Cocoa mix is sold at the Corner Store and Value Grocer.
  • In a cluster, each zone server runs its own share: start it on each one.

9. Backups and restores

SQLite: automatic

  • When: a consistent copy at every start and then every hour, made while the game runs.
  • Where: saves/backups/, or /data/backups in Docker.
  • How many: the newest 48.

Copy that folder off the machine now and then (rsync, restic, your cloud's snapshots).

To restore:

  1. Stop the server.
  2. Copy the backup over the save file.
  3. Delete the -wal and -shm files next to it.
  4. Start the server.
systemctl stop millennium-server
cp saves/backups/world-20261012-120000-01.db world.db && rm -f world.db-wal world.db-shm
systemctl start millennium-server

PostgreSQL: a timer

scripts/backup-postgres.sh takes a pg_dump (safe while the server runs) and keeps the newest copies. Enable the hourly timer in deploy/.

To restore:

DATABASE_URL=postgres://... scripts/restore-postgres.sh saves/backups/millennium-20261012-120000.dump

Do a test restore once, into a new database, before you need one for real. The project's own tests do exactly that on every change.

10. Upgrades

  1. Take a backup (SQLite does one at every start anyway).
  2. Get the new version (git pull) and build it (cargo build --release -p server, or docker compose build).
  3. Restart. Database changes (migrations) apply themselves at startup.
  4. Tell your players to update their game too. An older client is told it's out of date when it tries to log in.

11. Monitoring

  • The status page: with [metrics] bind = "127.0.0.1:9188", open http://127.0.0.1:9188/. From your laptop, use an SSH tunnel: ssh -L 9188:127.0.0.1:9188 your-server. It shows players per zone, frame time, database speed and bandwidth, and refreshes every 5 seconds.
  • Prometheus and Grafana: scrape /metrics. Uptime checks: /health answers ok.
  • The log: a status line every minute, such as status: 12 players (Downtown 9, Harbor Docks 3), frame 0.9 ms avg / 4.1 ms max at 190/s, db 0.3 ms avg .... Plus logins, kicks, bans, backups, events and errors.
MetricHealthyIf it isn't
millennium_frame_seconds_avg / _maxA few ms average; max under 33 ms (one tick)The server is overloaded: fewer players per process, or a cluster.
millennium_db_command_seconds_avg, millennium_db_queue_lengthUnder a few ms; queue near 0A slow disk or database: saves and logins lag.
millennium_network_sent_bytes_total (rate)About 3-10 KB/s per playerLots of players crowded together: expected; plan bandwidth for it.
millennium_players_online, _by_zoneYour normal crowdA sudden drop: check the log and the UDP firewall.
millennium_channel_sent_bytes_total{channel=...}Mostly Mutations (movement)Useful for finding out what's using the bandwidth.

12. Capacity and performance

Measured with the project's load test (200 bots wandering the same few blocks, all on one Apple M4 Pro):

PlayersFrame time (avg / worst)CPUMemorySent per player
200, mostly in one zone2.8 ms / 27 ms (33 ms budget)about 60% of one coreabout 265 MB8.4 KB/s
  • A small VPS (1-2 vCPU, 1-2 GB of RAM) comfortably runs a world for dozens of players. 200 is fine on one modern core.
  • Bandwidth goes up when players crowd together:
    • players only receive the people within about 40 m;
    • those further than 15 m are updated half as often;
    • positions are sent compactly.
    Plan for about 10 KB/s per player at peak.
  • Test your own machine with scripts/loadtest.sh 100 120. It needs the source and a Rust toolchain.
  • Slow frames? Build with cargo build --release -p server --features profile. The server then logs any frame slower than MILLENNIUM_SLOW_FRAME_MS (20) with the systems that took longest.
  • Leave multi_threaded off: in testing, one thread had far fewer slow frames.

13. Bigger worlds: clusters

When one process isn't enough, split the zones over several zone servers linked by a world hub. config/cluster/ in the source has a worked example.

millennium-server --hub --config config/cluster/hub.toml
millennium-server --config config/cluster/zone-a.toml
millennium-server --config config/cluster/zone-b.toml
  • Shared by every zone server: the same Postgres database, the same network.private_key and the same [cluster] secret (or MILLENNIUM_CLUSTER_SECRET).
  • Each zone server's own settings:
    • [cluster] zones: its zones;
    • [cluster] hub: the hub's address;
    • [[cluster.servers]]: where the other zones run, as players reach them.
  • The hub is small and keeps no game state. It relays presence, global, whisper and crew chat, and parties. Keep its port ([hub] bind) private.
  • Moving between servers is seamless: walking or driving into a zone on another server saves you and hands you over in a second.
  • Raids run on the server that owns the raid's zone, and they start from a character in another zone. So keep each raid zone with the zone it starts from: 12 (the Meridian vault) with downtown (1), and 13 (Warehouse 7) with the docks (2). Otherwise players are told the job isn't run from this server.
  • The zone numbers: 1 Downtown, 2 Harbor Docks, 3 Galleria Mall, 4 Abandoned Factory, 5 Maple Heights, 6 the rooftops, 7 Route 9, 8 Pinecrest, 9 Hollow Pines, 10 Lake Wren, 11 Mile 88 Truck Stop, 12 the Meridian vault (raid), 13 Warehouse 7 (raid), and 101-105 the houses in Maple Heights (keep them with zone 5).
  • Limits: trades only work on the same server, and events run per server. Food buffs and cooldowns travel with the character.

14. How it keeps players safe

  • Passwords are hashed with argon2id and travel only over TLS to the login service, never over the game connection. Recovery codes are stored the same way.
  • The certificate: clients remember your server's certificate on first connect and warn if it ever changes. So keep tls_dir safe, and tell players if you reinstall.
  • The server decides everything: movement, combat, loot, trades, prices and hacks. A modified client can't give itself items or money.
    • Trades, auctions and house sales are single database transactions: items can't be duplicated or lost.
  • Hacking can't be cheated: it's a code-breaking game, and the code never leaves the server. A modified client sees only the answers to its guesses, like a player does, so a bot needs just as many guesses (see docs/hacking.md). Cooldowns still limit how often anyone hacks.
  • Limits:
    • chat is rate-limited, filtered and capped at 255 characters;
    • logins are throttled per address and capped at 32 at once.
  • Keep private: the metrics address, the hub port and the database port. Players only need port 5888.

15. Troubleshooting

SymptomLikely causeFix
"Logged in, but the game server didn't answer"TCP 5888 works, UDP 5888 doesn'tOpen UDP 5888 on the firewall and router, mapped to the same port.
"The server's identity has changed"The certificate in tls_dir was lost or replacedRestore it from a backup, or tell players to remove the line from saves/known_servers.txt.
"could not listen on UDP/TCP port 5888"Another server (or a hosted game) is runningStop it, or change bind / auth_bind.
"failed to start database"Wrong url, or Postgres isn't up yetCheck the URL; systemd retries every 5 s.
A warning about the development keyprivate_key isn't setSet one (--generate-key).
Players get "Your game is out of date"The server was upgradedThey need the matching game version.
Lag, rubber-bandingThe server or its network is overloadedCheck frame time and bandwidth on the status page; see Capacity.
A player can't log in: "Too many failed attempts"10 wrong passwords within 10 minutes from that addressIt clears by itself in a few minutes.

Something missing? Open an issue.