Skip to content

Multiplayer review and fixes, shared traffic in multiplayer cruise (0.2.0) - #2

Merged
lowlevelmetal merged 60 commits into
mainfrom
integration
Oct 9, 2026
Merged

lowlevelmetal merged 60 commits into
mainfrom
integration

Conversation

@lowlevelmetal

Copy link
Copy Markdown
Owner

Multiplayer review and fixes, plus shared traffic and police in multiplayer cruise. Version 0.2.0. The four reviews are recorded in docs/review/multiplayer-*.md.

Fixes for reported problems

  • Booted back to the lobby: every race after the first in a session was sent back to the lobby right after loading. A return-to-lobby flag from the previous race was never cleared in the menus. Races are now numbered, and a return ends only its own race.

  • Remote cars jittering, which had several causes:

    • snapshots were stamped with the frame time instead of the physics step they carry;
    • remote cars were sampled at the frame time instead of on the step times;
    • a fixed 100 ms delay (now a per-car playout delay);
    • clock steps (it now slews);
    • lost snapshots: the sequenced channel, the single-state relay, and ENet's throttle.

    With 100 ms round trip and 1% loss, the error went from 22-37 cm rms to about 2 cm.

New

  • Shared traffic and police in multiplayer cruise:
    • the host runs it for every player and replicates it (about 8-11 KB/s per client);
    • a HOST SETTINGS option, on by default; off is MM2's original multiplayer cruise;
    • protocol version 2.

Other fixes

  • Lobby and session:
    • a load failure takes everyone back with a notice;
    • the session is serviced while a race loads;
    • no stale car states or game events between races;
    • chat by serial;
    • Cops vs. Robbers team cars, and a shared clock;
    • each driver's own transmission;
    • single-player setup is restored after a network race.
  • Network input:
    • names and text are sanitised;
    • car and city names are validated;
    • relay budgets per joiner;
    • packet and memory caps;
    • the LAN beacon answers only the LAN, at a bounded rate;
    • port-mapping state is validated;
    • a password lockout;
    • fuzz tests.
  • Font downloads try every mirror.

Testing

  • Full suite with game data: 868/868. Opponent sweep unchanged: 516/517 finished, 508/517 across the line.
  • Two-process checks on loopback:
    • three races in one session, with car and mode changes;
    • shared traffic matches between host and client at the same session moment.

🤖 Generated with Claude Code

lowlevelmetal and others added 30 commits October 8, 2026 21:11
file(DOWNLOAD) with EXPECTED_HASH turns a failed download into a CMake
error ("cannot compute hash on failed download"), so a timeout on the
first CTAN mirror ended the configure without trying the others; the
v0.1.0 tag's MinGW build failed that way. The hash is now checked after
the download, a mismatch moves on to the next mirror, and a font that no
mirror provides is a warning again, as the file's comment says.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The host cleaned up what joiners sent, but its cleanup dropped every
trailing non-ASCII character ("José" became "Jos") and could cut a name
inside a UTF-8 sequence when it added " (2)". Clients cleaned up nothing
the host sent: names, chat, the session name, kick and reject reasons
reached the UI with control characters and invalid UTF-8, the player list
grew to 255 entries (each one a car in the race), and the city name went
unchecked into file names and, as the last race, into the profile file,
where a newline added arbitrary lines.

sanitizeText keeps well-formed UTF-8 without control characters and cuts
on a character boundary. Car and city names must be plain base names
([A-Za-z0-9_-], up to 32 characters): the host gives a joiner with any
other car the default car and keeps the old one on a request, a client
replaces such a car with the default and ignores such a city. A client
keeps at most kMaxPlayers players and refuses a Welcome that does not
list it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The host relayed every chat line, PlayerRequest and game event a joiner
sent to every other player on reliable channels, with no limit: one
client could make the host send N-1 times its own flood and grow ENet's
unbounded outgoing queues.

Each joiner now has a budget per kind (chat 2 lines/s with a burst of 8,
player updates 10/s with 20, game events 30/s with 60), far above what
the game sends. Chat and events beyond it are dropped. A PlayerRequest
is always applied and its update relayed once the budget allows, so the
last car, colour, team and ready state still reaches everyone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ENet's defaults allow a 32 MiB packet and 32 MiB of undelivered data per
peer, and it allocates a fragmented packet's full size when the first
fragment arrives. Any connected peer, before it even sends Hello, could
make the host allocate tens of megabytes with a couple of datagrams, and
with 16 peer slots about a gigabyte.

