Files
Cinderwild/docs/MULTIPLAYER_PLAN.md

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).