Files
Cesarus85--mjolnir-vr/port/linux/NETCODE.md
T
2026-10-01 04:57:51 +13:00

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.