Most puzzle generators assign difficulty based on the number of starting clues or the time a brute-force solver takes. Neither metric matches human perception. A grid with few clues might resolve instantly if every move is forced by a simple pattern, while a densely populated board can stall a player who hasn't spotted a specific contradiction. Solsticio, a daily sun/moon logic puzzle, derives its rating from the actual deduction tiers a solver must use to reach a solution.
The problem with counting givens
Counting empty cells assumes that every missing piece adds equal cognitive load. This fails when a puzzle forces a player to chain multiple complex inferences versus a simple scan. Brute-force timing is equally unreliable; it measures machine speed, not human friction. A solver that backtracks aggressively finds a path quickly, but a human relying on logical deduction gets stuck on the same branch.
To align the rating with the player's experience, the generator tracks which specific techniques are required. The system defines four distinct tiers of deduction, ranging from immediate pattern recognition to hypothetical contradiction testing.
export const TIER = {
TRIO: 1, // completar trío bloqueado (evitar 3 iguales seguidos)
BALANCE: 2, // equilibrio de fila/columna casi llena
CONNECTOR: 3, // aplicar conector = / ×
CONTRA1: 4, // deducción encadenada por contradicción a 1 paso
};
These tiers are not arbitrary labels. They represent a strict hierarchy of logical complexity. Tier 1 handles local patterns like preventing three identical symbols in a row. Tier 2 manages global balance, ensuring rows and columns do not exceed half the grid size for either symbol. Tier 3 applies connector rules, where adjacent cells with specific relationships force a value. Tier 4 requires assuming a value, propagating consequences, and detecting a contradiction to prove the assumption false.
Separating the sweeps
The solver does not run a monolithic algorithm. Instead, it executes a series of "sweeps," where each pass attempts to fill cells using only one specific technique. This separation is critical for accurate scoring. If a solver mixes techniques in a single pass, it becomes impossible to know which level of logic was actually necessary to solve a specific cell.
Each sweep function iterates through the grid, checking if a cell can be filled solely by its assigned rule. If a cell is filled, the counter for that tier increments. If a sweep detects a contradiction, the solver flags the puzzle as invalid. The logic for Tier 1, which prevents trios of identical symbols, looks like this:
function sweepTrio(cells, n, connIndex) {
let filled = 0;
for (let i = 0; i < cells.length; i++) {
if (cells[i] !== EMPTY) continue;
const t0 = createsTrio(cells, n, i, 0);
const t1 = createsTrio(cells, n, i, 1);
if (t0 && t1) return CONTRA;
if (t0 === t1) continue; // ninguno o ambos válidos por trío → no fuerza
const fv = t0 ? 1 : 0;
if (!localLegal(cells, n, i, fv, connIndex)) return CONTRA;
cells[i] = fv;
filled++;
}
return filled;
}
// Tier 2 — Equilibrio: un valor excede n/2 en fila/col ⇒ la celda toma el otro.
Notice that sweepTrio returns immediately if it finds a contradiction or if it fills a cell. It does not proceed to check balance or connectors. This isolation ensures that tierCounts accurately reflects the minimum set of tools required to solve the puzzle. The main loop in the solver orchestrates these sweeps in order of increasing complexity.
export function deductiveSolve(puzzle, { maxTier = TIER.CONTRA1 } = {}) {
const { n, connectors = [] } = puzzle;
const cells = (puzzle.clues || puzzle.cells).slice();
const connIndex = buildConnIndex(connectors);
const tierCounts = { 1: 0, 2: 0, 3: 0, 4: 0 };
let maxUsed = 0;
for (;;) {
if (isComplete(cells)) break;
let r = sweepTrio(cells, n, connIndex);
if (r === CONTRA) return fail(cells, maxUsed, tierCounts);
if (r > 0) { maxUsed = Math.max(maxUsed, 1); tierCounts[1] += r; continue; }
if (maxTier >= TIER.BALANCE) {
r = sweepBalance(cells, n, connIndex);
if (r === CONTRA) return fail(cells, maxUsed, tierCounts);
if (r > 0) { maxUsed = Math.max(maxUsed, 2); tierCounts[2] += r; continue; }
}
if (maxTier >= TIER.CONNECTOR) {
r = sweepConnector(cells, n, connIndex);
if (r === CONTRA) return fail(cells, maxUsed, tierCounts);
if (r > 0) { maxUsed = Math.max(maxUsed, 3); tierCounts[3] += r; continue; }
}
if (maxTier >= TIER.CONTRA1) {
r = sweepContradiction(cells, n, connIndex);
if (r === CONTRA) return fail(cells, maxUsed, tierCounts);
if (r > 0) { maxUsed = Math.max(maxUsed, 4); tierCounts[4] += r; continue; }
}
break; // atascado: no se puede avanzar solo con deducción hasta maxTier
}
return { solved: isComplete(cells), cells, maxTier: maxUsed, tierCounts };
}
The loop continues until the grid is complete or no further progress can be made with the allowed techniques. If the solver gets stuck, it means the puzzle requires a higher tier than currently permitted, or the puzzle is broken. By tracking maxUsed, the system knows exactly which tier was the bottleneck.
Handling the hardest cases
The most difficult puzzles require Tier 4 logic: contradiction. This is where the solver must temporarily assume a value for an empty cell, run the direct propagation rules, and see if that assumption leads to an impossible state. If it does, the original cell must hold the opposite value.
This is computationally more expensive than a simple scan, so the solver isolates it in its own function. It tries one value, runs the direct propagation engine, and checks for a contradiction signal.
function sweepContradiction(cells, n, connIndex) {
for (let i = 0; i < cells.length; i++) {
if (cells[i] !== EMPTY) continue;
for (let v = 0; v < 2; v++) {
if (!localLegal(cells, n, i, v, connIndex)) continue; // ese valor ya es ilegal por tiers 1-3
const trial = cells.slice();
trial[i] = v;
if (propagateDirect(trial, n, connIndex) === 'contradiction') {
const fv = 1 - v;
if (!localLegal(cells, n, i, fv, connIndex)) return CONTRA;
cells[i] = fv;
return 1;
}
}
}
return 0;
}
// ── API pública ──────────────────────────────────────────────────────────────
// Resuelve por deducción pura (sin adivinar). `maxTier` limita la técnica más
// avanzada permitida (para generar niveles de una dificultad acotada).
The propagateDirect function is the engine that drives this check. It runs the Tier 1, 2, and 3 sweeps repeatedly until no more cells can be filled. If this process hits a contradiction, the assumption in the trial grid was wrong.
function propagateDirect(cells, n, connIndex) {
for (;;) {
let r = sweepTrio(cells, n, connIndex);
if (r === CONTRA) return 'contradiction';
if (r > 0) continue;
r = sweepBalance(cells, n, connIndex);
if (r === CONTRA) return 'contradiction';
if (r > 0) continue;
r = sweepConnector(cells, n, connIndex);
if (r === CONTRA) return 'contradiction';
if (r > 0) continue;
return 'ok';
}
}
// Tier 4 — Contradicción a 1 paso: si asumir v en una celda lleva a contradicción
// tras propagación directa, entonces la celda debe ser el otro valor.
// Rellena UNA celda y devuelve (para reiniciar por técnicas más simples después).
This structure allows the difficulty scorer to count exactly how many times a contradiction step was necessary. A puzzle that requires one contradiction is harder than one requiring none, but a puzzle requiring ten contradictions is significantly harder still. The scoring function captures this nuance.
export function scoreDifficulty(solveResult, { n, givenRatio }) {
const { maxTier, tierCounts } = solveResult;
let score;
switch (maxTier) {
case 0: score = 1; break; // resuelto sin técnicas (casi todo dado)
case TIER.TRIO: score = 2; break; // solo tríos
case TIER.BALANCE: score = 3; break; // hasta equilibrio
case TIER.CONNECTOR: score = 4; break; // requiere conectores
case TIER.CONTRA1: // requiere contradicción encadenada
score = 5 + Math.min(4, Math.floor((tierCounts[4] - 1) / 3)); // 5..9
break;
default: score = 6;
}
// Menos pistas ⇒ más difícil; muchas pistas ⇒ más fácil.
if (givenRatio < 0.32) score += 1;
if (givenRatio > 0.5) score -= 1;
return Math.max(1, Math.min(10, score));
}
// Etiqueta legible (para UI / bench).
The score starts at a base determined by the highest tier used. If the puzzle requires Tier 4, the base is 5. It then adds points based on the number of contradiction steps found in tierCounts[4]. Finally, it adjusts the score based on the density of given clues. Fewer clues increase the score, while a high ratio of givens decreases it. The final result is clamped between 1 and 10.
Translating numbers to labels
Raw scores are useful for generation, but players need human-readable labels. The system maps the numeric score to four distinct categories.
export function difficultyLabel(score) {
if (score <= 2) return 'Fácil';
if (score <= 4) return 'Medio';
if (score <= 7) return 'Difícil';
return 'Experto';
}
A score of 2 or less is "Fácil," indicating the puzzle relies on simple patterns. "Medio" covers the range where balance and connector rules appear. "Difícil" and "Experto" capture the puzzles that force the solver into contradiction territory. This mapping ensures that a player sees "Difícil" only when the generator has confirmed the puzzle requires the specific logical steps associated with that label.
The daily puzzle generation pipeline uses these metrics to filter candidates. It generates a grid, solves it with the tiered solver, and calculates the score. If the score falls outside the target band for the day, the generator discards the grid and tries again. This process guarantees that every daily puzzle matches its intended difficulty, not by guesswork, but by the explicit logical steps a solver must take to finish it.
Top comments (0)