TransportConfig now limits a packet to 64 KiB and a peer's waiting data
to 1 MiB on both ends. OpenMM2's largest message, a Welcome with 16
players and 32 extra settings, is about 5 KiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MM2's network cruise has no traffic and no police (mmGameMulti::Init
zeroes their densities). OpenMM2 adds them as an extra: the host runs
them and sends each client the cars near it. This is the wire and the
replication logic; the race wiring follows.

- net::AmbientStateMsg on a new unreliable, sequenced Ambient channel:
  per car the slot id, a generation that changes when the slot is
  reused, the model (catalog index), paint job, position relative to the
  message's origin (3 cm), orientation, the speed of a car on its rail or
  the full velocities of a knocked car or a police car, and flags
  (brake, horn, indicators, off its rail, wreck, siren, pursuit); police
  add their target, damage, rpm, throttle and gear. A rail car takes
  118 bits.
- SessionSettings::sharedTraffic (the host's lobby option, on by
  default) and RaceConfig::netTraffic; protocol version 2.
- game::TrafficHost picks each client's cars: the police chasing it,
  then the nearest within 200 m (kept until 230 m), within one
  unfragmented datagram. game::TrafficClient follows them with the
  remote players' interpolation buffer: every message is complete, so a
  car missing from a newer one has left, an older one never brings a car
  back, and a new generation is a new car rather than a blend across the
  city.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The second race of a session bounced everyone back to the lobby as soon as
it had loaded. When the host's race ended (a finish, the results, or Quit to
Lobby), RaceScreen::leaveRace called NetGame::returnToLobby and switched to
the menus; the session's ReturnedToLobby event reached NetGame only on the
menus' next update and set a "return pending" flag that nothing in the menus
took. The next race's first frame took it as its own, left the race, and the
host, still in that race's countdown, sent everyone back again (which set
the flag once more, so every later race bounced too). A joiner who had quit
a race early met the same stale flag alone.

NetGame now counts the races it sees start (raceNumber) and remembers which
race the last return to the lobby ended (backToLobby); the race screen keeps
the number of its race. A return and a new countdown arriving in one update
still end the old race and start the new one. Game events still queued when
a countdown starts are from an earlier race and are dropped.

The frontend script now survives a race (wait:race, and the commands after
the one that started it run when the menus are back), and mp:car changes
the lobby car as the garage does, which is how the bounce was reproduced.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The beacon answered every well-formed query from any source with an
advert about 17 times its size, up to 64 per frame: a spoofed source
turned a host with a reachable discovery port into a reflector, and a
spoofed broadcast source made one query reach a whole subnet. The
scanner kept every advert it heard, keyed by source and game port, so a
flood of spoofed adverts grew the session list without bound, and their
text reached the session list as sent.

The beacon now answers loopback, private addresses and the subnets of
this machine's interfaces (so VPN adapters on public ranges still work),
never broadcast, multicast or zero addresses, and at most 20 replies a
second with a burst of 40. The scanner lists at most 64 sessions, skips
adverts with game port 0 and cleans up their text like the session's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The crash-safe state file's ports were cast to 16 bits unchecked, so a
damaged file wrapped them around, and internal port 0 passed: the next
start's NAT-PMP or PCP clean-up then asked the router to delete internal
port 0, which RFC 6886 and RFC 6887 define as every mapping of this
machine, other programs' included. The PCP nonce's hex accepted signs.
The external address a UPnP device reported went to the lobby's status
line as given, and any device on the LAN can answer as the router.

Records with a port outside 1-65535 are ignored, the PCP/NAT-PMP
backend refuses to delete internal port 0, the nonce takes only hex
digits, and an external address that does not parse is dropped.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MM2's traffic is written for a list of players (aiMap +0x170, filled by
aiMap::AddPlayer): aiMap::Update populates the roads round each player's
room with that player's bit in each road's mask (aiPath::AddAmbPlayer /
RemAmbPlayer), aiGoalRandomDrive::Update and aiGoalRegainRail::Update
walk the list for the first player in a car's way and keep its index
(aiVehicleSpline +0xe6) for aiGoalAvoidPlayer, and the pose solver
refits a car when any player is within 100 m. OpenMM2 had folded that
into one player. Traffic::step now takes the list (one player in slot 0
is the single-player step, unchanged), with 16 slots where aiMap has
four, for the shared traffic of a network cruise.

