<?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: Ado Daniel Nj</title>
    <description>The latest articles on DEV Community by Ado Daniel Nj (@adodanieln).</description>
    <link>https://dev.to/adodanieln</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%2F3848240%2Fa6b00e2e-3734-4b29-b3db-0f1f5c8a281c.png</url>
      <title>DEV Community: Ado Daniel Nj</title>
      <link>https://dev.to/adodanieln</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/adodanieln"/>
    <language>en</language>
    <item>
      <title>How I Built the Matchmaking Engine Behind Beta Gamer</title>
      <dc:creator>Ado Daniel Nj</dc:creator>
      <pubDate>Sun, 05 Jul 2026 12:26:34 +0000</pubDate>
      <link>https://dev.to/adodanieln/how-i-built-the-matchmaking-engine-behind-beta-gamer-3jk6</link>
      <guid>https://dev.to/adodanieln/how-i-built-the-matchmaking-engine-behind-beta-gamer-3jk6</guid>
      <description>&lt;h2&gt;
  
  
  A technical deep dive into the nested group matchmaking engine, bot injection system, and game state manager powering Beta Gamer, a multi-tenant Games as a Service API built solo from Cameroon.
&lt;/h2&gt;

&lt;p&gt;This is the technical post I promised in the previous one.&lt;/p&gt;

&lt;p&gt;If you haven't read the story behind Beta Gamer yet, the short version is: I built a Games as a Service API that lets any developer embed real-time multiplayer games into their product with a single integration. 10 games. Multi-tenant. WebSocket-based. Built solo from Scratch.&lt;/p&gt;

&lt;p&gt;This post is about three specific engineering problems I had to solve that I hadn't seen clearly documented anywhere else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 1: A Matchmaking Engine That Thinks in Nested Groups
&lt;/h2&gt;

&lt;p&gt;Most matchmaking systems think in players. Two players join a queue, their ratings are close enough, they get matched. Simple.&lt;/p&gt;

&lt;p&gt;That works for 1v1. It falls apart the moment you need group or team games like battle royale, or anything where the structure of a match is more complex than two individuals facing each other.&lt;/p&gt;

&lt;p&gt;I needed a different mental model entirely.&lt;/p&gt;

&lt;p&gt;The structure I landed on is a nested array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;player&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;player&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;group&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;player&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;player&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;group&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;player&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;group&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The outer array is the match. The inner arrays are the groups. The engine doesn't care how many groups there are or how many players are in each. It just reads the structure and executes.&lt;/p&gt;

&lt;p&gt;This means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1v1 Chess is &lt;code&gt;[[p1], [p2]]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;4-player Ludo is &lt;code&gt;[[p1], [p2], [p3], [p4]]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;2v2 team game is &lt;code&gt;[[p1, p2], [p3, p4]]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;2v2v2 three-way team game is &lt;code&gt;[[p1, p2], [p3, p4], [p5, p6]]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A game with 100 solo players is 100 arrays of one player each&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One data structure. Zero special cases. Every game type falls out of configuration, not separate codepaths.&lt;/p&gt;

&lt;p&gt;Each game defines its own rules via a config:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;MatchConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;groupMinSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// minimum players per group&lt;/span&gt;
  &lt;span class="nl"&gt;groupMaxSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// maximum players per group&lt;/span&gt;
  &lt;span class="nl"&gt;minGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// minimum groups needed to start a match&lt;/span&gt;
  &lt;span class="nl"&gt;maxGroups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// maximum groups per match&lt;/span&gt;
  &lt;span class="nl"&gt;groupWaitMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// how long to wait for a group to fill&lt;/span&gt;
  &lt;span class="nl"&gt;lobbyWaitMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// how long to wait for the pool to fill&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fixed-size game sets &lt;code&gt;groupMinSize === groupMaxSize&lt;/code&gt;. A flexible game sets them differently and the match starts when the minimum is met.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two-Stage Pipeline
&lt;/h3&gt;

&lt;p&gt;The matchmaker runs in two stages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage 1 - Queue to Group.&lt;/strong&gt; Players join a queue. The matchmaker collects them into forming groups. When a group hits &lt;code&gt;groupMaxSize&lt;/code&gt; it promotes immediately. When &lt;code&gt;groupWaitMs&lt;/code&gt; fires, any group at or above &lt;code&gt;groupMinSize&lt;/code&gt; promotes. Groups below minimum wait or dissolve.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Player joins queue
       |
Assigned to forming group
       |
Group hits maxSize? -&amp;gt; Promote immediately
       |
Timer fires -&amp;gt; group &amp;gt;= minSize? -&amp;gt; Promote
                               -&amp;gt; below minSize? -&amp;gt; Wait or dissolve
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Stage 2 - Group to Match.&lt;/strong&gt; Promoted groups enter a pool. When the pool reaches &lt;code&gt;maxGroups&lt;/code&gt;, the match fires immediately. When &lt;code&gt;lobbyWaitMs&lt;/code&gt; fires, if the pool has at least &lt;code&gt;minGroups&lt;/code&gt;, the match fires with whatever is available.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Group promoted -&amp;gt; enters pool
                      |
pool.length &amp;gt;= maxGroups? -&amp;gt; Fire match immediately
                      |
lobbyWaitMs fires -&amp;gt; pool.length &amp;gt;= minGroups? -&amp;gt; Fire match
                                              -&amp;gt; Wait
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This two-stage design is what makes flexible group sizes possible. A lobby of 4 that only fills 3 does not cancel. It checks against &lt;code&gt;groupMinSize&lt;/code&gt; and proceeds if the threshold is met.&lt;/p&gt;

&lt;h3&gt;
  
  
  One Critical Boundary
&lt;/h3&gt;

&lt;p&gt;The matchmaker answers exactly one question: who plays against whom?&lt;/p&gt;

&lt;p&gt;It assembles &lt;code&gt;Array&amp;lt;Array&amp;lt;Player&amp;gt;&amp;gt;&lt;/code&gt; and hands it to the game engine. What happens inside the match, turn order, whether a whole group moves together or players alternate individually, how simultaneous moves are resolved, that is the game engine's responsibility entirely. The matchmaker does not know and does not need to know.&lt;/p&gt;

&lt;p&gt;This separation is intentional. A Chess engine and a Ludo engine have completely different turn structures. The matchmaker serving both of them should be agnostic to both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tenant (Developers) Hooks
&lt;/h3&gt;

&lt;p&gt;Tenants on hosted matchmaking can bypass either stage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;submitGroup(game, group, config)&lt;/code&gt; - skip the queue, inject a pre-formed group directly into the pool&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;startMatch(game, groups, config)&lt;/code&gt; - skip the pool, force a match to start immediately
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Tenant submits a pre-formed group of their own players&lt;/span&gt;
&lt;span class="nx"&gt;matchmaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submitGroup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chess&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;group-abc&lt;/span&gt;&lt;span class="dl"&gt;'&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="na"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Alice&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="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;chessConfig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Tenant forces two groups into a match immediately&lt;/span&gt;
&lt;span class="nx"&gt;matchmaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startMatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chess&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;group1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;group2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;chessConfig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The matchmaker does not care how the groups were formed on the tenant's side. It just executes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 2: Injecting Bots Into Live WebSocket Sessions via REST
&lt;/h2&gt;

