* 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
206 lines
13 KiB
Markdown
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.
|