ai::World::setOtherPlayers adds the network players to the traffic
(the pedestrians keep the local player only). World counts its light
steps, and advanceLightsTo lets a network client's light sets follow
the host's: they are deterministic from a reset. Traffic publishes a
spawn count per pool slot so the network can tell a recycled slot from
the car before it, and the police mark hits by the first player's car
(the local one) as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Back from a network race, the menus took the race's whole configuration
as the driver's: multiplayer set, no traffic, police or opponents, and the
host's difficulty. Leaving the session and starting a single-player cruise
then ran it with no police (19 before, 0 after, in San Francisco), with the
multiplayer rules and start picks, and nothing could be recorded. The menus
now keep the driver's state as the profile gives it (configFromProfile),
which already holds the network event and car (saveNetEvent, as MM2's
MultiStartGame -> BeDone); MM2's network games never touch the
single-player traffic and police settings either.

The sessions page under the lobby no longer starts the LAN browser, which
then searched every second through the races.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A network car is a kinematic body placed at its received position; its
collider has no inertia, so every impact saw it standing still. That
is MM2's static wall, and stays the default. An instance may now report
its motion (Instance::kinematicMotion, Body::kinematicMoves): its
collider then carries that velocity and spin into the impact, so a
traffic car a remote player hits on the host is knocked away as by a
moving car, and a client's car meets the shared traffic and police as
moving cars. Nothing in MM2 has such instances (OpenMM2 extra).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
mmNetObject::Init takes a network car through mmVehList::GetVehicleInfo,
which answers a name the list does not have with the default vehicle,
vpcoop (mmVehList::LoadAll). OpenMM2 tried to load the name as sent and,
when this machine lacked it (an add-on car), retried the load every
frame, logging a warning each time, while the player's car stayed
invisible and could not be hit.

netVehicle gives vpcoop for a name the catalog does not have, and a car
that still fails to load is tried once per car and paint job.

The traffic and pedestrian densities arrive as a percent byte, which
allowed up to 255 %; fromSessionSettings now keeps them at most full.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NetArena::AddRosterName cuts a name wider than the column to "%.6s...".
OpenMM2 took six bytes, which splits a UTF-8 character (a name such as
"aéééé" showed a replacement glyph). It now keeps six whole characters,
as the main menu's fit() does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NetGame::raceConfig() left RaceConfig::automatic at its default, so every
network race was driven with the automatic gearbox whatever the driver had
chosen, and the menus then saved that as the driver's choice. MM2's
network session data carries no transmission (mmInterface::GetSessionData)
and mmGame::Init sets vehTransmission::Automatic from the player's own
state. NetCar now holds the driver's choice: the lobby takes it from the
menus, the garage's PREV (applyLobbyCar) updates it, and the race and the
saved event (saveNetEvent, BeDone) use it. It never travels.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
In Cops vs. Robbers each machine put its own player in the team's car
(vpcop for the cops, vpmustang99 for the robbers) but drew and counted the
others with the car they had picked in the lobby. A robber who had picked
a police car was drawn as one, and RaceScreen's Cops and Robbers took him
for a cop on every machine but his own, so the gold rules disagreed. MM2's
lobby sets the player's car to the team's, locks the others and sends it
to the session (mmInterface, cnr_team, ChangePlayerData), so every machine
sees the same car.

game::raceCar now holds the rule, and NetGame applies it to every player:
raceConfig() for the local car, playerCar() and remoteCars() for the
others; the race's Cops and Robbers teams and the lobby roster use it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A fixed-seed fuzz test feeds random and mutated messages, LAN packets,
router replies and event payloads to every decoder, checking that what
decodes stays within the limits the writer could produce. Two more drive
a live host (from a joined peer, a peer that never said Hello and raw
datagrams below ENet) and a live client (from a fake host) with mutated
traffic and check that the session stays consistent.

It found that Transport::send passed a zero-length packet to ENet, whose
range coder then reads the packet's null data and crashes the sender.
Every message has at least its type byte, so send now refuses an empty
one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NetGame keeps the last 64 chat lines. The lobby remembered how many there
were when it opened (NetArena::ResetGameChat) and the race how many it had
posted, both as indices into that list: once it was full the indices stopped
moving, and neither showed a new line again for the rest of the session.
The race also started counting at 0, so its first frame posted every line
typed in the lobby on the HUD (seen at the start of a cruise), with a
network alert for each of the others' lines.

