mirror of
https://github.com/Cesarus85/ringfall-mjolnir-vr.git
synced 2026-10-24 13:00:28 +02:00
336 lines
21 KiB
Markdown
336 lines
21 KiB
Markdown
# Distributed netcode
|
|
|
|
The Xbox game plays system link in lockstep: clients send their input to
|
|
the host, the host sends every machine every player's input for each 30 Hz
|
|
tick, and every machine simulates the whole game from them, waiting for
|
|
each tick's update. A client therefore sees its own movement and shots a
|
|
full round trip late, and any machine whose simulation differs in the last
|
|
bit goes out of sync.
|
|
|
|
The native builds replace that (they no longer have the lockstep netcode)
|
|
with the model of later Halo engines (the "distributed" simulation of the
|
|
MonkeyNuts/Ares source)
|
|
with ideas from VALORANT's netcode articles, keeping the 30 Hz tick:
|
|
|
|
- **Every machine ticks on its own clock.** Nobody waits for anybody: a
|
|
client no longer runs only the ticks the host has sent. A client's clock
|
|
starts with the host's first game update and takes the host's time from
|
|
it (at a game's start as at a late join), so every machine reads the
|
|
game's timers alike.
|
|
- **Own player predicted.** A client drives its own player (and the
|
|
vehicle it drives) from its local input at once. Remote players are
|
|
driven by the inputs the host relays every tick, the latest one held
|
|
until a newer arrives.
|
|
- **Host authoritative.** The host alone decides damage, deaths, spawns,
|
|
pickups, scores and the game's objects; clients do not decide them but
|
|
apply what the host sends.
|
|
- **Corrections.** The host sends each client the authoritative state of
|
|
the players' units and the game's moving objects; a client moves its
|
|
copies toward it, a small error half of the way each tick, a larger one
|
|
at once, drawn gliding from where they were. A client's own unit and
|
|
vehicle are only corrected past a tolerance, so prediction does not
|
|
rubber-band: the host tells the client which of the client's ticks it
|
|
has its player at (its prediction come back), and the client compares
|
|
that with where it had the player at that tick, not now, and moves the
|
|
player by the difference, keeping what they did since. The host takes a
|
|
client's player where the client says within a tolerance of its own,
|
|
and no further from an anchor (one it took, a newer once a second) than
|
|
a player moves in the client's ticks since, those no more than its own
|
|
since and a little jitter (so the tolerance and the jitter are not gained
|
|
again each tick); a teleporter, which moves the host's own copy too,
|
|
starts afresh.
|
|
- **Shooter's hits.** A client reports what its own players hit; the host
|
|
checks the report (the player's, a weapon they carry, the target where
|
|
the host had it when the shooter saw it, no faster than weapons fire) and
|
|
deals the damage. What the shooter saw hit, hits.
|
|
|
|
A host's game advertisement says that it plays this netcode. A client does
|
|
not join a host that does not (one of network version 4 built before the
|
|
lockstep netcode was removed, set to play it): it tells the player why.
|
|
|
|
## Versions
|
|
|
|
The native builds' network code has a version, an unsigned 16-bit number
|
|
(`HALO_PORT_NETWORK_VERSION` in `port/linux/include/halo_port_limits.h`),
|
|
raised with any change to what the machines send each other. A host puts it
|
|
in its game's advertisement (reserved bytes that hosts built before there
|
|
was a version send as zeros, so they are version 0). A client does not join
|
|
a host of another version: it shows a message box that says which of the
|
|
two is newer, with both versions ("update the game" or "ask the host to
|
|
update"), and stays in the list of games. Version 1 was the first of this
|
|
netcode; version 2 lets a machine join a game in progress; version 3 puts
|
|
each player in its slot of the host's player list on every machine;
|
|
version 4 plays the European (PAL) maps as the North American ones
|
|
(`port/linux/game/pal_tags.c`); version 5 corrects a client's own player
|
|
against where it had them at the tick the host has them at, and checks
|
|
what the machines send each other more closely; version 6 sends a unit's
|
|
state and a player's input without the parts that are their defaults, fills
|
|
each datagram, sends damage and pickups only to the machines they concern,
|
|
stamps a hit with the host's tick the client had heard of, and checks hits
|
|
and a client's own player's moves more closely.
|
|
|
|
## Joining a game in progress
|
|
|
|
A game stays open when it starts (on the Xbox it closed, since every
|
|
machine had to simulate it from its first tick), and the game list shows
|
|
it. A machine that joins it is accepted as in the
|
|
pregame, and its players are added as the game adds a player in game:
|
|
every machine in the game spawns them, told by the game's own
|
|
`_message_server_add_player_ingame`. Then the host sends that machine alone
|
|
the game's settings and its start, with the host's game time (the start
|
|
message carries 16 bits of it); the machine loads the game, sets its clock
|
|
to that time and the ticks it spent loading, and takes the rest of the time
|
|
from the first game update if it is ahead (so that the game's timers read
|
|
as the host's). It takes up the host's count of updates where it is. When it has loaded, the distributed netcode
|
|
gives it the host's objects (network_objects.c), every player's statistics
|
|
and the game type's state, and it plays on as any other client.
|
|
|
|
The netcode names a player by its datum's index, which must be the same on
|
|
every machine, the one that joined too. That machine has not the players
|
|
who left (their datums stay until the game ends), nor the order in which
|
|
the others added players. So each player's datum is
|
|
its slot in the host's player list: every machine makes it there, and the
|
|
host gives a player added to the game in progress a slot whose datum is
|
|
free (`network_game_manager.c`). A player added to the game in progress
|
|
also gets its team and the game type's data, as the players at the start
|
|
do (in free for all, a team of its own).
|
|
|
|
The game is closed to joins while its machines load and after it ends.
|
|
A machine must join within 10 seconds of connecting; in a game, the host
|
|
drops a machine it has heard nothing from for 15 seconds, and one that
|
|
joined the game in progress and has not loaded in two minutes.
|
|
|
|
Until it has loaded, the machine hears none of the game's messages (which a
|
|
machine in the pregame refuses, and which the others no longer need), only
|
|
a pregame keep-alive every five seconds from the host
|
|
(`network_server_manager.c`, `network_server_message_handler.c`).
|
|
|
|
## Stages
|
|
|
|
1. (Done) Decoupled ticks: clients tick on their own clock with local input
|
|
for local players and the latest relayed input for remote ones; the host
|
|
no longer waits for clients; taps are accumulated so a quick button
|
|
press is never lost; out-of-sync checks off. The lockstep netcode is
|
|
gone: the host's per-tick game update carries no actions, and only
|
|
keeps the clients' count of its ticks.
|
|
2. (Done) Authority: clients skip damage, deaths, spawns, pickups, item
|
|
spawns, and scoring, and apply the host's state for them
|
|
(`port/linux/game/network_distributed.c`):
|
|
- every tick, every player's unit: which it is, alive or not, the seat
|
|
it rides, shields and health (down, recharging, the damage they show),
|
|
its powerups (camouflage and how long each has left), where it is (a
|
|
client's own player's position is its own, within a tolerance, and the
|
|
host takes it), and when dead who killed it;
|
|
- what a client's players pick up, which the host decides: the client
|
|
shows it (the HUD's message, the sound, a powerup's screen flash);
|
|
only that client is told;
|
|
- twice a second and with every kill, the players' statistics that
|
|
changed (once a second a few more, round them all, as the message is
|
|
unreliable); when it changes (looked at five times a second, sent at
|
|
most every other look unless the game ended; reliably, so never
|
|
again unchanged), the game type's state (scores, the flags, the balls
|
|
and their carriers, the king's hill, the players' speeds) and whether
|
|
the game is over. A machine that has loaded is sent all of both at once. It counts
|
|
the game type's events (captures, grabs and returns of the flags,
|
|
laps, the balls reset), and a client announces those it has not had,
|
|
as the host does its own; a client runs the rest of the game type's
|
|
update that only shows the game (waypoints, messages, sounds, a
|
|
carrier's speed).
|
|
|
|
The messages are a kind of their own (the game's unused "data" message
|
|
type), unreliable per tick, reliable for what must not be lost.
|
|
3. (Done) Object identity (`port/linux/game/network_objects.c`): the
|
|
game's units, vehicles, weapons and equipment are the host's, at the same
|
|
datum index (identifier and all) on every machine, so a message names
|
|
one by its index.
|
|
- The host tells its clients (reliably) of each such object it makes
|
|
(what it is, where, how it looks) and each it deletes; clients make
|
|
and delete theirs to match. A client that has loaded asks for all the
|
|
host's objects and is told when it has them; from then on it deletes
|
|
any such object the host has not told it of, and nothing but the
|
|
host's word deletes the host's.
|
|
- The objects placed when the map loads are placed alike everywhere:
|
|
the host's word finds a client's already there. Past loading, a
|
|
client's own objects (projectiles, effects: what only it sees) take
|
|
indices from the upper half of the object array, clear of the host's.
|
|
- Ten times a second, what every unit carries (the host's weapons, slot
|
|
for slot, their ammunition, the weapon in hand, the grenades); a
|
|
client moves the same weapon objects in and out of its units. A
|
|
change of weapons or grenades goes to every client at once; one of
|
|
ammunition only to the unit's player's machine at once, and to the
|
|
others as often as they are sent that player. A client takes its own
|
|
players' ammunition and grenade counts from the host only once a
|
|
round trip has passed since it last fired, reloaded or threw (what
|
|
the host says before then is from before). How old what a unit
|
|
carries is travels in 16 bits.
|
|
- Players take the units the host spawns them with, seats are the
|
|
host's (a client's own player's once it has ridden otherwise for
|
|
longer than a round trip), and the CTF flags and oddballs are the same
|
|
objects everywhere.
|
|
4. (Done) Corrections: the host sends each client where its moving objects
|
|
are (vehicles, items, bodies) as often as they are near that client's
|
|
nearest player (every tick within 25 world units, every second within
|
|
60, every third within 120, every fourth further off), once more to
|
|
every client as an object comes to rest, and a few of those at rest,
|
|
round them all. A client puts its copies there, and the difference is
|
|
drawn fading over a few ticks (`render_interpolation.c`) instead of a
|
|
jump. A client drives its own player's vehicle and sends where it is,
|
|
which the host takes within a tolerance, as it does its own player's
|
|
unit, and no further from an anchor (one it took, a newer once a
|
|
second) than the vehicle moves in the client's ticks since, those no
|
|
more than its own since and a little jitter (its speed, the host's
|
|
copy's or twice the tag's top speed, whichever is more, no more than 3
|
|
world units a tick, and a tenth more a tick, with the blend distance; a
|
|
teleporter falls back to the tolerance). The host sends the client its own vehicle
|
|
every third tick, with the client's tick it took the vehicle at
|
|
(`_distributed_object_predicted_bit` and a 16-bit time in the object's
|
|
state); the client compares that with where it had the vehicle at that
|
|
tick, and past 4 world units moves it by the difference, as it does
|
|
its own player's unit.
|
|
5. (Done) Hits (`port/linux/game/network_damage.c`):
|
|
- A client deals no damage. What its own players' shots, grenades,
|
|
melee and vehicles hit, it reports to the host (reliably), with the
|
|
host's latest tick it had heard of when it made the report.
|
|
- The host deals a report once it has checked it: from that machine's
|
|
player; damage one of their weapons (now or in the last ten seconds),
|
|
their grenades (while they have them, and for a while after) or
|
|
their vehicle (a driver's or gunner's) can deal (its projectiles'
|
|
impacts and detonations, followed through the tags), no harder than
|
|
it can be (all of it, but an airborne melee blow's half again); the
|
|
target within a few world units of where the host had it at the tick
|
|
the report was made at (a player's unit or vehicle: the host keeps a
|
|
second of where they were) or of where it is (more for a fast one);
|
|
a melee blow from where the host had the player; the impact at the
|
|
target (an explosion within its reach); and no more reports than the
|
|
weapon that deals them fires (its rate of fire and projectiles a
|
|
shot, with a margin; an explosion's hits count as one, when its
|
|
damage has a reach, and no object is hit twice by one). A report made
|
|
more than three seconds ago (a burst of them held back while the
|
|
network was out) is refused. A report is paid for before the host
|
|
looks through its history, so a flood of them costs the sender its
|
|
hits. The damage is dealt as a client's own hit can be: only the
|
|
flags such a hit has, and the host's multiplier and team, not the
|
|
report's, its owner the player's unit (or the vehicle it rides, or
|
|
a unit of theirs); a report with a number that is not finite, or a
|
|
node, region or material the target does not have, is refused. Its
|
|
own copies of a client's projectiles deal nothing (the report does).
|
|
- The host sends its clients the damage it dealt to units, and a client
|
|
replays what it does besides the harm (which the units' states
|
|
bring): the player's screen flash and shake, the unit's flinch, pain
|
|
sound, knockback and stun, the scope it knocks the player out of, and
|
|
who the HUD shows hit them. A killing blow it replays whole, so the
|
|
body falls as the shot had it and the kill is announced with the
|
|
host's killer. A killing blow goes to every client; other damage to
|
|
the machines of the unit's player, its riders and the damage's owner,
|
|
and of the clients sent that player this tick (who can see them); a
|
|
player's screen effects to that player's machine alone.
|
|
|
|
## Transport
|
|
|
|
What reaches the other machines, and how, decides how the game feels over
|
|
a real network as much as the model does (compared with Quake III, Source,
|
|
Unity's Netcode for Entities, lightyear, netfox and the Ares source):
|
|
|
|
- **Nothing held back.** The game's connections (the reliable messages:
|
|
objects made and deleted, the game type's state, hits, pickups) send each
|
|
write at once (`TCP_NODELAY`, in `xnet.c` for the game's sockets and in
|
|
`p2p.c` for internet play's): with Nagle's algorithm a small write waited
|
|
for the other end's delayed acknowledgement, up to 200 ms on Windows.
|
|
- **Input every tick, unreliably, each tick's buttons several times.** A
|
|
client sends the host its players' input after each tick, with the
|
|
buttons of the three ticks before it, and the host sends every client
|
|
every player's input as its tick ran it, the same way. Each tick's
|
|
buttons are taken once, from whichever message brings them first
|
|
(`player_queues_new.c`), so a press is lost only with four datagrams lost
|
|
in a row, and nothing waits for a lost one to be sent again (the game's
|
|
own per-tick update, reliable and so held up by any loss, now carries no
|
|
input). The host's clock still reaches the clients in it, and the game's
|
|
own client update, whose input the host no longer takes, goes ten times
|
|
a second instead of sixty; the host takes its own players' input at
|
|
each of its ticks.
|
|
- **As few datagrams a tick as they fill.** The unreliable messages of a
|
|
tick to a machine go together (`_distributed_message_batch`), saving each
|
|
one's headers (internet play's tunnel adds 36 bytes to every datagram: 15
|
|
of its header, 16 of its tag and 5 of its own inside), each message split
|
|
between two where one is full, so none goes out part empty; the machines'
|
|
datagrams go in a turn that starts one further each tick.
|
|
- **Stamped with their tick.** Every message carries the sender's tick
|
|
(its header's game time); an unreliable one (the units, the input, the
|
|
objects, the statistics, what units carry, the damage) that arrives after
|
|
a newer of its kind is dropped, so a late datagram never puts anything
|
|
back.
|
|
- **Taken at the tick.** The host takes a client's players' and vehicles'
|
|
positions (the latest of each) at its next tick, not as each arrives.
|
|
- **Fewer bytes.** Vectors travel in 16 bits a part, shields and health in
|
|
16 bits; a unit's state goes without the parts that are their defaults
|
|
(the vehicle and seat of one that rides nothing, the position of one that
|
|
rides, its up when straight up, the damage its shields and body show,
|
|
powerups and camouflage when it has none, the tick its own client
|
|
predicted it at to any other client), 35 bytes for a player on foot
|
|
instead of 64; a player's input goes with its tick's update once a
|
|
message, and the buttons of the three ticks before only where they
|
|
differ from the tick after (14 to 20 bytes instead of 24); what a unit
|
|
carries is sent when it changes (and once a second); the moving objects
|
|
as often as they are near the client's players, the objects at rest
|
|
round all of them, four a tick; the players' statistics when
|
|
they change (with every kill, twice a second), with sixteen more
|
|
players' once a second round them all; the game type's state when it
|
|
changes, at most two and a half times a second.
|
|
- **The players each client needs, when it needs them.** The host sends a
|
|
client every player's unit and input every tick when they are within 25
|
|
world units of the client's own players, every second tick within 60,
|
|
every third within 120, every fourth further off, and every sixth when no
|
|
cluster of the client's players' can see theirs (the map's potentially
|
|
visible set, which errs on the side of seeing: Halo's are coarse, and
|
|
most of a map sees most of it) or they are dead. A client whose players
|
|
are dead is sent the others as from where its players last were alive.
|
|
None is ever left out, but a player who has left the game, whose unit
|
|
goes only when it changes, and a dead player's input, which drives
|
|
nothing. Whatever changes what a client sees goes at once:
|
|
- a player's death, spawn or seat, and a player coming into sight;
|
|
- a player's input every tick while their buttons or weapon choices
|
|
change (the ticks it carries), so no jump, grenade or melee of theirs
|
|
is missed, even out of sight;
|
|
- a player a client's player aims at within 35 degrees at least every
|
|
second tick, and within 20 degrees through a scope every tick, so a
|
|
sniper sees a far player move as smoothly as a near one (as Ares
|
|
raises the priority of what a player zooms onto).
|
|
|
|
A client's own players' units go to it every tick, their input never (it
|
|
has its own).
|
|
- **The round trip.** A client's input messages tell the host the latest
|
|
host tick the client has had, which gives the host each client's round
|
|
trip (smoothed as TCP smooths its own, once a message). The host keeps a
|
|
second (32 ticks) of where players' units and vehicles were, and checks a
|
|
client's hit against where the target was from the host tick the client
|
|
had last heard of when it made the report (three ticks more), instead of
|
|
against where it is now with a wide margin: however long the report
|
|
took to come (the reliable channel's resending holds it up), that is
|
|
when the shooter saw the target (as far back as the host keeps). A
|
|
report made more than three seconds ago is refused.
|
|
|
|
## Testing
|
|
|
|
`debug.network_test` (`port/linux/game/network_test.c`) hosts or joins a
|
|
game without the menus (in a team game the joining player takes the other
|
|
team), and `debug.test_input` plays controller 1 with a scripted bot (or,
|
|
`look:<seed>`, one that stands still, only turning and looking up and
|
|
down); each machine logs every player's position, health and shields,
|
|
where they aim and face, their animation state and how hard they move,
|
|
weapons, grenades, score, kills and deaths every second, with the objects
|
|
made and removed and the hits reported, dealt, rejected and replayed, so
|
|
two machines' views of one game can be compared. `debug.network_test_kill`,
|
|
`debug.network_test_shoot`, `debug.network_test_vehicle` and
|
|
`debug.network_test_pickup` script kills, hits, a vehicle ride and a weapon
|
|
swap the bots' wandering does not reach, and `debug.network_test_score`
|
|
shortens the game, to test the next (`host:<map>:<variant>,<variant>...`
|
|
plays the variants in turn, the next once a game is over, as the host's
|
|
button on the scores does). `debug.network_latency` and
|
|
`debug.network_loss` hold back what a machine receives and drop some of its
|
|
datagrams, to test as over the internet.
|
|
|
|
The host logs to `debug.txt` when a player on another machine presses the
|
|
action button where the host has nothing for them to pick up, with where it
|
|
has them and the nearest item: a client that sees a pickup the host does
|
|
not.
|