<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: NitroPlayer</title>
    <description>The latest articles on DEV Community by NitroPlayer (@nitroplayer).</description>
    <link>https://dev.to/nitroplayer</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4204272%2Fb57a6d6b-2a07-41ea-8e1c-7bad2e4ce485.png</url>
      <title>DEV Community: NitroPlayer</title>
      <link>https://dev.to/nitroplayer</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nitroplayer"/>
    <language>en</language>
    <item>
      <title>Redesigning Solitaire for one thumb (and A/B testing where the deck goes)</title>
      <dc:creator>NitroPlayer</dc:creator>
      <pubDate>Sun, 01 Nov 2026 19:38:39 +0000</pubDate>
      <link>https://dev.to/nitroplayer/redesigning-solitaire-for-one-thumb-and-ab-testing-where-the-deck-goes-id7</link>
      <guid>https://dev.to/nitroplayer/redesigning-solitaire-for-one-thumb-and-ab-testing-where-the-deck-goes-id7</guid>
      <description>&lt;p&gt;Most of the people who play Solitaire on our site do it on a phone, held in one hand, often on the sofa in the evening. Many of them are over 60. Until this week our Klondike layout on phones was simply the desktop layout squeezed into a tall screen: the deck in the top corner, the buttons in a toolbar at the top, and the cards small.&lt;/p&gt;

&lt;p&gt;On a 6.5" phone held in one hand, the top of the screen is the hardest place to reach. And the deck is the thing you tap most in Klondike. So we rebuilt the portrait layout around the thumb.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyromzctfqn3ogrk8vxu2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyromzctfqn3ogrk8vxu2.jpg" alt="The new phone layout, deck at the top" width="585" height="1266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A bottom bar instead of a top toolbar.&lt;/strong&gt; Settings, New game, Hint and Undo now sit in a bar at the bottom, as big icon buttons with a label under each one. Undo is on the right, where a right thumb rests. Time, moves and score stay in a slim strip at the top, because you read them but never tap them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deck can live at the bottom.&lt;/strong&gt; In the settings sheet you can choose &lt;em&gt;Deck position: Top / Bottom&lt;/em&gt;. At the bottom, the deck and the waste pile sit just above the bar, so drawing a card is a short thumb move instead of a stretch. A small badge shows how many cards are left in the deck.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl1x8uegsmpncrrcp3s4f.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl1x8uegsmpncrrcp3s4f.jpg" alt="Deck at the bottom, next to the thumb" width="585" height="1266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Left or right hand.&lt;/strong&gt; A &lt;em&gt;Hand&lt;/em&gt; setting mirrors the deck and Undo to the other side. It's a small thing, but without it a left-handed player has to reach across the whole screen for every card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Draw 1 or Draw 3, from the next deal.&lt;/strong&gt; The choice used to be buried in a menu. It's now the first row of the settings sheet, and it applies from the next deal so a game in progress never changes under you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff27ic507isfifdizhehl.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff27ic507isfifdizhehl.jpg" alt="The settings sheet" width="585" height="1266"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Columns use the whole height.&lt;/strong&gt; In portrait there's a lot of empty felt under the columns. Long columns now fan down into that space before the cards start to overlap more, and face-down cards squeeze first so the face-up ones stay readable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dragging feels lighter.&lt;/strong&gt; While you drag, the card lifts slightly around your finger, every legal drop shows a soft glow, and the one under your finger glows stronger. Tapping a card still sends it to the best place, so dragging is optional.&lt;/p&gt;

&lt;p&gt;Landscape and desktop keep the original layout. The whole portrait mode is one class on the body, so the CSS stays scoped and nothing else moves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;portraitLayout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;(orientation: portrait) and (max-width: 700px)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;matches&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
  &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;classList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toggle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;np-sol-portrait&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_portrait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Top or bottom? We don't know yet, so we're testing it
&lt;/h2&gt;

&lt;p&gt;My guess was that the bottom deck is better. But players who have used Solitaire for 30 years expect the deck in the top corner, and "easier to reach" doesn't always mean "feels right".&lt;/p&gt;

&lt;p&gt;So players who never touched the setting get one of the two positions at random, once, and keep it. An explicit choice in the settings always wins over the test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;ab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;np-sol-ab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ab&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;top&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;ab&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bottom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ab&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;top&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bottom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;np-sol-ab&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ab&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// logged once per player as an event, so we can compare the two groups&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What we'll compare per variant: how many people leave in the first 10 seconds, how many games get finished, how many are won, play time, and how many come back the next day. Finished games matter more to us than taps: if the bottom deck makes people quit fewer games, that's the answer.&lt;/p&gt;

