407 lines
23 KiB
Markdown
407 lines
23 KiB
Markdown
# M2 multiplayer plan
|
|
|
|
Status: **plan, decisions agreed** (§12), nothing built yet. It refines `ARCHITECTURE.md` §2-3 into
|
|
concrete decisions for this codebase, as it stands after M1.
|
|
|
|
## 1. Goals
|
|
- **Self-hosted**, 2-8 players (up to 16 possible). No central service, no
|
|
accounts: a world lives on whoever hosts it.
|
|
- **Three ways to play, the same rules (Actions):**
|
|
- **Single-player**: no server; your game decides, exactly as now
|
|
(decided in M2.4, §7).
|
|
- **Host & play**: "Host game" on the title screen, and friends join you.
|
|
- **Dedicated server**: a headless "server only" process with no local
|
|
player. It keeps the world running (or paused) for whoever connects.
|
|
- **Server-authoritative:** the server decides everything, and clients send
|
|
intents (the Actions layer already works this way).
|
|
- **Co-op or adversarial**, chosen by server rules (§5).
|
|
|
|
## 2. Running a server
|
|
*Built in M2.1* (`scripts/net/net.gd`, `server_config.gd`, `identity.gd`).
|
|
The server accepts players, checks who they are and keeps the player list.
|
|
Since M2.3 it also runs the shared island (§5). Flags: `--server`,
|
|
`--port`, `--config`, `--server-name`, `--max-players`, `--password`,
|
|
`--world`, `--seed`, and `--exit-with <pid>` (quit when that process exits; the tests
|
|
use it so a crashed test never leaves a server running). Flags apply to
|
|
that run only and are never written to `server.json`.
|
|
```bash
|
|
# Dedicated server (no window, no player)
|
|
godot --headless --path . -- --server --config server.json
|
|
# or quick flags (override the config file)
|
|
godot --headless --path . -- --server --world "Valley of Us" --port 24242
|
|
```
|
|
- **Transport:** ENet over **UDP, one port**, default **24242**,
|
|
configurable. It works on Linux, macOS and Windows.
|
|
- **Reaching it from outside:**
|
|
- **Tailscale (recommended for friends).** Share the server machine from
|
|
your tailnet; friends install Tailscale and join `100.x.y.z:24242` (or
|
|
the MagicDNS name). Nothing is exposed to the internet.
|
|
- **Port forwarding.** Forward UDP 24242 on your router. No extra
|
|
software, but your home IP and that port are public.
|
|
- **A small cloud server.** The same command on a cheap Linux server.
|
|
- **Cloudflare:** a normal Cloudflare tunnel doesn't carry raw UDP.
|
|
Possible later via a **WebSocket transport** (Godot's
|
|
`WebSocketMultiplayerPeer`, TCP, a little more stutter on poor links)
|
|
behind `cloudflared`. It's planned as an optional `--transport ws` and
|
|
isn't needed for v1.
|
|
- **Runs as a service:** a sample systemd unit and a launchd plist go in
|
|
`server/`. Logs go to stdout, and a console accepts admin commands (§6).
|
|
|
|
## 3. Players: identity and names
|
|
- **Identity:** on first run each game install generates a private key
|
|
(RSA-2048, since Godot's Crypto has no Ed25519) and keeps it in
|
|
`user://identity/key.pem`, with the name and appearance in
|
|
`user://identity/profile.json`. The public key's hash is
|
|
the **player id**. On joining, the client signs a server-chosen challenge,
|
|
so nobody can claim someone else's id. No passwords and no central
|
|
accounts.
|
|
- **Name:** the first time you open the game it asks for a **display
|
|
name**, which you can change later in Settings.
|
|
- 3-16 characters: letters, digits, space, `_` and `-`. No leading or
|
|
trailing spaces, and no names that only differ by case.
|
|
- Shown on a **name tag over your character** to other players (a
|
|
billboarded `Label3D`, fading with distance, hidden behind walls), in
|
|
the player list (Tab) and in chat.
|
|
- A server **reserves a name for the player id that first used it**, so
|
|
someone else can't join as "Paul". If your name is taken there, you're
|
|
asked to pick another. Admins can rename and release names.
|
|
- **Character:** players need a visible body (today there's none). M2
|
|
includes a simple procedural low-poly character with idle, walk, run,
|
|
swim and swing poses, plus a held-item attachment. v1 appearance is
|
|
deliberately limited: tunic colour and hair colour from small palettes,
|
|
and a beard on/off. It's stored with your identity and sent on join.
|
|
|
|
## 4. Server preferences
|
|
One file, `server.json`, created with defaults on first run and editable
|
|
by hand. Admins can also change most settings in game (§6). `*` marks
|
|
settings fixed when the world is created.
|
|
|
|
```json
|
|
{
|
|
"server_name": "Paul's island",
|
|
"motd": "Welcome! Be nice to the wolves.",
|
|
"port": 24242,
|
|
"max_players": 8,
|
|
"password": "",
|
|
"world": "valley-of-us",
|
|
"worldlet": "valley", "_note": "* (see WORLDLETS_DESIGN.md)",
|
|
"seed": 1337, "_note2": "*",
|
|
|
|
"pvp": false,
|
|
"raiding": false,
|
|
"locks": "owner_and_friends",
|
|
"death_penalty": "grave",
|
|
"sleep": "all_online",
|
|
"pause_when_empty": true,
|
|
"day_length_minutes": 20,
|
|
"wolf_grace_days": 3,
|
|
"difficulty": "normal",
|
|
|
|
"autosave_minutes": 2,
|
|
"backups_kept": 10,
|
|
|
|
"admins": [], "_note3": "empty = first player to join becomes admin",
|
|
"allowlist": [],
|
|
"banned": []
|
|
}
|
|
```
|
|
|
|
What each rule means:
|
|
| Setting | Values | Effect |
|
|
|---|---|---|
|
|
| `pvp` | false / true | Whether players can hurt each other. With it off, weapons pass through other players. |
|
|
| `raiding` | false / true | Whether players can damage or dismantle **other players'** pieces. |
|
|
| `locks` | `off` / `owner` / `owner_and_friends` | Who may open or take from a chest, use a door or bed, or dismantle a piece. Pieces remember who placed them. |
|
|
| `death_penalty` | `grave` / `keep` / `drop_all` | Today's grave bag; keep everything; or drop it all on the ground (hardcore-ish). |
|
|
| `sleep` | `all_online` / `half` / `anyone` / `off` | When a night can be slept through (how many of those online must be in bed). |
|
|
| `pause_when_empty` | true / false | Freeze the world when nobody's on (fires don't burn out, no time passes). |
|
|
| `wolf_grace_days` | 0-10 | Days before wolves arrive; hunts start the night after. |
|
|
| `difficulty` | `easy` / `normal` / `hard` | Multipliers on hunger and diet drain, animal damage and pack size. |
|
|
|
|
Common setups:
|
|
- **Co-op (default):** PvP off, raiding off, locks for owner and friends.
|
|
- **Friendly rivals:** PvP on, raiding off. You can fight, but bases are
|
|
safe.
|
|
- **Adversarial:** PvP on, raiding on, locks for owners only, and
|
|
`drop_all` on death.
|
|
|
|
**Friends** are per player: a player lists other players (from the player
|
|
list) whose doors and chests they share. It's stored on the server.
|
|
|
|
## 5. Who owns what (the authority model)
|
|
| State | Owner | Clients get |
|
|
|---|---|---|
|
|
| Terrain, scenery, den site, weather | regenerated from worldlet + seed (deterministic) | nothing to sync except the seed, the clock and edits |
|
|
| Day/night clock | server | the time, every few seconds (clients interpolate) |
|
|
| Trees, rocks, pickups, bushes: damage, felled, regrowth | server | events (`node_hit`, `node_felled`, `regrown`), plus the full list on join |
|
|
| Building pieces, chests, fires, doors, beds, graves, dropped items | server (with an owner per piece) | create, remove and change events, plus a snapshot on join |
|
|
| Animals and wolves (AI, health) | server | position, yaw and state at 10-20 Hz; clients interpolate and animate |
|
|
| Projectiles, fishing floats | server | spawn events; clients simulate the flight visually |
|
|
| Each player's position | **client-predicted**, server-validated | everyone else sees it interpolated |
|
|
| Each player's inventory, stats, diet, buffs, cloak, bed | server | the owning client only (others see the held item and name) |
|
|
|
|
**Intents to RPCs.** Each of today's `Actions.*` intents (craft, pickup,
|
|
harvest_hit, place, dismantle, toggle_door, use_bed, melee, throw_spear,
|
|
shoot, butcher, cast_line, reel, wear, take_off, forage, eat, add_fuel,
|
|
transfer...) becomes a client-to-server RPC. The server runs the same
|
|
function it runs today, with the server's copy of the player, then sends
|
|
back the results. The rules don't move; only who calls them.
|
|
|
|
**Movement** *(built in M2.2: `net.gd`, `net_sync.gd`, `avatar.gd`)*. Your
|
|
own player moves locally with no delay, and the game sends its position,
|
|
facing, animation and held item to the server 20 times a second, each
|
|
stamped with the sender's own clock. The server checks every move against
|
|
the last accepted one:
|
|
- no faster than 9.5 m/s across the ground (sprint is 8) plus 1 m of slack;
|
|
- no rising faster than 9 m/s;
|
|
- not outside the island (a 3 m margin past the wall);
|
|
- not underground.
|
|
|
|
A refused move sends the player back to their last good position.
|
|
Respawning is a flagged teleport, allowed if it lands within 25 m of the
|
|
island spawn. Normal play is never corrected, so there's no rubber-banding.
|
|
This is the Valheim model, and simpler than replaying inputs on the server.
|
|
Since M2.3 the server also refuses a move whose straight line crosses a
|
|
wall, door frame (or closed door) or gable at waist height, checked from
|
|
both ends so someone a wall was built on top of can still step out.
|
|
|
|
The server sends everyone's positions 20 times a second. Each one is stamped
|
|
with when the player moved (their clock plus a smoothed per-player offset),
|
|
not when it was sent. Stamping by send time bunched moves into some
|
|
snapshots and left others empty, so avatars surged. Avatars are drawn
|
|
100 ms behind and interpolated, carrying on along their last velocity for up
|
|
to 0.25 s if data stops. The server also sends its seed, spawn point and
|
|
clock (every 10 s; the clock pauses when the server is empty), so every
|
|
player has the same island, sky and weather.
|
|
|
|
Measured in `tests/test_net_world.gd` at 100 ms ± 30 ms simulated latency:
|
|
a 4 m/s walk is seen at a median of 4.2 m/s, top 5.0, with no freezes, and
|
|
it ends within a centimetre of where the walker stopped.
|
|
|
|
**The shared world** *(built in M2.3: `world_net.gd`, autoload WorldNet)*.
|
|
The dedicated server runs the real world scene headless with nobody of its
|
|
own in it (`world.gd` `server_mode`), and saves it like a single-player
|
|
world, in `worlds/<world>/` next to `server.json`. It owns trees and rocks
|
|
(damage, felling, regrowth), pickups, berry bushes, dropped items, every
|
|
placed piece (walls, doors, chests, graves, fires, beds), fire fuel, door
|
|
state and the clock. Weather is worked out from the seed and the clock, so
|
|
it needs no syncing.
|
|
- A client's `Actions.*` intents that change the island become requests
|
|
(`_c2s_hit`, `_c2s_place`, `_c2s_take`, `_c2s_chest_click`...). The server
|
|
checks reach (from where it last saw that player), applies the change,
|
|
and tells everyone (`_s2c_node`, `_s2c_piece_added`...). Nothing changes
|
|
on your screen until the server says so, so two people grabbing the same
|
|
stick get one stick between them.
|
|
- Whatever a change earns you (wood, a pickup, a dismantled wall and
|
|
anything that collapsed with it, items out of a chest) the server sends
|
|
to you (`_s2c_give`). What doesn't fit lands at your feet.
|
|
- Pieces have a net id that's saved with them, so they keep it across
|
|
server restarts.
|
|
- Chest clicks happen on the server (someone else may be using the chest),
|
|
which hands back what you're holding.
|
|
- On joining, your world asks for a snapshot of everything once its
|
|
scenery is up, then gets each change in order. Regrowth only happens on
|
|
the server, never on top of a player.
|
|
- The world (fires, regrowth, the clock) pauses while nobody's on.
|
|
|
|
**Your character on the server** *(built in M2.4: `player_net.gd`,
|
|
`server_player.gd`, autoload PlayerNet)*. Online the server decides;
|
|
offline there's no server and your game decides, exactly as before.
|
|
- Each player has a character on the server with their inventory, hotbar
|
|
choice, cloak, inventory-screen cursor, bed and survival stats. The stats
|
|
run there: warmth from the server's fires and buildings, rain, hunger and
|
|
the diet. Sprinting and jumping cost stamina there too.
|
|
- Online, every `Actions.*` intent becomes a request (`PlayerNet.ask("eat",
|
|
[slot])`). The server runs the same Actions function on your character
|
|
and sends back your inventory (after every request, and whenever it
|
|
changes) and your stats (4 times a second). What your own game has in
|
|
its bag counts for nothing: a modified game that adds walls to its bag
|
|
can't build with them.
|
|
- Clicks in your own bag also happen on your screen at once, so dragging
|
|
items doesn't wait for the network. The server's copy replaces yours as
|
|
soon as it has handled every click you sent.
|
|
- Death happens on the server: a grave with your name on it (only you can
|
|
open it until the M2.6 rules), then you wake at your bed if it's still
|
|
standing, otherwise the island start.
|
|
- Characters are saved in `worlds/<world>/players/<player id>.json`, with
|
|
the world, so you come back where you left off.
|
|
|
|
**Wildlife, hunting, fishing and sleep** *(built in M2.5: `creature_net.gd`,
|
|
autoload CreatureNet)*.
|
|
- The server's world runs the animals and the wolf pack. Animals live
|
|
around every player (the budget per player, up to twice it however many
|
|
are on), and go once they're far from all of them. A player joining gets
|
|
some wildlife about them straight away.
|
|
- **The pack splits between the players it hunts**: wolf 1 takes the
|
|
nearest to the den, wolf 2 the next, preferring players away from a fire.
|
|
Each hunted player has their own circle and their own one-wolf-at-a-time
|
|
darts. Fire, torches and walls work per player as in single-player.
|
|
- Ten times a second everyone gets every animal, wolf and carcass (23 bytes
|
|
each, split so no packet goes over the MTU: an oversized unreliable packet
|
|
is simply lost) and every fishing float. Games show them as **puppets**
|
|
(`creature.gd`/`bobber.gd` `puppet`) that animate and can be hit or
|
|
butchered, and decide nothing. Something missing for a second goes.
|
|
- **Hitting** (club, spear thrust) sends the creature's id; the server runs
|
|
Actions.melee on your character there. **Shooting and throwing** send only
|
|
the aim: the arrow or spear starts from where the server knows your eyes
|
|
are. The server's projectile hits; everyone else's screens fly a copy for
|
|
show (sent once, when it's loosed). An arrow that misses lands as a
|
|
dropped item for anyone.
|
|
- **Fishing**: one float per player, on the server. Bites, misses and a
|
|
snapped line are told to the player whose float it is (`Actions.tell`).
|
|
- **Sleeping** follows `sleep` in `server.json`: `all_online` (default),
|
|
`half` or `anyone`. Getting into a bed you've claimed puts you "in bed"
|
|
(moving more than 1.5 m gets you out); once enough players are in bed
|
|
the night passes for everyone (fires burn down, things regrow, the clock
|
|
jumps to 06:00) and sleepers wake rested.
|
|
- The pack's howls and "the wolves are hunting tonight" go to everyone;
|
|
being bitten or gored flashes your screen red as in single-player.
|
|
|
|
Still to come: the server doesn't yet stop you sprinting when you're out of
|
|
stamina (it charges you, but doesn't stop you).
|
|
|
|
**Interest.** On a 320 m worldlet with 8 players, the server sends every
|
|
entity to everyone at first. Distance-based interest is a later
|
|
optimisation.
|
|
|
|
## 6. Admin and console
|
|
*(Built in M2.6: `chat.gd`, autoload Chat; `rules.gd`.)* In-game chat
|
|
starts with `/`; the server console takes the same commands without it
|
|
(the server reads its terminal when started with `--console`, which
|
|
`server/run_server.sh` does).
|
|
- Anyone: `/help`, `/players`, `/rules`, `/friend <name>`,
|
|
`/unfriend <name>`, `/friends`.
|
|
- Admins: `/kick <name>`, `/ban <name>`, `/unban <name>`, `/admin <name>`,
|
|
`/save`, `/time 08:00`, `/weather clear|cloudy|rain|storm|snow|blizzard|auto`,
|
|
`/set <rule> <value>` (changes it for everyone at once and saves it to
|
|
`server.json`: pvp, raiding, locks, death_penalty, sleep,
|
|
pause_when_empty, difficulty, wolf_grace_days, motd, server_name; bad
|
|
values are refused with what's allowed), `/say <message>`,
|
|
`/release-name <name>`.
|
|
- Not built: `/backup` (comes with backups in M2.7) and `/rename` (the name
|
|
is the player's own, set in their profile; `/release-name` frees one).
|
|
|
|
Chat: `T` to talk, Enter to send, Esc to cancel. It's global, so everyone
|
|
on the server sees it (no proximity chat in v1). Lines fade after 14 s and
|
|
all come back while you type. Plain chat is limited to a line every 0.4 s
|
|
per player (commands aren't).
|
|
|
|
**How the rules work** *(M2.6)*:
|
|
- Pieces remember who placed them (and graves who died). Everyone's game
|
|
knows, so the prompt says "Bob's · Chest". Pieces from before M2.6 are
|
|
anyone's.
|
|
- `locks`: who may open or take from chests and graves, open doors and use
|
|
beds. `owner_and_friends` (the default) lets the people you've
|
|
`/friend`ed in; `owner` keeps everyone else out; `off` opens everything.
|
|
- `raiding`: taking down a piece you aren't allowed to use. Off by default.
|
|
- `pvp`: with it off, weapons and arrows go straight through other players.
|
|
On, a swing at someone's avatar is checked on the server (reach,
|
|
weapon, stamina, cooldown) and arrows and thrown spears hit people.
|
|
Deaths say who did it ("Killed by Bob").
|
|
- `death_penalty`: `grave` (default), `keep` (wake with everything) or
|
|
`drop_all` (it's all scattered where you fell, for anyone).
|
|
- `difficulty`: easy / normal / hard scales hunger and diet drain (x0.75 /
|
|
1 / 1.3), animal and wolf damage (x0.7 / 1 / 1.4) and the pack (-1 / 0 /
|
|
+1 wolf).
|
|
- `wolf_grace_days`: the day wolves move in; they hunt from the next night.
|
|
- `sleep`: `all_online` (default), `half`, `anyone` or `off`.
|
|
|
|
## 7. Saves on the server
|
|
```
|
|
worlds/<world>/
|
|
meta.json world name, worldlet, seed, created, play time
|
|
world.json shared state: pieces (with owner ids), resource nodes,
|
|
pickups, regrowth, dropped items, clock
|
|
players/<player id>.json position, inventory, stats, diet, buffs, cloak, bed,
|
|
friends, name, appearance
|
|
backups/<timestamp>/ rolling copies (backups_kept)
|
|
```
|
|
*As of M2.3:* `worlds/<world>/meta.json` and `world.json` (with pieces'
|
|
net ids, no owners yet) are written next to `server.json` within 10 s of
|
|
any change, when a player leaves, and every `autosave_minutes`. (Ctrl+C
|
|
kills Godot without running any code, so there's no save on the way out.) An
|
|
existing world keeps the seed it was made with, even if `server.json`
|
|
changes. *As of M2.4:* `players/<player id>.json` too (position, facing,
|
|
inventory, hotbar choice, stats, diet, buffs, cloak, bed), saved with the
|
|
world, when the player leaves and when they die. Graves carry their
|
|
owner. Backups come in M2.7.
|
|
|
|
*Decision (M2.4):* single-player stays as it is, with no server inside it
|
|
("if it's single-player there is no server, so the game decides"). So
|
|
single-player saves need no migration: they keep working unchanged, and
|
|
`tests/test_save_load.gd` still proves it. The old idea of running
|
|
single-player as a loopback server is dropped.
|
|
- *(Originally: single-player saves migrated to a player file on first
|
|
load. Not needed since the M2.4 decision above.)*
|
|
- Per-player graves already work, since graves are pieces. With
|
|
`death_penalty: grave` they get an owner, and only the owner (and friends,
|
|
depending on locks) can loot them.
|
|
|
|
## 8. What has to change in the code
|
|
Today the code assumes one player in a few places. Each becomes "every
|
|
player" or "this player":
|
|
- `world.player` holds a dictionary of players by peer, and the local
|
|
player is a client-side notion.
|
|
- Wildlife spawns around each player within the budget. Wolves target the
|
|
nearest player who is outside, unprotected and not by a fire.
|
|
- The fishing float, the bow draw, the build ghost and the hands are per
|
|
player (some are client-only visuals).
|
|
- Sleeping follows the `sleep` rule, and it fast-forwards for everyone.
|
|
- HUD toasts become per-player server messages (`Actions.notify` gets a
|
|
target player).
|
|
- Autosave and the window-close save happen on the server. Clients just
|
|
disconnect.
|
|
- `Saves` splits world and player files (§7).
|
|
|
|
## 9. Phases
|
|
| Phase | Delivers | Done when |
|
|
|---|---|---|
|
|
| **M2.0** | worldlet W0 (terrain from data, no visible change), recommended first | seed 1337 generates the same height grid as today; all suites pass |
|
|
| **M2.1** *(done)* | network skeleton: `--server`, ENet on 24242, Join screen (address, name), handshake with identity and challenge, name rules, player list | two clients and a headless server on one Mac connect, see the player list, and disconnect cleanly |
|
|
| **M2.2** *(done)* | player bodies and name tags; predicted movement, interpolation for others | two players walk around together at 100 ms simulated latency without visible rubber-banding |
|
|
| **M2.3** *(done)* | world state replicated: resource nodes, pickups, pieces, chests, fires, doors, clock, weather | one chops a tree and the other sees it fall; one builds a wall and the other walks into it |
|
|
| **M2.4** *(done)* | intents as RPCs; per-player inventory, stats, diet, death and graves; per-player saves and migration | two players craft, eat, die and loot their own graves; an old single-player save loads |
|
|
| **M2.5** *(done)* | wildlife and wolves server-side, projectiles, fishing, sleeping with the rule | a pack hunts two players at once; arrows hit on both screens |
|
|
| **M2.6** *(done)* | server rules: PvP, raiding, locks and friends, death penalty, difficulty; admin console and chat | each preset (co-op, rivals, adversarial) behaves as described in §4 |
|
|
| **M2.7** *(done, see docs/HOSTING.md)* | hosting polish: "Host game" button, systemd and launchd samples, backups, a Tailscale how-to in the docs | Paul runs it on the home server, and a friend joins over Tailscale and plays for an hour |
|
|
|
|
## 10. Testing
|
|
- **Headless multi-process tests:** a script starts a headless server and
|
|
two headless "bot" clients that send scripted intents, then checks both
|
|
clients' views against the server's.
|
|
- **Network conditions:** a test transport adds latency, jitter and packet
|
|
loss, so prediction and reconciliation are tested at 100-200 ms.
|
|
- **Rules tests:** one per server rule (PvP off means no damage between
|
|
players; raiding off means you can't dismantle a stranger's wall; locks
|
|
keep strangers out of your chest).
|
|
- **Save tests:** world and player file round trips, backups.
|
|
- The single-player suites keep running as they are (no server).
|
|
- `tests/test_net_shared.gd` (M2.3) is the two-world version: this test is
|
|
one player and `tests/net_world_bot.gd` (a second process with its own
|
|
world, following commands the test writes as files) is the other. A
|
|
`--debug-regrow <k>` server flag speeds regrowth up for it.
|
|
|
|
## 11. Risks
|
|
- **Prediction feel** is the hard part. It gets an early phase (M2.2) so
|
|
it's tuned before everything else depends on it.
|
|
- **Sleeping and pausing with several players** can feel unfair, which is
|
|
why the rules are configurable.
|
|
- **Performance:** wildlife and wolves for 8 spread-out players. The
|
|
budget is per player but capped per world.
|
|
- **Security:** the server checks every intent (reach, cost, ownership,
|
|
cooldowns). Clients are never trusted for results. That's already how
|
|
Actions is written.
|
|
|
|
## 12. Decisions (agreed with Paul, 2026-11-04)
|
|
1. **Port:** UDP **24242** by default (configurable).
|
|
2. **Appearance in v1: limited.** Pick a tunic colour and a hair colour from
|
|
small palettes, plus a beard on/off toggle. More options later.
|
|
3. **Chat: global only.** No proximity chat in v1.
|
|
4. **Empty server: pause.** `pause_when_empty` defaults to `true`, so the
|
|
world freezes (no time, burning or regrowth) while nobody is on.
|
|
5. **Admin bootstrap: the first player** to join a new server with an
|
|
empty `admins` list becomes admin, and their id is written to
|
|
`server.json`. This is a stopgap and will be revisited before servers
|
|
are shared more widely (risk: whoever joins first wins, so start a new
|
|
server and join it yourself before telling friends the address).
|