Files
Moto 0e61a77346 Add comprehensive design and specification documentation (#1)
* Write the Unreal documentation set: design, steps, ideas, decisions and specs

Rewrites the design and engineering docs of the two earlier Unity projects
(Adventurer Guild, Project Malleable) for an Unreal Engine 5 prototype that
builds and proves three things in order: a movement controller, fighting and
crafting. Neither earlier core loop is carried; their ideas are catalogued as
features with what each needs.

- Docs/Design.md: the human summary (pillars, fixed decisions, glossary)
- Docs/Steps.md: a ladder of fifteen proofs, the first six in full
- Docs/Ideas.md: salvaged ideas from both projects, none scheduled
- Docs/Decisions.md: D-01..D-36, open decisions OD-01..OD-08, deferrals
- Docs/Spec/: notation, Architecture, Movement, Interaction, Combat,
  Crafting, Networking, Telemetry, UI, written as Unreal C++ skeletons
- CLAUDE.md and README.md for the repository


* Add the stat block: one attribute set on every body, every influence an effect

Every body (player, enemy, NPC) carries the same UStatBlockAttributeSet, and
every outside influence on it is a gameplay effect: base values, buffs,
debuffs, ground surfaces, carried weight, gear, being downed. Counters are
tags and attributes on the receiver: immunity blocks by tag, resistance
scales by attribute, cleanse removes by tag. Same status from several
sources: strongest wins; different statuses multiply.

- Docs/Spec/Stats.md: the block, effects and their five sources (abilities,
  areas, surfaces, items, the world), stacking, counters, readers, tests
- Movement: the movement component's tagged speed-multiplier map is removed;
  speed is tuning times the MoveSpeed attribute; the ground surface trace
  turns mud into an effect; the gym gets mud and ice patches
- Combat: the attribute set moves to Stats; enemies and kits carry
  FStatBlockDefaults; the funnel reads MagicResist and Immune.Damage tags;
  taunt and mark are statuses
- Interaction: carried weight is GE_Encumbered against CarryCapacity
- Crafting: class bonuses are the WorkSpeed and WorkQuality attributes;
  enchantments may grant a holder effect
- Steps: step 5 builds the block with mud, a volume and an immunity
- Decisions D-37 and D-38; design summary, CLAUDE.md, indexes, worklog
2026-09-15 17:54:00 +03:00

206 lines
13 KiB
Markdown

# Networking
Owns the authority model, the responsiveness tiers, the posture on prediction and tick rate, the session shape,
and the two seams (identity and persistence) that let the game outgrow one machine without a rewrite. It does
**not** own gameplay: what a shove does is in [Combat.md](Combat.md), what an insert does in
[Crafting.md](Crafting.md). Each feature spec states what is authoritative where; this document is the rule they
all follow and the reasoning behind it.
Read [Architecture.md](Architecture.md) first.
## The decision
```
[DECIDED] Server-authoritative from the first line, on the engine's own replication, with the dedicated server
as the real target.
There is no listen-server mode in the design and no "are we multiplayer" branch anywhere in gameplay code.
Standalone and listen-server play exist because the editor offers them and they are convenient; every actor,
subsystem and RPC is written as if the server were a separate process on another machine, and it is tested that
way from step 1 with Play In Editor's dedicated server option.
The engine's actor model already is this posture: AGameModeBase exists only on the server, properties replicate
one way, Server RPCs need an owning connection, movement is server-corrected. The work is to never undermine it,
not to build it.
```
Why this and not the earlier project's listen server with a relay: a host-authoritative peer is the one topology
that cannot grow. It ties the simulation to a player's machine, makes the host able to cheat freely, and turns
every later step toward a persistent shared world into a rewrite. Choosing the server posture now costs nothing
(the engine defaults to it) and closes no door.
## The authority table
The rollup. Each feature spec carries its own rows and the two must agree.
| State | Authority | Mechanism |
| --- | --- | --- |
| Player movement, jump, sprint, crouch | Server, owner predicts | Character movement's prediction and correction |
| Look pitch and head yaw | Owner writes | Replicated bytes, for the rig only; the server never reads them to decide anything |
| Camera | Local only | Never replicated, saved or read by the server |
| Ability activation | Client requests, server decides | GAS prediction keys; the owner's animation starts immediately |
| Damage, health, downed, dead | Server | The one execution; replicated attributes and tags |
| Hit detection | Server, using the owner's replicated aim | A little client trust in the aim; fine in cooperative play |
| Cooldowns | Server, client predicts | Cooldown effects; the HUD shows the prediction and takes the correction |
| Enemy brains and spawning | Server | StateTree ticks on the server; bodies replicate |
| Interaction | Client requests, server re-validates reach and permission | One RPC on the player's own component |
| Held objects | Server | Attached to the carrier, `CarriedBy` replicated; no physics while held |
| Dropped and thrown objects | Server | Server physics, replicated movement, predictive interpolation |
| Station state, buffers, operators | Server | Replicated properties |
| Crafting previews | Client computes | Hypotheticals; a wrong preview is a UI bug, never an exploit |
| Crafting verdicts and commits | Server | The same pure function, on the server, is the only one that counts |
| Activity inputs | Server | RPCs; the anvil strike is timestamped |
| Attributes and statuses on a body | Server; effects from a body's own abilities predicted | The stat block replicates; a server-applied speed change costs one small correction, see [Stats.md](Stats.md) |
| Player identity, class, known discoveries | Server | On the player state; replicated to the owner |
| Session membership | Server | Game mode and game state |
## Three responsiveness tiers
Several players in one room, all touching the same physical objects. The question is what each may do instantly
and what has to wait for the server, and one test assigns the tier: **does a rollback have a visible victim?**
```
TIER 1: PURELY LOCAL, never networked authority
camera and look, interaction targeting and prompts, every crafting preview, HUD, and your own movement
through the engine's prediction. Remote players interpolate; head and spine aim replicate so gaze survives.
TIER 2: PREDICT THE ANIMATION, NOT THE OWNERSHIP
pick up, insert, take output, equip, hand over, fire a projectile.
The reach or throw ANIMATION starts instantly and locally. The object changes hands when the server confirms.
Predicting your own arm costs nothing when wrong. Predicting who got the last ingot costs a snatch-back the
whole room can see, and with substances as individual contested objects that case is frequent, not theoretical.
TIER 3: TIMESTAMPED AND LAG-COMPENSATED
scored activity inputs: the anvil strike, and nothing else today.
The client stamps the input; the server evaluates it against what was true then. Because the zone's radius is
a deterministic function of time, that is arithmetic, not a rewind buffer: radius(clientTime), clamped to a
250 ms window, never scoring better than the best achievable at the earliest legitimate arrival.
```
**Standing rule:** no rollback of world state a player can already see. If a design would need one, move the
decision to the server and cover the round trip with animation (tier 2), or make it a pure function of time
(tier 3).
## Ask, then show
Every commit-shaped moment in the game (an item assembled, a substance processed, a weapon equipped, later a
purchase or a payout) already has a beat of feedback: the assembled sword materialising on the bench, the ingot
coming out of the bed. That beat is the latency budget. A server round trip fits inside it with room to spare, so
the client asks, waits for the commit, and then shows the result. No prediction, no reconciliation path, no
possibility of a corrected number. **Never show a number as final before it is committed.** A UI that does not lie
needs no reconciliation.
## Posture
This is a game played with friends, not a tick-perfect competition. Nothing here is adversarial, ranked or decided
by milliseconds, so the bar is "feels good to a few people in a room", and anything argued for on competitive
integrity grounds is arguing from a genre this game is not in.
| Not building | Why not |
| --- | --- |
| Rollback netcode or deterministic lockstep | Solves fighting-game and RTS problems; this is a cooperative sim |
| High tick rates | 20 to 30 Hz is ample when one input in the game is timing-scored |
| Client prediction beyond own movement and animation | Every further step buys responsiveness we do not need and costs rollbacks players can see |
| Lag compensation anywhere but the anvil | Nothing else scores timing |
| An anti-cheat arms race | Server authority removes the motive: no PvP, nothing to win by cheating but spoiling your own game |
The tie-breaker when a choice is close: prefer the option that fails gracefully and legibly over the one that is
technically optimal. Rejections explain themselves, nothing is destroyed silently, a stalled station says why.
## Prediction hygiene
- Test every replicated feature with the editor's network emulation at 100 ms and 5 % loss before calling it
done, and `p.NetShowCorrections 1` on. A correction you can see is a bug in a saved move or an ability's
prediction, not a fact of life.
- Server RPCs go through components on actors the client owns: the player's pawn, player state and controller.
Never through a prop. An RPC on an unowned actor silently drops.
- Every `Server` RPC validates its inputs and its instigator. `WithValidation` is not enough; the body re-checks
reach, permission and state, because the client's claim is a claim.
- Replicated arrays with per-element churn (buffers, hands, a future inventory) use `FFastArraySerializer`, not a
plain `TArray`, so a change sends the element and not the array.
- Actors at rest are dormant (`DORM_DormantAll`, flushed on change). A room of two hundred ingots must cost nothing
while nobody touches them.
- Relevancy is by distance from day one (`NetCullDistanceSquared` on every replicated actor that is not a player).
A world larger than one room cannot afford everything being relevant to everyone, and a rule that exists from the
first actor is free.
- Never do per-client work on the server that scales with the square of the player count. If a system needs
every player to know about every player, that is a design flag.
## Sessions
There is one session shape: a server running a map, players joining it. The game mode handles login, spawns a
`APlayerCharacter` per controller at a spawn point and re-spawns on death. Party size is the game state's player
count and is read by anything that scales (the encounter budget) and stamped onto every telemetry envelope.
Steam, lobbies, invitations and matchmaking are not built in these steps and are not designed here. The earlier
project's Steam lobby and friends-list join are in [`../Ideas.md`](../Ideas.md); when they arrive they sit in front
of the session and never inside gameplay.
**Two instances on one machine:** Play In Editor with two clients and the dedicated server option is the everyday
test. A packaged server (`<Project>Server` target) needs the engine built from source and arrives when the first
packaged build does; nothing in the code changes for it.
## Identity
```
[DECIDED] A player is an identity that outlives a body, a session and a server. Everything about a player that
would matter tomorrow lives on the player state or behind the persistence provider, never on the character actor.
```
Concretely: `APlayerState` carries a `FGuid PlayerId` minted by the server on first login (hashed for telemetry,
never a platform id or a name), the class kit, the ability system component with its attributes and cooldowns,
known characteristics, and later progression. The character is a body the world lends the player. A body dying,
a map changing or a player reconnecting never loses any of it.
## Persistence
```
[DECIDED] Persistence is behind one provider interface with a local-file implementation. Gameplay writes records
to the provider and never to disk, so a service-backed provider is a second implementation and not a refactor.
```
```cpp
// Source/<Project>Core/Persistence/PersistenceProvider.h
class IPersistenceProvider
{
public:
virtual ~IPersistenceProvider() = default;
virtual bool Load(const FString& Key, FString& OutJson) = 0; // records are JSON snapshots, idempotent, never deltas
virtual bool Save(const FString& Key, const FString& Json) = 0; // atomic: temp file then move
virtual void Remove(const FString& Key) = 0;
};
// FLocalFilePersistenceProvider writes under FPaths::ProjectSavedDir()/Records/. Registered on the
// persistence game-instance subsystem at startup. Nothing is persisted in steps 1 to 15; the seam exists so the
// first thing that is (a player's class, a crafted sword) does not choose the file system by accident.
```
Record rules, from the first record: identity is `FGuid`; content is referenced by primary asset id; every record
carries a `schema_version` with an adjacent migration function and a monotonic `revision`; derived fields are
recomputed on load; save-facing records are separate types from runtime instances.
## Telemetry over the wire
Every peer emits its own events. There is no forwarding to the server, because the interesting questions (did this
client's frame rate drop, which player did the shoving, did this preview disagree with the verdict) are per-peer
by nature. `is_server` and `player_id` on the envelope let a run be reassembled later. A `session_id` is minted
by the server and adopted by every client on join, so one session's events are one session and not four.
## Tests
- Functional, run with emulation on: two clients see the same held object on a third's body; a request from beyond
reach is refused; an ability activated at 100 ms shows no visible correction; a thrown object lands within
tolerance on every peer; the anvil's strike scores the same for a client at 0 ms and one at 150 ms who pressed
at the same moment.
## Open questions
- **Q1. Reconnect.** A dropped player should reconnect into the same session with the world exactly as it
continued without them. The identity decision makes it possible; the session shape does not yet do it. Write the
rule down before it is discovered in a playtest.
- **Q2. Voice.** Proximity voice is a large part of what makes a room of friends a room. Not in these steps;
parked in Ideas.
- **Q3. Seamless travel.** One map today. Travelling between maps with the party and the player state intact is
the engine's seamless travel and arrives with the second map.
- **Q4. Replication at scale.** The Replication Graph and the newer Iris system exist for worlds with many actors
and many players. Not now; the dormancy and relevancy rules above are what keep the option cheap.