<?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: Focss</title>
    <description>The latest articles on DEV Community by Focss (@focss).</description>
    <link>https://dev.to/focss</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%2F3752556%2F4475335a-07f0-4195-9d20-2a55bbabbd2e.png</url>
      <title>DEV Community: Focss</title>
      <link>https://dev.to/focss</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/focss"/>
    <language>en</language>
    <item>
      <title>Reading 839 Game Boards Out of Screenshots with OpenCV and k-means</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:11:13 +0000</pubDate>
      <link>https://dev.to/focss/reading-839-game-boards-out-of-screenshots-with-opencv-and-k-means-21jj</link>
      <guid>https://dev.to/focss/reading-839-game-boards-out-of-screenshots-with-opencv-and-k-means-21jj</guid>
      <description>&lt;h1&gt;
  
  
  Reading 839 game boards out of screenshots with OpenCV and k-means
&lt;/h1&gt;

&lt;p&gt;I run &lt;a href="https://block-out.org/" rel="noopener noreferrer"&gt;Block Out Guide&lt;/a&gt;, a fan site for the mobile puzzle game &lt;em&gt;Block Out! - Color Sort Puzzle&lt;/em&gt;. Every published level gets a static page with its video walkthrough, a board screenshot, and links to the neighbouring levels.&lt;/p&gt;

&lt;p&gt;The easy version of that site is a template: fill in the level number, embed the YouTube video, done. But then every page says essentially the same thing. A player stuck on level 412 already knows the level number. What they don't know is what the board looks like.&lt;/p&gt;

&lt;p&gt;The site already had one screenshot per level: the YouTube thumbnail, 1280×720. So I wrote &lt;code&gt;extract-board-data.py&lt;/code&gt;, a Python script that reads the board out of each screenshot and emits a JSON description: palette, colour shares, background, and a compact layout map. It currently covers 839 boards, and the whole output is about 1.1 MB.&lt;/p&gt;

&lt;p&gt;This post covers the image-processing decisions, including the ones that did not work the first time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The screenshots are not standardized
&lt;/h2&gt;

&lt;p&gt;There are two templates in the playlist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a promo card, with the board on the left and the "BLOCK OUT" logo on the right;&lt;/li&gt;
&lt;li&gt;an in-game screenshot, with the board in the middle, the logo on the left and a player watermark on the right.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So a fixed crop is out. It would either clip landscape boards or include the logo. Both templates do put the board inside the same purple rounded frame, so the first idea was to find the frame.&lt;/p&gt;

&lt;p&gt;The frame works for most promo cards. On some boards, though, blocks in blue or purple pass the same colour test as the frame, and landscape boards are wider than they are tall, which breaks the frame's assumed proportions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find the board with a content mask
&lt;/h2&gt;

&lt;p&gt;The second approach looks for the most vivid blob in the middle band of the image. The character sits to the left of the board; the promo logo and the watermark sit to its right. So the scan is restricted to x = 205..860, keeping pixels with high saturation and value, then closing the gaps and taking the largest connected component.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;hsv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cvtColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLOR_RGB2HSV&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;hsv&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;110&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hsv&lt;/span&gt;&lt;span class="p"&gt;[...,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uint8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;
&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;BOARD_SCAN_X0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BOARD_SCAN_X1&lt;/span&gt;&lt;span class="p"&gt;):]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="n"&gt;kernel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStructuringElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MORPH_RECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;closed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;morphologyEx&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MORPH_CLOSE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kernel&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connectedComponentsWithStats&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;closed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The morphological close is what makes this work. Individual blocks are separated by thin dark gaps, and a 17×17 closing kernel bridges them into one component, so the board becomes one blob instead of a pile of separate rectangles.&lt;/p&gt;

&lt;p&gt;Components smaller than 140×140 are discarded, and the largest remaining one by area wins. The fixed side margins keep the character and the watermark out of the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ignore the frame, keep the pale blocks
&lt;/h2&gt;

&lt;p&gt;The content box is still slightly too large: it includes part of the rounded purple shell. My first instinct was to trim it by saturation, which deletes white and pale blocks sitting on the edge. Boards with white pieces at the border made that obvious.&lt;/p&gt;

&lt;p&gt;The fix is to measure the frame's own colour instead of assuming it. The script samples the purple frame mask inside the detected box, takes the median, and keeps the page navy as a constant. A pixel counts as content only if it is far enough from both:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;frame_distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;frame_rgb&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;page_distance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;PAGE_RGB&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frame_distance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page_distance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The thresholds are sums across the three RGB channels, so 55 means roughly 18 per channel. That is cheap, and it held up across both templates. The tight bounding box of the content is then inset by 3% to drop anti-aliased edge pixels, with a floor of 3 pixels so it does not eat a real edge column.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sample a lattice. Do not pretend it is the game grid
&lt;/h2&gt;

&lt;p&gt;Next question: what is the cell grid? The honest answer is that it varies by level, and I do not have the game's internal map data. So the script does not try to recover the exact grid. It samples a fixed 20×28 lattice and takes the median colour of each patch.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GRID_NY&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GRID_NX&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;patch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;py0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;py1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;px0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;px1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;cells&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;patch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reshape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;axis&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Median rather than mean: the blocks are flat shaded, but there are highlights, shadows and anti-aliased boundaries. The median throws those away. A mean would smear a boundary pixel into the cell colour.&lt;/p&gt;

&lt;p&gt;The published data is colour shares and layout samples. A 20×28 sampling grid gives a stable resolution for comparing boards and a readable map. It does not claim the game has exactly 560 cells.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cluster the colours in Lab, merge by hue
&lt;/h2&gt;

&lt;p&gt;Each board has 3 to 13 distinct colours, with a median of 8. But k-means on raw RGB with k=14 splits one colour into its highlight and shadow variants. So the pipeline converts the samples to CIE Lab, runs k-means, and then merges the fragments.&lt;/p&gt;

&lt;p&gt;The merge rules are deliberately asymmetric:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;chromatic clusters merge when the hue distance is at most 14° and the saturation difference is at most 0.25;&lt;/li&gt;
&lt;li&gt;achromatic clusters (chroma ≤ 25) merge by value, with a tolerance of 28/255;&lt;/li&gt;
&lt;li&gt;when a tiny cluster is folded into a neighbour, Lab distance down-weights lightness by a factor of 0.35.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;swarm_distance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lab_a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lab_b&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;lab_a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lab_b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lab_a&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lab_b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                 &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;LIGHTNESS_WEIGHT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lab_a&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="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lab_b&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One special case: the navy empty board and the purple frame share a hue. Merge purely by hue and they collapse into one cluster, after which the background gets reported as the frame. The fix is a lightness split. If both clusters sit in hue 220 to 285 and one is dark (value below 0.48) while the other is bright (value above 0.55), the script keeps them separate.&lt;/p&gt;

&lt;p&gt;Clusters below 2% of the sampled cells are folded into their nearest neighbour, so a stray highlight does not become a palette entry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Name the colours and find the empty board
&lt;/h2&gt;

&lt;p&gt;Palette entries get coarse names from hue (red, orange, yellow, green, cyan, blue, purple, pink) plus greys by value. Duplicate names are disambiguated with lightness bands, which is where names like "pale blue" and "light blue" come from. Two greens that land in different bands are different colours on screen.&lt;/p&gt;

&lt;p&gt;Then there is the background. The empty board is the largest dark-blue group that is not the frame. The criteria: blue leads red and green by at least 15, value is below 0.68, saturation is below 0.80, and the group covers at least 8% of the board. A bright blue block fails that test.&lt;/p&gt;

