Devlog ·
Two browsers, one match: PS2 online play between two tabs
An online match on Retrario is two complete PlayStation 2 emulators, one in each player's browser, running the same frames with the same inputs. No server runs the game. Only the controller inputs, a few control messages and a state hash every 50 frames cross the network.
The lockstep
To the game, the PlayStation 2 version of PES 2013, the two players are two controllers on one console. Here each browser takes its own player's input and gets the other's from the network. For both copies to stay identical, both must apply every input on the same frame.
So the engine runs a delay lockstep. At frame f the VM thread stamps the local input for frame f + d into a ring in shared memory and waits for the peer's input of frame f. The delay d is counted in frames, 20 ms each at 50 Hz, between 2 and 12. The page moves the rings over a WebRTC data channel. Every packet repeats the frames the peer has not acknowledged yet, up to 64, so a lost packet costs nothing as long as a later one arrives.
The delay is measured, not guessed:
- The start. The lobby pings for 3 s before the match. Each sample is half the round trip plus the jitter; the delay covers the 99th percentile of that, plus one frame.
- Up, at once. When more than 0.5 % of the inputs in the last 5 s arrived late (the VM really waited), the host announces a higher delay for an agreed future frame and both sides apply it there.
- Down, only at a boundary. A decrease goes to the smallest delay the last 60 s on both sides support, and only while the game is not in play: a pause, a break, the menus. The engine publishes a play marker; the change is applied only if both engines still say "not in play" at that frame, otherwise both cancel it and the host tries at the next boundary.
The transport
The lobby is a WebSocket on the same server as the page: presence, rooms by a 6-letter code, invites, the ready state, and the relay of WebRTC offers, answers and candidates. The match itself never goes through it. Inputs travel on a data channel between the two browsers; hashes and lockstep messages on a second, control channel.
Two homes behind two routers cannot always reach each other directly. The server hands the page its ICE servers: STUN to find a direct path, and when a relay key is configured, short-lived TURN credentials, issued only to players admitted to a room. The page samples the selected candidate pair through the match and reports whether it ran relayed never, part of the time or always.
Determinism
A lockstep is only as good as the emulator's determinism: same inputs, same frames, bit for bit, on two different machines with threads scheduled differently. The graphics thread was designed for that from the start: the VM thread waits for it only at points fixed in the emulated execution order, never on a timer.
Three status reads were the exception. Their fast paths could return a value that depended on how far the graphics thread had got, so for a while they were switched off during matches. A real match on that build ran 125,092 frames, 38.9 minutes, with no desync, and showed the price: the host's VM waited 427 s for the graphics thread, 3.4 ms per frame, and the guest stalled 28 % of the match. The fix made each read a pure function of the emulated sequence, so both peers read the same value without waiting.
The proof is hashes. A 100,000-frame run with the worker threads against one without them compared main RAM and the graphics output every 50 frames: 2,000 of 2,000 equal for each. The I/O processor's RAM differed in 240 bytes of kernel bookkeeping, traced to when the sound thread completes its transfers, and never reached the game's side. Every engine change since passes a check that compares main RAM, I/O processor RAM and graphics hashes every 50 frames and fails on any mismatch or gap. In a match, the engines publish a state hash every 50 frames and the peers compare them; a mismatch ends the match as a desync instead of letting two games drift apart.
When the link drops
A Wi-Fi hiccup should not end a match. A link is lost when the channels close or fail, or when nothing at all arrives for 2 s. The VM is then held where it is and the page repairs the link: an ICE restart first, then a new link through the lobby, relay-only every second attempt. Every packet carries a connection epoch, so anything from the old link is dropped.
Once messages flow again, both sides exchange what the other lacks and agree on a resume barrier: the first state-hash frame at or after the last confirmed frame plus the delay. Both run to it, compare the state hash of that frame, and only then go on. A different hash ends the match as "desync after reconnect". A reconnection that has not finished 18 s after the loss was detected ends it as "link lost", because the engine gives up after 20 s of waiting and the wait starts about 2 s before a silent link is noticed.
The numbers
The online gate is a scripted harness: two headless Chromes on one machine, each with its own profile, play full matches through the lobby's own UI under a fixed bad network:
| Measure | Result |
|---|---|
| Conditions | 80 ms round trip, 10 ms jitter, 0.5 % loss, plus 5 s cuts |
| Matches | 10 × 6,000 frames: 10 completed |
| Ended by a cut | 0 |
| Desyncs | 0; every reconnection hash-checked |
| Starting delay | 5 frames |
| Play stalls, reconnections apart | 0-0.01 % per match |
| Resume after a 5 s cut | about 6 s (0.75 s after closed channels) |
| Burst loss (2 more matches) | delay 5 → 8 → 11-12 frames, stalls 0.76 % and 1.93 % |
| Decrease at a boundary | 8 → 4 frames |
A second check drives two tabs through one match with a delay increase, a symmetric 5 s cut, a decrease, a closed channel, stale packets and lost delay announcements. It passed 16 of 16 on the last two releases.
What a smooth match needs
In a lockstep the slower machine sets the pace: every hitch on one side becomes a stall on the other. Both players need a computer that meets tier 1, which the page checks before Play: more than 4 logical cores, 8 GB of memory as the browser reports it, and a hardware graphics renderer. Below that the page says so instead of promising a smooth match.
What is not built
- No matchmaking and no rankings. You play people you know: a friend from your list, or someone you gave a room code.
- The harness numbers come from simulated links on one machine. A match between two homes over a real network on the current release is still to be measured, and the relay-only fallback has not been exercised in a browser.
- Whether the first minutes of a session hold 50 fps is the subject of the other post, and online it matters twice.
The emulation itself is the Play! emulator and its contributors' work: the Play! project on GitHub. If you have a friend and two computers that qualify, start a match.