Every line now has a serial; the lobby shows the lines from the serial it
opened at, and the race posts the lines from the serial it started at.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A wrong password cost a guesser only a reconnect: the host answered each
new connection with a fresh challenge, so a lobby password could be
guessed online at the rate of round trips, on up to 16 connections at
once.

The host now counts wrong passwords per address and, after five within a
minute, disconnects that address's new connections with BadPassword
before sending a challenge until the minute is over. The joiner sees the
usual "wrong password".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A client's session kept the last state its race screen had submitted and
went on sending it at the snapshot rate all through the lobby; the host
stored it and passed it on to the others. The next race then began with
that car drawn, and collided with as a kinematic body, where it had
stopped in the last race until its new states arrived.

The session now replicates cars only during a race: a state submitted in
the lobby is ignored, a late VehicleState or WorldState arriving there is
dropped, and the countdown and the return to the lobby clear every car's
buffer and the local state on the host and the clients alike.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A city that failed to load sent the race screen straight to the menus.
For the host of a network race that left the session in the race's
countdown or game phase: the lobby's GO DRIVE could never start another
race (NetGame::startRace needs the lobby phase) and the joiners waited in
a race that would not end. A failed load now leaves through leaveRace, so
the host takes everyone back to the lobby (seen with a session set to a
city the data does not have: both machines back in the lobby, and the
next GO DRIVE starts again); a network race whose own car cannot be
loaded leaves the same way.

While a network race loads, its session is serviced between the parts,
so that a machine that loads more slowly than the others is not timed out
by them (ENet gives up on a peer that has not answered for 5 s).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The control options page changes the driver's transmission too, and it can
be opened from the lobby (the strip's OPTIONS); only the garage's PREV
handed the choice to the lobby car. The menus now give the lobby car the
transmission they have when the race starts. NetGame::setLocalCar sends
the car to the session only when the car, paint job or team changed, so
this costs no message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
docs/review/multiplayer-input.md lists what was read and tested, the
fifteen fixed findings with their commits, what was checked and found
correct, the open findings and what other areas should pick up.
docs/multiplayer.md describes the limits now applied to untrusted input:
packet sizes, text cleanup, base-name checks for cars and cities, player
counts, relay budgets, the password lockout, who the LAN beacon answers
and the checks on the port mapping record.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With the host's lobby option on (it is by default), a network cruise
keeps the traffic and the police that mmGameMulti::Init takes away:

- The host's lobby has a SHARED TRAFFIC panel in cruise (on/off, the
  single-player cruise's traffic and cop density sliders, defaults 0.5
  and 1 as RaceMenuBase::SetStateRace), carried to the clients by the
  session settings; off gives MM2's network cruise exactly.
- The host runs ai::World's traffic at its density with every player in
  aiMap's player list and the cruise's police posts; the other players'
  kinematic cars are players to the traffic and suspects to the police,
  and strike traffic cars as moving cars. Every 50 ms each client gets
  its cars (game::TrafficHost), with the light sets' step count.
- A client runs no traffic or police of its own: it interpolates the
  received cars (game::TrafficClient), draws them with the traffic
  renderer and the police as cars with their sirens, plays their engines,
  horns and sirens (a cop chasing it starts the chase music), shows the
  police in pursuit on the map, collides with them as moving kinematic
  instances (game::TrafficProxies), runs its light sets to the host's
  steps, and its pedestrians shy from the received cars off their rails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The other players' cars are kinematic bodies placed every frame from
their snapshots. Their colliders had no velocity, so every impact
treated them as walls standing still: closing on a car doing 30 m/s at
1 m/s cost the chaser 12.6 m/s, a rear tap threw the car back and
rubbing side by side dragged it as against a barrier. In MM2 those cars
were simulated vehCars (mmNetObject) with velocities of their own.

A kinematic collider now takes its velocity from its body's ICS (which
RaceScreen fills from the snapshot), so impacts work on the relative
speed; the network car still does not give way (infinite mass).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The "cars slightly bounce" report, measured with a host and a client on
one machine (and through a 100 ms round trip with jitter and loss):

- Snapshots were stamped with the frame's time, but the state they
  carry is the last fixed simulation step's, up to 16.7 ms older. 50 ms
  of stamps carried 3 or 4 steps of motion, so a car at 140 km/h surged
  back and forth by up to 0.35 m (0.1 m vertically on a slope) even on
  loopback. NetGame::submitLocalState now takes the state's age (the
  fixed step's unstepped remainder) and stamps the session time the
  state belongs to, measured once a frame (NetGame::frameTime).
