<?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: Itai Zeilig</title>
    <description>The latest articles on DEV Community by Itai Zeilig (@itai_zeilig_bb4920bda5007).</description>
    <link>https://dev.to/itai_zeilig_bb4920bda5007</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1840320%2Ff74aa6da-1c91-4231-8b7b-ada778daa7bf.png</url>
      <title>DEV Community: Itai Zeilig</title>
      <link>https://dev.to/itai_zeilig_bb4920bda5007</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/itai_zeilig_bb4920bda5007"/>
    <language>en</language>
    <item>
      <title>Building a real-time multiplayer hub on Nakama in 4 weeks: socket events to Zustand, what I'd do differently</title>
      <dc:creator>Itai Zeilig</dc:creator>
      <pubDate>Mon, 27 Apr 2026 20:55:28 +0000</pubDate>
      <link>https://dev.to/itai_zeilig_bb4920bda5007/building-a-real-time-multiplayer-hub-on-nakama-in-4-weeks-socket-events-to-zustand-what-id-do-17cp</link>
      <guid>https://dev.to/itai_zeilig_bb4920bda5007/building-a-real-time-multiplayer-hub-on-nakama-in-4-weeks-socket-events-to-zustand-what-id-do-17cp</guid>
      <description>&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.amazonaws.com%2Fuploads%2Farticles%2Fipwu0d26ji5n1dad67sz.png" 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.amazonaws.com%2Fuploads%2Farticles%2Fipwu0d26ji5n1dad67sz.png" alt=" " width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Quick context&lt;/p&gt;

&lt;p&gt;I'm a solo dev building SQL Protocol, a browser game where you play a covert operative and every mission is a real SQL query against a real Postgres database. Three modes: 15-chapter story, timed interview drills, and 1v1 Arena PvP.&lt;/p&gt;

&lt;p&gt;It shipped, people played it, and the consistent feedback was: "the world feels empty." Even with PvP, between missions it played like a single-player tutorial.&lt;/p&gt;

&lt;p&gt;This article is the four-week story of turning that into a real shared world without rewriting the engine.&lt;/p&gt;

&lt;p&gt;The plan&lt;/p&gt;

&lt;p&gt;Two requirements:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every map is shared. Walk into the hub, you see other operatives. Walk into a mission map, you see whoever else is running it.&lt;/li&gt;
&lt;li&gt;Chat. A global channel for everyone online and a per-map channel for whoever is in your current area.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Constraint: no full rewrite. The single-player code had to keep working.&lt;/p&gt;

&lt;p&gt;Why Nakama&lt;/p&gt;

&lt;p&gt;I was already using Nakama for auth, storage, and RPCs. The realtime primitives I needed - matches, presence, channels - were already in the box. Picking a second realtime stack would have meant two operational surfaces and two auth flows. So Nakama for everything realtime, full stop.&lt;/p&gt;

&lt;p&gt;Architecture: two transports, two concerns&lt;/p&gt;

&lt;p&gt;| Transport | For |&lt;br&gt;
| HTTP/RPC | One-shot reads and writes. Cases, progress, leaderboards, storage. |&lt;br&gt;
| WebSocket | Persistent push. Player positions, chat, presence. |&lt;/p&gt;

&lt;p&gt;This split keeps caching honest. RPC reads go through TanStack Query. WebSocket events go through Zustand stores. Components subscribe to slices.&lt;/p&gt;

&lt;p&gt;The big lesson: socket events write directly to Zustand&lt;/p&gt;

&lt;p&gt;The first version of the multiplayer hub assigned socket handlers inside React useEffects. The handlers closed over component state. Stale closures everywhere. Other-player positions would freeze, drift, or disappear.&lt;/p&gt;

&lt;p&gt;The fix was to stop putting realtime state in React entirely. Socket handlers call useGameStore.getState() and setState() directly, components subscribe to slices.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onmatchdata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useGameStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getState&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;useGameStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setState&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;players&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="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pos&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;players&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useGameStore&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;players&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things this gives you for free:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;No stale closures. getState() always reads current state.&lt;/li&gt;
&lt;li&gt;No per-tick React re-render. 60fps remote-player movement never triggers a render unless the slice you subscribe to changes.&lt;/li&gt;
&lt;li&gt;The handler is correct on Day 1 of writing it, no useRef gymnastics.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Server-authoritative positions&lt;/p&gt;

&lt;p&gt;The client never decides where another player is. The local client sends position inputs, the server stamps them, broadcasts the authoritative state to all clients in the match. This is the same pattern any modern multiplayer engine uses, and it gives you anti-cheat for free.&lt;/p&gt;

&lt;p&gt;For a SQL game, anti-cheat matters less for movement and more for the rest of the game state (XP, progress, query validation). But applying the same discipline to positions kept the architecture consistent.&lt;/p&gt;

&lt;p&gt;Chat: two channels, not one&lt;/p&gt;

&lt;p&gt;I shipped chat with a single global channel first. Felt wrong immediately. A global channel is a highway, you can't have a conversation. So I added a per-map channel.&lt;/p&gt;

&lt;p&gt;Server side: a Nakama channel per map id, plus the global channel. Clients join the map channel on map enter, leave on map exit, stay subscribed to global throughout.&lt;/p&gt;

&lt;p&gt;Client side: a single ChatStore holds messages tagged by channel. The chat panel filters by tab. Speech bubbles read the same store. So a chat message and a speech bubble are the same event, not two systems.&lt;/p&gt;

&lt;p&gt;What surprised me&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The "world feels alive" threshold is much lower than I expected. Three concurrent users is enough. Walking past one other agent while typing a query changes the whole feel.&lt;/li&gt;
&lt;li&gt;Position interpolation matters more than I thought. Raw socket positions look choppy. A simple lerp toward the latest server position fixed it without buffering.&lt;/li&gt;
&lt;li&gt;The chat panel needed instant show/hide. No max-height transitions. Fancy animation made high-frequency UI feel laggy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I'd do differently&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build the speech bubble + chat panel as one feature, not two. I split them, regretted it, merged them.&lt;/li&gt;
&lt;li&gt;Write the position lerp from day one. I shipped raw positions first and immediately had to redo it.&lt;/li&gt;
&lt;li&gt;Stub out chat in the very first multiplayer prototype. Watching characters walk silently next to each other for a week made the world feel deader than before the update.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Try it&lt;/p&gt;

&lt;p&gt;SQL Protocol is free, in browser, desktop only.&lt;br&gt;
&lt;a href="https://sqlprotocol.com" rel="noopener noreferrer"&gt;https://sqlprotocol.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions in the comments. Especially interested in feedback from people doing browser-based realtime multiplayer with sandboxed user-typed code, that's a niche.&lt;/p&gt;

</description>
      <category>webde</category>
      <category>gamedev</category>
      <category>multiplayer</category>
      <category>nakama</category>
    </item>
  </channel>
</rss>
