135 lines
11 KiB
Markdown
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.
|