<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Canuckle game]]></title><description><![CDATA[Canuckle game - Canadian wordle]]></description><link>https://canuckle.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 09:59:50 GMT</lastBuildDate><atom:link href="https://canuckle.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building an Unlimited Wordle-Style Game: Duplicate Letters, Word Bags, Streaks, and Accessible Feedback]]></title><description><![CDATA[Learn the four things most Wordle clones get wrong: duplicate-letter scoring, repeat words, fragile stats storage, and colour-only feedback.]]></description><link>https://canuckle.hashnode.dev/building-an-unlimited-wordle-style-game-duplicate-letters-word-bags-streaks-and-accessible-feedback</link><guid isPermaLink="true">https://canuckle.hashnode.dev/building-an-unlimited-wordle-style-game-duplicate-letters-word-bags-streaks-and-accessible-feedback</guid><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[wordle]]></category><category><![CDATA[Game Development]]></category><dc:creator><![CDATA[Niraj Mittal]]></dc:creator><pubDate>Thu, 29 Oct 2026 13:33:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a82989859f68357c2ab384c/5044624c-96ec-4cd7-96c6-4ffb8235ec4b.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most Wordle clones get one of four things wrong: duplicate-letter scoring, repeated words in unlimited mode, fragile streak and stats storage, or colour-only feedback. This post shows how to fix each one in plain JavaScript.</p>
<hr />
<p>Wordle clones are a popular first project, and a lot of them work fine until you test an edge case. The daily format hides many problems, because you only ever see one word per day.</p>
<p>Unlimited mode exposes them. I’ve been studying how <a href="https://canucklegames.com/canuckle-unlimited/">Canuckle Unlimited</a> approaches this: it’s a Canadian-themed word game where a new five-letter word loads the moment you finish a round, while streaks and stats carry across rounds. Features like that raise real engineering questions, and this post works through them.</p>
<p>By the end you’ll have:</p>
<ol>
<li>A correct guess-evaluation function that handles repeated letters</li>
<li>A “shuffle bag” word picker that avoids repeats in endless play</li>
<li>A resilient stats and streak tracker</li>
<li>An accessible feedback system that doesn’t rely on colour alone</li>
</ol>
<p>All code is framework-free, so you can drop it into any stack.</p>
<h3>1. Scoring guesses correctly (the duplicate-letter trap)</h3>
<p>The most common bug is scoring each letter independently. It breaks when a guess contains more copies of a letter than the answer does.</p>
<p>Consider the answer <code>MAPLE</code> and the guess <code>LLAMA</code>. The guess has two L’s and two A’s, but the answer has one of each. If you mark every matching letter yellow, you’ll show clues that don’t exist.</p>
<p>The fix is a two-pass algorithm:</p>
<ul>
<li><strong>Pass 1:</strong> mark exact matches (correct), and count the unmatched answer letters.</li>
<li><strong>Pass 2:</strong> for each remaining guess letter, mark it “present” only if the counter still has that letter, then decrement.</li>
</ul>
<pre><code class="language-javascript">const STATE = { CORRECT: "correct", PRESENT: "present", ABSENT: "absent" };

export function evaluateGuess(guess, answer) {
  const len = answer.length;
  const result = Array(len).fill(STATE.ABSENT);
  const remaining = {};

  // Pass 1: exact matches, and tally the answer letters left over
  for (let i = 0; i &lt; len; i++) {
    if (guess[i] === answer[i]) {
      result[i] = STATE.CORRECT;
    } else {
      remaining[answer[i]] = (remaining[answer[i]] || 0) + 1;
    }
  }

  // Pass 2: misplaced letters, limited by what's left in the tally
  for (let i = 0; i &lt; len; i++) {
    if (result[i] === STATE.CORRECT) continue;
    const ch = guess[i];
    if (remaining[ch] &gt; 0) {
      result[i] = STATE.PRESENT;
      remaining[ch]--;
    }
  }
  return result;
}
</code></pre>
<p>Test it with known cases:</p>
<pre><code class="language-javascript">import assert from "node:assert/strict";

