I build NitroPlayer, a site of classic browser games: Solitaire, Sudoku, Mahjong, FreeCell, Checkers, Hearts and so on. A lot of our players are 45+, and the feature they use most is not a fancy one. It's this: send a link to your sister, your grandson or a coworker, and you both play the same game at the same time.
No app, no account, no lobby to understand. Tap the link, play.
When I started, I assumed this needed WebSockets and a realtime server. It turned out it didn't. Here's the setup that has been running in production, on ordinary PHP + MySQL hosting, with no cron jobs and no socket server.
1. The invite is a 6-character code
A private match is a row in a table with a short code. The link is just /games/sudoku/?race=K7MXQ2.
function match_code(): string {
// no 0/O, 1/I/L: people read these codes out loud on the phone
$abc = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$s = '';
for ($i = 0; $i < 6; $i++) $s .= $abc[random_int(0, strlen($abc) - 1)];
return $s;
}
32 characters, 6 positions: about a billion codes, which is plenty when matches expire after a few minutes. Dropping the look-alike characters mattered more than I expected. Grandparents do read the code to each other over the phone.
2. Both players get the same game from a seed
For "race" games (who finishes the Sudoku first, who clears the Solitaire deal in fewer moves) the fairness rule is simple: same puzzle for both. The server picks a random seed when the match is created and stores it with the match. Each browser generates the deal locally from that seed.
// any small seeded PRNG works; what matters is that both clients run the same code
function rng(seed) {
return function () {
seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const deck = shuffle(fullDeck(), rng(match.seed)); // identical on both screens
The server mostly sends one integer (plus the puzzle itself for the few games where generating one on the client would be slow). That keeps payloads tiny, and the game code is the same as in solo mode: while a match runs, the game's random source is simply seeded. The daily puzzle works the same way, keyed to the date, so everyone gets the same Sudoku today and the leaderboard is fair.
3. A 1-second heartbeat instead of a socket
Each client posts its progress about once a second: cells filled, cards moved, "I finished". The response carries the opponent's progress back.
POST /api/duel.php { action: "tick", code: "K7MXQ2", progress: 41, done: false }
→ { opponent: { name: "Mia", progress: 37 }, clock: 83.4, state: "playing" }
For a race to finish a puzzle, one second of latency is invisible. You see your friend's progress bar move, you feel the pressure, that's the game. For turn-based games (Checkers, Reversi, Hearts) the same request returns the last move, and a turn simply arrives within a second. Nobody playing Checkers with their grandson has ever complained about 800 ms.
What we gained by not running a socket server:
- It runs on the same PHP-FPM pool as the rest of the site. Nothing extra to deploy, monitor or restart.
- Phones going to sleep, switching apps and changing networks just work. The next request picks up where things stand. No reconnect logic.
- Every request is stateless and cacheable in the usual places, and a broken client can't hold a connection open.
4. The server owns the clock and the result
The client is never trusted with the outcome. The match row stores started_at with millisecond precision, and the elapsed time is computed in SQL on every request:
SELECT m.*, TIMESTAMPDIFF(MICROSECOND, m.started_at, NOW(3)) / 1000000 AS elapsed
FROM duel_matches m WHERE m.code = ?
When a client says "done", the server checks it against the seed and against a minimum plausible time for that game. A Sudoku "solved" in 3 seconds isn't recorded. The result is written once, inside a transaction with the match row locked (SELECT ... FOR UPDATE), so two simultaneous "I won!" requests can't both win.
5. Housekeeping happens lazily, inside requests
There's no cron. Countdown, time-up, "your friend left" and cleanup all run as a side effect of the next request that touches the match:
- The countdown ends when
NOW()passesstarted_at. Nothing needs to "fire". - If one player hasn't sent a heartbeat for ~45 seconds, the next request from the other one marks them as gone and closes the match.
- An invite nobody opened just expires: the row knows its age, and the next request that reads it treats it as gone.
This felt hacky at first. In practice it's the most reliable part of the system, because there's no second process that can be down.
What I'd tell someone building something similar
- Ask what latency your game actually needs. For card and puzzle games, a 1-second poll is a feature, not a compromise.
- Send seeds, not boards. Same code path as solo play, tiny payloads, and replays come for free.
- Make the link the whole UX. Our biggest jump in people actually playing together came from removing steps (no sign-up, no "create room" screen), not from adding features.
- Design codes for humans who will read them aloud.
If you want to see it working, open any game on NitroPlayer, choose "Play with a friend" and send the link to a second device. Happy to answer questions about the setup in the comments.
Top comments (0)