<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Museek]]></title><description><![CDATA[Museek]]></description><link>https://museek.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Thu, 05 Nov 2026 07:54:26 GMT</lastBuildDate><atom:link href="https://museek.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a multiplayer music quiz where everyone brings their own playlist (Next.js + Socket.io)]]></title><description><![CDATA[Build a multiplayer music quiz with Next.js and Socket.io: safe playlist imports from 3 platforms, typo-tolerant scoring and a server-driven game clock.]]></description><link>https://museek.hashnode.dev/building-a-multiplayer-music-quiz-where-everyone-brings-their-own-playlist-next-js-socket-io</link><guid isPermaLink="true">https://museek.hashnode.dev/building-a-multiplayer-music-quiz-where-everyone-brings-their-own-playlist-next-js-socket-io</guid><category><![CDATA[Next.js]]></category><category><![CDATA[SocketIO]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[Alex Xiao]]></dc:creator><pubDate>Thu, 22 Oct 2026 15:40:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ada2c53416709d240e942ac/62e5f6aa-4587-457e-ad32-bd759c6eecb4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I'm Alex, a web developer in Brussels. This is the story of <a href="https://museek.xiao-web.com">Museek</a>, a free multiplayer music quiz I built on my own: everyone brings a public Spotify, Deezer or Apple Music playlist, Museek shuffles all the tracks together, and each round you guess the artist or the title. Up to 12 players, no account, desktop, tablet or mobile.</p>
<p>Two things up front, because they shape the design: <strong>it is not a race</strong> (speed doesn't count, everyone can score on every track), and there is <strong>no built-in chat</strong>, so the easiest way to play is in a voice call with friends.</p>
<p>The code is on <a href="https://github.com/AXYJ/museek">GitHub</a>. Here are the parts that were the most interesting to build.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ada2c53416709d240e942ac/8e0d4eff-40a0-4808-8f17-3fb1ba1b07ba.png" alt="Museek lobby" style="display:block;margin:0 auto" />

<h2>The stack</h2>
<p>Next.js (React, TypeScript) on the front, Node.js + Express + <a href="http://Socket.io">Socket.io</a> on the back, in one monorepo. There is no database: rooms live in memory on the server and disappear when the last player leaves.</p>
<h2>1. Importing playlists from three platforms</h2>
<p>The idea only works if a player can paste a link from the service they already use. Each platform needed a different approach:</p>
<ul>
<li><strong>Deezer</strong>: the public API (<code>api.deezer.com/playlist/{id}</code>).</li>
<li><strong>Spotify</strong>: playlists are read from the public embed page, which exposes the track list in its page data. I used the official API only for albums.</li>
<li><strong>Apple Music</strong>: the playlist page is scraped with Cheerio.</li>
</ul>
<p>An honest caveat: two of the three rely on page structures I don't control, so they can break when the platforms change them. That's the main maintenance risk of the project.</p>
<h3>Never trust a pasted link</h3>
<p>Players paste arbitrary links, and the server fetches them. Left unchecked, that is a server-side request forgery (SSRF) hole: someone could make the server call an internal address. So links go through a strict allow-list:</p>
<pre><code class="language-ts">const ALLOWED_HOSTS = {
  deezer: /^(?:[\w-]+\.)?deezer\.com$|^deezer\.page\.link$/,
  spotify: /^(?:[\w-]+\.)?spotify\.com$|^spotify\.link$|^spotify\.app\.link$/,
  apple: /^(?:music|itunes)\.apple\.com$/,
};
</code></pre>
<p>The check runs on the hostname parsed by <code>new URL</code>, never on the raw text, so <code>https://evil.com/?x=deezer.com</code> and <code>https://deezer.com@evil.com</code> are refused. Only <code>https</code> without a custom port is accepted. Short links (<code>spotify.link</code>, <code>deezer.page.link</code>) are followed redirect by redirect, and every hop must stay on an allowed domain. There is a test file dedicated to this.</p>
<h2>2. Typo-tolerant answers</h2>
<p>Players type the artist and/or the title. The comparison is forgiving: case and accents are ignored, and everything that isn't a letter or a digit is stripped.</p>
<pre><code class="language-ts">export function normalizeString(str: string): string {
  return str
    .toLowerCase()
    .normalize("NFD")
    .replace(/[̀-ͯ]/g, "")
    .replace(/[^\p{L}\p{M}\p{N}]/gu, "");
}
</code></pre>
<p>Artists are split on <code>,</code>, <code>&amp;</code>, <code>feat.</code>, <code>with</code>… and each one is checked. Scoring is simple: 1 point for the artist (0.5 if there are several and you only find some of them) plus 1 point for the title. An autocomplete suggests names from the loaded playlists, which removes most typing errors.</p>
<p>The normalisation of each track is computed once when the game starts, not on every answer.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ada2c53416709d240e942ac/016b99e8-9afb-4ca2-9c87-732909ad0799.webp" alt="Museek lobby" style="display:block;margin:0 auto" />

<h2>3. Non-Latin names</h2>
<p>Many playlists mix scripts: Japanese, Chinese, Korean, Cyrillic. Players on a Latin keyboard can't type those, so each track also gets an "international" (romanised) name, and both versions are accepted. The pipeline is:</p>
<ul>
<li>Latin text stays as is.</li>
<li>Anything with kana goes through Kuroshiro (Japanese reading, kanji included).</li>
<li>Other scripts (Chinese, Korean, kanji alone…) go to an LLM through the Groq API, and fall back to local transliteration if the call fails or no key is set.</li>
</ul>
<p>Only titles and artist names are sent to the API, never usernames or IP addresses. It's documented in the app's privacy page.</p>
<h2>4. A server-driven game clock</h2>
<p>Every player must see the same countdown, even with a laggy phone or a throttled background tab. Instead of counting down on each client, the server only sends two timestamps:</p>
<pre><code class="language-ts">function getGameTiming(time: number, gameStartTime: number): GameTiming {
  return { gameStartTime, serverNow: Date.now(), time };
}
</code></pre>
<p>The client estimates the gap between the two clocks once, then derives the round, the phase and the time left from the current time:</p>
<pre><code class="language-ts">const toGameClock = (timing) =&gt; ({
  startTime: timing.gameStartTime,
  offset: timing.serverNow - Date.now(),
  time: timing.time,
});
</code></pre>
<p>A round is <code>time</code> seconds of guessing, 5 seconds of reveal and 2 seconds of transition. Because the state is a pure function of the clock, a player who reloads the page or comes back from another app lands on the right screen without any "catch-up" message.</p>
<p>The limit: network latency is ignored, so clocks can differ by a few dozen milliseconds. Since speed doesn't affect the score, that's fine, which is one more reason the game isn't a race.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6ada2c53416709d240e942ac/144a0ed5-a5ab-4fd0-8ebf-11b82f52b2c9.webp" alt="Museek lobby" style="display:block;margin:0 auto" />

<h2>5. Disconnections and reconnection</h2>
<p>On mobile, players switch apps all the time. Each player has a private ID stored in the browser; on reconnect the server swaps the socket and sends back the current state. A 5-minute grace period before a disconnected player is removed, and the host role is transferred if the host doesn't come back. One subtle bug class: a stale socket firing its <code>disconnect</code> event after the player has already reconnected on a new one. The handler ignores it when <code>player.socketId !== socket.id</code>.</p>
<h2>6. Privacy by default</h2>
<p>No database, no analytics, no ads. Room data is purged when the room empties. Other players never receive your reconnection ID or your playlist link, only a random public ID. The server also rate-limits per IP (room creation, playlist loads, bad room codes, simultaneous connections).</p>
<h2>7. Testing with real clients</h2>
<p>The backend tests start their own server and play games with real <a href="http://Socket.IO">Socket.IO</a> clients: answers and scores, host rights, identity, disconnections and reconnections, reset, mixed-script playlists, the link allow-list, and rate limits. Testing at the socket level caught more real problems than unit tests alone would have.</p>
<h2>Try it</h2>
<p><a href="https://museek.xiao-web.com">museek.xiao-web.com</a>: free, no signup. It's a multiplayer game, so bring a few friends and a voice call. Feedback is very welcome, especially on onboarding and on playlist links that don't load.</p>
<p>Museek is an independent project, not affiliated with Spotify, Deezer or Apple.</p>
<p>I used an AI assistant while building the project and to help draft this post</p>
]]></content:encoded></item></channel></rss>