// Duplicate letters in the guess, single copies in the answer
assert.deepEqual(
  evaluateGuess("LLAMA", "MAPLE"),
  ["present", "absent", "present", "present", "absent"]
);

// Classic case: only one E can be yellow
assert.deepEqual(
  evaluateGuess("SPEED", "ABIDE"),
  ["absent", "absent", "present", "absent", "present"]
);

// Exact matches take priority over misplaced ones
assert.deepEqual(
  evaluateGuess("LOOSE", "MOOSE"),
  ["absent", "correct", "correct", "correct", "correct"]
);
</code></pre>
<p>Write these tests before you build any UI. Scoring logic is the one part of the game that must never be wrong.</p>
<h3>2. A shuffle bag for unlimited mode</h3>
<p>In unlimited mode, the naive approach is <code>words[Math.floor(Math.random() * words.length)]</code>. With a modest word list, you’ll see the same word twice in a row far sooner than players expect, and it feels like a bug.</p>
<p>A <strong>shuffle bag</strong> fixes this: shuffle the whole list once, deal words from it until it’s empty, then reshuffle. Every word appears once per cycle.</p>
<pre><code class="language-javascript">function shuffle(arr) {
  // Fisher–Yates
  for (let i = arr.length - 1; i &gt; 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [arr[i], arr[j]] = [arr[j], arr[i]];
  }
  return arr;
}

export function createWordBag(words) {
  let bag = [];
  let last = null;

  return function nextWord() {
    if (bag.length === 0) {
      bag = shuffle([...words]);
      // Avoid a repeat across the reshuffle boundary
      if (bag.length &gt; 1 &amp;&amp; bag[bag.length - 1] === last) {
        [bag[0], bag[bag.length - 1]] = [bag[bag.length - 1], bag[0]];
      }
    }
    last = bag.pop();
    return last;
  };
}
</code></pre>
<p>Usage :</p>
<pre><code class="language-javascript">const nextWord = createWordBag(["MOOSE", "TOQUE", "LOONIE", "HOSER"].filter(w =&gt; w.length === 5));
let answer = nextWord();
</code></pre>
<p>Two details matter here. The array is copied before shuffling so your source list isn’t mutated, and the boundary check prevents the same word appearing at the end of one cycle and the start of the next.</p>
<p>If your game has a themed list (Canadian geography, slang, wildlife, food), this approach also guarantees players see the full range of categories before any repeats.</p>
<h3>3. Tracking streaks and stats safely</h3>
<p>Unlimited mode needs a reason to keep playing, and streaks provide it. The state you need is small:</p>
<pre><code class="language-javascript">const DEFAULT_STATS = {
  played: 0,
  wins: 0,
  streak: 0,
  maxStreak: 0,
  distribution: [0, 0, 0, 0, 0, 0], // wins by number of guesses
};

const KEY = "wordgame:stats:v1";

export function loadStats() {
  try {
    const raw = localStorage.getItem(KEY);
    return raw ? { ...DEFAULT_STATS, ...JSON.parse(raw) } : { ...DEFAULT_STATS };
  } catch {
    return { ...DEFAULT_STATS }; // storage blocked or data corrupted
  }
}

export function recordResult(stats, won, guessCount) {
  const next = { ...stats, distribution: [...stats.distribution] };
  next.played += 1;

  if (won) {
    next.wins += 1;
    next.streak += 1;
    next.maxStreak = Math.max(next.maxStreak, next.streak);
    next.distribution[guessCount - 1] += 1;
  } else {
    next.streak = 0;
  }

  try {
    localStorage.setItem(KEY, JSON.stringify(next));
  } catch {
    /* private mode or quota exceeded: keep playing without persistence */
  }
  return next;
}
</code></pre>
<p>Design notes worth copying:</p>
<ul>
<li><strong>Version your storage key</strong> (<code>:v1</code>). When you change the stats shape later, you can migrate instead of crashing on old data.</li>
<li><strong>Wrap storage in try/catch.</strong> Private browsing and blocked storage throw errors, and a game that crashes on load because of that is a bad first impression.</li>
<li><strong>Merge with defaults</strong> when loading, so new fields don’t break existing players.</li>
<li><strong>Never mutate state in place.</strong> Returning a new object makes UI updates predictable.</li>
</ul>
<h3>4. Feedback that doesn’t depend on colour alone</h3>
<p>Tile colours are the core of the game, and also an accessibility problem. Red/green or red/yellow palettes are hard for many colourblind players, and colour alone fails screen reader users entirely.</p>
<p>Do two things.</p>
<p><strong>First, drive colours from CSS variables so a high-contrast mode is a one-line toggle:</strong></p>
<pre><code class="language-javascript">:root {
  --tile-correct: #c8102e;
  --tile-present: #e0a800;
  --tile-absent:  #5a5a5a;
}

