Some games enter a platform with their identity already settled. The board changes, the generator changes, perhaps the renderer changes, but the design language is familiar. PolyPivot did not arrive that way. It began as a spatial idea: irregular pieces, visible rotation points, permitted overlaps, and a goal that asks the player to make everything cover one rectangle again without holes or double occupancy.

That changes the fundamental development question. In Sudoku we can generate a solution, remove clues and test uniqueness. In a path puzzle we can construct a valid network and hide part of its information. In PolyPivot the problem was not simply producing a correct final state. We needed an initial state that was meaningfully scrambled while still preserving a legitimate path home under one very specific mechanic: every piece rotates by ninety degrees around one of its pivots, some pieces have two possible axes, and a move may send part of a piece outside the target board or on top of another piece.

The safest way to approach that problem was to stop asking “how do we solve this chaos?” and begin from the solution. Build a valid cover, choose pivots, apply only legal rotations and save the inverse sequence. The result is not a proof of uniqueness and it is not a universal solver. It is a narrower and more useful guarantee for generation: every puzzle created by this process has at least one known route back.

This is the engineering deep dive behind the public PolyPivot guide. It also continues two earlier Devlog themes: generation is not solving and finishing a puzzle is not the same as making it playable. PolyPivot forced both ideas to become concrete in a brand-new engine.

Designing from the solved state changes the entire problem

We could have scattered pieces randomly and then searched for a sequence of pivot rotations that happened to assemble them. That approach has one attractive property: the generator could discover starting positions that were not produced from a known solution path. It also creates a large search problem before the game has even proved itself fun. State count grows with the number of pieces, the number of pivots per piece, the two rotation directions and the freedom to move through overlapping or outside-board intermediate configurations.

The implemented generator reverses that direction. First, the target rectangle is partitioned into connected regions. Those regions are the solved pieces. Each piece receives one or two pivot cells. Then legal rotations are applied from the solved state within an expanded workspace. Every successful rotation is recorded. The known route back is simply that list in reverse order with every direction inverted.

PolyPivot reversible generation pipeline from deterministic seed and solved partition to a legal scramble and verified inverse move sequence
The generator splits a solved rectangle into connected pieces, assigns pivots, applies legal turns within the workspace and replays the inverse sequence. That establishes one route back, not uniqueness or optimal difficulty. Open the full-size diagram

The current generator makes that structure explicit:

function generate(profile, difficultyIndex, variant = 0) {
  const difficulty = DIFFICULTIES[difficultyIndex];
  const seed = hash32(
    `polypivot:${profile.id}:${difficulty.id}:${variant}`
  );
  const rng = makeRng(seed);

  const regions = partitionBoard(profile, difficulty, rng);
  const basePieces = regions.map((cells, index) => ({
    id: index,
    color: colorForPiece(index),
    cells: cloneCells(cells),
    solvedCells: cloneCells(cells),
    pivots: pivotIndicesFor(cells, dualSet.has(index))
  }));

  const scrambled = scramblePieces(basePieces, profile, difficulty, rng);
  return {
    pieces: scrambled.pieces,
    solvedPieces: basePieces,
    scrambleMoves: scrambled.scrambleMoves,
    reverseMoves: scrambled.reverseMoves
  };
}

The stable seed is part of the debugging contract. Profile, difficulty and variant produce the same generated instance for the current algorithm. If a combination fails a test or exposes an ugly geometric edge case, we can return to it exactly. Randomness stops being fog and becomes a reproducible input.

Before anything can rotate, the pieces have to be worth rotating

Starting from a solution does not remove the first hard problem: the whole board must be partitioned into connected regions. A trivial partition—regular rectangles, for example—would simplify implementation but weaken the game. PolyPivot needs irregular silhouettes because much of the spatial reasoning comes from recognizing how a piece might return to a gap after several rotations.

The partitioner begins with separated seed cells. Seed selection rewards distance so regions do not all originate in the same corner. Each region then grows by claiming cells on its frontier. Smaller regions are favoured during growth to keep areas reasonably balanced, while small shape biases stop every region from turning into the same kind of blob.

One rule is non-negotiable: every region must remain connected. The solved board must cover the full target area exactly once, with no gaps and no overlaps. Those properties are checked later in the engine’s self-test. We do not merely trust that the constructive process “should” have produced a legal cover.

Difficulty already affects this stage. Current profiles range from 10×8 to 16×14, and difficulty changes the target piece area. Harder configurations tend toward more and smaller pieces, more scramble candidates and a larger ratio of dual-pivot pieces. Difficulty is therefore not just “do more moves”. It alters the geometry the player must read.

The primary pivot is deliberately not random

Once a region becomes a piece, we need a cell around which it can rotate. The primary pivot is selected by minimizing the sum of Manhattan distances to all cells in the piece. It is not a continuous geometric centre because the pivot must be an actual cell. The heuristic tends to choose a reasonably central point and avoids many absurd rotations around extreme tips.

function pivotIndicesFor(cells, dual) {
  let primary = 0;
  let best = Infinity;

  for (let i = 0; i < cells.length; i++) {
    const score = cells.reduce(
      (sum, cell) => sum + manhattan(cells[i], cell),
      0
    );
    if (score < best) {
      best = score;
      primary = i;
    }
  }

  if (!dual || cells.length < 5) return [primary];

  let secondary = primary;
  let far = -1;
  for (let i = 0; i < cells.length; i++) {
    const distance = manhattan(cells[primary], cells[i]);
    if (distance > far) {
      far = distance;
      secondary = i;
    }
  }
  return secondary === primary ? [primary] : [primary, secondary];
}

Dual-axis pieces choose the second pivot far away from the first. That does not prove every pivot pair creates an elegant decision, but it makes the second axis spatially meaningful. Two neighbouring pivots might be technically distinct while feeling almost identical to a player.

The dual-pivot ratio rises with difficulty. On easy it is an occasional complication; on expert it becomes a frequent part of reading the board. Complexity grows through transformation choices rather than through hidden exceptions to the rules.

Rotation is a tiny geometric operation with large consequences

Rotation is discrete. For every cell, the engine takes the offset from the chosen pivot and applies a ninety-degree transform. One direction uses one coordinate permutation; the opposite direction uses its inverse. The result is accepted only when every new cell remains inside the workspace.

function rotateCells(cells, pivotIndex, dir) {
  const pivot = cells[pivotIndex];
  return cells.map(cell => {
    const dx = cell.c - pivot.c;
    const dy = cell.r - pivot.r;
    return dir > 0
      ? {r: pivot.r + dx, c: pivot.c - dy}
      : {r: pivot.r - dx, c: pivot.c + dy};
  });
}

function validRotation(cells, pivotIndex, dir, profile) {
  const next = rotateCells(cells, pivotIndex, dir);
  return next.every(cell => inWorkspace(cell, profile)) ? next : null;
}

The word “workspace” matters. Pieces are allowed to move partially outside the target rectangle during play. If every intermediate state had to stay entirely within the final board, many useful manoeuvres would disappear. Each profile therefore includes a margin around the target area. Players can rotate through that space and bring pieces back, but movement is still bounded.

This also makes victory more interesting than comparing arrays. A piece may be legal inside the workspace while still sitting outside the target. It may overlap another piece. It may leave a hole. The final condition is about coverage of the target rectangle, not simply about every piece returning to one memorized coordinate list.

Overlap is not merely an error state; it is part of the visual language

Allowing pieces to overlap creates a rendering problem. If the most recently drawn piece simply covers the others, information disappears: a shared cell looks like the top piece only. Basic alpha transparency makes the result depend heavily on draw order and can produce muddy colours.

PolyPivot instead mixes the colours of all pieces occupying a cell. The current function uses the root mean square of each RGB channel. It is not an attempt to simulate physical paint or perfect perceptual colour science. It is a stable way to keep mixed cells bright and visibly distinct.

function mixColors(colors) {
  if (colors.length === 1) return colors[0];
  const rgbs = colors.map(hexRgb);

  return rgbHex([0, 1, 2].map(i =>
    Math.sqrt(
      rgbs.reduce((sum, rgb) => sum + rgb[i] * rgb[i], 0)
      / rgbs.length
    )
  ));
}

That turns an invalid state into useful feedback. An overlap is not only “wrong”; the local colour tells the player that multiple pieces are competing for the same cell. Colour participates in the puzzle’s readability instead of being decorative.

The base piece palette also needs enough separation. Colours advance around hue using a golden-angle-style increment with small saturation and lightness variation. The goal is not to assign a permanent identity to piece number seven across games. It is to reduce the chance that neighbouring pieces consistently end up almost indistinguishable.

The win condition is defined by occupancy, not memory

Every state can produce an occupancy map: which piece ids are present in each coordinate, how many piece cells lie outside the target rectangle, how many positions contain more than one piece, and how many target cells are not occupied exactly once. Victory then becomes straightforward: zero outside, zero overlaps and zero uncovered cells.

This is conceptually stronger than requiring every piece to match its original solvedCells array. If the geometry happened to admit a different arrangement that still covers the rectangle exactly once, it should be accepted. The engine validates the property explained to the player rather than an implementation identity only the generator knows.

solvedCells still helps generation and some diagnostics, but it is not the final authority. Separating “the state used to construct an instance” from “the rule that defines success” prevents a convenient internal representation from silently becoming an extra game rule.

The reversible scramble is the minimum solvability guarantee

The generator evaluates several scramble candidates. For each piece, it applies a number of rotations based on difficulty. Pivot choice and direction are pseudo-random, but a rotation is recorded only if it stays in the workspace. The resulting state receives a heuristic score: overlap count, outside cells, displaced pieces and move count all contribute. The highest-scoring candidate that is not accidentally already solved wins.

The crucial line comes at the end:

const reverseMoves = [...best.moves]
  .reverse()
  .map(move => ({...move, dir: -move.dir}));

return {
  pieces: best.pieces,
  scrambleMoves: best.moves,
  reverseMoves,
  stats: best.stats
};

Every legal ninety-degree rotation has an inverse. If we begin from a valid solution, apply a valid sequence and retain the opposite sequence, we know at least one route home. That is dramatically cheaper than solving every generated puzzle from scratch.

It is also a limited guarantee, and the limits matter. Reversibility does not prove uniqueness. It does not prove the saved path is shortest. It does not prove the state is enjoyable. It does not rule out shortcuts or alternate solutions. PolyPivot therefore does not confuse its generator with a full solver. This is the same distinction discussed in Generation is not solving: constructing an instance with one known solution path and proving global properties of the solution space are different engineering problems.

Self-tests turn reversibility into an executable property

reverseMoves would be little more than optimism if it were never replayed. The engine includes a selfTest() that walks every profile, every difficulty and several deterministic variants. Each generated puzzle must satisfy structural invariants.

The total area of solved pieces must equal the board area. Every piece must have enough cells and be connected. Pivot indices must reference real cells. The solved cover cannot overlap and must include every target cell. At least one dual-pivot piece must exist. The scramble cannot still be solved. Finally, the scrambled pieces are cloned and every reverse move is applied. The resulting state must satisfy the real win condition.

That last test is especially useful because it exercises the whole chain of assumptions: rotations really are invertible, pivots still point at the intended cells after scrambling, and the reverse ordering has been constructed correctly. A mathematical idea becomes a regression test.

Undo and redo do not cheat with the secret solution

The player has undo and redo, but those commands do not consume reverseMoves. They maintain snapshots of the actual play session. Before a rotation, current state is pushed into history. Undo moves the active state onto the redo stack and restores the previous snapshot; redo reverses that operation.

The distinction is important. reverseMoves belongs to generation and validation. Undo belongs to human interaction. If the UI used the generator’s reverse path, “undo” would quietly steer the player toward the hidden canonical route even after the player had taken a completely different sequence. The game should remember what the player did, not replace it with what the generator expected.

Hints follow the same philosophy. The current heuristic looks for a clearly problematic piece based on outside cells and overlaps. It does not reveal the next secret reverse move. The hint focuses attention without turning play into a replay of the generator’s private history.

Completing the board had to speak Blupoli’s shared language

PolyPivot landed at almost the same moment the progress system was being rebuilt around GameResult. That made it a useful pressure test for the new platform contract. The engine knows common measurements—size, difficulty and moves—and it also knows game-specific facts such as piece count and how many pieces have two pivots.

function completeIfNeeded() {
  if (!isSolvedPieces(pieces, profile())) return false;

  finished = true;
  clearSaved();
  render(false);

  solved({
    size: profile().label,
    difficulty: difficulty().label,
    moves,
    pieces: pieces.length,
    dualPivots: pieces.filter(piece => piece.pivots.length === 2).length
  });
  return true;
}

The common fields fit naturally. Specific values can be retained in normalized metadata. The engine does not import the progress repository or know anything about IndexedDB. It reports what it knows. This is exactly the kind of game we wanted to test against the contract: something new and domain-specific should not force a platform schema expansion every time it invents one metric.

The complementary story is in When the catalogue started remembering. PolyPivot is one of the clearest examples of why shared fields and extensible metadata need to coexist.

The onboarding broke, and that was useful evidence

The first PolyPivot feature commit was not the end of the implementation. A follow-up had to repair the interactive help runtime and align Blupoli state and progress. That is a useful reminder that a mechanically correct engine can still be an incomplete product.

A pivot mechanic is not well explained by hiding a paragraph below the board. Onboarding needs to point at a piece, explain the visible screw, show that a dual-axis piece can switch pivots, and clarify the difference between being inside the workspace and being correctly placed inside the target rectangle. If that layer fails to mount against the current runtime, the game breaks exactly where a new player learns how to interact with it.

The repair added coverage around PolyPivot’s interactive help and arrived alongside migration of runtime globals toward Blupoli. That evidence supports an older Devlog lesson from Finishing a puzzle is not the same as making it playable: solution detection is only one requirement. A public game also needs onboarding, shared controls, saved state, restoration, theming, localization, progress and stable host integration.

The brand namespace appeared inside the engine integration too

PolyPivot arrived during the removal of active references to the previous product namespace. That cleanup was not limited to visible copy. The host, globals and persisted runtime state were moving toward Blupoli contracts. A brand-new integration was therefore a useful test: if the engine or onboarding expected a legacy global, the migration still had a hole.

Follow-up fixes hardened the host around Blupoli globals, safer handling of missing game configuration and lossless migration of persisted state. There is no need to erase those corrections from the story. Adding a new engine during an architectural transition is valuable precisely because it exercises paths that old unit coverage may not have touched.

The editorial rule—use the old product name only as historical context—has a technical equivalent. Compatibility code can recognize an older namespace when doing so protects existing user state, but new runtime data should not continue producing fresh legacy state.

Difficulty has several levers instead of one magic number

PolyPivot currently defines four difficulty profiles with separate parameters. Target piece area changes. The ratio of dual-pivot pieces changes. The range of scramble rotations changes. The number of candidate scrambles changes. Expert therefore tends to be spatially denser, expose more pivot choices and begin further away according to the current heuristic.

We cannot honestly claim those four levels form a psychometrically calibrated difficulty curve. There is not enough player data to say that one parameter doubles perceived challenge or that expert has a particular completion rate. What we can say from the implementation is that difficulty adjusts several independent dimensions of the problem rather than simply renaming the same generator output.

That distinction matters. A procedural system can generate states that are more complex according to an internal function while still needing human tuning. The scramble score measures geometry. It does not measure frustration, elegance, or the moment when a player suddenly recognizes the key piece. Those are product qualities that will require actual play and observation.

Generating several candidates is a cheap way to reject weak scrambles

One random sequence can produce a technically valid but weak puzzle. Two rotations may almost cancel each other. Too many pieces may remain near their target. Instead of building a heavy optimiser, the engine samples several legal candidates and keeps the best according to a simple score.

This is useful because it separates validity from approximate quality. Every candidate still comes from legal moves and keeps a reversible route. Among valid states, the heuristic prefers more visible disorder. If later testing suggests a better metric—spatial spread, piece displacement, number of meaningful choices—we can replace the score without changing the fundamental solvability guarantee.

The score also stays private. The player never sees “difficulty 81 because the overlap score is high”. It is an internal selection tool, not a product truth that would become hard to revise.

SVG is not just a convenient renderer here

PolyPivot renders through SVG, which fits cell geometry, contours and pivot points particularly well. A piece can group shape, screw markers and selection state without manual rasterisation. Pivot markers remain explicit interactive elements, and the board stays crisp at different sizes.

The visual layer also has to represent shared cells. The occupancy map already required by game logic can drive cell colour. The same concept tells us whether a coordinate is empty, occupied by one piece or shared by several. Reusing the logical occupancy model in rendering reduces the chance that the screen appears to show a different overlap state from the one victory logic actually measures.

That does not make SVG a universal Blupoli rule. The project already contains different engine and rendering strategies because puzzles have different needs. PolyPivot happens to benefit from a surface where discrete geometry and visible interaction anchors are first-class.

A reproducible seed is also an editorial and debugging tool

Being able to reconstruct a puzzle from profile, difficulty and variant helps far beyond unit tests. It makes bug reports concrete. It makes screenshots reproducible. It lets us compare generator changes against known examples. If one expert configuration produces an awkward piece, we can keep the exact inputs instead of trying to describe “the weird one I saw yesterday”.

That is especially valuable in a large puzzle catalogue, where a report like “sometimes expert looks wrong” is nearly impossible to investigate. Controlled determinism turns an anecdote into a case.

There is a limitation: if the generation algorithm changes, the same seed inputs may produce different output even though the hash string is unchanged. If long-term puzzle identity eventually requires exact stability across engine versions, generation itself will need a versioned identity. At the moment, deterministic variants primarily support current-version testing, debugging and repeatable content.

What PolyPivot still does not prove

The generator guarantees at least one known solution path through reversal. It does not guarantee a unique solution. It does not calculate an optimal solution length. It does not prove every generated level has a minimum depth. The hint heuristic is not a solver. Colour mixing prioritizes visual readability rather than strict colorimetric modelling. Difficulty is parameterized, not yet statistically calibrated against a large player population.

Those limitations do not make the engine provisional. They define the boundary of what has actually been demonstrated. That precision makes future work easier. If uniqueness becomes important, we need state-space analysis. If hints need to become smarter, partial search can be introduced. If variants need stable identity forever, generation versioning can be added. None of that is required to state the current guarantee accurately: puzzles produced by this process have a valid, known, automatically verified route home.

Designing backwards was the most important decision

The PolyPivot pattern is useful beyond this one game. When forward generation creates a difficult validation problem, it can be worth searching for a reversible operation and constructing instances from a solved state. That does not replace a solver when uniqueness or full difficulty analysis matters. It can, however, radically reduce the cost of producing valid content.

The crucial condition is that the transformation must use the public rules of the game. PolyPivot’s scramble is made from the same pivot rotations the player can perform. Pieces are not teleported by a hidden generator-only operation. That is why reversing the sequence has meaning inside the actual mechanic.

The second lesson is that a puzzle idea does not end at its generator. PolyPivot still had to join the same platform as everything else: onboarding, saved state, undo/redo, localization, theme, progress, result contracts and quality gates. Its engine can remain original without becoming an island.

That balance may be the most important part of the work. We want Blupoli games to have room for mechanics that are genuinely strange without every new idea requiring a new platform. PolyPivot can have dual axes, mixed colours and reversible generation. When a session ends, it can still say something simple and shared: this game, this size, this difficulty, these moves, this result.

You can explore the game from the Blupoli Puzzles catalogue and read the player-facing introduction in the PolyPivot guide. To see how that completion becomes shared history, statistics and future challenge infrastructure, continue with When the catalogue started remembering.

PolyPivot began as a geometry question. It ended up becoming an architecture test as well: a game can be extremely different on the inside and still behave like a good Blupoli citizen on the outside. That is exactly the kind of difference we want to preserve.