&lt;p&gt;Every sampled cell is then assigned to its nearest remaining group in Lab. Cells excluded as frame or page become &lt;code&gt;.&lt;/code&gt; in the map. Percentages are re-based on the assigned cells, so the published map and the published numbers cannot disagree. Finally the palette is sorted so index 0 is always the largest colour.&lt;/p&gt;

&lt;h2&gt;
  
  
  The output
&lt;/h2&gt;

&lt;p&gt;Each level ends up with a compact record like this (real data from level 1):&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="nl"&gt;"b"&lt;/span&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="mi"&gt;251&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;62&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;703&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;669&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"g"&lt;/span&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="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bghex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#232460"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bgname"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"navy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"bgpct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;27.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"c"&lt;/span&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="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"hex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#EDE7E7"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"white"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;17.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cells"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"edge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &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="nl"&gt;"center"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&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="nl"&gt;"hex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#343234"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"near-black"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;14.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cells"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;65&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"edge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"center"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;46&lt;/span&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="nl"&gt;"hex"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#EBA512"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orange"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;13.4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"cells"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;62&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"edge"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;21&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"center"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;79&lt;/span&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"l"&lt;/span&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="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"...................."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"223333333222444444.."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"223333333222444444.."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"223333332222444444.."&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;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 &lt;code&gt;edge&lt;/code&gt; and &lt;code&gt;center&lt;/code&gt; fields are the share of a colour's cells in the outer two-cell border versus the central half of the board. They exist so a page can say "this colour mostly sits at the edges" without shipping the full map to every visitor.&lt;/p&gt;

&lt;p&gt;The layout uses base-36 characters, so up to 36 colours can be encoded without changing the format. In practice the maximum is 13.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it over 839 images
&lt;/h2&gt;

&lt;p&gt;A few operational details that saved time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The script reads from a local R2 WebP dump first and only fetches a missing thumbnail over HTTP, with exponential backoff, into a gitignored cache.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ProcessPoolExecutor&lt;/code&gt; runs across all CPU cores with &lt;code&gt;chunksize=8&lt;/code&gt;. The work is pure numpy and OpenCV, so processes are the right tool; per-image work does not benefit from threads in Python.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--limit&lt;/code&gt; is the smoke test. &lt;code&gt;--min-id&lt;/code&gt; / &lt;code&gt;--max-id&lt;/code&gt; re-run a range and merge the results into the existing JSON, so fixing ten bad boards does not mean redoing 839.&lt;/li&gt;
&lt;li&gt;The final JSON is written with compact separators and keys sorted numerically. That keeps the payload small and the diffs stable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Current stats: 839 boards, 3 to 13 colours per board (median 8), and 578 boards with a detected empty-board background. The other 261 boards did not meet the background test.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this data is not
&lt;/h2&gt;

&lt;p&gt;The limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The map is not the game's exact grid. It is a 20×28 sampling lattice, so the shares describe the artwork, not legal moves.&lt;/li&gt;
&lt;li&gt;It says nothing about doors, paths or solvability. That would need the game's own data.&lt;/li&gt;
&lt;li&gt;The input can lie. Levels 1 to 10 share a single compilation video, so they share one thumbnail and produce ten identical board records.&lt;/li&gt;
&lt;li&gt;Template changes break it. The side margins that exclude the character and watermark are constants. A new screenshot layout would need those adjusted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even with those limits, each level page gets a palette, the dominant colours, and a map of where they sit. A player deciding whether to attempt a board can use that.&lt;/p&gt;

&lt;p&gt;The script is about 650 lines including comments, and the pipeline is deliberately boring: NumPy, OpenCV and Pillow, no ML model, no GPU. If you are doing something similar with game screenshots, the two decisions that mattered most were measuring the frame colour instead of thresholding saturation, and being honest that the sampling grid is a sampling grid.&lt;/p&gt;

&lt;p&gt;The site it feeds: &lt;a href="https://block-out.org/" rel="noopener noreferrer"&gt;https://block-out.org/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Two bugs I hit building a 575-page Next.js site with no server</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Sat, 17 Oct 2026 04:47:31 +0000</pubDate>
      <link>https://dev.to/focss/two-bugs-i-hit-building-a-575-page-nextjs-site-with-no-server-4l5i</link>
      <guid>https://dev.to/focss/two-bugs-i-hit-building-a-575-page-nextjs-site-with-no-server-4l5i</guid>
      <description>&lt;p&gt;I run a guide site for &lt;em&gt;Sand Blocks: Drop Puzzle&lt;/em&gt;, a mobile physics puzzle where you pull colored sand into pits. Every level gets a page with a video, a board image, and a short written guide. Right now that is 575 level pages, plus a searchable index, range hubs, a blog, and the usual about, contact, and privacy pages. The sitemap has 607 URLs.&lt;/p&gt;

&lt;p&gt;The site has no accounts and no user-generated content. New levels arrive in batches every week or two. A rebuild is a perfectly good content update mechanism, so I set &lt;code&gt;output: "export"&lt;/code&gt; and kept the database out of the request path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;export&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;unoptimized&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;remotePatterns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;img.mobilecasualgames.com&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;trailingSlash&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;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With static export I gave up API routes, ISR, middleware, and server-side image optimization. The level data comes from Neon, but only at build time: a script dumps it to &lt;code&gt;src/data/levels.json&lt;/code&gt;, and the pages import that file. The same JSON feeds the client-side filter and the sitemap generator, so the site cannot disagree with itself.&lt;/p&gt;

&lt;p&gt;That part worked. The two problems below did not show up in a normal browser test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The og:url that pointed at the homepage
&lt;/h2&gt;

&lt;p&gt;The first version of &lt;code&gt;app/layout.tsx&lt;/code&gt; had this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SITE_ORIGIN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;siteName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SITE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;title&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="nx"&gt;description&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I assumed a level page that set its own &lt;code&gt;openGraph.title&lt;/code&gt; and &lt;code&gt;openGraph.description&lt;/code&gt; would replace the layout's &lt;code&gt;openGraph&lt;/code&gt; object. Next.js merges metadata field by field. The page kept its own title, description, and image, and inherited &lt;code&gt;url: "https://sand-blocks.org"&lt;/code&gt; from the layout. Every level page told social crawlers it was the homepage.&lt;/p&gt;

&lt;p&gt;I found it with &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt;, not with a browser. The canonical link was right. The &lt;code&gt;og:url&lt;/code&gt; was wrong. Nothing looked broken in dev tools, and the page rendered fine, so it could have sat there for months.&lt;/p&gt;

&lt;p&gt;The fix was to remove &lt;code&gt;url&lt;/code&gt; from the layout entirely and set it only on pages that need it:&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;// app/levels/[id]/page.tsx&lt;/span&gt;
&lt;span class="nx"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;absoluteUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/levels/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="c1"&gt;// title, description, image, ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;metadataBase&lt;/code&gt; handles relative URLs for canonical and images, but it does not fill in &lt;code&gt;og:url&lt;/code&gt;. If you use a shared layout, check a deep page's rendered head before you trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default level list that was not in the HTML
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;/levels&lt;/code&gt; page has a search box and pagination. My first version put the whole browser in a client component and read &lt;code&gt;useSearchParams()&lt;/code&gt; to get &lt;code&gt;?q=&lt;/code&gt; and &lt;code&gt;?page=&lt;/code&gt;. During static export, Next.js skipped prerendering that subtree. The exported HTML had the page shell and nothing else: no level links, no range links. A crawler that does not run JavaScript saw an empty page.&lt;/p&gt;

&lt;p&gt;This one is easy to miss because the site works in a browser. React hydrates, the client component renders, and the list appears. You have to curl the exported HTML or disable JavaScript to see the hole.&lt;/p&gt;

&lt;p&gt;The fix was to split the page into two layers. The server component renders the default view, including the range navigation and the first 40 level cards. The client component only takes over when the URL has filter state.&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="c1"&gt;// app/levels/page.tsx (server component)&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"levels-default-view"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LevelsControls&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LevelsGrid&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;allLevels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="nx"&gt;PAGE_SIZE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LevelsPagination&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;LevelsBrowser&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;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 tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;LevelsBrowser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSearchParams&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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;q&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="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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&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&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&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;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="nf"&gt;useEffect&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;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;levels-default-view&lt;/span&gt;&lt;span class="dl"&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;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;active&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;active&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;active&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;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Filter the bundled levels.json and render the result.&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With no query string, the client component returns null and the exported HTML stays visible. With &lt;code&gt;?q=level 275&lt;/code&gt; or &lt;code&gt;?page=4&lt;/code&gt;, it hides the default view and renders the filtered list from the same JSON. The default view is not wrapped in the client boundary, so it survives the export.&lt;/p&gt;

&lt;p&gt;I also moved the range navigation out of the client component. It used to render only after hydration, which meant the links between the 40-level range pages were missing from the HTML. Now those links are in the server-rendered part, and each range page gets a real link from the hub.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sitemap generation with a merge step
&lt;/h2&gt;

&lt;p&gt;The sitemap script rebuilds the URLs it owns: static pages, level pages, range hubs, author pages, blog posts. It parses the existing sitemap first and keeps anything it does not recognize. That sounds fussy until you have hand-added a landing page and watched a generator delete it on the next run.&lt;/p&gt;

&lt;p&gt;After a deploy I run a small IndexNow script that reads the sitemap and pings Bing, Yandex, Seznam, and Naver. Google finds pages on its own schedule. For a site that grows by adding level pages, the nudge is worth the two minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tradeoff looks like
&lt;/h2&gt;

&lt;p&gt;Static export is not free. There are no API routes, no incremental regeneration, no middleware, and no server-side image resizing. Level thumbnails are pre-sized WebP files on Cloudflare R2. The search is a client-side filter over a JSON file. The 404 is a static page. The production deploy is a directory of HTML, CSS, JS, and images served by a Cloudflare Worker with an assets binding.&lt;/p&gt;

&lt;p&gt;For this site, that is a good trade. The content changes at human speed, and the pages are read far more than they are written. There is no per-user state. The build takes longer than a server-rendered deploy would, and I am fine with that.&lt;/p&gt;

&lt;p&gt;If you are building something similar, the two checks I would run first are: curl a deep page and read the head, then look at the exported HTML with JavaScript disabled. Both bugs above passed a normal browser test.&lt;/p&gt;

&lt;p&gt;The site is at &lt;a href="https://sand-blocks.org/" rel="noopener noreferrer"&gt;https://sand-blocks.org/&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Syncing level data from seven games into a static Next.js site</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Mon, 12 Oct 2026 12:21:57 +0000</pubDate>
      <link>https://dev.to/focss/syncing-level-data-from-seven-games-into-a-static-nextjs-site-kk3</link>
      <guid>https://dev.to/focss/syncing-level-data-from-seven-games-into-a-static-nextjs-site-kk3</guid>
      <description>&lt;p&gt;&lt;a href="https://mobilecasualgames.com/" rel="noopener noreferrer"&gt;Mobile Casual Games&lt;/a&gt; is a guide hub for puzzle games. It currently covers seven of them, and the level rows behind those guides add up to just over 8,000. New walkthroughs keep landing in the database, so the data moves constantly. The site itself is an &lt;code&gt;output: "export"&lt;/code&gt; Next.js build: no runtime API, no database connection, nothing fetched when a visitor loads a page. Every level list, every range page, every redirect was decided at build time from JSON files that live in the repository.&lt;/p&gt;

&lt;p&gt;That raises a practical question: when the data keeps changing, how does it get from wherever it was produced into a static bundle without anything breaking in between? This is how the pipeline works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the rows come from
&lt;/h2&gt;

&lt;p&gt;The source of truth is a Neon Postgres database with two tables. &lt;code&gt;games&lt;/code&gt; holds store metadata: name, developer, icon URL, Play Store link, rating, install count. &lt;code&gt;game_levels&lt;/code&gt; holds one row per level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="k"&gt;game&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;name&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;title&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;video&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;id&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;watch&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;url&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;img&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;url&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;created&lt;/span&gt;&lt;span class="err"&gt;_&lt;/span&gt;&lt;span class="k"&gt;at&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;video_id&lt;/code&gt; is a YouTube id, &lt;code&gt;watch_url&lt;/code&gt; the matching watch link, &lt;code&gt;img_url&lt;/code&gt; the cover thumbnail we uploaded to our own image host. Getting rows into that database is a separate job per game. For Food Hunt, our capture pipeline downloads each walkthrough video, extracts the cover frame, and uploads it to Cloudflare R2, so the site never hotlinks. For another game the source was someone's WordPress walkthrough site, which exposed the native REST API. A small Python script (&lt;code&gt;scraper/scrape_game.py&lt;/code&gt;) walks the sitemap, filters slugs by a per-game prefix, and pulls clean JSON in batches of 50 slugs per request. It writes both JSON and CSV. The REST API turned out to be the right call there: the cached HTML pages had ad blocks injected into the body, and parsing around them would have been fragile.&lt;/p&gt;

&lt;p&gt;Whatever the source, once the rows are in Postgres the web repository stops caring about them until it needs a fresh copy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two sync scripts, because history
&lt;/h2&gt;

&lt;p&gt;There are two download scripts, which looks redundant until you see which files each one owns.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;node scripts/download-data.mjs                       &lt;span class="c"&gt;# games.json + levels.json&lt;/span&gt;
node scripts/download-levels.mjs rotaterings meowdoku &lt;span class="c"&gt;# src/data/levels/&amp;lt;game&amp;gt;.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;download-data.mjs&lt;/code&gt; is the original. It was written when the hub was a single-game site for Food Hunt, and it still writes those two legacy files: every game row into &lt;code&gt;games.json&lt;/code&gt;, and the full Food Hunt level list into &lt;code&gt;levels.json&lt;/code&gt;, mapped to the shape the page components render.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;download-levels.mjs&lt;/code&gt; is the multi-game sibling. You pass it one or more game names and it rewrites &lt;code&gt;src/data/levels/&amp;lt;game&amp;gt;.json&lt;/code&gt;, one snapshot per game. Everything newer reads from these files: level counts, the &lt;code&gt;1-100&lt;/code&gt; style range pages, the Level Guide in the navigation, the sitemaps, and, for the two games without a dedicated site, the level pages themselves.&lt;/p&gt;

&lt;p&gt;Both scripts do the same three things beyond the query itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Force IPv4 first - Neon is fronted by Cloudflare and returns both A and&lt;/span&gt;
&lt;span class="c1"&gt;// AAAA records; some sandboxes/serverless runtimes can't route IPv6.&lt;/span&gt;
&lt;span class="nx"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDefaultResultOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ipv4first&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// env resolution: real environment first, then --env-file, then ./.env&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;postgres&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;connect_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and they write the snapshot with a small envelope:&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="nl"&gt;"game"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rotaterings"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"updatedAt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-12T03:26:45.530Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;170&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"levels"&lt;/span&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="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &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="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Level 1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"image"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://img.mobilecasualgames.com/rotaterings/level-1.webp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"videoId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"embedUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.youtube.com/embed/..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"watchUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://www.youtube.com/watch?v=..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"difficulty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Beginner"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"publishDate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-12"&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;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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;updatedAt&lt;/code&gt; and &lt;code&gt;count&lt;/code&gt; make the git diff readable. When you commit a refreshed snapshot, the diff header tells you which game was re-pulled and roughly how much moved, without opening the file. If a query returns zero rows the script warns and skips instead of writing an empty snapshot, so a typo in the game name cannot quietly erase 1,400 levels.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the snapshots at build time
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;src/data/games.ts&lt;/code&gt; is the registry. Each snapshot is a static import, and each game is one entry with its display metadata, a theme color, and the level data attached:&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;rotateringsLevels&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;@/data/levels/rotaterings.json&lt;/span&gt;&lt;span class="dl"&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;GAMES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;GameEntry&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="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;game_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;rotaterings&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;shortTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Rotate Rings&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;themeColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#2eaae4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;// no levelUrlPrefix - the hub hosts this game's level pages itself&lt;/span&gt;
    &lt;span class="na"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rotateringsLevels&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StoredLevel&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nx"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ...six more&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;levelUrlPrefix&lt;/code&gt; field decides where a game's level detail pages live. Five of the seven games have their own guide site, so on the hub &lt;code&gt;/&amp;lt;game&amp;gt;/&amp;lt;id&amp;gt;&lt;/code&gt; is a 301 to &lt;code&gt;https://that-site/levels/&amp;lt;id&amp;gt;&lt;/code&gt; and the hub only renders lists. Smash Fest and Rotate Rings have no dedicated site, so the hub renders their pages directly.&lt;/p&gt;

&lt;p&gt;Above the registry, &lt;code&gt;src/data/levels.ts&lt;/code&gt; is a plain accessor module: &lt;code&gt;fetchLevels&lt;/code&gt;, &lt;code&gt;getLevel&lt;/code&gt;, &lt;code&gt;getLevelIds&lt;/code&gt;, &lt;code&gt;getMaxLevel&lt;/code&gt;, &lt;code&gt;getFeaturedLevels&lt;/code&gt;. Every function takes a &lt;code&gt;gameName&lt;/code&gt; as its first argument and resolves it through the registry, so the same code serves all seven games and nothing in the page components knows or cares which snapshot a number came from.&lt;/p&gt;

&lt;p&gt;One detail worth copying: difficulty is recomputed on read, not trusted from the file.&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;function&lt;/span&gt; &lt;span class="nf"&gt;asLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StoredLevel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Level&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="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="nf"&gt;difficultyFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&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="c1"&gt;// thresholds live in src/lib/types.ts&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;The snapshots were produced by different scripts at different times, and those scripts used different thresholds (the old single-game script bucketed at 100/250/400, the multi-game one at 100/500/1500). Deriving the value from the level number at render time makes the stored field meaningless, which means the snapshots no longer have to agree with anything. The same idea holds for the database: &lt;code&gt;created_at&lt;/code&gt; is repurposed as the video publish date for structured data, because it is the only date the table carries.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the database lags behind
&lt;/h2&gt;

&lt;p&gt;Rows sometimes exist before they are inserted into Postgres, right after a fresh scraping run. There are two side doors for that case. &lt;code&gt;import-levels-from-csv.mjs&lt;/code&gt; takes the same CSV the scraper produces and writes the identical &lt;code&gt;&amp;lt;game&amp;gt;.json&lt;/code&gt; snapshot, using the same row-to-level mapping. &lt;code&gt;build-data.py&lt;/code&gt; is the older door: it generated the original Food Hunt snapshots straight from the capture pipeline's output directory. Both exist so a snapshot can be regenerated from whatever artifact currently has the data, and the registry cannot tell the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  Derived files, regenerated on every build
&lt;/h2&gt;

&lt;p&gt;The snapshots feed three derived outputs, all produced before &lt;code&gt;next build&lt;/code&gt; runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Redirects.&lt;/strong&gt; &lt;code&gt;generate-redirects.mjs&lt;/code&gt; reads the &lt;code&gt;levelUrlPrefix&lt;/code&gt; values straight out of &lt;code&gt;games.ts&lt;/code&gt; and writes the per-game block in &lt;code&gt;public/_redirects&lt;/code&gt;. One rule per game, not per level:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight apache"&gt;&lt;code&gt;&lt;span class="err"&gt;/&lt;/span&gt;foodhunt/1-100   /foodhunt/1-100   200
&lt;span class="err"&gt;/&lt;/span&gt;foodhunt/*       https://food-hunt.org/levels/:splat   301
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The splat is one rule because Cloudflare Pages caps a project at 2,100 redirects, and Pixel Flow alone has 2,089 levels. A per-level rule set would not fit, and it would need regenerating every time the game shipped a new batch. The self-rewrites for the block pages (&lt;code&gt;/foodhunt/1-100&lt;/code&gt; stays on the hub) are listed before the catch-all so the splat cannot swallow them. The same script handles the blog-to-level redirect for walkthrough articles, since a guide with &lt;code&gt;level: 7&lt;/code&gt; in its front matter renders on the level page, not at its old &lt;code&gt;/blog/&amp;lt;slug&amp;gt;&lt;/code&gt; URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guide badges.&lt;/strong&gt; &lt;code&gt;&amp;lt;LevelCard /&amp;gt;&lt;/code&gt; is a client component, and it needs to know which levels have a written guide. The markdown sources live server-side (&lt;code&gt;content/blog/*.md&lt;/code&gt; front matter plus &lt;code&gt;src/data/blog.ts&lt;/code&gt;, which imports &lt;code&gt;node:fs&lt;/code&gt;), so &lt;code&gt;generate-guide-levels.mjs&lt;/code&gt; scans them before each dev server or build and writes a tiny &lt;code&gt;guide-levels.json&lt;/code&gt;. The client component reads that. Adding a guide stays a one-file change in &lt;code&gt;content/blog/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sitemaps.&lt;/strong&gt; &lt;code&gt;generate-seo.mjs&lt;/code&gt; builds URLs from the registry and the snapshots. Since the five games with dedicated sites keep their level pages elsewhere, the main sitemap carries the hub's own pages plus the range blocks, and the two hub-hosted games get their own sitemap files (&lt;code&gt;smashfestlevel.xml&lt;/code&gt;, &lt;code&gt;rotateringslevel.xml&lt;/code&gt;) so a thousand-plus level URLs do not bury everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bundling limits
&lt;/h2&gt;

&lt;p&gt;Static imports of 8,000 rows are server-side only, and the design keeps them there. &lt;code&gt;toGameCard()&lt;/code&gt; strips every field except name, icon, rating, and &lt;code&gt;levelCount: game.levels.length&lt;/code&gt;, which is all a card renders. The navigation's Level Guide receives a compact index per game (min, max, and the array of missing ids) instead of the id list. A browser that opens the level picker learns which numbers exist without downloading eight thousand objects, and the full snapshots never enter a client chunk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding the eighth game
&lt;/h2&gt;

&lt;p&gt;The whole point of the arrangement shows up here. A new game needs its rows in the database (or a CSV), one run of &lt;code&gt;download-levels.mjs &amp;lt;game&amp;gt;&lt;/code&gt; to write the snapshot, one entry in &lt;code&gt;games.ts&lt;/code&gt;, and nothing else. Range pages, counts, nav menu, redirects or hosted pages, sitemaps, and the build all follow from those two artifacts, and every step leaves a reviewable diff in git.&lt;/p&gt;

&lt;p&gt;You can see the result at &lt;a href="https://mobilecasualgames.com/" rel="noopener noreferrer"&gt;mobilecasualgames.com&lt;/a&gt;, and the pipeline described here is what keeps its seven level databases in one repository, refreshed with a single command and a commit.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>staticsite</category>
      <category>postgres</category>
    </item>
    <item>
      <title>How 913 Puzzles Became 7,304 Levels: Procedural Variation on a Precomputed Puzzle Bank</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Sun, 04 Oct 2026 03:26:01 +0000</pubDate>
      <link>https://dev.to/focss/how-913-puzzles-became-7304-levels-procedural-variation-on-a-precomputed-puzzle-bank-31d2</link>
      <guid>https://dev.to/focss/how-913-puzzles-became-7304-levels-procedural-variation-on-a-precomputed-puzzle-bank-31d2</guid>
      <description>&lt;p&gt;Meowdoku ships over a hundred levels and keeps growing. Every one of them has to be provably solvable. This is a Star Battle-style puzzle, so a malformed board is a dead end the player can't solve.&lt;/p&gt;

&lt;p&gt;You can either generate levels at runtime with a solver or hand-author them. Neither works well in practice. Runtime generation puts a backtracking search on the critical path of a player tapping "Next." Hand-authoring doesn't scale past a few dozen boards.&lt;/p&gt;

&lt;p&gt;What we ended up doing: &lt;strong&gt;store a finite bank of verified puzzles, and derive variation from geometry instead of content.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The data shape
&lt;/h2&gt;

&lt;p&gt;A bank is a JSON dictionary of difficulty tiers. Each entry is small: a region map and a solution.&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="nl"&gt;"1"&lt;/span&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="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"seed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"regionMap"&lt;/span&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="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="nl"&gt;"solution"&lt;/span&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="mi"&gt;2&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&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="nl"&gt;"r"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &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="nl"&gt;"steps"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&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;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="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;regionMap[r][c]&lt;/code&gt; is the color-region index of each cell. &lt;code&gt;solution&lt;/code&gt; is stored compactly: &lt;strong&gt;one column index per row&lt;/strong&gt;, not a 2D grid. Since the rules guarantee exactly one cat per row, &lt;code&gt;solution[r] = c&lt;/code&gt; fully describes the answer in &lt;code&gt;n&lt;/code&gt; numbers instead of &lt;code&gt;n²&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That's compact for storage, but it doesn't give you a shape you can rotate. You can't rotate an array of row indices; the shape isn't there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Picking a level is a pure function
&lt;/h2&gt;

&lt;p&gt;Level selection depends only on the level number, which is what lets the server reproduce the same board:&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;index&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;levelNum&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;rawList&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;levelNum&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// 4 rotations × 2 reflections&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;index&lt;/code&gt; walks the bank; &lt;code&gt;transform&lt;/code&gt; picks one of eight symmetries of a square: four rotations, optionally mirrored. Because rotation and reflection preserve the puzzle's constraints, a solved puzzle stays solved under any transform. So the player doesn't see "the same puzzle again," they see a different board that happens to share a skeleton.&lt;/p&gt;

&lt;p&gt;Multiplying that out: the 6×6 bank holds 913 entries, which becomes 7,304 distinct boards.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transform, and the shape problem
&lt;/h2&gt;

&lt;p&gt;The transform itself has one awkward step:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;transformPuzzle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;regionMap&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="nx"&gt;solution&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="nx"&gt;transform&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;regionMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;transform&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;regionMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;solution&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// The stored solution is row → col, which isn't a rotatable shape.&lt;/span&gt;
  &lt;span class="c1"&gt;// Rebuild it as a 2D boolean grid first.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;catGrid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&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;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;r&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;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;catGrid&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&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;newMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;regionMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;row&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;newCats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;catGrid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;row&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;step&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;step&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rotatedMap&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rotatedCats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&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;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;r&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;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;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;c&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;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="o"&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;rotatedMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;      &lt;span class="c1"&gt;// 90° clockwise&lt;/span&gt;
        &lt;span class="nx"&gt;rotatedCats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;newCats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;newMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rotatedMap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;newCats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rotatedCats&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                                  &lt;span class="c1"&gt;// then mirror horizontally&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;r&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;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="o"&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;newMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nx"&gt;newCats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Collapse the boolean grid back into the compact row → col form&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newSolution&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;Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;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;r&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;r&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;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;c&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;c&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;c&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;newCats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;newSolution&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;break&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;regionMap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newMap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;solution&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;newSolution&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;One thing to remember: &lt;strong&gt;compact storage formats and geometric operations rarely mix.&lt;/strong&gt; Expanding to a 2D grid, doing both operations there, and collapsing back at the end is clearer and less bug-prone than trying to shortcut the conversion.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same function runs on the server
&lt;/h2&gt;

&lt;p&gt;The server can compute the exact board the client is playing, because level selection is pure. It loads the same bank file, caches it in memory, and re-runs the rules to validate a claimed solution:&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;// src/lib/serverLevelValidator.ts&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;const&lt;/span&gt; &lt;span class="nx"&gt;cat&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cats&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rowSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r&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="na"&gt;valid&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;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Multiple cats in row &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;colSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;c&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="na"&gt;valid&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;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Multiple cats in col &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;regionMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;c&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;regionSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;region&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="na"&gt;valid&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;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Multiple cats in region &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;region&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;rowSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;colSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;regionSet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;region&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;So the server can derive the board from the level number alone. No board state has to be transmitted, stored, or trusted. The same property also powers the daily challenge, which hashes the UTC date string into an &lt;code&gt;(index, transform)&lt;/code&gt; pair so every player worldwide gets an identical puzzle with zero server-side scheduling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs
&lt;/h2&gt;

&lt;p&gt;There are trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bank size.&lt;/strong&gt; The level data is ~67 MB of JSON across 34 files. That's a significant payload, which is why banks are split by grid size and variant type, fetched on demand, and served with &lt;code&gt;Cache-Control: immutable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory.&lt;/strong&gt; A client-side &lt;code&gt;Map&lt;/code&gt; cache holds preloaded levels, and we keep it bounded by only preloading the next five levels. On the server, the same cache is fine because the working set is a handful of files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A solver you still trust.&lt;/strong&gt; The bank is the thing you verified once. If a bad puzzle ever gets in, transforms will faithfully replicate it eight ways.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Precomputing correctness once and varying the geometry is what made this scale. The bank stays small, and the player still sees a new board every level.&lt;/p&gt;




&lt;p&gt;Play it here: &lt;strong&gt;&lt;a href="https://meowdokuguide.com/online" rel="noopener noreferrer"&gt;https://meowdokuguide.com/online&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>gamedev</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>YouTube Playlist to a 100% Static</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Tue, 18 Aug 2026 06:55:04 +0000</pubDate>
      <link>https://dev.to/focss/youtube-playlist-to-a-100-static-18b6</link>
      <guid>https://dev.to/focss/youtube-playlist-to-a-100-static-18b6</guid>
      <description>&lt;p&gt;Every so often you find a mobile game you enjoy but can't finish. &lt;strong&gt;Food Hunt&lt;/strong&gt; is one of those match-3-style puzzle games where you clear colored tiles by tapping — and the difficulty climbs fast. The content lives on YouTube as a long playlist of level-by-level walkthrough videos, one video per level, each titled &lt;code&gt;Food Hunt level N walkthrough solution&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I wanted to turn that playlist into a proper website: a place where players can &lt;strong&gt;search, filter, and jump straight to the exact level they're stuck on&lt;/strong&gt;, with an embedded video, a difficulty label, and even adjacent-level navigation.&lt;/p&gt;

&lt;p&gt;What I didn't want: a slow CMS, a monthly server bill, or a fragile scraper that breaks every time a video title changes.&lt;/p&gt;

&lt;p&gt;The result is &lt;a href="https://food-hunt.org/" rel="noopener noreferrer"&gt;Food Hunt&lt;/a&gt; — a site serving &lt;strong&gt;585 level pages&lt;/strong&gt;, generated entirely at build time, deployed on Cloudflare's edge, with content refreshed weekly by an automated pipeline. No web server, no runtime database connection, no per-page API calls.&lt;/p&gt;

&lt;p&gt;Here's the full technical breakdown: the data pipeline, the static frontend, and the SEO machinery — including the patterns I'd reuse (or avoid) next time.&lt;/p&gt;




&lt;h2&gt;
  
  
  System architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;flowchart LR
    YT[YouTube Playlist&amp;lt;br/&amp;gt;Food Hunt level N walkthrough] --&amp;gt; CRAWL[yt-dlp&amp;lt;br/&amp;gt;extract_flat metadata]
    CRAWL --&amp;gt; VIDEO[JSON: video links + level numbers&amp;lt;br/&amp;gt;regex-parsed from titles]
    VIDEO --&amp;gt; THUMB[Thumbnail download&amp;lt;br/&amp;gt;i.ytimg.com, concurrent]
    THUMB --&amp;gt; WEBP[Pillow → WebP]
    WEBP --&amp;gt; R2[Cloudflare R2&amp;lt;br/&amp;gt;img.mobilecasualgames.com]
    CRAWL --&amp;gt; DB[(Neon Postgres&amp;lt;br/&amp;gt;serverless)]
    DB --&amp;gt; SNAP[Build-time snapshot&amp;lt;br/&amp;gt;download-data.mjs → JSON]
    SNAP --&amp;gt; NEXT[Next.js static export&amp;lt;br/&amp;gt;generateStaticParams]
    NEXT --&amp;gt; SEO[sitemap.xml + robots.txt&amp;lt;br/&amp;gt;IndexNow submission]
    SEO --&amp;gt; PAGES[Cloudflare Pages&amp;lt;br/&amp;gt;global edge]
    PAGES --&amp;gt; USR[Visitors]
    R2 --&amp;gt; USR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The whole thing is a &lt;strong&gt;one-way data flow&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Python pipeline&lt;/strong&gt; (runs weekly) pulls the YouTube playlist, parses it, downloads thumbnails, uploads them to R2, and upserts records into Neon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build step&lt;/strong&gt; (runs on deploy) downloads the database into local JSON snapshots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; statically generates every page from those snapshots and emits sitemaps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; serves the exported &lt;code&gt;out/&lt;/code&gt; directory from the edge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let me walk through each layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 1: The data pipeline (Python)
&lt;/h2&gt;

&lt;p&gt;The source of truth is a YouTube playlist. I used &lt;strong&gt;yt-dlp&lt;/strong&gt; with &lt;code&gt;extract_flat&lt;/code&gt; — which returns playlist metadata &lt;em&gt;without downloading any video&lt;/em&gt;, making it fast and cheap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;ydl_opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quiet&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no_warnings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extract_flat&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;in_playlist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;yt_dlp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;YoutubeDL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ydl_opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ydl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ydl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extract_info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PLAYLIST_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;download&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Parsing structure from video titles
&lt;/h3&gt;

&lt;p&gt;The playlist is a flat list of videos; the only structure is encoded in the titles (&lt;code&gt;Food Hunt level 1&lt;/code&gt;, &lt;code&gt;Food Hunt level 2&lt;/code&gt;, ...). So the "schema" is a pair of regexes. I classify every entry into three buckets — single level, level range, or unknown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_entry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Returns (kind, level_or_None, start_or_None, end_or_None)&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RANGE_REGEX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;range&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LEVEL_REGEX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;single&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details worth copying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;unknown&lt;/code&gt; is a first-class bucket.&lt;/strong&gt; Unparseable titles (private videos, renamed videos) don't crash the pipeline — they land in a report you can eyeball.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video IDs are extracted defensively&lt;/strong&gt; from multiple URL formats (&lt;code&gt;?v=&lt;/code&gt;, &lt;code&gt;youtu.be/&lt;/code&gt;, &lt;code&gt;/embed/&lt;/code&gt;), because playlist entries don't always use the same URL shape.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Thumbnails: concurrent download + WebP
&lt;/h3&gt;

&lt;p&gt;Each level page needs an image. YouTube serves thumbnails at &lt;code&gt;i.ytimg.com/vi/&amp;lt;id&amp;gt;/...&lt;/code&gt;, so I download them concurrently with a &lt;code&gt;ThreadPoolExecutor&lt;/code&gt;, then convert everything to &lt;strong&gt;WebP&lt;/strong&gt; with Pillow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;concurrent.futures&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;as_completed&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;ThreadPoolExecutor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_workers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;futures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fetch_one&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;fut&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;as_completed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;futures&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# save {index:03d}_{video_id}.png → .webp
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why WebP? It's typically &lt;strong&gt;25–35% smaller&lt;/strong&gt; than PNG for these screenshot-style thumbnails, and the site is configured to only emit WebP anyway.&lt;/p&gt;

&lt;h3&gt;
  
  
  Storage: Cloudflare R2 + Neon Postgres
&lt;/h3&gt;

&lt;p&gt;Two storage targets, two very different access patterns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Images → Cloudflare R2.&lt;/strong&gt; Uploaded with &lt;code&gt;boto3&lt;/code&gt; (R2 speaks the S3 API), and served through the CDN domain &lt;code&gt;img.mobilecasualgames.com&lt;/code&gt;. The upload step is &lt;strong&gt;idempotent&lt;/strong&gt;: it skips objects that already exist, so re-running the pipeline never re-uploads 585 images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Records → Neon.&lt;/strong&gt; A serverless Postgres database accessed with &lt;code&gt;psycopg2&lt;/code&gt;. There are three tables: &lt;code&gt;games&lt;/code&gt; (game metadata), &lt;code&gt;game_levels&lt;/code&gt; (one row per level + video), and &lt;code&gt;game_guides&lt;/code&gt; (auto-generated walkthrough text + FAQ content).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The pattern that saved me: incremental merge
&lt;/h3&gt;

&lt;p&gt;The pipeline's most important rule is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Existing levels are never overwritten. Only missing levels are added.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The script reads the levels already in the database, then upserts &lt;em&gt;only&lt;/em&gt; the ones that don't exist yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_existing_levels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# from Neon
&lt;/span&gt;&lt;span class="n"&gt;missing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lv&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;parsed_levels&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;lv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;level&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;missing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;sn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert_level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# INSERT ... ON CONFLICT DO NOTHING
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why does this matter?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Video titles occasionally change or get removed. Re-running the crawl must not silently clobber a previously correct record.&lt;/li&gt;
&lt;li&gt;The YouTube playlist is &lt;em&gt;append-only&lt;/em&gt; in practice — new levels get added at the end. So the diff is almost always &lt;code&gt;new_levels = all_levels - existing&lt;/code&gt;, which is exactly the merge behavior you want.&lt;/li&gt;
&lt;li&gt;It makes the pipeline &lt;strong&gt;safe to run weekly, unattended&lt;/strong&gt;, which is what the automation is built for.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Automation
&lt;/h3&gt;

&lt;p&gt;Everything above runs on a schedule (a weekly cron in my case): crawl → download → integrate → rebuild the site. Because each step is idempotent and produces a JSON report, a failed run can be re-run without any manual cleanup.&lt;/p&gt;




&lt;h2&gt;
  
  
  Part 2: The frontend (Next.js)
&lt;/h2&gt;

&lt;p&gt;The site itself is deliberately boring in the best way. &lt;strong&gt;Next.js 14 (App Router) + TypeScript + Tailwind CSS&lt;/strong&gt;, with one crucial setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// next.config.mjs&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;export&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// no Node server — emit pure static HTML&lt;/span&gt;
  &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;unoptimized&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="c1"&gt;// static export can't use the image optimizer at runtime&lt;/span&gt;
    &lt;span class="na"&gt;formats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/webp&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Build-time data, not runtime data
&lt;/h3&gt;

&lt;p&gt;There is &lt;strong&gt;no database connection and no API at runtime&lt;/strong&gt;. Instead, a small script (&lt;code&gt;download-data.mjs&lt;/code&gt;, using the &lt;code&gt;postgres&lt;/code&gt; package) pulls the whole dataset from Neon into versioned JSON snapshots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/data/
├── games.json     # game metadata
└── levels.json    # 585 levels: id, name, image, videoId, embedUrl, watchUrl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the data layer (&lt;code&gt;levels.ts&lt;/code&gt;) reads those JSON files directly. This is the key trade-off: &lt;strong&gt;you trade real-time data for zero runtime cost and instant scaling.&lt;/strong&gt; For a walkthrough site that updates weekly, that's the right trade.&lt;/p&gt;

&lt;p&gt;A nice side effect: the data snapshot lives in the repo, so deploys are reproducible and the build fails loudly if the data is malformed.&lt;/p&gt;

&lt;h3&gt;
  
  
  One static page per level
&lt;/h3&gt;

&lt;p&gt;Every level gets its own URL (&lt;code&gt;/levels/1&lt;/code&gt;, &lt;code&gt;/levels/2&lt;/code&gt;, ...) via &lt;code&gt;generateStaticParams&lt;/code&gt;, and its own &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; via &lt;code&gt;generateMetadata&lt;/code&gt;:&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateStaticParams&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;levelsData&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;levels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&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="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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateMetadata&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;params&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Food Hunt &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; Walkthrough`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Watch the full video walkthrough for &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;. &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&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="s2"&gt; strategy...`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`/levels/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&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="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;video.other&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;absoluteUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/levels/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;level&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;videos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;watchUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summary_large_image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;image&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;p&gt;Because &lt;code&gt;output: "export"&lt;/code&gt; produces plain HTML files, every one of the 585 pages is a &lt;strong&gt;crawlable, indexable, fast-loading static document&lt;/strong&gt; — no client-side rendering wall between Googlebot and the content.&lt;/p&gt;

&lt;p&gt;A few component-level details I'm happy with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;YouTubeEmbed&lt;/code&gt;&lt;/strong&gt; — a wrapper around the standard iframe embed, with lazy loading and a consistent 16:9 aspect ratio.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;LevelsBrowser&lt;/code&gt;&lt;/strong&gt; — a client component that does search + difficulty filter + pagination entirely in the browser over the preloaded JSON.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adjacent-level navigation&lt;/strong&gt; — every level page links to the previous/next level, which is both good UX and good internal linking for SEO.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Part 3: SEO &amp;amp; operations
&lt;/h2&gt;

&lt;p&gt;For a content site whose entire purpose is being found on Google for queries like &lt;em&gt;"food hunt level 240 walkthrough"&lt;/em&gt;, SEO isn't an afterthought — it's the product.&lt;/p&gt;

&lt;h3&gt;
  
  
  sitemap.xml: generated, but never destructive
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;sitemap.xml&lt;/code&gt; is generated from the data snapshot — but with a merge rule I wish more tools had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Static pages (&lt;code&gt;/&lt;/code&gt;, &lt;code&gt;/levels&lt;/code&gt;, &lt;code&gt;/download&lt;/code&gt;) and level pages (&lt;code&gt;/levels/\d+&lt;/code&gt;) are &lt;strong&gt;regenerated&lt;/strong&gt; from the data.&lt;/li&gt;
&lt;li&gt;Everything else (blog posts, custom landing pages) is &lt;strong&gt;preserved verbatim&lt;/strong&gt; from the existing sitemap.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// generate-seo.mjs (simplified)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseExistingSitemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public/sitemap.xml&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;staticUrls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildStaticUrls&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;          &lt;span class="c1"&gt;// from levels.json&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customUrls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isStaticOrLevel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&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;merged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;staticUrls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;customUrls&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="nf"&gt;writeSitemap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;merged&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the sitemap script is &lt;em&gt;safe to run on every deploy&lt;/em&gt; — it will never wipe out manually added URLs. That's the same philosophy as the database merge, applied one layer up.&lt;/p&gt;

&lt;h3&gt;
  
  
  robots.txt + IndexNow
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; is generated alongside the sitemap into &lt;code&gt;public/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;After each deploy, an &lt;strong&gt;IndexNow&lt;/strong&gt; submission script pings search engines (Bing, Yandex) with the updated URL list, so new levels get indexed quickly instead of waiting for the next natural crawl.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Deployment: Cloudflare Pages
&lt;/h3&gt;

&lt;p&gt;With &lt;code&gt;output: "export"&lt;/code&gt;, the &lt;code&gt;out/&lt;/code&gt; directory is a fully static site. Deploying is just a matter of pointing Cloudflare Pages at it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// wrangler.jsonc&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;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"foodhunt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"assets"&lt;/span&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="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"directory"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./out"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"not_found_handling"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"404-page"&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;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;You get global CDN caching, HTTPS, and zero infrastructure to maintain — the site is just files.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd do differently next time
&lt;/h2&gt;

&lt;p&gt;A few honest takeaways from building this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Don't trust playlist order.&lt;/strong&gt; I initially assumed playlist order == level order. It isn't always — the regex-parsed level number is the only reliable key. Always parse and validate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency is non-negotiable for automation.&lt;/strong&gt; Every step (download, upload, upsert, sitemap) must be safe to run twice. This single property is what makes unattended weekly runs possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Static export changes image strategy.&lt;/strong&gt; With &lt;code&gt;output: "export"&lt;/code&gt; you lose &lt;code&gt;next/image&lt;/code&gt;'s on-demand optimizer, so pre-optimizing to WebP in the pipeline was the right call — do image optimization &lt;em&gt;upstream&lt;/em&gt;, not at runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Metadata is code, treat it that way.&lt;/strong&gt; Per-page &lt;code&gt;generateMetadata&lt;/code&gt; gives you canonical URLs, Open Graph video cards, and Twitter cards for free — it's the highest-leverage SEO work in a Next.js project.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Try it out
&lt;/h2&gt;

&lt;p&gt;If you're stuck on a level of Food Hunt (or just curious to see 585 static pages in the wild), check out &lt;a href="https://food-hunt.org/" rel="noopener noreferrer"&gt;&lt;strong&gt;food-hunt.org&lt;/strong&gt;&lt;/a&gt; — search for your level, watch the walkthrough, and move on.&lt;/p&gt;

&lt;p&gt;The stack in one line: &lt;strong&gt;Python + yt-dlp → Cloudflare R2 + Neon Postgres → Next.js static export → Cloudflare Pages&lt;/strong&gt;, held together by idempotent, incremental, fully automated data flows.&lt;/p&gt;

&lt;p&gt;If you're building something similar — a content site from a semi-structured source — I hope the merge-don't-overwrite pattern and the build-time-snapshot trick save you a few late nights.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>website</category>
    </item>
    <item>
      <title>Finding Pixel Flow levels with a 768-bit RGB pHash visual search</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Thu, 02 Apr 2026 06:47:36 +0000</pubDate>
      <link>https://dev.to/focss/finding-pixel-flow-levels-with-a-768-bit-rgb-phash-visual-search-2ji8</link>
      <guid>https://dev.to/focss/finding-pixel-flow-levels-with-a-768-bit-rgb-phash-visual-search-2ji8</guid>
      <description>&lt;p&gt;I’m a big fan of the game Pixel Flow, but I often find myself getting completely stuck on specific levels. Because it is a purely visual grid game, you cannot simply search for a solution using text. To solve this, I built a reverse image search engine specifically designed to find level solutions based on a screenshot.&lt;/p&gt;

&lt;p&gt;The challenge was figuring out how to do image matching quickly, accurately, and without heavy infrastructure. Here is how I approached the technical side:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Multi-Channel pHash (Per-Channel RGB)&lt;br&gt;
Most perceptual hashing (pHash) implementations convert an image to grayscale before processing. However, in a game like Pixel Flow, color is the primary piece of information. Discarding color would mean losing the ability to distinguish between levels with similar layouts but different color palettes.&lt;br&gt;
To fix this, I split the image into its Red, Green, and Blue channels and computed a 256-bit pHash for each channel independently.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Combining into a 768-Bit Fingerprint&lt;br&gt;
After generating a 256-bit hash for all 3 channels, I concatenated them. This creates a combined fingerprint of 768 bits (256 * 3). This captures both the structural layout and the color distribution of the game level perfectly.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Hexadecimal Compression&lt;br&gt;
Storing and querying a raw 768-bit binary string isn't the most efficient approach for database queries. To reduce the data footprint, I convert the binary string into a hexadecimal string. This brings the size down to just 192 characters while retaining all the precision.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Fast Hamming Distance Matching&lt;br&gt;
When a user uploads a screenshot, the system generates its 768-bit hash on the fly. It then compares this hash against the saved database of known level hashes. By calculating the Hamming distance (the number of positions at which the corresponding bits are different), it can rapidly find the closest match.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Displaying the Top 6 Matches&lt;br&gt;
Finally, the system pulls the 6 records with the smallest Hamming distance and displays them to the user. Showing a small cluster of top matches instead of just one gives the user fallback options in case there are minor discrepancies due to image cropping, phone UI overlays, or screen dimming.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I'd love to hear your feedback on this! Are there more efficient ways to handle color-sensitive image hashing for grid-based games?&lt;/p&gt;

&lt;p&gt;Link to the site: &lt;a href="https://pixelflowonline.net/" rel="noopener noreferrer"&gt;https://pixelflowonline.net/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>algorithms</category>
      <category>programming</category>
      <category>showdev</category>
      <category>sideprojects</category>
    </item>
    <item>
      <title>From Zero to Ranked: Building a High-Performance NBA Player Randomizer with Next.js 14 &amp; Cloudflare</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Sun, 15 Feb 2026 02:47:35 +0000</pubDate>
      <link>https://dev.to/focss/from-zero-to-ranked-building-a-high-performance-nba-player-randomizer-with-nextjs-14-cloudflare-5f1i</link>
      <guid>https://dev.to/focss/from-zero-to-ranked-building-a-high-performance-nba-player-randomizer-with-nextjs-14-cloudflare-5f1i</guid>
      <description>&lt;p&gt;As a developer and an NBA fan, I noticed a recurring problem in communities like r/NBA2K: players often get "choice paralysis" when starting a new MyLeague rebuild or a Blacktop 1v1 session. Static lists are boring, and existing randomizers are often cluttered with ads or lack specific filters.&lt;br&gt;
I decided to build a high-performance solution: . Here’s how I leveraged Next.js 14 and Cloudflare to create a tool that is not only fast but also highly optimized for SEO.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Stack: Why Next.js and Cloudflare?
&lt;/h2&gt;

&lt;p&gt;For a tool-based website, performance is the primary SEO signal. I chose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 14 (App Router): For its superior handling of Server Components and built-in Image Optimization.&lt;/li&gt;
&lt;li&gt;Cloudflare Pages: To deploy at the edge, ensuring the generator loads instantly regardless of the user's location.&lt;/li&gt;
&lt;li&gt;Tailwind CSS: For a "dark mode" aesthetic that resonates with the gaming community.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  2. Data Strategy: JSON vs. CSV
&lt;/h2&gt;

&lt;p&gt;While many data-heavy sites rely on databases, I opted for a JSON-driven architecture.&lt;br&gt;
Performance: Next.js can import JSON files directly, allowing the JS engine to parse data with zero runtime overhead compared to CSV.&lt;br&gt;
Filtering Logic: Storing player data in a structured JSON format allowed for instant client-side filtering by Team, Position, and Country without a single API call.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Core Logic: Ensuring True Randomness
&lt;/h2&gt;

&lt;p&gt;A common complaint with randomizers is "repetitive results." To ensure every player in the NBA database has an equal chance, I implemented the Fisher-Yates Shuffle algorithm.&lt;br&gt;
This algorithm ensures an unbiased permutation of the player array.&lt;br&gt;
When a user applies a filter—like "Center from France"—the tool first creates a filtered subset and then applies the shuffle to provide a fresh result every click.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. SEO for Tools: Beyond the Meta Tag
&lt;/h2&gt;

&lt;p&gt;Tools often suffer from "Thin Content" issues in the eyes of Google. To break into the first page of search results, I focused on:&lt;br&gt;
Topical Authority: Instead of just a button, I added a comprehensive FAQ section explaining the tool's use cases for NBA 2K Challenges and Fantasy Basketball.&lt;br&gt;
Dynamic Metadata: Each page uses Next.js 14’s generateMetadata to ensure that keywords like "Random NBA Team" and "2K26 Randomizer" are indexed correctly.&lt;br&gt;
Internal Linking: I built a "Topic Cluster" by linking the main player generator to specialized tools like the NBA Team Generator.&lt;br&gt;
Check out the live project here: &lt;a href="https://randomnbaplayergenerator.net/" rel="noopener noreferrer"&gt;https://randomnbaplayergenerator.net/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
    <item>
      <title>Random Nba Player Generator - A Free Tool</title>
      <dc:creator>Focss</dc:creator>
      <pubDate>Wed, 04 Feb 2026 10:00:16 +0000</pubDate>
      <link>https://dev.to/focss/random-nba-player-generator-a-free-tool-37g4</link>
      <guid>https://dev.to/focss/random-nba-player-generator-a-free-tool-37g4</guid>
      <description>&lt;h2&gt;
  
  
  NBA Random Player Generator – What It Is
&lt;/h2&gt;

&lt;p&gt;This free online tool instantly generates a NBA random player from both current and all-time rosters. Whether you're settling debates, playing fantasy games, or creating fun challenges, this generator gives you new players with every click.&lt;/p&gt;

&lt;h2&gt;
  
  
  How To Generate Random Nba Player
&lt;/h2&gt;

&lt;p&gt;The Random NBA Player Generator works by leveraging a comprehensive database that includes detailed information on both current and historical NBA players. This database is meticulously compiled and regularly updated to ensure it encompasses a broad spectrum of players from different eras of the NBA. When a user interacts with the Random NBA Player Generator, the tool executes a randomized selection process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently Asked Questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is the Random NBA Players Generator free?
&lt;/h3&gt;

&lt;p&gt;Yes, the Random NBA Player Generator is 100% free to use. Our mission is to provide a high-quality, accessible tool for the global basketball community. There are no hidden subscription fees, no "premium" tiers for specific teams, and no mandatory registration or login required. You can generate an unlimited number of players, experiment with all filtering combinations, and use the tool for your personal projects or gaming sessions without ever reaching for your wallet. We believe that the love for the game should be accessible to everyone, and this tool is our contribution to that vision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does it include current NBA players?
&lt;/h3&gt;

&lt;p&gt;Yes, the generator includes active NBA players from recent seasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use this tool for games or challenges?
&lt;/h3&gt;

&lt;p&gt;Absolutely. Many users use it for NBA trivia, fantasy challenges, or social media content.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is each result truly random?
&lt;/h3&gt;

&lt;p&gt;Yes. Each click generates a new, random NBA player from the database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I filter players by team or position?
&lt;/h3&gt;

&lt;p&gt;Currently, the generator provides fully random results. Filters such as team, position, or era may be added in future updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why would someone use a random NBA player generator?
&lt;/h3&gt;

&lt;p&gt;People use random NBA player generators to discover new players, make unbiased selections, settle debates, or simply have fun with basketball-related challenges.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to experience it, you can click on the link
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://randomnbaplayergenerator.net/" rel="noopener noreferrer"&gt;https://randomnbaplayergenerator.net/&lt;/a&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
    </item>
  </channel>
</rss>