&lt;p&gt;This one took me longer than I expected.&lt;/p&gt;

&lt;p&gt;The problem: a tenant has a hosted session in a pending state. Not enough real players are online. They want to fill the empty slots with bots. They call a REST endpoint to trigger this. But the game room might already exist as a live in-memory WebSocket session on the server.&lt;/p&gt;

&lt;p&gt;A REST handler and a WebSocket server are two different runtime contexts. The REST handler can write to the database. It cannot directly reach into the socket server and mutate a live room.&lt;/p&gt;

&lt;p&gt;The solution is an internal event bus.&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="c1"&gt;// REST endpoint - bot injection handler&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;room&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;socketEventbus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bot:inject&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;game&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;botPlayer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="nx"&gt;botPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;socketId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;botPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;displayName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;isBot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;botPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;difficulty&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Socket server - listens for injection events&lt;/span&gt;
&lt;span class="nx"&gt;socketEventbus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;bot:inject&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;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BotInjectPayload&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;room&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;multiGameStateManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRoom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;roomId&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;room&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;room&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="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;         &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;botPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;socketId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;botPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;socketId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;botPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;isBot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;      &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;botPlayer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;difficulty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// notify existing players that a bot joined&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The REST layer and the WebSocket layer are fully decoupled. Neither knows about the other's internals. The event bus is the only bridge.&lt;/p&gt;

&lt;p&gt;One detail that matters: if the room does not exist in memory yet, the session is pending and no one has connected, the endpoint only updates the database. When players connect and the room initialises, it reads the updated player list from the database and the bot is already there. No injection event needed.&lt;/p&gt;

&lt;p&gt;Tenants can also bring their own bots. If the request body contains a &lt;code&gt;playerId&lt;/code&gt;, that specific bot is used. If not, the platform picks from the tenant's configured bot pool automatically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Problem 3: Game State That Survives Everything
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Reconnection
&lt;/h3&gt;

&lt;p&gt;When a player disconnects, the game does not end. The room switches to &lt;code&gt;paused&lt;/code&gt; and a 60-second reconnection window opens.&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="nf"&gt;handleDisconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socketId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reconnectWindow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// pause room, start countdown&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;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paused&lt;/span&gt;&lt;span class="dl"&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;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;handlePlayerQuit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;reconnectWindow&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;disconnectedPlayers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeout&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;handleReconnect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;playerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newSocketId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// clear countdown, remap socket, resume game&lt;/span&gt;
  &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;disconnectData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;player&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;socketId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newSocketId&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;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paused&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;playing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&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;If the window expires, the player is treated as having quit. With remaining players, the game continues and the turn index adjusts. With one player left, that player wins. With zero, the room is deleted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Singleton Across Hot Reloads
&lt;/h3&gt;

&lt;p&gt;Next.js re-evaluates modules on every file change in development. A regular module-level singleton gets destroyed on every hot reload and all active rooms are lost.&lt;/p&gt;

&lt;p&gt;The fix is attaching the singleton to &lt;code&gt;globalThis&lt;/code&gt;:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;globalThis&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__multiGameStateManager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__multiGameStateManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;MultiGameStateManager&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;multiGameStateManager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MultiGameStateManager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__multiGameStateManager&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First load creates the instance. Every subsequent reload finds it already there and reuses it. Rooms survive hot reloads completely.&lt;/p&gt;

&lt;h3&gt;
  
  
  AFK Timer Cleanup
&lt;/h3&gt;

&lt;p&gt;Every room runs an AFK timer. If a player stalls their turn, they get forfeited. But when a game ends normally, if the AFK timer is not cleared, it fires later into a room that no longer exists.&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="nf"&gt;endGame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;finished&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;room&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;winner&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;winner&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;clearAfkTimer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// always clear before deleting&lt;/span&gt;
  &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteRoom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&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;Clear the timer first. Then schedule the cleanup. Order matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Haven't Built Yet
&lt;/h2&gt;

&lt;p&gt;Everything above is turn-based. One player or group acts, then the next. The server is the authority on state because moves are sequential.&lt;/p&gt;

&lt;p&gt;Simultaneous move games, football, shooters, anything where every player acts at the same time, are a different problem entirely. You need a tick-based system that collects moves within a window, validates each against the same snapshot of game state, resolves conflicts, then broadcasts the result atomically.&lt;/p&gt;

&lt;p&gt;The nested group structure already supports it. &lt;code&gt;[[p1, p2], [p3, p4]]&lt;/code&gt; works for a 2v2 shooter exactly as it does for a 2v2 turn-based game. The matchmaker does not change. The game engine does.&lt;/p&gt;

&lt;p&gt;That is the next frontier. When I solve it, there will be another post.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Beta Gamer docs: &lt;a href="https://beta-gamer.onrender.com/docs/overview" rel="noopener noreferrer"&gt;https://beta-gamer.onrender.com/docs/overview&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;npm: @beta-gamer/react and @beta-gamer/react-native&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>gamedev</category>
      <category>architecture</category>
      <category>development</category>
    </item>
    <item>
      <title>I Almost Didn't Build Beta Gamer</title>
      <dc:creator>Ado Daniel Nj</dc:creator>
      <pubDate>Sun, 05 Jul 2026 01:02:22 +0000</pubDate>
      <link>https://dev.to/adodanieln/i-almost-didnt-build-beta-gamer-2ogh</link>
      <guid>https://dev.to/adodanieln/i-almost-didnt-build-beta-gamer-2ogh</guid>
      <description>&lt;h2&gt;
  
  
  The real story behind building a multiplayer game API from Cameroon - the failed 3D game, the financial pressure, the nights I almost quit, and why I kept going anyway.
&lt;/h2&gt;

&lt;p&gt;I wanted to build a game like Free Fire.&lt;/p&gt;

&lt;p&gt;Not an API. Not a platform. Not infrastructure. A real game. Something with players running, shooting, the kind of chaos that makes people forget they have responsibilities.&lt;/p&gt;

&lt;p&gt;I watched tutorials on Three.js and got started. Built an object class for each game component, hooked everything into a central render method, watched a 3D world appear on my screen from nothing. That feeling, I don't think I'll ever forget it. 🎮&lt;/p&gt;

&lt;p&gt;Then the game started scaling.&lt;/p&gt;

&lt;p&gt;Player controllers. Animation states. Interactions. Physics. Collision detection at speed. The tutorials had gotten me through the door. But scaling is where tutorials stop and real engineering begins. It became clear fast that I didn't have the architecture for what I was trying to build. I had to stop, step back, and go back to vanilla JS and 2D games.&lt;/p&gt;

&lt;p&gt;That wasn't failure. That was education. I learned more about how 3D games actually work from crashing into those walls than I ever could have from just following along. I understand 3D game architecture better now because of that breakdown.&lt;/p&gt;

&lt;p&gt;But it forced a question. If I can't build the game I want yet, while learning ... what can I build that actually matters?&lt;/p&gt;




&lt;h2&gt;
  
  
  The Problem I Kept Seeing
