Millennium Admin Guide - Help
1. Ways to run a world
| Setup | Good for | How |
|---|---|---|
| Host Game | A 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 server | A world that's always on: a club, a community, a public server. | Docker, or a systemd service on Linux (below). SQLite or PostgreSQL. |
| Cluster | More 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
- Set a signing key. Run
millennium-server --generate-keyand put the result innetwork.private_key, or in theMILLENNIUM_PRIVATE_KEYenvironment variable. Without it the server uses an all-zero development key, warns at startup, and anyone could forge a login. - Name your admins in
[chat] admins. These are account names, not character names. - Open port 5888 for TCP and UDP. TCP is the login, UDP is the game. Players need both.
- Check your backups. SQLite backs itself up. For Postgres, enable the backup timer and try a restore once.
- Turn on the status page (
[metrics] bind = "127.0.0.1:9188"), and keep it private. - Change the default Postgres password if you use the bundled
docker-compose.yml. - 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_URLMILLENNIUM_PRIVATE_KEYMILLENNIUM_CLUSTER_SECRETRUST_LOG
Top level
| Key | Default | What it does |
|---|---|---|
log_filter | info | Log levels, e.g. info,server=debug. RUST_LOG overrides it. |
multi_threaded | false | Run the simulation on several threads. Off is faster for this game (see Capacity). |
[network]
| Key | Default | What it does |
|---|---|---|
bind | 0.0.0.0:5888 | The game (UDP). |
auth_bind | 0.0.0.0:5888 | The login service (TLS over TCP). Clients assume the same port number as the game. |
tls_dir | saves/tls | Where 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_clients | 64 | Players connected at once. See Capacity before raising it a lot. |
[database]
| Key | Default | What 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_connections | 5 | Postgres connection pool size. |
save_interval_secs | 10 | How often positions are written. Everything else (items, cash, quests...) is saved the moment it changes. |
backup_dir | saves/backups | SQLite only: where automatic backups go. |
backup_interval_minutes | 60 | SQLite only: a backup at startup and then this often (0 = off). |
backup_keep | 48 | SQLite only: how many backups to keep. |
[chat]
| Key | Default | What it does |
|---|---|---|
admins | [] | Account names with admin powers (see Being an admin). |
burst, per_second | 5, 1 | Chat rate limit: bursts of 5 messages, then 1 per second. |
strikes_before_mute, auto_mute_secs | 5, 60 | Hitting the limit 5 times in a row means an automatic 60 s mute. |
history_global, history_whispers | 50, 50 | Lines 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]
| Key | Default | What it does |
|---|---|---|
day_minutes | 40 | Real minutes per game day. Clients get it when they log in. |
clock_shift_hours | 0 | Moves 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_minutes | 10 | How long before that midnight the countdown starts. |
y2k_minutes | 30 | How long the city has to fix the bug. |
[metrics]
| Key | Default | What it does |
|---|---|---|
bind | (empty: off) | Address for the status page, /metrics and /health. Keep it private (127.0.0.1 or firewalled). |
log_every_secs | 60 | A 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.
| Command | What it does |
|---|---|
/admin | The 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 name | No 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 name | Lift a ban. |
/mod reports, /mod resolve id | List open reports in chat; close one. |
/mod resetcode name | A new password recovery code for a player who lost theirs (see accounts). |
/mod event y2k [minutes] [goal], /mod event stop | Start or call off the New Year's Eve event (see events). |
/give item [n], /givecash n, /givexp skill n, /givecar model | For 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
/adminand rest the mouse on a report to read the chat it came with. - A suggested ladder:
- a word in a whisper;
- a mute (
/mute name 30); - a kick;
- a ban that grows: 24 hours, then a week (
/mod ban name 168 reason), then permanent.
- 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 namegives 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.
- 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 y2kgives a 10-minute countdown and a goal based on how many are online at midnight./mod event y2k 2 60gives a 2-minute countdown and a goal of 60. - Or schedule it: set
y2k_event_atin[world]. The countdown startsy2k_countdown_minutesbefore. - 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_minutesruns 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/backupsin Docker. - How many: the newest 48.
Copy that folder off the machine now and then (rsync, restic, your cloud's snapshots).
To restore:
- Stop the server.
- Copy the backup over the save file.
- Delete the
-waland-shmfiles next to it. - 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
- Take a backup (SQLite does one at every start anyway).
- Get the new version (
git pull) and build it (cargo build --release -p server, ordocker compose build). - Restart. Database changes (migrations) apply themselves at startup.
- 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", openhttp://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:/healthanswersok. - 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.
| Metric | Healthy | If it isn't |
|---|---|---|
millennium_frame_seconds_avg / _max | A 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_length | Under a few ms; queue near 0 | A slow disk or database: saves and logins lag. |
millennium_network_sent_bytes_total (rate) | About 3-10 KB/s per player | Lots of players crowded together: expected; plan bandwidth for it. |
millennium_players_online, _by_zone | Your normal crowd | A 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):
| Players | Frame time (avg / worst) | CPU | Memory | Sent per player |
|---|---|---|---|---|
| 200, mostly in one zone | 2.8 ms / 27 ms (33 ms budget) | about 60% of one core | about 265 MB | 8.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.
- 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 thanMILLENNIUM_SLOW_FRAME_MS(20) with the systems that took longest. - Leave
multi_threadedoff: 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_keyand the same[cluster] secret(orMILLENNIUM_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_dirsafe, 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
| Symptom | Likely cause | Fix |
|---|---|---|
| "Logged in, but the game server didn't answer" | TCP 5888 works, UDP 5888 doesn't | Open 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 replaced | Restore 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 running | Stop it, or change bind / auth_bind. |
| "failed to start database" | Wrong url, or Postgres isn't up yet | Check the URL; systemd retries every 5 s. |
| A warning about the development key | private_key isn't set | Set one (--generate-key). |
| Players get "Your game is out of date" | The server was upgraded | They need the matching game version. |
| Lag, rubber-banding | The server or its network is overloaded | Check 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 address | It clears by itself in a few minutes. |
Something missing? Open an issue.