&lt;p&gt;The test started today, so there are no results yet. I'll post a follow-up when there's enough data to say something honest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small things we learned on the way
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Put a word under every icon.&lt;/strong&gt; A gear or a lightbulb is obvious to us; it isn't to everyone. "Settings" and "Hint" cost a few pixels and remove the guessing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't change the rules mid-game.&lt;/strong&gt; Draw 1 / Draw 3 applies from the next deal, so a game in progress never changes under you, and daily puzzles and duels keep the rules everyone else plays with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the bar out of the system gesture area.&lt;/strong&gt; On phones with a home indicator, the bar needs &lt;code&gt;env(safe-area-inset-bottom)&lt;/code&gt; padding, or the Undo button fights with the swipe-home gesture.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try it, it's at &lt;a href="https://nitroplayer.com/games/solitaire/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=solitaire-one-thumb" rel="noopener noreferrer"&gt;nitroplayer.com/games/solitaire&lt;/a&gt;. It works best on a phone, held in one hand. I'd love to hear where you'd put the deck.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>gamedev</category>
      <category>ux</category>
    </item>
    <item>
      <title>Multiplayer card and puzzle games with no WebSockets: a 6-letter link, a shared seed and a 1-second heartbeat</title>
      <dc:creator>NitroPlayer</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:38:40 +0000</pubDate>
      <link>https://dev.to/nitroplayer/multiplayer-card-and-puzzle-games-with-no-websockets-a-6-letter-link-a-shared-seed-and-a-1-second-2gkc</link>
      <guid>https://dev.to/nitroplayer/multiplayer-card-and-puzzle-games-with-no-websockets-a-6-letter-link-a-shared-seed-and-a-1-second-2gkc</guid>
      <description>&lt;p&gt;I build &lt;a href="https://nitroplayer.com/" rel="noopener noreferrer"&gt;NitroPlayer&lt;/a&gt;, 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: &lt;strong&gt;send a link to your sister, your grandson or a coworker, and you both play the same game at the same time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No app, no account, no lobby to understand. Tap the link, play.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The invite is a 6-character code
&lt;/h2&gt;

&lt;p&gt;A private match is a row in a table with a short code. The link is just &lt;code&gt;/games/sudoku/?race=K7MXQ2&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;match_code&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// no 0/O, 1/I/L: people read these codes out loud on the phone&lt;/span&gt;
    &lt;span class="nv"&gt;$abc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nv"&gt;$abc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;random_int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;strlen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$abc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nv"&gt;$s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 &lt;em&gt;do&lt;/em&gt; read the code to each other over the phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Both players get the same game from a seed
&lt;/h2&gt;

&lt;p&gt;For "race" games (who finishes the Sudoku first, who clears the Solitaire deal in fewer moves) the fairness rule is simple: &lt;strong&gt;same puzzle for both&lt;/strong&gt;. 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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// any small seeded PRNG works; what matters is that both clients run the same code&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;|=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mh"&gt;0x6D2B79F5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imul&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;61&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;^&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4294967296&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;shuffle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fullDeck&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// identical on both screens&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A 1-second heartbeat instead of a socket
&lt;/h2&gt;

&lt;p&gt;Each client posts its progress about once a second: cells filled, cards moved, "I finished". The response carries the opponent's progress back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;POST /api/duel.php  { action: "tick", code: "K7MXQ2", progress: 41, done: false }
→ { opponent: { name: "Mia", progress: 37 }, clock: 83.4, state: "playing" }
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What we gained by not running a socket server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It runs on the same PHP-FPM pool as the rest of the site. Nothing extra to deploy, monitor or restart.&lt;/li&gt;
&lt;li&gt;Phones going to sleep, switching apps and changing networks just work. The next request picks up where things stand. No reconnect logic.&lt;/li&gt;
&lt;li&gt;Every request is stateless and cacheable in the usual places, and a broken client can't hold a connection open.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. The server owns the clock and the result
&lt;/h2&gt;

&lt;p&gt;The client is never trusted with the outcome. The match row stores &lt;code&gt;started_at&lt;/code&gt; with millisecond precision, and the elapsed time is computed in SQL on every request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPDIFF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MICROSECOND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;started_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;elapsed&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;duel_matches&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 (&lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;), so two simultaneous "I won!" requests can't both win.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Housekeeping happens lazily, inside requests
&lt;/h2&gt;

&lt;p&gt;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:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The countdown ends when &lt;code&gt;NOW()&lt;/code&gt; passes &lt;code&gt;started_at&lt;/code&gt;. Nothing needs to "fire".&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;An invite nobody opened just expires: the row knows its age, and the next request that reads it treats it as gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone building something similar
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ask what latency your game actually needs.&lt;/strong&gt; For card and puzzle games, a 1-second poll is a feature, not a compromise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Send seeds, not boards.&lt;/strong&gt; Same code path as solo play, tiny payloads, and replays come for free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the link the whole UX.&lt;/strong&gt; Our biggest jump in people actually playing together came from removing steps (no sign-up, no "create room" screen), not from adding features.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Design codes for humans&lt;/strong&gt; who will read them aloud.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want to see it working, open any game on &lt;a href="https://nitroplayer.com/games/sudoku/" rel="noopener noreferrer"&gt;NitroPlayer&lt;/a&gt;, choose "Play with a friend" and send the link to a second device. Happy to answer questions about the setup in the comments.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>javascript</category>
      <category>gamechallenge</category>
      <category>games</category>
    </item>
  </channel>
</rss>