&lt;/h2&gt;

&lt;p&gt;Every time I looked at apps, betting platforms, fintech products, social apps, I kept asking the same question. Why does adding a multiplayer game to your product require hiring an entire game engineering team? Why is that barrier so high?&lt;/p&gt;

&lt;p&gt;A game company wants Ludo. A fintech app, Duolingo wants Chess as a loading interface. A social platform wants something to keep users engaged. None of them want to learn WebSocket architecture, matchmaking theory, or game state synchronization. They just want the game.&lt;/p&gt;

&lt;p&gt;Nobody had built the bridge.&lt;/p&gt;

&lt;p&gt;So I decided to. A Games as a Service API. One integration and any developer can embed real-time multiplayer games into their product. Under their brand. With their rules. Without touching a single line of game logic.&lt;/p&gt;

&lt;p&gt;I called it Beta Gamer. And I started looking for people to build it with me.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Part Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;I found nobody.&lt;/p&gt;

&lt;p&gt;Not because people weren't interested. Because I couldn't pay them. And you cannot ask people to dedicate serious time to your vision without compensating them. That's just reality.&lt;/p&gt;

&lt;p&gt;See, the thing about being an African freelance developer is that your income is your oxygen. When a freelance project comes in, you eat. When it doesn't, you don't. No income means no internet. No internet means no development. The loop is vicious and most people outside of it don't understand how completely it can shut you down. 🥀🥀&lt;/p&gt;

&lt;p&gt;There were periods I couldn't work on Beta Gamer at all. Not because I didn't want to. Because I had to survive first.&lt;/p&gt;

&lt;p&gt;There were nights I thought, should I just ship it with 2 games? Get something out there, prove the concept, add more later? But I knew what that meant. The matchmaking engine I was building was designed to scale. Shipping early and upgrading it later would have been hell. The architecture would have fought me every step of the way.&lt;/p&gt;

&lt;p&gt;So I made a decision that cost me time but saved me from a worse problem. I would build it right the first time, even if it took longer.&lt;/p&gt;

&lt;p&gt;That decision required patience I sometimes didn't have. Power cuts in the middle of sessions. Mobile data as my only connection. Academic deadlines running parallel to a platform that needed my full attention. Supabase requiring me to be online just to test authentication flows, or searching a bug, &lt;/p&gt;




&lt;p&gt;which sounds like a small thing until your data budget runs out at 11pm and you're mid-feature.&lt;/p&gt;

&lt;p&gt;I won't pretend it didn't get to me. It did.&lt;/p&gt;

&lt;p&gt;But here's the thing about building something genuinely hard. The difficulty becomes the proof. Every impossible thing you solve gets baked into the product permanently. It stays there, working, long after the stress of building it is forgotten.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Kept Me Going
&lt;/h2&gt;

&lt;p&gt;Honestly? The problems. 🔥&lt;/p&gt;

&lt;p&gt;Every new thing I added that seemed impossible before I tried it. Like figuring out how to let external tenants inject bots into live WebSocket sessions through an API endpoint. Not hardcoded bots. Not internal bots. Bots controlled from outside the system entirely, by companies integrating Beta Gamer into their own products. I solved that and sat back and just stared at the screen.&lt;/p&gt;

&lt;p&gt;Or designing the group matchmaking engine.&lt;/p&gt;

&lt;p&gt;Most platforms build 1v1 first and bolt group support on later. That's why their matchmaking systems become a mess. I started with the general solution and let everything else follow.&lt;/p&gt;

&lt;p&gt;The structure is Group[x] vs Group[y], where x and y are player counts that fall within a min and max defined per game. A game with fixed sizes just sets min and max equal. A game with flexible sizes sets them differently and the match starts when the minimum is met. 1v1 Chess is Group[1] vs Group[1]. Four player Ludo is four groups of one. A flexible team game is Group[2-4] vs Group[2-4].&lt;/p&gt;

&lt;p&gt;One engine. One set of rules. Every game type falls out of configuration, not separate code. Reusable. Modular. The game defines its own rules and the engine just executes them.&lt;/p&gt;

&lt;p&gt;And if tenants want to manage their own matchmaking logic from outside the system? They can do that too.&lt;/p&gt;

&lt;p&gt;Then there's the thinking I haven't fully built yet but can't stop thinking about. Turn-based games, Chess, Ludo, Checkers, one player moves then the other. Clean. But football, shooting games, everyone moves simultaneously. Different timing model. Different conflict resolution. Different architecture entirely.&lt;/p&gt;

&lt;p&gt;That thought kept me at the keyboard on nights I probably should have slept.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where Beta Gamer Is Now
&lt;/h2&gt;

&lt;p&gt;10 games live. Chess, Ludo, 8-Ball Pool, Archery, Piano Tiles, Connect 4, Check (Chkobe, an African card game similar to Uno), and more. Multi-tenant architecture with isolated matchmaking per partner. Hosted matchmaking mode where tenants control their own queues. Bots injectable via API endpoint. Flexible group sizes with configurable min and max per game.&lt;/p&gt;

&lt;p&gt;The SDK ships three things separately. UI components, socket hooks, and game hooks. Tenants can use the default UI, replace it entirely, or just take the hooks and build whatever interface they want on top. You will never know it is Beta Gamer running underneath unless someone tells you. That is the point.&lt;/p&gt;

&lt;p&gt;React and React Native packages are live on npm: @beta-gamer/react and @beta-gamer/react-native.&lt;/p&gt;

&lt;p&gt;Built alone. In roughly 5 to 6 months of real working time, stretched across a longer calendar of power cuts, connectivity drops, financial pressure, and a full academic load that ended with a BSc in Computer Science from the University of Buea.&lt;/p&gt;

&lt;p&gt;About 80% done. Not because I ran out of ideas. Because I refuse to ship something until it is right.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Hope For
&lt;/h2&gt;

&lt;p&gt;I am not writing this for sympathy. I am writing it because I think there are developers reading this who are building something real, under real constraints, with no safety net, and they need to know that the building is worth it even before the reward shows up.&lt;/p&gt;

&lt;p&gt;The next post will be the technical one. How the group engine actually works. How bot injection via API is architected. How I think about simultaneous move resolution for real-time games.&lt;/p&gt;

&lt;p&gt;But I wanted to tell this part first.&lt;/p&gt;

