← back to Games Agentabrams
games/sudoku/index.html
800 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>NEON SUDOKU</title>
<style>
:root{
--bg:#0a0e14;
--panel:#0f151f;
--grid-line:#1c2a3a;
--grid-line-strong:#2fd2ff;
--given:#e6f4ff;
--entry:#38f0c0;
--note:#5f7a92;
--conflict:#ff3b5c;
--accent:#2fd2ff;
--sel:#12303f;
--sel-peer:#0e1f2b;
--sameval:#123448;
}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
html,body{margin:0;padding:0;height:100%;}
body{
background:var(--bg);
color:#cfe6f5;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
display:flex;
flex-direction:column;
align-items:center;
justify-content:flex-start;
min-height:100%;
padding:12px 10px 24px;
touch-action:manipulation;
overflow-x:hidden;
}
h1{
margin:4px 0 10px;
font-size:clamp(18px,5vw,30px);
font-weight:800;
letter-spacing:.34em;
text-transform:uppercase;
color:#eaf7ff;
text-shadow:0 0 8px rgba(47,210,255,.6),0 0 22px rgba(47,210,255,.25);
text-align:center;
}
.hud{
display:flex;
flex-wrap:wrap;
gap:8px 14px;
align-items:center;
justify-content:center;
margin-bottom:12px;
width:100%;
max-width:560px;
}
.hud .item{
display:flex;
flex-direction:column;
align-items:center;
line-height:1.1;
}
.hud .label{
font-size:10px;
letter-spacing:.18em;
text-transform:uppercase;
color:#5f7a92;
}
.hud .val{
font-size:16px;
font-weight:700;
font-variant-numeric:tabular-nums;
color:var(--accent);
text-shadow:0 0 8px rgba(47,210,255,.4);
}
.controls{
display:flex;
flex-wrap:wrap;
gap:8px;
justify-content:center;
margin-bottom:14px;
width:100%;
max-width:560px;
}
select,button{
font-family:inherit;
background:var(--panel);
color:#cfe6f5;
border:1px solid var(--grid-line);
border-radius:8px;
padding:9px 12px;
font-size:13px;
letter-spacing:.05em;
cursor:pointer;
transition:border-color .15s,box-shadow .15s,background .15s;
}
select:focus,button:focus{outline:none;border-color:var(--accent);}
button:hover,select:hover{border-color:var(--accent);}
button:active{transform:translateY(1px);}
button.primary{
border-color:var(--accent);
color:#eaf7ff;
box-shadow:0 0 10px rgba(47,210,255,.25) inset;
}
button.toggled{
background:#123448;
border-color:var(--entry);
color:var(--entry);
box-shadow:0 0 10px rgba(56,240,192,.3) inset;
}
#boardWrap{
width:min(94vw,520px);
aspect-ratio:1/1;
max-height:60vh;
margin-bottom:16px;
}
#board{
width:100%;
height:100%;
display:grid;
grid-template-columns:repeat(9,1fr);
grid-template-rows:repeat(9,1fr);
background:var(--grid-line-strong);
border:2px solid var(--grid-line-strong);
border-radius:6px;
gap:1px;
box-shadow:0 0 22px rgba(47,210,255,.25);
overflow:hidden;
}
.cell{
position:relative;
background:var(--panel);
display:flex;
align-items:center;
justify-content:center;
font-size:clamp(16px,5vw,28px);
font-weight:600;
color:var(--entry);
cursor:pointer;
user-select:none;
font-variant-numeric:tabular-nums;
}
.cell.given{color:var(--given);font-weight:700;}
/* thick 3x3 box borders using margins */
.cell.br{margin-right:1px;box-shadow:2px 0 0 0 var(--grid-line-strong);}
.cell.bb{margin-bottom:1px;box-shadow:0 2px 0 0 var(--grid-line-strong);}
.cell.br.bb{box-shadow:2px 0 0 0 var(--grid-line-strong),0 2px 0 0 var(--grid-line-strong);}
.cell.peer{background:var(--sel-peer);}
.cell.sameval{background:var(--sameval);}
.cell.selected{background:var(--sel);box-shadow:inset 0 0 0 2px var(--accent);}
.cell.selected.br,.cell.selected.bb,.cell.selected.br.bb{box-shadow:inset 0 0 0 2px var(--accent);}
.cell.conflict{color:var(--conflict);text-shadow:0 0 10px rgba(255,59,92,.7);}
.cell.conflict.given{color:var(--conflict);}
.notes{
position:absolute;
inset:2px;
display:grid;
grid-template-columns:repeat(3,1fr);
grid-template-rows:repeat(3,1fr);
pointer-events:none;
}
.notes span{
display:flex;
align-items:center;
justify-content:center;
font-size:clamp(7px,2vw,11px);
font-weight:600;
color:var(--note);
line-height:1;
}
.pad{
display:grid;
grid-template-columns:repeat(5,1fr);
gap:8px;
width:min(94vw,520px);
margin-bottom:10px;
}
.pad button{
padding:14px 0;
font-size:20px;
font-weight:700;
border-radius:10px;
}
.pad .num{color:#eaf7ff;}
.pad .fn{font-size:12px;letter-spacing:.08em;}
.msg{
min-height:22px;
font-size:14px;
letter-spacing:.1em;
text-transform:uppercase;
color:var(--entry);
text-shadow:0 0 10px rgba(56,240,192,.5);
text-align:center;
font-weight:700;
}
.best{font-size:11px;color:#5f7a92;letter-spacing:.12em;text-transform:uppercase;text-align:center;margin-top:2px;}
@media (max-width:360px){
h1{letter-spacing:.2em;}
.pad button{padding:11px 0;font-size:17px;}
}
</style>
</head>
<body>
<h1>Neon Sudoku</h1>
<div class="hud">
<div class="item"><span class="label">Time</span><span class="val" id="timer">00:00</span></div>
<div class="item"><span class="label">Difficulty</span><span class="val" id="diffLbl">Easy</span></div>
<div class="item"><span class="label">Mode</span><span class="val" id="modeLbl">Fill</span></div>
</div>
<div class="controls">
<select id="diffSel" aria-label="Difficulty">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
<button class="primary" id="newBtn">New Puzzle</button>
<button id="notesBtn">Notes: Off</button>
</div>
<div id="boardWrap"><div id="board" aria-label="Sudoku board"></div></div>
<div class="pad" id="pad"></div>
<div class="msg" id="msg"></div>
<div class="best" id="best"></div>
<script>
(function(){
"use strict";
var STORE_KEY = "abrams-sudoku";
var SIZE = 9;
// ---------- utilities ----------
function shuffled(arr){
var a = arr.slice();
for (var i = a.length - 1; i > 0; i--){
var j = Math.floor(Math.random() * (i + 1));
var t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
// ---------- solution generator (bounded backtracking) ----------
// Returns a full valid 9x9 grid (array of 81 ints 1-9), or null on failure.
function generateSolution(){
var grid = new Array(81).fill(0);
var steps = { count: 0 };
var MAX_STEPS = 600000; // hard cap so we can never spin forever
function fits(idx, val){
var r = (idx / 9) | 0, c = idx % 9;
var br = r - r % 3, bc = c - c % 3;
for (var i = 0; i < 9; i++){
if (grid[r*9 + i] === val) return false;
if (grid[i*9 + c] === val) return false;
}
for (var rr = 0; rr < 3; rr++){
for (var cc = 0; cc < 3; cc++){
if (grid[(br+rr)*9 + (bc+cc)] === val) return false;
}
}
return true;
}
function solve(pos){
if (steps.count++ > MAX_STEPS) return false;
if (pos >= 81) return true;
if (grid[pos] !== 0) return solve(pos + 1);
var nums = shuffled([1,2,3,4,5,6,7,8,9]);
for (var k = 0; k < 9; k++){
var v = nums[k];
if (fits(pos, v)){
grid[pos] = v;
if (solve(pos + 1)) return true;
grid[pos] = 0;
}
if (steps.count > MAX_STEPS) return false;
}
return false;
}
if (solve(0)) return grid;
return null;
}
// ---------- solver: count solutions up to a limit (for uniqueness) ----------
function countSolutions(puzzle, limit){
var grid = puzzle.slice();
var steps = { count: 0 };
var MAX_STEPS = 400000;
var found = 0;
function fits(idx, val){
var r = (idx / 9) | 0, c = idx % 9;
var br = r - r % 3, bc = c - c % 3;
for (var i = 0; i < 9; i++){
if (grid[r*9 + i] === val) return false;
if (grid[i*9 + c] === val) return false;
}
for (var rr = 0; rr < 3; rr++){
for (var cc = 0; cc < 3; cc++){
if (grid[(br+rr)*9 + (bc+cc)] === val) return false;
}
}
return true;
}
// find the empty cell with fewest candidates (MRV) to keep it fast/bounded
function pickCell(){
var best = -1, bestCount = 10;
for (var idx = 0; idx < 81; idx++){
if (grid[idx] !== 0) continue;
var cnt = 0;
for (var v = 1; v <= 9; v++){ if (fits(idx, v)) cnt++; }
if (cnt < bestCount){ bestCount = cnt; best = idx; if (cnt <= 1) break; }
}
return best;
}
function solve(){
if (steps.count++ > MAX_STEPS) return true; // bail -> treat as "give up", stops recursion
var idx = pickCell();
if (idx === -1){ found++; return found >= limit; }
for (var v = 1; v <= 9; v++){
if (fits(idx, v)){
grid[idx] = v;
if (solve()) { grid[idx] = 0; return true; }
grid[idx] = 0;
}
if (steps.count > MAX_STEPS) return true;
}
return false;
}
solve();
return found;
}
// ---------- build a puzzle at difficulty ----------
var DIFF = {
easy: { clues: 40 },
medium: { clues: 32 },
hard: { clues: 26 }
};
// Make puzzle: start from full solution, remove cells (symmetric) while
// keeping a unique solution, bounded by attempts. Falls back gracefully.
function makePuzzle(difficulty){
var solution = null;
// bounded retries to obtain a solution
for (var g = 0; g < 8 && !solution; g++){
solution = generateSolution();
}
if (!solution){
// absolute fallback: a hard-coded valid solved grid
solution = FALLBACK_SOLUTION.slice();
}
var targetClues = (DIFF[difficulty] || DIFF.easy).clues;
var puzzle = solution.slice();
var order = shuffled((function(){ var a=[]; for(var i=0;i<81;i++) a.push(i); return a; })());
var removed = 0;
var toRemove = 81 - targetClues;
var attempts = 0;
var MAX_ATTEMPTS = 200; // hard cap on removal attempts
for (var oi = 0; oi < order.length && removed < toRemove && attempts < MAX_ATTEMPTS; oi++){
var idx = order[oi];
if (puzzle[idx] === 0) continue;
attempts++;
var saved = puzzle[idx];
puzzle[idx] = 0;
// keep unique: count solutions up to 2
var cnt = countSolutions(puzzle, 2);
if (cnt !== 1){
puzzle[idx] = saved; // revert, not removable
} else {
removed++;
}
}
return { puzzle: puzzle, solution: solution };
}
// A known-good completed grid used only if generation ever fails entirely.
var FALLBACK_SOLUTION = [
5,3,4, 6,7,8, 9,1,2,
6,7,2, 1,9,5, 3,4,8,
1,9,8, 3,4,2, 5,6,7,
8,5,9, 7,6,1, 4,2,3,
4,2,6, 8,5,3, 7,9,1,
7,1,3, 9,2,4, 8,5,6,
9,6,1, 5,3,7, 2,8,4,
2,8,7, 4,1,9, 6,3,5,
3,4,5, 2,8,6, 1,7,9
];
// ---------- game state ----------
var state = {
puzzle: [], // starting clues (0 = empty)
solution: [], // full solution
values: [], // current user values (includes givens)
notes: [], // array of Set-like objects per cell
given: [], // boolean per cell
selected: -1,
notesMode: false,
difficulty: "easy",
startTime: 0,
elapsed: 0,
running: false,
solved: false
};
var timerHandle = null;
// ---------- DOM refs ----------
var boardEl = document.getElementById("board");
var padEl = document.getElementById("pad");
var timerEl = document.getElementById("timer");
var diffLblEl = document.getElementById("diffLbl");
var modeLblEl = document.getElementById("modeLbl");
var diffSelEl = document.getElementById("diffSel");
var newBtn = document.getElementById("newBtn");
var notesBtn = document.getElementById("notesBtn");
var msgEl = document.getElementById("msg");
var bestEl = document.getElementById("best");
var cellEls = [];
function buildBoard(){
boardEl.innerHTML = "";
cellEls = [];
for (var idx = 0; idx < 81; idx++){
var r = (idx / 9) | 0, c = idx % 9;
var cell = document.createElement("div");
cell.className = "cell";
if (c === 2 || c === 5) cell.classList.add("br");
if (r === 2 || r === 5) cell.classList.add("bb");
cell.setAttribute("data-idx", String(idx));
(function(i){
cell.addEventListener("click", function(){ selectCell(i); });
})(idx);
var notes = document.createElement("div");
notes.className = "notes";
for (var n = 1; n <= 9; n++){
var sp = document.createElement("span");
sp.setAttribute("data-n", String(n));
notes.appendChild(sp);
}
cell.appendChild(notes);
boardEl.appendChild(cell);
cellEls.push(cell);
}
}
function buildPad(){
padEl.innerHTML = "";
for (var n = 1; n <= 9; n++){
var b = document.createElement("button");
b.className = "num";
b.textContent = String(n);
(function(v){ b.addEventListener("click", function(){ inputNumber(v); }); })(n);
padEl.appendChild(b);
}
var eraseB = document.createElement("button");
eraseB.className = "fn";
eraseB.textContent = "ERASE";
eraseB.addEventListener("click", function(){ inputNumber(0); });
padEl.appendChild(eraseB);
}
// ---------- timer ----------
function fmt(sec){
var m = Math.floor(sec / 60), s = sec % 60;
return (m < 10 ? "0" : "") + m + ":" + (s < 10 ? "0" : "") + s;
}
function startTimer(){
stopTimer();
state.startTime = Date.now() - state.elapsed * 1000;
state.running = true;
timerHandle = setInterval(function(){
if (!state.running) return;
state.elapsed = Math.floor((Date.now() - state.startTime) / 1000);
timerEl.textContent = fmt(state.elapsed);
}, 250);
}
function stopTimer(){
state.running = false;
if (timerHandle){ clearInterval(timerHandle); timerHandle = null; }
}
// ---------- best times ----------
function loadBest(){
try {
var raw = localStorage.getItem(STORE_KEY);
if (!raw) return {};
var obj = JSON.parse(raw);
return (obj && typeof obj === "object") ? obj : {};
} catch (e) { return {}; }
}
function saveBest(obj){
try { localStorage.setItem(STORE_KEY, JSON.stringify(obj)); } catch (e) {}
}
function showBest(){
var b = loadBest();
var t = b[state.difficulty];
if (typeof t === "number"){
bestEl.textContent = "Best (" + state.difficulty + "): " + fmt(t);
} else {
bestEl.textContent = "Best (" + state.difficulty + "): --:--";
}
}
function recordBest(){
var b = loadBest();
var cur = b[state.difficulty];
if (typeof cur !== "number" || state.elapsed < cur){
b[state.difficulty] = state.elapsed;
saveBest(b);
return true;
}
return false;
}
// ---------- conflict detection ----------
function conflictSet(){
var conflicts = {};
// rows, cols, boxes
function check(indices){
var seen = {};
for (var i = 0; i < indices.length; i++){
var v = state.values[indices[i]];
if (v === 0) continue;
if (seen[v] === undefined) seen[v] = [indices[i]];
else seen[v].push(indices[i]);
}
for (var val in seen){
if (seen[val].length > 1){
for (var j = 0; j < seen[val].length; j++){
conflicts[seen[val][j]] = true;
}
}
}
}
var r, c, i;
for (r = 0; r < 9; r++){
var row = [];
for (c = 0; c < 9; c++) row.push(r*9 + c);
check(row);
}
for (c = 0; c < 9; c++){
var col = [];
for (r = 0; r < 9; r++) col.push(r*9 + c);
check(col);
}
for (var br = 0; br < 3; br++){
for (var bc = 0; bc < 3; bc++){
var box = [];
for (var rr = 0; rr < 3; rr++)
for (var cc = 0; cc < 3; cc++)
box.push((br*3+rr)*9 + (bc*3+cc));
check(box);
}
}
return conflicts;
}
// ---------- rendering ----------
function render(){
var conflicts = conflictSet();
var selVal = state.selected >= 0 ? state.values[state.selected] : 0;
var selR = state.selected >= 0 ? (state.selected / 9) | 0 : -1;
var selC = state.selected >= 0 ? state.selected % 9 : -1;
for (var idx = 0; idx < 81; idx++){
var cell = cellEls[idx];
var v = state.values[idx];
// reset classes but keep border classes + base
cell.classList.remove("given","conflict","selected","peer","sameval");
if (state.given[idx]) cell.classList.add("given");
var notesEl = cell.querySelector(".notes");
var textNode = cell.childNodes[cell.childNodes.length - 1];
// We store text in a dedicated text node: rebuild it
// Clear existing value text node (first non-notes child)
// Simpler: set cell's own text via a data span
// Remove any prior value text nodes:
for (var k = cell.childNodes.length - 1; k >= 0; k--){
var ch = cell.childNodes[k];
if (ch.nodeType === 3) cell.removeChild(ch);
}
if (v !== 0){
notesEl.style.display = "none";
cell.insertBefore(document.createTextNode(String(v)), notesEl);
} else {
notesEl.style.display = "";
var set = state.notes[idx];
var spans = notesEl.querySelectorAll("span");
for (var s = 0; s < spans.length; s++){
var n = s + 1;
spans[s].textContent = (set && set[n]) ? String(n) : "";
}
}
if (conflicts[idx]) cell.classList.add("conflict");
// highlighting relative to selection
if (state.selected >= 0 && idx !== state.selected){
var r = (idx / 9) | 0, c = idx % 9;
var sameBox = ((r - r%3) === (selR - selR%3)) && ((c - c%3) === (selC - selC%3));
if (r === selR || c === selC || sameBox) cell.classList.add("peer");
if (selVal !== 0 && v === selVal) cell.classList.add("sameval");
}
}
if (state.selected >= 0){
cellEls[state.selected].classList.add("selected");
}
}
function selectCell(idx){
state.selected = idx;
render();
}
// ---------- input ----------
function inputNumber(v){
if (state.selected < 0 || state.solved) return;
var idx = state.selected;
if (state.given[idx]) return;
if (v === 0){
// erase
state.values[idx] = 0;
state.notes[idx] = {};
render();
return;
}
if (state.notesMode){
if (state.values[idx] !== 0) state.values[idx] = 0;
if (!state.notes[idx]) state.notes[idx] = {};
state.notes[idx][v] = !state.notes[idx][v];
render();
} else {
state.values[idx] = (state.values[idx] === v) ? 0 : v;
state.notes[idx] = {};
render();
checkSolved();
}
}
function checkSolved(){
// must be fully filled
for (var i = 0; i < 81; i++){
if (state.values[i] === 0) return;
}
// must match solution (guarantees valid & correct)
for (var j = 0; j < 81; j++){
if (state.values[j] !== state.solution[j]) return;
}
// solved!
state.solved = true;
stopTimer();
var isBest = recordBest();
showBest();
msgEl.textContent = isBest ? "Solved! New best time!" : "Solved!";
}
// ---------- new game ----------
function newGame(){
msgEl.textContent = "Generating...";
// defer so the "Generating..." paints before the (bounded) work
setTimeout(function(){
try {
var built = makePuzzle(state.difficulty);
state.puzzle = built.puzzle.slice();
state.solution = built.solution.slice();
state.values = built.puzzle.slice();
state.given = [];
state.notes = [];
for (var i = 0; i < 81; i++){
state.given.push(built.puzzle[i] !== 0);
state.notes.push({});
}
state.selected = -1;
state.solved = false;
state.elapsed = 0;
timerEl.textContent = "00:00";
msgEl.textContent = "";
render();
startTimer();
showBest();
} catch (e) {
// never throw to the page; fail to a trivial fallback
msgEl.textContent = "";
var sol = FALLBACK_SOLUTION.slice();
var pz = sol.slice();
// blank out a handful of cells deterministically
var blanks = [0,10,20,30,40,50,60,70,80];
for (var b = 0; b < blanks.length; b++) pz[blanks[b]] = 0;
state.puzzle = pz.slice();
state.solution = sol.slice();
state.values = pz.slice();
state.given = [];
state.notes = [];
for (var k = 0; k < 81; k++){ state.given.push(pz[k] !== 0); state.notes.push({}); }
state.selected = -1;
state.solved = false;
state.elapsed = 0;
timerEl.textContent = "00:00";
render();
startTimer();
showBest();
}
}, 20);
}
// ---------- controls ----------
function setNotesMode(on){
state.notesMode = on;
notesBtn.textContent = "Notes: " + (on ? "On" : "Off");
notesBtn.classList.toggle("toggled", on);
modeLblEl.textContent = on ? "Notes" : "Fill";
}
function moveSelection(dr, dc){
var idx = state.selected;
if (idx < 0){ selectCell(0); return; }
var r = (idx / 9) | 0, c = idx % 9;
r = Math.max(0, Math.min(8, r + dr));
c = Math.max(0, Math.min(8, c + dc));
selectCell(r*9 + c);
}
function onKey(e){
var key = e.key;
if (key >= "1" && key <= "9"){
inputNumber(parseInt(key, 10));
e.preventDefault();
return;
}
if (key === "0" || key === "Backspace" || key === "Delete"){
inputNumber(0);
e.preventDefault();
return;
}
if (key === "ArrowUp"){ moveSelection(-1, 0); e.preventDefault(); return; }
if (key === "ArrowDown"){ moveSelection(1, 0); e.preventDefault(); return; }
if (key === "ArrowLeft"){ moveSelection(0, -1); e.preventDefault(); return; }
if (key === "ArrowRight"){ moveSelection(0, 1); e.preventDefault(); return; }
if (key === "n" || key === "N"){ setNotesMode(!state.notesMode); e.preventDefault(); return; }
}
// ---------- init ----------
function init(){
buildBoard();
buildPad();
diffSelEl.addEventListener("change", function(){
state.difficulty = diffSelEl.value;
diffLblEl.textContent = diffSelEl.options[diffSelEl.selectedIndex].text;
newGame();
});
newBtn.addEventListener("click", function(){ newGame(); });
notesBtn.addEventListener("click", function(){ setNotesMode(!state.notesMode); });
document.addEventListener("keydown", onKey);
// pause timer when tab hidden, resume on return
document.addEventListener("visibilitychange", function(){
if (document.hidden){
state.running = false;
} else if (!state.solved && state.values.length){
state.startTime = Date.now() - state.elapsed * 1000;
state.running = true;
}
});
state.difficulty = diffSelEl.value;
diffLblEl.textContent = diffSelEl.options[diffSelEl.selectedIndex].text;
setNotesMode(false);
showBest();
newGame();
}
if (document.readyState === "loading"){
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();
</script>
</body>
</html>