- Remote cars were sampled at the frame's time while the local car, the
  camera and every simulated object move in whole 60 Hz steps: a car
  alongside shook by half a step (29 cm rms at 35 m/s, 144 fps), and at
  60 fps 4-10% of frames jumped by a whole step. RaceScreen now samples
  them once a frame at the time the simulation reaches after the
  frame's steps (World::remainderAfter) and uses that one sample for the
  physics body, the drawing, the HUD and Cops and Robbers.
- The fixed 100 ms interpolation delay is too short over the Internet:
  at 100 ms round trip 14-40% of frames extrapolated and snapped back
  (up to 0.56 m, 0.48 m vertically). Each remote car now has its own
  playout delay, what its snapshots needed to arrive in time over the
  last 3 s (SnapshotBuffer::requiredDelay), between 50 and 500 ms,
  growing quickly and shrinking slowly so the shown time never jumps.
- A client's clock estimate stepped whenever the best sample changed,
  moving every car by its speed times the step. The shown offset now
  slews toward the estimate at 5% while a race runs (SlewedClock), and
  the clock has sub-millisecond precision.
- Snapshots go out on a steady 20 Hz cadence instead of drifting to the
  next frame each time, and only new states are sent.
- States are ignored in the lobby, and a client forgets its car on the
  return to the lobby, so the last race's car does not appear in the
  next one.

OPENMM2_NET_TRACE=<file> writes the snapshots taken in and, once a
frame, the local car and the remote cars as drawn, for diagnosing
replication.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
As the network review asks of every received value:

- A received car's paint job is held to the jobs its model can show,
  and a model always comes from the client's own catalog.
- The host's police are shown at most 32 at a time; each model is
  loaded once per car shown at a time (a car that leaves keeps its
  parts for the next of its model), a model that fails is not tried
  again, and a new generation or paint job reuses the car.
- The fuzzer feeds AmbientState through every decoder and a live
  client, and a hostile host's bit-flipped messages never give the
  client a car out of range.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lowlevelmetal and others added 29 commits October 8, 2026 22:43
