Files
dungeoncraft/docs/MULTIPLAYER.md
T

135 lines
11 KiB
Markdown

# Multiplayer - plan and status
Multiplayer is only available for users of a server (not in the standalone file).
## Decisions
- Modes: **versus** (every player a master with an own heart), **coop** (every player an own master,
together against AI masters and/or the humans) and a **coop campaign of 20 missions**.
- 1 to 4 players per match; free slots are empty unless the host marks them as AI with `set_ai` before the start.
- The match **pauses while a player is missing** (connection lost); a player who **leaves** is replaced by an AI.
- Before the start, the host manually marks a free seat as AI with `{"t":"set_ai","side":n,"ai":true}` and clears it again with `ai:false`; side 0 stays human.
- The humans (hero waves of the Order) as a third party (can be switched off), chat, alliances/teams.
- Coop is "separate but helpful": own heart, gold, mana, research and creatures; allies never fight each
other, their creatures help each other, gold can be sent, allied ground can be walked; a fallen heart only
knocks out that player (who may keep watching); the allies win when all enemies have fallen.
- Lobby: an open list of matches under *Multiplayer* in the title menu; the host chooses mode, map, AI slots,
humans on/off and teams; logged-in users join; the match starts when everybody is ready.
## Architecture: deterministic lockstep
Every client runs the same simulation (the same WASM module, so floating point results are identical).
The server does not simulate; it relays:
1. Clients send their **commands** (dig, build, cast, hand, orders, ...) to the server.
2. Every 100 ms the server sends **turn N**: the commands of all players for that turn.
3. Each client applies turn N and simulates five fixed steps of 20 ms. Turns arrive in order; a client waits
for the next turn, so a missing player stalls everybody - exactly the "pause while missing" rule.
4. Clients send a **checksum** of the game state every few seconds; a mismatch (desync) is reported.
Only the commands travel over the network, so bandwidth is tiny and no game state has to be serialised
per turn. Fog of war, particles and the camera are local and not part of the shared state.
## What is there already
- **Per-master state** (`game.rs`): every master has his own gold, mana, hand, research, payday, flag,
core... `Game::switch_to(side)` puts one master in the active fields, the simulation walks through all
masters step by step. A single player game has no stored masters and is unaffected.
- **Commands** (`commands.rs`): every order a player gives is an `Act` (`Dig`, `Room`, `Tower`, `Trap`,
`Cast`, `Flag`, `Pickup`, `Drop`, `Slap`, `Attack`, `Guard`, `CancelOrders`, ...) wrapped in a
`Command { side, seq, act }`. Creature targets are *ids*, so they mean the same on every client.
The tools in `main.rs` only queue commands now - nothing touches the world directly.
- **Steps**: the simulation runs in fixed steps of `STEP` = 20 ms, 50 steps a second (`Game::update` feeds real
time into them, `Game::apply_turn(cmds, steps)` is the lockstep entry point; one server turn of 100 ms is five
steps). Commands are applied at step boundaries, so single player and a match run exactly the same path.
Particles, effects and the camera keep running in real time: they are local and not part of the shared state.
- **Who speaks**: messages, hints and interface sounds belong to the master who is being processed
(`Game::speaking`); what another master orders never shows up on my screen.
- **Turns and checksums** (`netplay.rs`): `Game::start_match(sides, local)` starts a match,
`Game::take_orders()` hands the local orders to the server, `Game::receive_turn(cmds)` applies a confirmed
turn (five steps) and `Game::checksum()` compares the worlds of the clients - a peer with a different number
is reported once (`net.desync`). A client without the next turn stands still: that is the "pause while
missing" rule, and the tests run two clients of one match side by side.
- Savegames, tests and the standalone file are unchanged: `cargo test --release` covers the command path
(including "the same actions on both paths build the same world").
## Server and client
- **Relay** (`server/src/netplay.rs`, route `/api/netplay/ws`, proxied by nginx): rooms with a six-letter code,
seats, ready flags, a 100 ms turn clock, checksum forwarding, chat (`all` / `team` channel), pause when a player
is missing (`resume` by side 0), `abort`, `leave` (a started human seat becomes AI-controlled), `set_ai` for
empty seats before the start, and a `ping`/`pong` round trip. The message list is at the top of the file.
- **Client bridge** (`src/lobby.rs`, WebSocket in `www/dc_plugin.js`): `MatchClient` turns orders into JSON and
turns back into commands, tracks the lobby state (rooms, seats, AI seats), chat and the relay ping (`ping_ms`,
shown by the ping debug overlay).
- **Menu**: *Multiplayer* in the title menu with the match list, create (versus or co-op mission), join by code,
seat choice, AI seat toggles for the host, ready/start, chat; the match keeps running in the background while
menus are open.
- **AI seats** (`Game::sync_ai_sides`): empty seats marked by the host and seats of players who left are played
by the simplified rival AI on every client, so they stay deterministic.
- **Co-op** (`Game::coop`): the master sides are allies against the heroes; allied creatures never target each
other, allied ground does not block claims. Co-op campaign matches use `campaign::start_coop_mission`.
- **Editor campaigns in co-op**: on the Match page the host can switch from the built-in campaign to the
campaign last opened in the editor (`Menu::campaign_doc`) and pick one of its missions that fits co-op
(`campaign::is_editor_coop_mission`: map at least 72x72, a goal besides rival masters). The create message
carries the whole campaign (`"doc"`, the `CampaignDoc` JSON, up to `MAX_WS_MESSAGE` = 40 MB per WebSocket
message); the relay keeps it in the room and adds it to the `start` message (room updates only show
`"editor": true`). Every client builds the mission with `campaign::start_editor_coop_mission(doc, i, seed)` from
the room's shared seed (rival masters of the map and the Rivals goal are left out, like in built-in co-op),
then the usual co-op setup. Block scripts run from side 0 in the fixed step.
- **Editor campaign runs over several missions**: when a mission ends, the host's end dialog offers *Next mission*
(won: the branch of the reached goal, `CampaignDoc::next_mission_choice` with `Game::match_goal_done`) or *Try
again* (lost: the same mission); the others see "waiting for the host" and may leave. The host sends
`{"t":"next","mission":i,"seed":run_seed,"carry":[[side,Carry],..]}`; the relay checks that he is side 0 and that
the mission exists, resets the turn clock, drops pending orders and sends `next` (seed, sides, teams, AI seats,
carry, no campaign) to every client. The clients keep the campaign of the `start`, drop turns of the old mission
and build the new one with `start_editor_coop_mission(doc, i, run_seed)`, then `campaign::apply_coop_carry`.
The carry (`campaign::coop_carry_from`, computed by the host from the shared final world) is per master: his
five strongest creatures and 20% of his gold, like the single-player `Carry`. The host stores the progress
(missions done, chosen branches, run seed) in local storage under `dc.coop_editor_progress` (campaign name ->
`CampaignProgress`). A run ends when no next mission fits co-op.
- **Teams in versus**: on the Match page every seat has a team A-D (default: a team of its own), set with
`{"t":"set_team","team":n}` for the own seat; the host may add `"side"` for a free or AI seat. Only before the
start. Room updates carry `team` per seat, the room list `teams`, and `start` a `teams` list in the order of
`sides` (`Game::set_teams`). Masters of one team are allies exactly like co-op partners (`Game::allied`: no
attacks, their creatures help each other, a tower spares them, the fog shows the team mates' ground, creatures
and towers). AI seats take part in teams as well.
- **Fallen hearts**: a master whose soul core falls is out (`Game::out_sides`, shared state, in the checksum). His
creatures fight on without a heart (no economy: no mana, no new creatures, no payday), the creatures in his hand
are put down, the core tiles become floor and his orders are ignored. The player watches: no fog, free camera,
chat; the match goes on for the others (`Game::spectating`, local only).
- **End of a match**: decided only from shared state (`Game::check_match_end`), so every client stops in the same
step: a versus match is over when only one team has masters (or AI seats) left - that team wins, the others lose;
if all seats are one team, the humans' gate is the enemy. A mission (co-op) is lost when every heart fell, and
won by its goals (mana springs count for any master). The checksum only mixes in *whether* the world still runs.
- **Gold for allies**: `Act::SendGold { to, amount }` (100, 500, 1000 or all), in the creature overview and the
pause menu (the ally button switches between allies). It runs in the fixed step on every client, never sends
more than the sender has nor more than the receiver's treasury takes; the receiver gets a message and a sound.
- **Room list**: rooms show what they play - versus, the built-in co-op mission, or the editor campaign's name and
the title of its current mission (`campaign`, `title` in `rooms` and `room`).
- **Shared simulation side**: the shared part of a step (waves, units, towers, rivals, dwarves) runs with the
first master switched in on every client; only the end check and the fog use the local master.
## Steps
| # | Step | Status |
|---|---|---|
| A | Simulation per master: gold, mana, research, rooms, hand, flag, traps, payday... per side; up to four masters (red, green, blue, yellow) | built |
| B | All player actions as commands (`Command` + `Game::apply`), single player uses the same path; fixed time step | built |
| C | Server: lobby and match relay over WebSocket (axum), turns, pause on disconnect, AI takeover on leave, chat, ping | built |
| D | Client: *Multiplayer* menu (lobby, create, join, seats, ready), WebSocket bridge in `dc_plugin.js`, chat, ping | built |
| E | Alliances and co-op rules, AI masters for free and abandoned seats | built: co-op alliance, teams A-D in versus, sending gold, fallen hearts as spectators, AI seats |
| F | Co-op campaign: 20 missions with story (EN/DE) | partly built: co-op matches reuse the single player missions that fit the master areas (`coop_mission_indexes`); no own co-op missions or story yet |
| G | Editor campaigns in co-op over several missions (branches, carry, host progress), campaign names in the room list | built |
Still open: own co-op campaign missions with story; how savegames treat a running match.
## Masters and sides
A match plays on the four master sides `0, 2, 3, 4` (side 1 is the heroes'): red (side 0, `Owner::Player`),
green (`Owner::Rival1`), blue (`Owner::Rival2`) and yellow (`Owner::Rival3`). `Game::start_masters(sides,
local)` carves a 9x9 starting area and a 3x3 soul core for every master and remembers his economy in a
`MasterState`; the places are worked out from the map size alone, so all clients agree without passing them
over the network.