:root[data-contrast="high"] {
  --tile-correct: #f5792a; /* orange */
  --tile-present: #1f6feb; /* blue */
  --tile-absent:  #3a3a3a;
}

.tile[data-state="correct"] { background: var(--tile-correct); }
.tile[data-state="present"] { background: var(--tile-present); }
.tile[data-state="absent"]  { background: var(--tile-absent); }
</code></pre>
<p>Orange and blue remain distinguishable for most colour-vision types, which is why many word games use that pair for high-contrast mode.</p>
<p><strong>Second, expose state as text.</strong> Every tile needs a label, and results should be announced:</p>
<pre><code class="language-javascript">function renderTile(el, letter, state, position) {
  el.textContent = letter;
  el.dataset.state = state;
  el.setAttribute(
    "aria-label",
    `${letter}, position ${position + 1}, ${state === "present" ? "in word, wrong spot" : state === "correct" ? "correct" : "not in word"}`
  );
}
</code></pre>
<pre><code class="language-javascript">&lt;div id="status" role="status" aria-live="polite" class="sr-only"&gt;&lt;/div&gt;
</code></pre>
<p>Update <code>#status</code> after each guess with something like “Guess 3 of 6: two letters correct, one in the wrong spot.” Players using assistive tech get the same information sighted players get from colour.</p>
<p>You can also add a non-colour cue to the tiles themselves, such as a small icon or pattern for “present” versus “correct,” which helps in bright sunlight and on low-quality screens, too.</p>
<h3>5. Putting it together</h3>
<p>A minimal game loop that combines all four pieces:</p>
<pre><code class="language-javascript">const nextWord = createWordBag(WORDS);
let stats = loadStats();
let answer = nextWord();
let guesses = [];

function submitGuess(guess) {
  if (!VALID_WORDS.has(guess)) return announce("Not in word list");

  const result = evaluateGuess(guess, answer);
  guesses.push({ guess, result });
  renderRow(guesses.length - 1, guess, result);

  const won = result.every(s =&gt; s === "correct");
  const lost = !won &amp;&amp; guesses.length === 6;

  if (won || lost) {
    stats = recordResult(stats, won, guesses.length);
    showEndScreen({ won, answer, stats });
  }
}