A client's cars beyond 80 m on their rails now get their state in every
other message; in the others they come as 13 bits (id, generation, "still
there"), so a missing car still means it has gone and nothing comes back
from a lost message. Near cars, cars off their rails, the police and
cars new to the client keep their state in every message.

In a two-player San Francisco cruise at traffic density 0.5 with 63 cars
round the client, the host's stream to it fell from 17.5 to 11.0 KB/s.
The client's cars within 120 m of it were 2 cm from the host's (median;
90 % within 10 cm, 27 cm at worst, for far cars between messages).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The host settings page remembered the host's mode and city for the menus
but not the race or its setup, so the single race menu could hold a race
mode with cruise's race and setup. Hosting, choosing a circuit in the host
settings and leaving the session, the race menu's GO then started the
circuit with no opponents (4 from the menu directly) and could record
nothing. The page now also keeps the race and applies its defaults, as
the race menu does (RaceMenuBase::SetStateRace); MM2's HostRaceMenu edits
the one state pack that the race menu shows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The host relayed a joiner's snapshot times unchanged, so a broken or
hostile sender could stamp its car minutes ahead: every receiver would
drag the car toward that state and hold its playout delay on it. A
receiver now ignores states more than 1 s ahead of its session clock or
5 s behind it (more than any clock error or playout delay), and the
host relays only the states it took in.

Tests for the replication timing: the playout delay's measurement,
the slewed client clock, states carrying their simulation time end to
end, nothing replicated in the lobby, World::remainderAfter. The
multiplayer document describes the time stamps, the playout delay, the
clock and OPENMM2_NET_TRACE.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A multiplayer checkpoint race or circuit ends on the host once every player
is counted, and the host then takes everyone back to the lobby. The last
player to finish is still in its 3 s post-race wait when that return
arrives, and its race screen left with the race's bare configuration: no
results page for the player whose finish completed the race. MM2's host
sends 0x211 when everyone is counted, and every machine goes to its
results (mmMultiRace / mmMultiCircuit::UpdateGame state 5). The same race
could happen between a Cops and Robbers limit announced on a joiner and
the host's return.

When the host ends the race (or the session ends), a race this machine has
finished or lost, or a Cops and Robbers game whose limit it has announced,
now leaves with its results as at its own ending.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The host options dialog trims the password it hosts with; the password
dialog sent what was typed, so a stray space before or after the right
password was refused as wrong.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The lobby now shows notices while the session goes on (a race that could
not load), so a notice left from an earlier session must not appear in
the next one: hosting and joining start with none.

The multiplayer notes said the host settings add Snowing to the weather;
the page offers Clear to Raining (RaceMenuBase::IncWeather stops there).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The root cause of the lobby bounce (a return to the lobby left pending in
the menus ended the next race), the fixes made, what was checked and found
correct, and the findings left open, among them MM2's wait for every
player to load before the start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The host's view of a client's car lags the client's own by the
interpolation delay and the trip, so a client that hits a moving traffic
car as it shows it often misses it on the host, where the collision then
never happens. The client now reports such a hit (a reliable game event:
the car's id and generation and its own velocity before the step, at most
once a second per car). If that car is still on its rail on the host and
the client's car is near it there, the host knocks it off as the hit would
have (aiVehicleInstance::AttachEntity) with the impulse of a car of the
client's mass meeting it at that velocity (elasticity 0.25), and the
result comes back with the next messages. A hit the host saw itself has
already taken the car off its rail and the report is dropped.

Also two automation aids: OPENMM2_DEBUG_NETTRAFFIC logs both sides' cars,
hits and knocks for comparison, and OPENMM2_DEBUG_NET_SHOT_MS ends a
network race at a session time, so every machine's --screenshot shows the
same moment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The shared traffic follows the cars' replication lifecycle: the host
sends it during a race only, a client drops it in the lobby, and the
countdown and the return to the lobby clear what was queued.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Measured with a host and two clients, each behind a relay adding
75 ms +-30 ms per hop:

- The host held a joiner's state for its own 20 Hz tick and sent only
  the newest one per player: of two that arrived within one tick the
  older was lost, a quarter of the client-to-client snapshots at 30 ms
  of jitter, and every one waited up to a tick longer. Joiners' states
  now go on to the others as soon as they arrive.
- The state channel was unreliable but sequenced: ENet drops a packet
  that arrives after a newer one, which with packets reordered by the
  network cost 15-20% of the snapshots. A snapshot that arrives late
  still fills a hole in the receiver's time-ordered buffer, so the
  channel is now unsequenced; with reordering every snapshot arrived
  and no view extrapolated.
- ENet's packet throttle drops unreliable packets at the sender after
  round-trip spikes (0.94 for seconds in these tests). The game's few
  small packets are not what congests a link; each side's peer now
  never throttles down.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MM2's network games have neither racers nor police (mmGameMulti::Init);
OpenMM2's shared cruise traffic, when the host has it on, brings the
cruise's police posts back for the host to drive, and the races still
have none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
docs/multiplayer.md: the Ambient channel, the AmbientState message and
its encoding, interest management, the far cars' alternation, the
complete-message rule, the client's hit reports, the untrusted-input
checks, the lobby panel, the automation hooks, the measured bandwidth
and accuracy, and the tests. docs/ai.md: MM2's player list and the
traffic's 16 slots. docs/gamemodes.md: a network cruise's traffic with
the option on and off. docs/parity/openmm2-only.md records the extra and
its deviations from mmGameMulti::Init (the maintainer's decision of
2026-10-09: shared, host-authoritative, a lobby option on by default);
the new files get their manifest rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The lobby's host settings box leaves its fourth line empty in cruise;
it now says whether the host shares its traffic and police (OpenMM2
extra), so the joiners know before the race.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A race or circuit that waits for every finisher compared the results
it had (the player's own and the others') with the players listed now.
A player who finished and then left the session still counted, so with
three players one could finish, leave, and end the race (and send
everyone back to the lobby) while the third was still driving.

MM2's host compares its finish count with the players in the session as
each finish arrives (mmMultiRace::GameMessage 0x206). OpenMM2 now waits
until every player still in the race has finished, or the finish
timeout runs out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A host and a client NetGame on loopback start a cruise; the host's cars
(two traffic cars and a cop chasing the client), chosen by TrafficHost,
go out on the Ambient channel and the client follows them with
TrafficClient at the interpolation delay within 35 cm of where they were
at that session time; the client's hit report reaches the host; nothing
is sent in the lobby. Also keeps the shared traffic's new lines within
110 columns.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A joiner who quits a running race goes back to the lobby and stays in
the session. The others went on drawing its car frozen where it
stopped (a solid obstacle, held after 250 ms of extrapolation), and a
race or circuit waited for its finish until the 60 or 120 s finish
timeout ran out.

In MM2 the player left the session (mmGameMulti::QuitNetwork), and the
others took its car out and stopped counting it (SystemMessage 0x2d,
mmNetObject::Deactivate). The quitter now tells the others
(GameEventType::LeftRace): they take its car out of the race (drawing,
collisions, HUD, Cops and Robbers, which also drops gold it carried),
show "has left the game", and leave it out of the racers they wait for.
The race screen takes the frame's game events once and hands them to
the race and the Cops and Robbers rules.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each machine counted the time limit (and its warnings and lockouts) in
frames from the moment its own race screen loaded, through the start
countdown: the clocks disagreed by the difference in load times, so one
machine showed "Time's up" and its results while the others played on.

mmMultiCR::UpdateGame starts the clock when it enables the racers
(state 2). OpenMM2's cars go at the shared start time, so the rules now
run on the session clock from that time: with a host and a client the
remaining time agreed to the millisecond.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The other players' cars were silent. MM2 builds them as vehCars with
their vehCarAudioContainer (engine, tyres, horn; a police car's siren)
and mmNetObject::PositionUpdate drives it from each packet: the pedals,
the horn and siren bits, the gear.

Each network car now gets the AI cars' positioned car audio, fed from
its kinematic vehCarSim (which turns its engine and gears from the
snapshot's speed and pedals: 7000 rpm in first, shifting as the sender
did) with the snapshot's throttle, brake, gear, horn, siren and wreck.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The other players' wheels were drawn at their rest positions, spun from
the snapshot's speed and turned by a fixed half radian at full lock, so
they hung in the air or sank into the road over every bump and kerb.

vehCarModel::Draw takes a car's wheels from its vehCarSim. The kinematic
network car's sim already runs its wheels each sample against the ground
under the placed body (suspension, roll, steering from the snapshot's
pedals); the drawing now uses those wheel matrices.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A cruise without shared traffic reads no game events, so whatever a peer
sends (up to its relay budget of 30 a second) piled up for the whole
session. The queue now keeps the newest 256; the modes that read events
take them every frame and never come near it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
netprobe relay <host[:port]> --port N --delay MS --jitter MS --loss
PERCENT [--reorder] forwards each player's datagrams to the host and
back with a delay of delay +- jitter (uniform) each way, dropping the
given share, and keeps them in order unless --reorder. Players join the
relay's port: a host and its clients on one machine then see an
Internet link, which is how the replication fixes were measured (the
system's tc/netem needs root).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The shared traffic's kinematic motion (Body::kinematicMoves, reported
to the collider per collision) replaces this branch's collider pointer
to the body's ICS, and the network players' cars now report their
motion in every mode, not only in shared-traffic cruise: as still walls
they cost a chaser 12.6 m/s on a 1 m/s tap. The state channel stays
unsequenced; the ambient channel keeps ENet's sequencing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The client drew the shared traffic a fixed 100 ms behind the session
clock. Its messages come over the host's link on the host's 50 ms
cadence, as the host's car does: over a 150 ms round trip with jitter
27% of the logged traffic cars ran past their newest message and were
extrapolated. The traffic is now shown at least as far back as the
host car's playout delay (15%, the rest being the far cars whose state
comes in every other message), on the frame's precise session time.

The host's handling of the clients' traffic hit reports read the game
events itself; since the race screen takes each frame's events once
(for the race and Cops and Robbers rules), it now reads that frame's
list, as a client's cruise no longer needs to throw them away.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The jitter the maintainer reported, its four causes in the network cars'
timing (stamps, frame-rate sampling, the fixed delay, the stepping
clock) and the network's share (sequenced drops, the per-tick relay,
ENet's throttle), measured before and after with a host and two clients
behind lossy relays; the other in-race findings, what was checked, and
what is left open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
SharedTrafficNet.HostAndClientInOneProcess failed on the Windows runner:
it picked ports from 51000-63000, inside Windows' dynamic range
(49152-65535), where the system reserves and hands out ports, and the bind
failed. test_netgame.cpp's range reached 56998 as well, and the lobby and
sync tests shared one block. Each file now has its own block below 49152.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lowlevelmetal
lowlevelmetal merged commit 0c23b02 into main Oct 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant