<?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[BorkyBold's devlog]]></title><description><![CDATA[BorkyBold's devlog]]></description><link>https://borkybold.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aee6abf7b742b3cc2ca258e/85cfaccb-04ae-4fe5-a76c-fc73aa154b4a.png</url><title>BorkyBold&apos;s devlog</title><link>https://borkybold.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 10 Nov 2026 12:00:23 GMT</lastBuildDate><atom:link href="https://borkybold.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Generating a daily two-piece puzzle backwards: search first, goal last]]></title><description><![CDATA[How one breadth-first search over pairs of positions builds, scores and hints TANDEM]]></description><link>https://borkybold.hashnode.dev/generating-a-daily-two-piece-puzzle-backwards-search-first-goal-last</link><guid isPermaLink="true">https://borkybold.hashnode.dev/generating-a-daily-two-piece-puzzle-backwards-search-first-goal-last</guid><category><![CDATA[GameDev]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[puzzle]]></category><dc:creator><![CDATA[BorkyBold]]></dc:creator><pubDate>Sun, 08 Nov 2026 22:20:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aee6abf7b742b3cc2ca258e/64597748-c9dd-457f-b2de-f82594784a22.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>NOCTURNE ISLAND is a small browser game I make on my own: four daily puzzles, a new set at 00:00 UTC, the same set for everyone, no sign-up. You can try it at <a href="https://nocturne-island.com/?utm_source=hashnode">nocturne-island.com</a>. In <a href="https://borkybold.hashnode.dev/how-i-guarantee-exactly-one-answer-in-my-daily-pump-puzzle">my last post</a> I wrote about how the pump puzzle (BLOOM) makes sure it has exactly one answer. This one is about TANDEM, the two-piece puzzle, and the generator behind it. The whole generator comes down to one breadth-first search, and I run it before I know where the goal is.</p>
<h2>The rules</h2>
<p>TANDEM is a 6×5 grid with 7 to 9 wall tiles and two pieces, 1 and 2. Each piece has its own station. There is only one set of arrows: when you press left, both pieces try to move left at the same time. A piece that would hit a wall or the edge stays where it is. If both pieces would end up on the same cell, or would swap cells, neither moves. This is the real step function from <code>shared/logic.ts</code>:</p>
<pre><code class="language-ts">export function tandemStep(c: TandemConfig, positions: [number, number], dir: Direction): [number, number] {
  const step = (p: number) =&gt; {
    const x = p % c.width, y = Math.floor(p / c.width);
    const [dx, dy] = dir === 'up' ? [0, -1] : dir === 'right' ? [1, 0] : dir === 'down' ? [0, 1] : [-1, 0];
    if (x + dx &lt; 0 || x + dx &gt;= c.width || y + dy &lt; 0 || y + dy &gt;= c.height) return p;
    const next = p + dx + dy * c.width;
    return c.walls.includes(next) ? p : next;
  };
  const a = step(positions[0]), b = step(positions[1]);
  // A shared destination or a swap leaves both couriers in place.
  return a === b || a === positions[1] &amp;&amp; b === positions[0] ? [...positions] : [a, b];
}
</code></pre>
<p>One detail: a piece may step into the cell the other piece is leaving, so they can move in a line. They only both stop when the other piece is stuck in that cell (<code>a === b</code>) or when they would swap. The whole game is about using walls to stop one piece while the other keeps going.</p>
<h2>The state is the pair</h2>
<p>You can't plan each piece on its own, because every arrow moves both. A position in the game is the pair (cell of piece 1, cell of piece 2). Cells are numbered 0 to 29, so the search keys a pair as <code>p1 * 30 + p2</code>. With 7 to 9 walls there are 21 to 23 floor cells, and the pieces never share a cell, so there are at most 23 × 22 = 506 positions, each with four arrows out of it. That is small enough to search completely, on the server when the puzzle is made and in the browser while you play.</p>
<p>The search, from the same file:</p>
<pre><code class="language-ts">export function tandemSearch(c: TandemConfig, from = c.start, goal: [number, number] | null = c.goals) {
  const size = c.width * c.height, key = (p: [number, number]) =&gt; p[0] * size + p[1];
  const queue = [{ positions: [...from], parent: -1, dir: null }];
  const seen = new Set([key(from)]), output = [];
  for (let head = 0; head &lt; queue.length; head++) {
    const node = queue[head];
    if (!goal || node.positions[0] === goal[0] &amp;&amp; node.positions[1] === goal[1]) {
      const path = []; let at = head;
      while (queue[at].parent !== -1) { path.push(queue[at].dir); at = queue[at].parent; }
      output.push({ positions: node.positions, path: path.reverse() });
      if (goal) return output;
    }
    for (const dir of DIRECTIONS) {
      const next = tandemStep(c, node.positions, dir), id = key(next);
      if (!seen.has(id)) { seen.add(id); queue.push({ positions: next, parent: head, dir }); }
    }
  }
  return output;
}
</code></pre>
<p>(I trimmed the type annotations on the locals; the logic is unchanged.) The queue is a plain array with a moving head index, and it doubles as the parent table, so a path is rebuilt by walking <code>parent</code> links back to the start. Give it a goal and it returns the shortest route to that goal. Pass <code>null</code> and it returns every reachable position, each with a shortest route to it. That second mode is the generator.</p>
<h2>Generate backwards: pick the goal last</h2>
<p>The obvious way is to put the stations somewhere at random and then check that the puzzle can be solved. I do it the other way round: place the walls and the two pieces, run the search from the start with no goal, and then pick the stations from the positions the search found at the distance I want.</p>
<pre><code class="language-ts">export function generateTandem(seed: string): PrivatePuzzle {
  const rng = randomSource(`tandem-v1:${seed}`), all = Array.from({ length: 30 }, (_, i) =&gt; i);
  for (let attempt = 0; attempt &lt; 100; attempt++) {
    const walls = shuffle(all, rng).slice(0, 7 + Math.floor(rng() * 3)), floor = all.filter(i =&gt; !walls.includes(i));
    if (!connected(floor, 6, 5)) continue;
    const start = shuffle(floor, rng).slice(0, 2) as [number, number];
    const config: TandemConfig = { kind: 'tandem', width: 6, height: 5, walls, start, goals: [...start] };
    const candidates = tandemSearch(config, start, null).filter(s =&gt; s.path.length &gt;= 9 &amp;&amp; s.path.length &lt;= 15 &amp;&amp; s.positions[0] !== start[0] &amp;&amp; s.positions[1] !== start[1]);
    if (!candidates.length) continue;
    const chosen = candidates[Math.floor(rng() * candidates.length)];
    config.goals = chosen.positions;
    return { config, solution: { path: chosen.path, par: chosen.path.length } };
  }
  // The fallback uses the same validated generator, with a fixed independent seed.
  if (seed !== 'tandem-fallback-v1') return generateTandem('tandem-fallback-v1');
  throw new Error('No bounded tandem layout');
}
</code></pre>
<p>That gives me a few things without extra code:</p>
<ul>
<li>The puzzle can always be solved, because the stations are a position the search actually reached.</li>
<li>The par is exact. BFS finds positions in order of distance, so the route stored with each position is a shortest one. Par is somewhere from 9 to 15 moves.</li>
<li>Neither piece starts on its own station (the <code>!== start</code> checks).</li>
<li>The stored route is what the game shows as the replay after you finish.</li>
</ul>
<p><code>connected</code> is a plain flood fill over single floor cells. It is only a cheap first filter: a connected floor doesn't mean every pair of cells can be reached, because the pieces move together. The pair search makes the real decision.</p>
<h2>A real board</h2>
<p>This is the board from the cover image, a screenshot I took while testing (<code>#</code> wall, <code>1</code> and <code>2</code> the pieces, <code>a</code> and <code>b</code> their stations):</p>
<pre><code>. . . # # #
. . . . . .
# . . # b #
. . . . . .
. a . # 1 2
</code></pre>
<p>From the start, only "up" does anything. Left: piece 1 hits the wall and piece 2 would move into its cell, so both stay. Right: piece 1 would move onto piece 2, which is stuck at the edge, so both stay. Down is the edge. The search reaches 448 of the 506 possible positions, 155 of them are 9 to 15 moves away, and the stations it picked are 9 moves away: up, left, up, up, left, down, right, down, left.</p>
<h2>What I measured</h2>
<p>I wanted numbers rather than impressions, so I ran a copy of the generator loop with counters on 1,000 made-up seed strings, and checked that every puzzle it made was identical to what <code>generateTandem</code> returns for the same seed:</p>
<ul>
<li>485 seeds got a puzzle on the first attempt. The worst one needed 15 attempts out of the 100 allowed, and the fallback seed was never used.</li>
<li>All 1,062 rejected attempts were wall layouts that split the floor into separate areas. Not once was the list of candidate goals empty.</li>
<li>Reachable positions per puzzle: median 446, minimum 197, maximum 506.</li>
<li>Par: 251 puzzles at 9 moves, 207 at 10, 162 at 11, 147 at 12, 104 at 13, 74 at 14, 55 at 15.</li>
</ul>
<p>That last line is skewed toward short routes, and the reason is in the code: I pick uniformly among positions, not among lengths, and there are fewer positions far from the start. In the example board the search found 83 positions at distance 9, 47 at 10, 24 at 11, 10 at 12 and none further. If I wanted each length equally often, I would pick a length first and then a position at that length. So far I've left it as it is.</p>
<p>Two more things the counters showed. The median puzzle has 3 different shortest routes, and 254 of the 1,000 have exactly one, which is why the replay says "one of the shortest routes". And a move can't always be reversed with the opposite arrow: if one piece was stuck and the other moved, the opposite arrow can move both. So in principle you can reach a position from which the stations can't be reached at all. Only one of the 1,000 puzzles had any such position. The game still handles it: the max score shows a dash and a line says "No route from this position. Undo or restart to continue."</p>
<h2>Hints and scoring use the same search</h2>
<p>A hint is the first step of <code>tandemSearch(c, state.positions)</code>, the shortest route from where you are now. The score is:</p>
<pre><code class="language-ts">export function tandemPoints(actions: number, par: number) {
  return Math.max(10, 100 - Math.max(0, actions - par));
}
</code></pre>
<p>Every move, undo, restart and hint counts as an action. A press that moves neither piece is refused and costs nothing. Solving always gives at least 10 points. The browser runs the same search after each move to show the best score you can still get: actions so far plus the remaining shortest route, passed through the same function. So the number on screen and the number the server saves come from the same code.</p>
<p>The seed itself comes from a signed day key, and the finished puzzle is stored once per day, so everyone gets the same board. I described that part in the previous post.</p>
<h2>What I'd still like to know</h2>
<p>Par length is only a rough measure of difficulty. A long route can be mostly plain walking, and a short one can hinge on a single moment where one piece has to be held against a wall. I could try other measures, like the number of shortest routes or how many steps of the route depend on one piece being stopped, but I haven't decided which one matches how hard a day feels. If you make generated puzzles, how do you rate difficulty?</p>
<p>Today's board is at <a href="https://nocturne-island.com/?utm_source=hashnode">nocturne-island.com</a>.</p>
]]></content:encoded></item><item><title><![CDATA[How I guarantee exactly one answer in my daily pump puzzle]]></title><description><![CDATA[Six pumps, six beds, one hidden answer: how my daily browser puzzle uses a rank check and a 4096-case brute force to guarantee a unique solution.]]></description><link>https://borkybold.hashnode.dev/how-i-guarantee-exactly-one-answer-in-my-daily-pump-puzzle</link><guid isPermaLink="true">https://borkybold.hashnode.dev/how-i-guarantee-exactly-one-answer-in-my-daily-pump-puzzle</guid><category><![CDATA[GameDev]]></category><category><![CDATA[TypeScript]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[BorkyBold]]></dc:creator><pubDate>Sat, 07 Nov 2026 00:09:37 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aee6abf7b742b3cc2ca258e/884c9ce7-10e0-4446-84af-9d99731e024e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I make a small browser game called NOCTURNE ISLAND: four short daily puzzles on a moonlit pixel island. Everyone gets the same set each day, and it's free with no sign-up (<a href="https://nocturne-island.com/?utm_source=hashnode">nocturne-island.com</a>). This post is about one of the four, BLOOM, and the property I cared about most when writing its generator: every day's puzzle must have exactly one correct answer.</p>
<h2>The puzzle</h2>
<p>BLOOM has six pumps and six flower beds. Each pump has a setting from 0 to 3 and feeds two or three beds. A bed gets the sum of the settings of every pump connected to it. The board shows which pump feeds which bed and how much water each bed needs. You find the six settings.</p>
<p><img src="https://cdn.hashnode.com/uploads/covers/6aee6abf7b742b3cc2ca258e/87db0346-c9c1-4bcb-bd81-26a6735f2f36.png" alt="A solved BLOOM board from an earlier day: six beds on the left, the pipe wiring in the middle, six pumps set from 0 to 3 on the right" /></p>
<p>The whole "physics" of the game is one function:</p>
<pre><code class="language-ts">// links[p] = the beds pump p feeds, levels[p] = its setting (0–3)
function bloomWater(c: BloomConfig, levels: number[]) {
  return c.targets.map((_, bed) =&gt;
    c.links.reduce((sum, beds, p) =&gt; sum + (beds.includes(bed) ? levels[p] : 0), 0));
}
</code></pre>
<p>If you squint, that's a 6×6 system of linear equations, <code>A · x = t</code>. <code>A[bed][pump]</code> is 1 when the pump feeds the bed, <code>x</code> holds the pump settings and <code>t</code> the targets. That's exactly why uniqueness doesn't come for free. Random wiring regularly gives you two pumps that feed the same beds, or a pump whose beds are covered by a combination of other pumps. Then you can move water from one pump to another and still hit every target.</p>
<p>The game scores by comparing water, not settings, so a second answer wouldn't be marked wrong. But the puzzle would stop being a deduction puzzle. At some point you'd have to guess. And the hint button ("set the first wrong pump to its correct value") would point at one specific answer, possibly overwriting a perfectly valid one you were heading towards.</p>
<h2>Build the answer first, then reject</h2>
<p>The generator isn't clever. Each attempt goes like this:</p>
<ol>
<li>Wire each pump to 2 or 3 random beds.</li>
<li>Build the matrix and throw it away unless it has full rank.</li>
<li>Pick the hidden settings and compute the targets <em>from</em> them, so a solution exists by construction.</li>
<li>Reject boards whose targets fall outside 1–7 or that have too few pumps actually running.</li>
</ol>
<p>Simplified, but faithful to the real code:</p>
<pre><code class="language-ts">function generateBloom(seed: string) {
  const rng = randomSource(`bloom-v1:${seed}`), beds = [0, 1, 2, 3, 4, 5];
  for (let attempt = 0; attempt &lt; 100; attempt++) {
    const links = beds.map(() =&gt; shuffle(beds, rng).slice(0, rng() &lt; .5 ? 2 : 3));
    const matrix = beds.map(bed =&gt; links.map(fed =&gt; Number(fed.includes(bed))));
    if (!fullRank(matrix)) continue;           // could have more than one answer
    const levels = pickLevels(rng);            // the hidden answer
    const config = { kind: 'bloom', links, targets: beds.map(() =&gt; 0) };
    config.targets = bloomWater(config, levels);   // clues come from the answer
    if (config.targets.some(n =&gt; n &lt; 1 || n &gt; 7)) continue;
    return { config, solution: { levels } };
  }
  return generateBloom('bloom-fallback-v1');   // fixed seed known to pass
}
</code></pre>
<p>Why full rank is enough: if <code>A</code> is invertible, <code>A · x = t</code> has exactly one real solution, so there's at most one with whole numbers from 0 to 3. And we know one exists, because we built <code>t</code> from it.</p>
<p>Two details I got wrong in my head before getting them right in code:</p>
<p><strong>Use the right field.</strong> It's tempting to think "0/1 matrix, do it mod 2". That would be wrong, because water adds like normal numbers. A matrix can be singular mod 2 and still invertible over the reals: three pumps wired in a triangle (<code>[1,1,0] [0,1,1] [1,0,1]</code>) has determinant 2. So <code>fullRank</code> is plain Gauss–Jordan elimination over floats with a small tolerance. With a 6×6 of zeros and ones, that's perfectly safe.</p>
<p><strong>Full rank is sufficient, not necessary.</strong> A singular matrix can still have only one solution inside the 0–3 box, because the "other" answer would need a negative setting or a 4. I reject those boards anyway. It costs a few extra attempts and keeps the guarantee simple enough that I can actually trust it.</p>
<h2>Let brute force be the referee</h2>
<p>The rank check is fast and runs every time a puzzle is generated. But it's a clever argument, and clever arguments break quietly when you change the rules later (say, four beds per pump). Luckily the space is tiny: 4⁶ = 4096 settings. So the tests just try all of them:</p>
<pre><code class="language-ts">export function solveBloom(c: BloomConfig): number[][] {
  const solutions: number[][] = [];
  for (let mask = 0; mask &lt; 4 ** c.links.length; mask++) {
    let v = mask;
    const levels = c.links.map(() =&gt; { const n = v % 4; v = Math.floor(v / 4); return n; });
    if (bloomWater(c, levels).every((w, i) =&gt; w === c.targets[i])) solutions.push(levels);
  }
  return solutions;
}

// for 100 independent daily seeds:
assert.deepEqual(solveBloom(c), [puzzle.solution.levels]);
</code></pre>
<p>Exactly one solution, and it's the stored one. The same test then plays those settings through the server's own rules function and expects a score of 100. The rank check is the guarantee at generation time, and the brute force is how I know the guarantee is real.</p>
<h2>Same puzzle for everyone</h2>
<p>Generation is deterministic. The PRNG is tiny: an FNV-1a hash of the seed string feeds a mulberry32-style generator, and every game gets its own namespace (<code>bloom-v1:</code> plus the day seed). The day seed is an HMAC of the UTC date and game name with a server-side key, so tomorrow's board can't be computed in advance.</p>
<p>The first request of a day generates the puzzle and stores it in SQLite keyed by <code>(day, game)</code> with <code>INSERT OR IGNORE</code>, then reads back whatever row is stored. Two simultaneous "first" requests still serve the same puzzle. A later code change can't rewrite a day that has already been played either, because stored days are never regenerated. The browser only receives the wiring and the targets. The hidden settings stay on the server until you finish.</p>
<h2>Scoring that forgives being close</h2>
<p>Exact answers are the goal, but near misses shouldn't score zero. Each bed scores <code>1 − |water − target| / target</code>, floored at 0. The six beds are averaged and multiplied by 100. Too much and too little water cost the same, and twice the target is as bad as leaving a bed dry. A hint sets the first wrong pump to its correct value, locks it and costs 15 points.</p>
<h2>What I'd take to the next generator</h2>
<ul>
<li>Construct the answer first and derive the clues from it. Existence is then free, so you only have to fight for uniqueness.</li>
<li>Find a cheap, conservative uniqueness check for the generator, even if it rejects some good boards.</li>
<li>When the search space is small, brute-force it in tests. It's the cheapest proof you'll ever write.</li>
</ul>
<p>If you try today's BLOOM at <a href="https://nocturne-island.com/?utm_source=hashnode">nocturne-island.com</a>, I'd really like to hear how it went. Did the wiring feel solvable by reasoning, or did you end up nudging pumps until the numbers matched? And if you've built puzzle generators yourself, do you check uniqueness at generation time, only in tests, or both?</p>
]]></content:encoded></item></channel></rss>