function playAgain() {
  answer = nextWord();
  guesses = [];
  resetBoard();
}
</code></pre>
<p>Note that <code>playAgain()</code> doesn’t touch <code>stats</code>. That’s what makes streaks continue across rounds in unlimited mode.</p>
<h3>Common mistakes to avoid</h3>
<table>
<thead>
<tr>
<th>Mistake</th>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>Per-letter scoring</td>
<td>Phantom yellow tiles on repeated letters</td>
<td>Two-pass algorithm with a tally</td>
</tr>
<tr>
<td><code>Math.random()</code> per round</td>
<td>Same word twice in a row</td>
<td>Shuffle bag</td>
</tr>
<tr>
<td>Unguarded <code>localStorage</code></td>
<td>Crash in private browsing</td>
<td>try/catch with defaults</td>
</tr>
<tr>
<td>Colour-only feedback</td>
<td>Unusable for colourblind players</td>
<td>Contrast mode + text labels</td>
</tr>
<tr>
<td>One list for answers and valid guesses</td>
<td>Players can’t enter reasonable words</td>
<td>Keep a larger “valid guesses” set separate from the answer list</td>
</tr>
</tbody></table>
<p>That last row is worth highlighting. Your <strong>answer list</strong> can be small and carefully curated, such as a themed list of Canadian words, while your <strong>accepted guess list</strong> should be much larger, so players aren’t punished for trying a legitimate word.</p>
<h3>Why a themed word list is worth the effort</h3>
<p>Engineering aside, the biggest differentiator for a word game is content. A generic dictionary makes every clone interchangeable. A curated, themed list changes how people play, because common pattern-matching shortcuts stop working and players have to reason about the theme. If you pair that with a short fact after each round, even a lost game leaves the player with something.</p>
<h3>Wrapping up</h3>
<p>None of these pieces is complicated, but each one is easy to get wrong, and together they separate a polished game from a tutorial project:</p>
<ol>
<li>Score with two passes, and test duplicates first.</li>
<li>Use a shuffle bag for unlimited play.</li>
<li>Version and guard your persisted stats.</li>
<li>Make feedback readable without colour.</li>
</ol>
<p>If you want to see these ideas in a live product, play a few back-to-back rounds and watch what happens between puzzles: how fast the next word loads, how the streak carries over, and what the post-round content adds. Then try building your own version with a theme you care about.</p>
<p><em>What edge cases have you hit building word games? Drop them in the comments and I’ll cover the best ones in a follow-up.</em></p>
]]></content:encoded></item><item><title><![CDATA[Generating Solvable Waffle Puzzles: Crossing Constraints, Seeded Scrambles, and Duplicate-Letter Scoring]]></title><description><![CDATA[How to build the grid, find six words that agree at nine crossings, and scramble it so a solution always exists, in plain JavaScript.]]></description><link>https://canuckle.hashnode.dev/generating-solvable-waffle-puzzles-crossing-constraints-seeded-scrambles-and-duplicate-letter-scoring</link><guid isPermaLink="true">https://canuckle.hashnode.dev/generating-solvable-waffle-puzzles-crossing-constraints-seeded-scrambles-and-duplicate-letter-scoring</guid><dc:creator><![CDATA[Niraj Mittal]]></dc:creator><pubDate>Thu, 29 Oct 2026 10:16:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a82989859f68357c2ab384c/d914a3e6-18f3-4c94-abdd-01728de4fee0.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A waffle puzzle looks like a 5x5 grid, but if you model it that way, the code gets awkward fast. The board has gaps, the words share letters, and the scramble has to leave the player with something that can actually be solved.</p>
<p>I built the grid version of Canuckle, <a href="https://canucklegames.com/canuckle-waffle/">Canuckle Waffle</a>, as a separate mode next to the single-word game. The word-guessing logic didn’t carry over at all. This post covers the parts that had to be written from scratch: the data model, the generator, the scramble, and the scoring. I covered per-mode storage in <a href="https://canuckle.hashnode.dev/scaling-a-web-puzzle-game-architecture-seo-schemas-and-state-isolation">an earlier post</a>, so I’m skipping that here.</p>
<p>All code below is plain JavaScript with no dependencies.</p>
<h3>The grid has 21 cells, not 25</h3>
<p>Three words run across (rows 0, 2, 4) and three run down (columns 0, 2, 4). The four cells where an odd row meets an odd column don’t exist. Six five-letter words would be 30 letters, but the 9 crossings are shared, which leaves 30 − 9 = 21 unique tiles.</p>
<p>I store the board as a flat array of 21 letters and precompute which cell indices belong to each word:</p>
<pre><code class="language-javascript">const SIZE = 5;
const CELL = {};
let count = 0;

for (let r = 0; r &lt; SIZE; r++) {
  for (let c = 0; c &lt; SIZE; c++) {
    if (!(r % 2 === 1 &amp;&amp; c % 2 === 1)) CELL[r + ',' + c] = count++;
  }
}
// count === 21

const WORDS = [
  ...[0, 2, 4].map(r =&gt; [0, 1, 2, 3, 4].map(c =&gt; CELL[r + ',' + c])), // across
  ...[0, 2, 4].map(c =&gt; [0, 1, 2, 3, 4].map(r =&gt; CELL[r + ',' + c])), // down
];
</code></pre>
<p><code>WORDS[0..2]</code> are the across words and <code>WORDS[3..5]</code> are the down words. Every other function in this post works from that table, so rendering, swapping, scoring and win detection never deal with rows and columns directly.</p>
<h3>Six words that agree at nine crossings</h3>
<p>Choosing six random words is easy. Choosing six whose letters match at every crossing is the hard part.</p>
<p>Across word <em>i</em> (row 2i) crosses down word <em>j</em> (column 2j) at one cell. That gives the constraint <code>across[i][2j] === down[j][2i]</code>, nine times over. The useful observation is that once you fix the three across words, each down word is almost fully determined. Down word <em>j</em> must have these letters at positions 0, 2 and 4:</p>
<pre><code class="language-plaintext">down[j][0] = across[0][2j]
down[j][2] = across[1][2j]
down[j][4] = across[2][2j]
</code></pre>
<p>So I index the word pool by the letters at positions 0, 2 and 4, and each down word becomes a lookup:</p>
<pre><code class="language-javascript">function buildIndex(words) {
  const byKey = new Map();
  for (const w of words) {
    const key = w[0] + w[2] + w[4];
    if (!byKey.has(key)) byKey.set(key, []);
    byKey.get(key).push(w);
  }
  return byKey;
}

const pick = (arr, rand) =&gt; arr[Math.floor(rand() * arr.length)];

function generateSolution(pool, themed, rand, attempts = 20000) {
  const byKey = buildIndex(pool);
  let best = null;
  let bestScore = -1;

  for (let n = 0; n &lt; attempts; n++) {
    const across = [pick(pool, rand), pick(pool, rand), pick(pool, rand)];

    const downs = [0, 1, 2].map(j =&gt; {
      const key = across[0][2 * j] + across[1][2 * j] + across[2][2 * j];
      const options = byKey.get(key);
      return options ? pick(options, rand) : null;
    });

    if (downs.includes(null)) continue;
    const all = [...across, ...downs];
    if (new Set(all).size !== 6) continue; // no repeated words

    const score = all.filter(w =&gt; themed.has(w)).length;
    if (score &gt; bestScore) {
      best = { across, downs };
      bestScore = score;
      if (score === 6) break;
    }
  }
  return best &amp;&amp; { ...best, score: bestScore };
}
</code></pre>
<p>The crossings can never disagree, because the lookup key is built from exactly the cells the down word shares with the across words. That removes the need to validate nine constraints after the fact.</p>
<h3>Why a themed word list needs a filler pool</h3>
<p>The search above works well with a large general dictionary. A themed list is a different situation. A Canadian word bank (provinces, slang, wildlife, food) is small, and the chance that a random triple of themed words produces three matching keys in that same small list is low. In practice, a purely themed pool rarely produces a full grid.</p>
<p>The <code>pool</code> / <code>themed</code> split in the function above is how I handle it. The pool contains themed words plus plain English filler, and <code>themed</code> is the subset I actually want. The search keeps the candidate with the most themed words and stops early if it finds a grid made entirely of them. On most days that means five themed words and one filler word, which is why the puzzle page tells players that one word may look less Canadian than the rest.</p>
<p>If you’re building a themed variant of any word game, expect this and plan for it. Hiding it tends to confuse players more than explaining it does.</p>
<h3>Scrambling backwards so a solution always exists</h3>
<p>The obvious scramble is to shuffle all 21 tiles. The trouble is that you then know nothing about how many swaps the puzzle needs, so you can’t set a fair move limit.</p>
<p>I start from the solved grid and apply a fixed number of random swaps. The puzzle is solvable in at most that many swaps by construction, because you can simply reverse them.</p>
<pre><code class="language-javascript">function acceptable(grid, solution) {
  const misplaced = grid.filter((ch, i) =&gt; ch !== solution[i]).length;
  const solvedWords = WORDS.filter(w =&gt;
    w.every(c =&gt; grid[c] === solution[c])
  ).length;
  return misplaced &gt;= 12 &amp;&amp; solvedWords === 0;
}

function scramble(solution, rand, swaps = 10) {
  let grid;
  do {
    grid = solution.slice();
    for (let i = 0; i &lt; swaps; i++) {
      const a = Math.floor(rand() * 21);
      const b = Math.floor(rand() * 21);
      if (a === b || grid[a] === grid[b]) { i--; continue; }
      [grid[a], grid[b]] = [grid[b], grid[a]];
    }
  } while (!acceptable(grid, solution));
  return grid;
}
</code></pre>
<p>Two details matter here. Swapping two identical letters is a wasted move that changes nothing visible, so the loop retries instead of counting it. And random swaps can undo each other, so the result sometimes comes out too easy. The <code>acceptable</code> check rejects any scramble that leaves a word already solved or fewer than 12 tiles out of place.</p>
<p>A 10-swap scramble gives an honest “solvable in 10” guarantee, and the move limit can be set a few moves above that.</p>
<p>One thing I deliberately don’t compute is the true minimum number of swaps. For a permutation of distinct letters it’s the number of misplaced tiles minus the number of cycles among them. With repeated letters, you’d have to pick the assignment of duplicates that maximises cycles, which is a harder problem than it looks. The construction-based guarantee avoids that entirely, at the cost of the true minimum sometimes being lower than 10.</p>
<h3>Same puzzle for everyone, no backend</h3>
<p>For a daily puzzle I wanted every player to get the same grid without a server deciding it. A seeded random number generator does that. The seed is the day number, and the generator above produces the same output for the same seed.</p>
<pre><code class="language-javascript">function mulberry32(seed) {
  return function () {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    let t = Math.imul(seed ^ (seed &gt;&gt;&gt; 15), 1 | seed);
    t = (t + Math.imul(t ^ (t &gt;&gt;&gt; 7), 61 | t)) ^ t;
    return ((t ^ (t &gt;&gt;&gt; 14)) &gt;&gt;&gt; 0) / 4294967296;
  };
}

const dayNumber = (d = new Date()) =&gt;
  Math.floor(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86400000);

const rand = mulberry32(dayNumber());
const solution = toGrid(generateSolution(pool, themed, rand));
const board = scramble(solution, rand);
</code></pre>
<p>I build the day number from the local date parts, not from <a href="http://Date.now"><code>Date.now</code></a><code>()</code>, so the puzzle changes at the player’s local midnight. The tradeoff is that two players in different time zones can see different puzzles on the same UTC day.</p>
<p>The other tradeoff is that shipping the generator to the browser means anyone reading the source can work out tomorrow’s answer. For a casual daily game, I accepted that. If it matters for yours, generate the puzzles at build time and ship a static schedule instead.</p>
<p><code>toGrid</code> just writes each word’s letters into the cells from the <code>WORDS</code> table:</p>
<pre><code class="language-javascript">function toGrid({ across, downs }) {
  const grid = new Array(21);
  across.forEach((w, i) =&gt; WORDS[i].forEach((cell, k) =&gt; (grid[cell] = w[k])));
  downs.forEach((w, j) =&gt; WORDS[3 + j].forEach((cell, k) =&gt; (grid[cell] = w[k])));
  return grid;
}
</code></pre>
<h3>Feedback when one tile belongs to two words</h3>
<p>Single-word games colour each letter by comparing it to the answer. A waffle tile at a crossing belongs to two words, and repeated letters make the naive comparison wrong, so I score per word with the usual two-pass approach and then merge.</p>
<pre><code class="language-javascript">const RANK = { absent: 0, present: 1, correct: 2 };

function scoreWord(cells, grid, solution) {
  const out = cells.map(() =&gt; 'absent');
  const remaining = {};

  cells.forEach((cell, i) =&gt; {
    if (grid[cell] === solution[cell]) out[i] = 'correct';
    else remaining[solution[cell]] = (remaining[solution[cell]] || 0) + 1;
  });

  cells.forEach((cell, i) =&gt; {
    if (out[i] === 'correct') return;
    const ch = grid[cell];
    if (remaining[ch] &gt; 0) { out[i] = 'present'; remaining[ch]--; }
  });
  return out;
}

function scoreGrid(grid, solution) {
  const status = new Array(grid.length).fill('absent');
  for (const word of WORDS) {
    const s = scoreWord(word, grid, solution);
    word.forEach((cell, i) =&gt; {
      if (RANK[s[i]] &gt; RANK[status[cell]]) status[cell] = s[i];
    });
  }
  return status;
}
</code></pre>
<p>The first pass marks exact matches and counts the solution letters that are still unaccounted for in that word. The second pass hands out “present” only while there are unaccounted letters of that type left. A tile at a crossing takes the better of its two results.</p>
<h3>Detecting a win without punishing alternate solutions</h3>
<p>My first win check compared the grid to the stored solution cell by cell. That’s correct for the intended answer but strict about everything else, and with repeated letters there can be other arrangements where all six words are valid.</p>
<p>Checking the words instead handles that:</p>
<pre><code class="language-javascript">const isSolved = (grid, validWords) =&gt;
  WORDS.every(cells =&gt; validWords.has(cells.map(c =&gt; grid[c]).join('')));
</code></pre>
<p>If all six words read as valid entries in the acceptable-word set, the player is done. The intended solution always passes, and so does any other arrangement that happens to work.</p>
<p>What I still haven’t settled is the exact minimum-swap count when letters repeat. If you’ve solved that cleanly for a 21-tile board, I’d like to see the approach.</p>
]]></content:encoded></item><item><title><![CDATA[Scaling a Web Puzzle Game: Architecture, SEO Schemas, and State Isolation]]></title><description><![CDATA[If you search for web-based word puzzle games today, you will find thousands of implementations. Most are quick forks of open-source Wordle…]]></description><link>https://canuckle.hashnode.dev/scaling-a-web-puzzle-game-architecture-seo-schemas-and-state-isolation</link><guid isPermaLink="true">https://canuckle.hashnode.dev/scaling-a-web-puzzle-game-architecture-seo-schemas-and-state-isolation</guid><dc:creator><![CDATA[Niraj Mittal]]></dc:creator><pubDate>Mon, 17 Aug 2026 07:04:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a82989859f68357c2ab384c/c3e79052-a0e5-4cac-ba42-a213e68afa7c.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you search for web-based word puzzle games today, you will find thousands of implementations. Most are quick forks of open-source Wordle repos—single-page apps with a hardcoded dictionary array and basic local storage state handling.</p>
<p>When I took over the architecture and development for <strong>Canuckle</strong> (a Canadian-themed word puzzle ecosystem originally conceptualized by Mark Rogers in 2022), the immediate challenge was clear: How do you evolve a single-format script into a resilient, performant, and search-optimized multi-mode web application?</p>
<p>Here is a breakdown of the technical hurdles, architectural decisions, and schema workflows behind turning a lightweight word game into a scalable platform.</p>
<h3>1. Game State Isolation Across Multiple Modes</h3>
<p>One major flaw with basic word game implementations is state leakage. When you expand a single game into multiple modes (e.g., standard 5-letter, 6-letter expansion, unlimited practice, and grid variants), sharing a single storage namespace creates state collisions.</p>
<p>To prevent daily puzzle progress from overwriting unlimited sessions, we decoupled the state persistence layer using strict key-scoping:</p>
<pre><code class="language-javascript">// Utility: Namespaced state management for game modes
const createGameStore = (modeKey) =&gt; {
  const STORAGE_PREFIX = `canuckle_state_${modeKey}`;

  return {
    get: () =&gt; {
      try {
        const raw = localStorage.getItem(STORAGE_PREFIX);
        return raw ? JSON.parse(raw) : null;
      } catch (e) {
        console.error("Failed to parse local game state", e);
        return null;
      }
    },
    save: (state) =&gt; {
      try {
        localStorage.setItem(STORAGE_PREFIX, JSON.stringify({
          ...state,
          lastUpdated: new Date().toISOString()
        }));
      } catch (e) {
        console.error("Quota exceeded or storage disabled", e);
      }
    }
  };
};

