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 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:

MeasureResult
Conditions80 ms round trip, 10 ms jitter, 0.5 % loss, plus 5 s cuts
Matches10 × 6,000 frames: 10 completed
Ended by a cut0
Desyncs0; every reconnection hash-checked
Starting delay5 frames
Play stalls, reconnections apart0-0.01 % per match
Resume after a 5 s cutabout 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 boundary8 → 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

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.