&lt;p&gt;Because the code does not exist without the person who wrote it. And the person is never just the code. 🏗️&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Beta Gamer docs (Demo): &lt;a href="https://beta-gamer.onrender.com/docs/overview" rel="noopener noreferrer"&gt;https://beta-gamer.onrender.com/docs/overview&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;npm: @beta-gamer/react and @beta-gamer/react-native&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>gamedev</category>
      <category>africandev</category>
      <category>saas</category>
    </item>
    <item>
      <title>Archive: A Narrative Investigation Game About Curating Human History</title>
      <dc:creator>Ado Daniel Nj</dc:creator>
      <pubDate>Mon, 22 Jun 2026 03:23:20 +0000</pubDate>
      <link>https://dev.to/adodanieln/archive-a-narrative-investigation-game-about-curating-human-history-1ca0</link>
      <guid>https://dev.to/adodanieln/archive-a-narrative-investigation-game-about-curating-human-history-1ca0</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for the &lt;a href="https://dev.to/challenges/june-game-jam-2026-06-03"&gt;June Solstice Game Jam&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Archive — The Last Historian of Humanity
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What if history wasn't discovered... but selected?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;History is often treated as something permanent—something waiting to be uncovered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Archive&lt;/strong&gt; asks a different question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;What happens when humanity loses the ability to tell the difference between truth, memory, and fabrication?&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You play as the final Archivist after the collapse of civilization. Humanity's knowledge survives, but it has become fragmented, contradictory, and corrupted. Your responsibility is no longer to preserve everything—you must decide &lt;strong&gt;what deserves to be remembered.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every decision changes the civilization that will inherit your version of history.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Archive is a narrative investigation game where players reconstruct humanity's past by examining historical memories, investigating evidence, resolving contradictions, and deciding what becomes official history.&lt;/p&gt;

&lt;p&gt;Unlike traditional mystery games, there is rarely a perfect answer.&lt;/p&gt;

&lt;p&gt;Instead, every investigation asks questions such as:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Should conflicting memories be preserved?&lt;/li&gt;
&lt;li&gt;Is stability more important than truth?&lt;/li&gt;
&lt;li&gt;Can compassion justify rewriting history?&lt;/li&gt;
&lt;li&gt;If no one can verify the past, what does "truth" even mean?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each recovered memory is presented as a historical article. Players investigate through classified documents, research papers, witness testimonies, forensic reports, personal journals, and government archives before making irreversible decisions.&lt;/p&gt;

&lt;p&gt;Those decisions reshape the civilization that follows.&lt;/p&gt;

&lt;p&gt;By the end of the game, players don't simply receive a score—they discover the kind of society they created.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why It Fits the Theme
&lt;/h2&gt;

&lt;p&gt;The June Solstice represents a turning point.&lt;/p&gt;

&lt;p&gt;It is the moment when one season gives way to another, when light begins yielding to darkness, or darkness begins yielding to light.&lt;/p&gt;

&lt;p&gt;Archive explores a similar transition.&lt;/p&gt;

&lt;p&gt;Not between seasons...&lt;/p&gt;

&lt;p&gt;but between &lt;strong&gt;certainty and uncertainty.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Human civilization reaches a moment where objective history begins to disappear. Every decision the player makes determines what survives that transition.&lt;/p&gt;

&lt;p&gt;The game is ultimately about how societies evolve through the stories they choose to preserve.&lt;/p&gt;




&lt;h2&gt;
  
  
  Core Gameplay
&lt;/h2&gt;

&lt;p&gt;Players:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Recover fragmented historical memories.&lt;/li&gt;
&lt;li&gt;Explore multiple investigation paths.&lt;/li&gt;
&lt;li&gt;Analyze evidence with varying reliability and bias.&lt;/li&gt;
&lt;li&gt;Resolve contradictions between historical accounts.&lt;/li&gt;
&lt;li&gt;Decide which memories become part of civilization's official record.&lt;/li&gt;
&lt;li&gt;Shape the philosophical identity of humanity's future.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No two playthroughs necessarily create the same civilization.&lt;/p&gt;




&lt;h2&gt;
  
  
  Video Demo
&lt;/h2&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://the-last-memory-of-earth.vercel.app/demo.mp4" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;the-last-memory-of-earth.vercel.app&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;p&gt;The video demonstrates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Archive exploration&lt;/li&gt;
&lt;li&gt;Investigation system&lt;/li&gt;
&lt;li&gt;Evidence paths&lt;/li&gt;
&lt;li&gt;Decision making&lt;/li&gt;
&lt;li&gt;Civilization evaluation&lt;/li&gt;
&lt;li&gt;Multiple possible historical outcomes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sorry about the low vibe during that, i was extremely tired 😅.&lt;/p&gt;