export const dailyStore = createGameStore("daily_5");
export const unlimitedStore = createGameStore("unlimited");
</code></pre>
<p>This decoupled pattern ensures that mode toggling operates predictably without requiring complex global state reducers.</p>
<ol>
<li>URL Architecture: Dedicated Routing vs. Dynamic Toggles A common anti-pattern in casual game clones is burying different play modes behind modal toggles or UI tabs on the root route. While it simplifies state management, it creates severe discoverability and caching penalties:</li>
</ol>
<p>Intent Isolation: Users searching for a daily puzzle vs. an "unlimited practice" session have fundamentally different query intents.</p>
<p>Granular Caching: Dynamic single-page toggles make static caching and edge delivery harder to configure per route.</p>
<p>We decoupled each format into dedicated static endpoints (/unlimited, /plus-6-letter, /waffle-grid). Each route serves pre-rendered HTML shells with dedicated OpenGraph tags and isolated canonical references.</p>
<ol>
<li>Structured Data: JSON-LD for Rich Web Entities Web utility apps frequently struggle with search visibility because search engines cannot execute complex gameplay loops during initial crawling. To communicate page intent programmatically, we integrated structured JSON-LD schemas covering WebApplication, FAQPage, and HowTo definitions.</li>
</ol>
<p>Here is the modular schema structure implemented on the primary gameplay route:</p>
<pre><code class="language-json">&lt;script type="application/ld+json"&gt;
{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "WebApplication",
      "@id": "https://canucklegame.ca/#webapp",
      "name": "Canuckle",
      "applicationCategory": "GameApplication",
      "operatingSystem": "All",
      "browserRequirements": "Requires JavaScript. Requires HTML5.",
      "offers": {
        "@type": "Offer",
        "price": "0",
        "priceCurrency": "USD"
      }
    },
    {
      "@type": "FAQPage",
      "@id": "https://canucklegame.ca/#faq",
      "mainEntity": [
        {
          "@type": "Question",
          "name": "How does the daily puzzle reset work?",
          "acceptedAnswer": {
            "@type": "Answer",
            "text": "The daily puzzle refreshes automatically at midnight local time based on the user's localized timezone offset."
          }
        }
      ]
    }
  ]
}
&lt;/script&gt;
</code></pre>
<p>Implementing validated schemas eliminates crawler ambiguity and provides clear entity associations directly within search indices.</p>
<h3>4. Handling Regional Character Sets and Dictionaries</h3>
<p>Building a regional-themed game requires strict data validation. In our case, supporting Canadian English meant curating custom dictionary arrays where spelling variants (e.g., <em>colour</em> vs. <em>color</em>, <em>favour</em> vs. <em>favor</em>) are handled gracefully without breaking letter-count validation loops.</p>
<p>Instead of bundling massive dictionary JSON files into the client entry bundle, we split word validation into:</p>
<ol>
<li><p><strong>Target Word List:</strong> Loaded synchronously with the bundle for fast client-side win/loss evaluation.</p>
</li>
<li><p><strong>Acceptable Guesses List:</strong> Lazy-loaded asynchronously upon first user input or checked against an optimized in-memory Set lookup.</p>
</li>
<li><pre><code class="language-javascript">// O(1) word validation lookup using Set
const validWordSet = new Set(acceptableWordsArray);

export const validateGuess = (guess) =&gt; {
  if (guess.length !== 5) return false;
  return validWordSet.has(guess.toLowerCase());
};
</code></pre>
</li>
</ol>
<h3>Key Takeaway</h3>
<p>Building web apps in popular genres isn't just about recreating the basic loop—it comes down to the operational discipline around the codebase:</p>
<ul>
<li><p>Modular state persistence.</p>
</li>
<li><p>High-intent URL routing.</p>
</li>
<li><p>Comprehensive JSON-LD structured data.</p>
</li>
<li><p>Performance-conscious bundle loading.</p>
</li>
</ul>
<p>When you bridge the gap between simple script forks and production-ready web engineering, even familiar formats can be scaled into reliable, high-performance platforms.</p>
<p>Project Reference: <a href="https://canucklegames.com/">Canuckle Game</a></p>
]]></content:encoded></item></channel></rss>