Files
Cinderwild/docs/MULTIPLAYER_PLAN.md

18 KiB

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, one code path:
    • Single-player: the game runs a server inside itself and connects to it (loopback). It plays exactly as now.
    • 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.

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

{
  "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 / off When a night can be slept through.
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.

Until later phases, while online: your inventory, stats and crafting stay on your own game and aren't saved by the server (M2.4), so the server trusts what you say you're putting into the world (a wall, a chest deposit, a grave's contents) but decides everything taken out. Animals are off (M2.5). Sleeping is off (it needs the sleep rule, M2.5). Respawning ignores beds. The HUD says so.

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

In-game chat starts with /; the server console takes the same commands without it.

  • /players, /kick <name>, /ban <name>, /unban, /admin <name>
  • /save, /backup, /time 08:00, /weather clear|rain|storm|snow
  • /rules (show) and /set pvp on (change at runtime, saved to server.json)
  • /rename <old> <new>, /release-name <name>
  • /say <message> (a server message)

Chat: T to talk. It's global, so everyone on the server sees it (no proximity chat in v1).

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. Player files, owners and backups come in M2.4 and M2.7.

  • Single-player saves (today's world.json with a "player" block) are migrated on first load. That block becomes the local player's file. The test suite keeps an old-format save fixture to prove it.
  • 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 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 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 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 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, migration of old saves, backups.
  • The existing 9 suites keep running in single-player (loopback) mode.
  • 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).