&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;GitHub Repository:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(&lt;a href="https://github.com/acetennyson/The-Last-Memory-of-Earth/" rel="noopener noreferrer"&gt;https://github.com/acetennyson/The-Last-Memory-of-Earth/&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Test Live
&lt;/h2&gt;

&lt;p&gt;Public domain:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(&lt;a href="https://the-last-memory-of-earth.vercel.app/" rel="noopener noreferrer"&gt;https://the-last-memory-of-earth.vercel.app/&lt;/a&gt;)&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Archive was designed around one core philosophy:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;History is not merely remembered, it is curated.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Rather than treating lore as optional background reading, every piece of narrative content exists as gameplay.&lt;/p&gt;

&lt;p&gt;The project includes systems for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;branching investigations&lt;/li&gt;
&lt;li&gt;layered evidence&lt;/li&gt;
&lt;li&gt;contradictory historical records&lt;/li&gt;
&lt;li&gt;civilization philosophy&lt;/li&gt;
&lt;li&gt;procedural ending evaluation&lt;/li&gt;
&lt;li&gt;markdown-powered historical articles&lt;/li&gt;
&lt;li&gt;dynamic narrative progression&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The game is built using React, TypeScript, Capacitor, and a fully data-driven content architecture that allows new story arcs, memories, investigations, and evidence to be added without modifying gameplay systems.&lt;/p&gt;

&lt;p&gt;One of the biggest design challenges was avoiding "good" and "bad" endings.&lt;/p&gt;

&lt;p&gt;Instead, the game evaluates the civilization created by the player's accumulated decisions across values such as Truth, Freedom, Compassion, Progress, Power, and Legacy.&lt;/p&gt;

&lt;p&gt;The result is an ending that reflects &lt;strong&gt;who humanity became&lt;/strong&gt;, rather than simply whether the player succeeded.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prize Category
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Best Ode to Alan Turing
&lt;/h3&gt;

&lt;p&gt;Archive is deeply inspired by questions surrounding computation, information, and artificial intelligence.&lt;/p&gt;

&lt;p&gt;As the Archive evolves, it begins connecting information in ways humans never anticipated, raising questions similar to those explored by Alan Turing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can a machine create knowledge?&lt;/li&gt;
&lt;li&gt;Can intelligence emerge from information alone?&lt;/li&gt;
&lt;li&gt;When does computation become interpretation?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rather than focusing on AI as a tool, the game explores AI as a historical force capable of reshaping civilization itself.&lt;/p&gt;




&lt;h3&gt;
  
  
  Best Google AI Usage
&lt;/h3&gt;

&lt;p&gt;Google AI played an important role throughout development.&lt;/p&gt;

&lt;p&gt;It was used as a creative collaborator for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;worldbuilding&lt;/li&gt;
&lt;li&gt;historical consistency&lt;/li&gt;
&lt;li&gt;narrative iteration&lt;/li&gt;
&lt;li&gt;investigation design&lt;/li&gt;
&lt;li&gt;story arc refinement&lt;/li&gt;
&lt;li&gt;gameplay balancing&lt;/li&gt;
&lt;li&gt;technical architecture discussions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rather than generating finished content automatically, Google AI became part of the iterative design process, helping refine ideas while preserving a consistent narrative vision.&lt;/p&gt;




&lt;h2&gt;
  
  
  Final Thoughts
&lt;/h2&gt;

&lt;p&gt;Archive isn't about winning.&lt;/p&gt;

&lt;p&gt;It isn't even about discovering the truth.&lt;/p&gt;

&lt;p&gt;It's about confronting one uncomfortable possibility:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;History never remembers everything.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only what someone chose to preserve.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>devchallenge</category>
      <category>gamechallenge</category>
      <category>gamedev</category>
    </item>
    <item>
      <title>The Weekend I Finally Finished 17 Projects</title>
      <dc:creator>Ado Daniel Nj</dc:creator>
      <pubDate>Fri, 12 Jun 2026 00:09:11 +0000</pubDate>
      <link>https://dev.to/adodanieln/the-weekend-i-finally-finished-17-projects-3lpl</link>
      <guid>https://dev.to/adodanieln/the-weekend-i-finally-finished-17-projects-3lpl</guid>
      <description>&lt;p&gt;You know that feeling when you open a folder you haven't touched in months and find... yourself?&lt;/p&gt;

&lt;p&gt;Not metaphorically. I mean literally — old projects you don't even remember writing, with code that's somehow &lt;em&gt;good&lt;/em&gt;? And you're sitting there like, "wait, &lt;em&gt;I&lt;/em&gt; wrote this?"&lt;/p&gt;

&lt;p&gt;That was my Saturday.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Mess
&lt;/h2&gt;

&lt;p&gt;I decided it was time. My portfolio was a ghost town. My GitHub was full of half-finished dreams. You know the drill — you start a project, get 80% there, then a shinier thing comes along and suddenly it's gathering dust for a year.&lt;/p&gt;

&lt;p&gt;I had 17 of those.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LIS&lt;/strong&gt; — An AI tutoring app for Cameroonian students. Works offline. Has a &lt;em&gt;Learning DNA&lt;/em&gt; that adapts to how you study. I literally submitted this for a social impact prize and then... just left it there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mbende Stay&lt;/strong&gt; — A booking platform with mobile money payments. Fully functional. Zero visitors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"418 I'm a Teapot"&lt;/strong&gt; — A game where you play as a teapot that can never win and an AI &lt;em&gt;roasts you&lt;/em&gt; every time you fail. I'm not kidding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;elementTouch&lt;/strong&gt; — A JavaScript library I built from scratch. Drag and drop. Typewriter effects, and more. A whole code editor. Just... sitting there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And I'm looking at all of this thinking: &lt;em&gt;bro, you really just built things and then forgot to tell anyone?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;So I made a decision: polish them all, put them in my portfolio, and actually show the work.&lt;/p&gt;

&lt;p&gt;First step: build an admin import tool. One click, seed 17 projects into Firestore. Felt like a genius.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then the Universe Laughed
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;npm run build&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;ReferenceError: location is not defined
    at v (.next/server/app/admin/blog/new/page.js:1:11128)
    at y (.next/server/app/admin/projects/import/page.js:1:16875)
    at y (.next/server/app/admin/projects/new/page.js:1:8262)
    at j (.next/server/app/admin/resumes/new/page.js:1:4056)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four pages. Dead. And I have &lt;em&gt;no idea&lt;/em&gt; where &lt;code&gt;location&lt;/code&gt; is coming from.&lt;/p&gt;

&lt;p&gt;You ever debug a Firebase + Next.js app and feel like you're chasing a ghost? Because I was definitely chasing a ghost.&lt;/p&gt;

&lt;p&gt;Turns out, Firebase's SDK references &lt;code&gt;location&lt;/code&gt; (as in &lt;code&gt;window.location&lt;/code&gt;) at the module level. Not inside a function you call. At &lt;em&gt;import time&lt;/em&gt;. So when Next.js tries to server-render your admin page, Node.js is like "what's &lt;code&gt;location&lt;/code&gt;? never heard of her."&lt;/p&gt;

&lt;p&gt;The fix?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/dynamic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./page.client&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="na"&gt;ssr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrapped each page in &lt;code&gt;next/dynamic&lt;/code&gt; with &lt;code&gt;ssr: false&lt;/code&gt;. Basically told Next.js: &lt;em&gt;"Don't even look at this page on the server. Not once. Not ever. Just send the JS and let the browser figure it out."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Moved all the actual code to &lt;code&gt;page.client.tsx&lt;/code&gt;. Build passed. I felt like a god for approximately 12 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Betrayal
&lt;/h2&gt;

&lt;p&gt;Then I looked at my blog.&lt;/p&gt;

&lt;p&gt;You see, my blog posts are stored in Firestore as HTML. And somewhere along the way, I'd written them with beautiful Tailwind dark mode classes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"not-dark:bg-white dark:bg-gray-900 dark:shadow-lg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the problem: &lt;strong&gt;Tailwind v4's JIT compiler has never heard of your database.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It scans your source files — your &lt;code&gt;app/&lt;/code&gt;, your &lt;code&gt;components/&lt;/code&gt;, your &lt;code&gt;lib/&lt;/code&gt;. It does NOT make a Firebase query to find class names.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;dark:bg-gray-900&lt;/code&gt; in a blog post? Never compiled. It renders as plain text with zero styles. My "beautiful blog content" was just... floating in the void. No background. No shadow. Nothing.&lt;/p&gt;

&lt;p&gt;I sat there staring at the screen feeling personally betrayed by a CSS framework.&lt;/p&gt;

&lt;p&gt;But hey, I fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prepareHtml&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b(?:&lt;/span&gt;&lt;span class="sr"&gt;not-&lt;/span&gt;&lt;span class="se"&gt;)?&lt;/span&gt;&lt;span class="sr"&gt;dark:&lt;/span&gt;&lt;span class="se"&gt;[^\s&lt;/span&gt;&lt;span class="sr"&gt;"'&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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;Strip the prefixes. Then override everything with CSS variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="nc"&gt;.blog-content&lt;/span&gt; &lt;span class="nc"&gt;.text-gray-900&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;inherit&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.blog-content&lt;/span&gt; &lt;span class="nc"&gt;.bg-white&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.blog-content&lt;/span&gt; &lt;span class="nc"&gt;.bg-gray-900&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;transparent&lt;/span&gt; &lt;span class="cp"&gt;!important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nc"&gt;.blog-content&lt;/span&gt; &lt;span class="nc"&gt;.shadow-md&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;.blog-content&lt;/span&gt; &lt;span class="nc"&gt;.shadow-lg&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;box-shadow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt; &lt;span class="cp"&gt;!important&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;Now every hardcoded class respects the theme. Dark mode, light mode, it just works. The blog content is finally alive.&lt;/p&gt;




&lt;h2&gt;
  
  
  Then There Were 17
&lt;/h2&gt;

&lt;p&gt;So now I've got 17 projects in my portfolio. On one page. One. Grid. Screen. You see the problem.&lt;/p&gt;

&lt;p&gt;I needed pagination. Not the boring kind — something that actually looks good:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chevrons that bounce when you hover&lt;/li&gt;
&lt;li&gt;Page numbers with the accent color on active&lt;/li&gt;
&lt;li&gt;Ellipsis that only show when there's actually a gap&lt;/li&gt;
&lt;li&gt;A quiet little "Page X of Y · N projects" at the bottom&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The button logic nearly broke me. You try handling "first page, last page, exactly 5 pages, exactly 6 pages, ellipsis-or-no-ellipsis" without your brain melting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useMemo&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="na"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;totalPages&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;safePage&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;// ... more edge cases&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;safePage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;totalPages&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works. It's clean. I'm proud of it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Actually Learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. You probably have better code than you think
&lt;/h3&gt;

&lt;p&gt;Open that old project. The one you're embarrassed about. I bet it's not as bad as you remember. We're all way too hard on ourselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Admin pages don't need SSR
&lt;/h3&gt;

&lt;p&gt;If your page is behind a login wall, there's zero SEO value in server-rendering it. Just use &lt;code&gt;ssr: false&lt;/code&gt; and save yourself the headache. Your admin panel will survive without being indexed by Google.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Tailwind v4 JIT is amazing until your content is dynamic
&lt;/h3&gt;

&lt;p&gt;If your HTML lives in a database, plan ahead. CSS variables are your escape hatch. Hardcoded utility classes in data strings will come back to haunt you.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. A portfolio is never done
&lt;/h3&gt;

&lt;p&gt;Every time I thought "okay this is final," I found another project, another bug, another thing to improve. That's not failure. That's just what building in public looks like.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Left
&lt;/h2&gt;

&lt;p&gt;elementTouch still has 2 unfinished tasks out of 7. I'll get to them.&lt;/p&gt;

&lt;p&gt;And there's a missing &lt;code&gt;admin/blog/edit/[id]&lt;/code&gt; page that keeps crashing the build. Just one day.&lt;/p&gt;




&lt;p&gt;Anyway, that's my weekend. What's the most embarrassing bug you've fought with Next.js + Firebase? Drop it below so I don't feel alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a href="https://adodanielnj.vercel.app/" rel="noopener noreferrer"&gt;My Portfolio&lt;/a&gt;
&lt;/h2&gt;

</description>
      <category>nextjs</category>
      <category>firebase</category>
      <category>portfolio</category>
      <category>architecture</category>
    </item>
    <item>
      <title>We Built a “Stripe for African Mobile Money” — Then Discovered Why It Couldn’t Exist</title>
      <dc:creator>Ado Daniel Nj</dc:creator>
      <pubDate>Thu, 16 Apr 2026 09:31:42 +0000</pubDate>
      <link>https://dev.to/adodanieln/we-built-a-stripe-for-african-mobile-money-then-discovered-why-it-couldnt-exist-3ica</link>
      <guid>https://dev.to/adodanieln/we-built-a-stripe-for-african-mobile-money-then-discovered-why-it-couldnt-exist-3ica</guid>
      <description>&lt;p&gt;As developers, we love hard problems.&lt;/p&gt;

&lt;p&gt;So we set out to solve one that almost every African developer has faced:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;“Why do I need a different integration for every mobile money provider in every country?”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our answer was a project we called &lt;strong&gt;UAPL (Unified African Payment Layer)&lt;/strong&gt; — a single API that could sit on top of providers like &lt;strong&gt;Campay&lt;/strong&gt;, &lt;strong&gt;Flutterwave&lt;/strong&gt;, MTN MoMo, Orange Money, Airtel Money, and others.&lt;/p&gt;

&lt;p&gt;The goal?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Give developers a Stripe-like experience for mobile money across Africa.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Technically, we nailed it.&lt;/p&gt;

&lt;p&gt;Legally, we walked into a wall we didn’t know existed.&lt;/p&gt;

&lt;p&gt;This post is about the architecture we built, the performance we achieved, the failures we discovered &lt;strong&gt;before launch&lt;/strong&gt;, and why we had to pivot the entire idea.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧠 The Technical Vision
&lt;/h2&gt;

&lt;p&gt;UAPL was designed as an orchestration layer with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adapter pattern&lt;/strong&gt; for each provider&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Unified Payment Object (UPO)&lt;/strong&gt; to normalize all APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redis-backed transaction ledger&lt;/strong&gt; for USSD/async flows&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebSocket events&lt;/strong&gt; for real-time transaction updates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic provider failover&lt;/strong&gt; (MTN → Orange if one fails)&lt;/li&gt;
&lt;li&gt;A clean SDK so devs never see provider differences&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;From a systems design perspective, it was beautiful.&lt;/p&gt;

&lt;p&gt;You could write:&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="nx"&gt;uapl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pay&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;XAF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;677000000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And UAPL would figure out the provider, handle retries, normalize responses, and notify your app in real time.&lt;/p&gt;

&lt;p&gt;We load-tested the orchestration layer. It was fast. Stateless. Horizontally scalable. Provider-agnostic. Exactly what African devs need.&lt;/p&gt;




&lt;h2&gt;
  
  
  💥 The Discovery That Changed Everything
&lt;/h2&gt;

&lt;p&gt;While preparing for real integrations with &lt;strong&gt;Banque des États de l'Afrique Centrale (BEAC)&lt;/strong&gt; zone providers, we went deeper into regulatory documentation and aggregator terms.&lt;/p&gt;

&lt;p&gt;And then we saw it.&lt;/p&gt;

&lt;p&gt;A sentence that changes everything for fintech builders:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Any entity that routes, orchestrates, settles, or intermediates payments between merchants and licensed providers may be classified as a Payment Service Provider (PSP).&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Even if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You never touch the money&lt;/li&gt;
&lt;li&gt;You only pass API calls&lt;/li&gt;
&lt;li&gt;You act as “middleware”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Legally, you are &lt;strong&gt;in the payment flow&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Which means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need PSP licenses&lt;/li&gt;
&lt;li&gt;You fall under central bank regulation&lt;/li&gt;
&lt;li&gt;Aggregators can block you for “reselling” their infrastructure&lt;/li&gt;
&lt;li&gt;Settlement logic makes you a financial intermediary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In CEMAC, this is enforced under &lt;strong&gt;Commission Bancaire de l'Afrique Centrale (COBAC)&lt;/strong&gt; oversight.&lt;/p&gt;

&lt;p&gt;We weren’t building a developer tool.&lt;/p&gt;

&lt;p&gt;We were accidentally building an unlicensed financial institution.&lt;/p&gt;




&lt;h2&gt;
  
  
  ❌ The Failures We Caught Early
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;strong&gt;Automatic Failover Is Not Allowed&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Our proudest feature:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If Campay's MTN API fails → automatically retry with Tranzak's MTN API&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Sounds smart. Completely illegal in practice.&lt;/p&gt;

&lt;p&gt;Why? Merchants are KYB’d &lt;em&gt;per provider&lt;/em&gt;. You can’t reroute their transactions without explicit registration.&lt;/p&gt;




&lt;h3&gt;
  
  
  2. &lt;strong&gt;Settlement Scheduling Makes You a PSP&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;We planned:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“UAPL pays out to developers on a schedule”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That single sentence legally converts you into a money handler.&lt;/p&gt;

&lt;p&gt;Instant PSP classification.&lt;/p&gt;




&lt;h3&gt;
  
  
  3. &lt;strong&gt;Aggregators Can Shut You Down Overnight&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Platforms like &lt;strong&gt;Flutterwave&lt;/strong&gt; or &lt;strong&gt;Campay&lt;/strong&gt; can decide you are “reselling” their API as a platform.&lt;/p&gt;

&lt;p&gt;They don’t need to argue. They just revoke your keys.&lt;/p&gt;

&lt;p&gt;Game over.&lt;/p&gt;




&lt;h3&gt;
  
  
  4. &lt;strong&gt;The Illusion of “One License for Many Countries”&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;We assumed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Get a CEMAC license → operate everywhere in the zone&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Reality:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Country-level approvals&lt;/li&gt;
&lt;li&gt;Telco-level approvals&lt;/li&gt;
&lt;li&gt;Data residency considerations&lt;/li&gt;
&lt;li&gt;Central bank notifications&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A regional license is not a passport. It’s permission to start more paperwork.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧩 The Big Realization
&lt;/h2&gt;

&lt;p&gt;We thought we were building:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Stripe for African mobile money&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But what we needed to build was:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Stripe.js for African mobile money&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A critical difference.&lt;/p&gt;

&lt;p&gt;One is a regulated payments company.&lt;/p&gt;

&lt;p&gt;The other is a developer SDK.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔁 The Pivot
&lt;/h2&gt;

&lt;p&gt;We redesigned UAPL to be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;pure SDK and orchestration library&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Developers bring &lt;strong&gt;their own provider credentials&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;No routing of funds&lt;/li&gt;
&lt;li&gt;No settlement&lt;/li&gt;
&lt;li&gt;No payout control&lt;/li&gt;
&lt;li&gt;No position in the legal payment chain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, that designs UAPL as a software. Not fintech.&lt;/p&gt;

&lt;p&gt;And that version is &lt;strong&gt;legally unstoppable&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧭 Lessons for Developers Building in Fintech
&lt;/h2&gt;

&lt;p&gt;Before writing code, answer these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Who is the regulator in this space?&lt;/li&gt;
&lt;li&gt;Does my system decide where money goes?&lt;/li&gt;
&lt;li&gt;Does my system delay, batch, or settle funds?&lt;/li&gt;
&lt;li&gt;Am I unintentionally “intermediating” transactions?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If yes, you are not building a tool.&lt;/p&gt;

&lt;p&gt;You are building a financial institution.&lt;/p&gt;




&lt;h2&gt;
  
  
  🗣️ Why I’m Sharing This
&lt;/h2&gt;

&lt;p&gt;Because from an engineering standpoint, this was one of the best systems we’ve ever designed.&lt;/p&gt;

&lt;p&gt;And it still would have failed.&lt;/p&gt;

&lt;p&gt;Not due to bugs.&lt;br&gt;
Not due to scaling.&lt;br&gt;
Not due to product-market fit.&lt;/p&gt;

&lt;p&gt;But because we didn’t start with regulatory research.&lt;/p&gt;

&lt;p&gt;If you’re a developer building in Africa’s fintech space, learn from this before you spend months building the wrong thing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Research first. Architect second. Code third.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>architecture</category>
      <category>systemdesign</category>
      <category>startup</category>
    </item>
    <item>
      <title>418: I'm a Teapot</title>
      <dc:creator>Ado Daniel Nj</dc:creator>
      <pubDate>Mon, 13 Apr 2026 07:24:53 +0000</pubDate>
      <link>https://dev.to/adodanieln/youre-a-bad-gamer-2ai0</link>
      <guid>https://dev.to/adodanieln/youre-a-bad-gamer-2ai0</guid>
      <description>&lt;p&gt;This is a submission for the &lt;a href="https://dev.to/challenges/aprilfools-2026"&gt;DEV April Fools Challenge&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;418: I'm a Teapot (and You're a Bad Gamer)&lt;/p&gt;

&lt;p&gt;A high performance, SEO-optimized, AI integrated gaming platform built solely to ensure the player &lt;br&gt;
never wins.&lt;/p&gt;

&lt;p&gt;You control a teapot. You run. You jump. You die. On every death, Gemini analyzes your failure and &lt;br&gt;
adjusts the physics to be slightly more insulting next time. There is no finish line. There never was.&lt;/p&gt;

&lt;p&gt;Key features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Endless Runner&lt;/strong&gt; — one button to jump. Except it's not always that button.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotating Controls&lt;/strong&gt; — the jump key silently changes every run. The HUD shows last run's key. This 
is documented nowhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input Gaslighting&lt;/strong&gt; — every ~10th jump has a 200ms delay. Wall contact permanently degrades your 
grip. Mash the keyboard and gravity drops to near zero — the teapot floats away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Vibe Check&lt;/strong&gt; — on every death, your stats (jumps, wall hugs, rage mashes) are sent to Gemini. 
It returns new physics values and a personalized roast. Gets meaner over time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global Loss Leaderboard&lt;/strong&gt; — powered by Firebase. Tracks deaths, not wins. "You are ranked #3 most 
pathetic globally."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fake Patch Notes&lt;/strong&gt; — v2.4.1 changelog includes "Fixed bug where player could win (unintended)" and
"Removed finish line (was causing confusion)."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fake Loading Tips&lt;/strong&gt; — "Tip: There is no finish line. There never was."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broken Controls Page&lt;/strong&gt; — /controls documents the controls incorrectly. Never acknowledged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support Ticket System&lt;/strong&gt; — after 5 deaths a "Report a Bug" button appears. Submit a complaint, 
receive HTTP 418 and a Gemini-generated gaslighting response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share Your Shame&lt;/strong&gt; — one click shares your death count and AI roast to X, Facebook, WhatsApp, 
Telegram, Reddit, or LinkedIn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;418 Redirect&lt;/strong&gt; — survive 90 seconds and get redirected to /418: 
{"status": 418, "message": "I'm a Teapot", "reason": "Server is currently brewing. Your victory has been lost in transit."}&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Demo
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://not-deep.vercel.app/" rel="noopener noreferrer"&gt;Run here&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/acetennyson/not-deep" rel="noopener noreferrer"&gt;GitHub repo&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How I Built It
&lt;/h2&gt;

&lt;p&gt;Stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; — frontend + API routes. The game UI and the AI backend live in the same codebase, 
deployed as a single container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phaser 3&lt;/strong&gt; — 2D game engine running in a dynamically imported client component (no SSR). Handles 
physics, collision, procedural obstacle generation, and all the gaslighting mechanics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 1.5 Flash (primary) + Groq / Llama 3.3 70B (fallback)&lt;/strong&gt; — on every death, player action 
data is POSTed to /api/judge. The AI returns a JSON payload with new physics values (gravity, speed, 
jumpForce, mass, drag, delayEvery) and a personalized roast. The prompt explicitly instructs it to get
meaner with each death.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firebase Firestore&lt;/strong&gt; — global leaderboard. Every death writes a record. Rank is calculated by 
counting sessions with fewer deaths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Cloud Run&lt;/strong&gt; — the entire app is Dockerized and deployed as a single container. Because some
problems deserve enterprise-grade infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The physics are real. jumpForce / mass determines jump height. drag affects mid-air deceleration. &lt;br&gt;
Gemini can increase your mass to make your jumps pathetically short, or drop gravity to 50 so the &lt;br&gt;
teapot floats off screen. The game is genuinely engineered to be bad.&lt;/p&gt;

&lt;p&gt;The support ticket endpoint returns HTTP 418 by design. This is not a bug. It is the only honest thing&lt;br&gt;
in the entire codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prize Category
&lt;/h2&gt;

&lt;p&gt;Best Ode to Larry Masinter, The player character is a teapot. The win condition redirects to a raw &lt;br&gt;
418 JSON response. The support system returns 418. The game is named after RFC 2324. Larry Masinter &lt;br&gt;
wrote a joke in 1998 that has never been removed from the spec. This game is its spiritual successor. &lt;br&gt;
It is completely useless. It took real engineering to make it this bad.&lt;/p&gt;

&lt;p&gt;Best Google AI Usage. Gemini is not a hint system or a chatbot, it is a passive-aggressive game &lt;br&gt;
designer that watches you fail and makes the next run worse. It also generates your death roast and &lt;br&gt;
your support ticket rejection. Every interaction with the AI is designed to make you feel worse about &lt;br&gt;
yourself. This is the correct use of AI.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>418challenge</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Global Freelancer Sync — an AI-powered command center for freelancers managing clients</title>
      <dc:creator>Ado Daniel Nj</dc:creator>
      <pubDate>Mon, 30 Mar 2026 06:05:51 +0000</pubDate>
      <link>https://dev.to/adodanieln/global-freelancer-sync-an-ai-powered-command-center-for-freelancers-managing-clients-3joe</link>
      <guid>https://dev.to/adodanieln/global-freelancer-sync-an-ai-powered-command-center-for-freelancers-managing-clients-3joe</guid>
      <description>&lt;p&gt;This is a submission for the &lt;a href="https://dev.to/challenges/notion-2026-03&lt;br&gt;%0A-04"&gt;Notion MCP Challenge&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;Global Freelancer Sync — an AI-powered command center for freelancers managing clients &lt;br&gt;
across timezones.&lt;/p&gt;

&lt;p&gt;As a freelancer in Cameroon (WAT), I work with clients in New York, London, and Tokyo. Every&lt;br&gt;
week I face the same questions: Who should I reach out to right now? Is it a good time to &lt;br&gt;
send this update? What should I even say?&lt;/p&gt;

&lt;p&gt;Doing this manually for 8+ clients across 5 timezones is tedious and easy to get wrong. So I&lt;br&gt;
built a system that handles it automatically.&lt;/p&gt;

&lt;p&gt;Here's what it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Connects to your Notion Clients database and reads each client's timezone, project 
context, and last update&lt;/li&gt;
&lt;li&gt;Shows a live world clock strip — who's available right now, who's sleeping, and how long 
until each client's window opens&lt;/li&gt;
&lt;li&gt;Uses AI (Gemini, Claude, DeepSeek, Groq, HuggingFace with automatic fallback) to draft 
personalized outreach emails using your project context and sent message history&lt;/li&gt;
&lt;li&gt;Lets you review and approve drafts — you stay in control&lt;/li&gt;
&lt;li&gt;Auto-sends emails via Gmail when clients enter their active window, even while you sleep&lt;/li&gt;
&lt;li&gt;Logs every action to Supabase with a real-time activity feed&lt;/li&gt;
&lt;li&gt;Includes a chat assistant that knows your full client state and can answer questions like 
"Who should I reach out to first today?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The core philosophy: AI handles the timing and the words. You handle the decisions. Human-in&lt;br&gt;
-the-loop, by design.&lt;/p&gt;
&lt;h2&gt;
  
  
  Video Demo
&lt;/h2&gt;


&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
      &lt;div class="c-embed__body flex items-center justify-between"&gt;
        &lt;a href="https://adodanielnj.vercel.app/demo/assistant.mp4" rel="noopener noreferrer" class="c-link fw-bold flex items-center"&gt;
          &lt;span class="mr-2"&gt;adodanielnj.vercel.app&lt;/span&gt;
          

        &lt;/a&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;


&lt;h2&gt;
  
  
  Show us the code
&lt;/h2&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/acetennyson/global-freelancer-assistant" rel="noopener noreferrer"&gt;https://github.com/acetennyson/global-freelancer-assistant&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Live demo: global-freelancer-assistant.vercel.app&lt;/p&gt;

&lt;p&gt;Tech stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 15 (App Router)&lt;/li&gt;
&lt;li&gt;Notion SDK + MCP TypeScript SDK&lt;/li&gt;
&lt;li&gt;Supabase (activity logs, draft history, sent history, Realtime)&lt;/li&gt;
&lt;li&gt;Multi-provider AI: Gemini 2.5 Flash → Claude → DeepSeek → Groq → HuggingFace&lt;/li&gt;
&lt;li&gt;Nodemailer (Gmail SMTP)&lt;/li&gt;
&lt;li&gt;Vercel Cron (every 15 minutes)&lt;/li&gt;
&lt;li&gt;Framer Motion + next-themes (light/dark/system)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How I Used Notion MCP
&lt;/h2&gt;

&lt;p&gt;Notion is the single source of truth for everything client-related. The MCP server exposes &lt;br&gt;
three tools that any MCP-compatible AI client (like Claude Desktop) can call:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;get_client_local_times&lt;/code&gt;&lt;br&gt;
Returns every active client's current local time, availability status, and whether they're &lt;br&gt;
within their send window. Claude can call this to answer "who's available right now?" &lt;br&gt;
without any manual timezone math.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;generate_client_outreach&lt;/code&gt;&lt;br&gt;
Takes a client_id, checks if the client is in their business hours window, reads their &lt;br&gt;
project context from Notion (Project, Last_Update, Next_Action), generates a personalized &lt;br&gt;
draft using Gemini, and writes it directly back to the AI_Draft column in Notion. The &lt;br&gt;
freelancer sees the draft appear in their dashboard and can approve or edit before sending.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sync_and_draft&lt;/code&gt;&lt;br&gt;
A combined tool that fetches the client from Supabase, checks their timezone window, &lt;br&gt;
generates a context-aware draft, and writes it to Notion — all in one call. Designed for &lt;br&gt;
batch workflows where you want to draft for all available clients at once.&lt;/p&gt;

&lt;p&gt;What MCP unlocks:&lt;br&gt;
Without MCP, this would be a closed app — useful only through the dashboard. With MCP, any &lt;br&gt;
AI agent can become a timezone-aware outreach assistant. You can tell Claude Desktop: "Draft &lt;br&gt;
updates for all clients who are currently in business hours" and it calls &lt;br&gt;
get_client_local_times, identifies the available ones, then calls generate_client_outreach &lt;br&gt;
for each — writing personalized drafts into Notion for you to review. The AI does the work. &lt;br&gt;
You approve and send.&lt;/p&gt;

&lt;p&gt;That's the human-in-the-loop system the challenge asked for.&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>notionchallenge</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
