Here is a tiny grid with a few open slots and a fixed list of words. Pick a word for each slot. Wherever an across and a down slot share a square, their letters must match — a single clash makes the whole fill illegal.
<p class="hint">Pick a 3-letter word for each slot. Wherever an Across and a Down slot share a square, their letters <b>must</b> match. Checking a fill is instant — finding one means backtracking through a tree of choices.</p>
<div id="grid" class="grid"></div>
<div class="slots" id="slots"></div>
<div class="status" id="status">Choose a word for every slot.</div>
<div class="btns">
<button id="check" type="button">Check fill</button>
<button id="solve" type="button">Auto-solve</button>
<button id="reset" type="button" class="ghost">Clear</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.grid { display: grid; grid-template-columns: repeat(3, 44px); gap: 3px; margin: .4rem 0; }
.sq { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
font: 700 20px ui-monospace, monospace; border-radius: 6px; user-select: none; text-transform: uppercase; }
.sq.fill { background: #fff; border: 1px solid #1d3557; color: #1d3557; }
.sq.fill.clash { background: #ffe0e3; border-color: #c92f3c; color: #c92f3c; }
.sq.block { background: #1d3557; }
.slots { display: flex; flex-direction: column; gap: .4rem; margin: .6rem 0; }
.slot { display: flex; align-items: center; gap: .5rem; font-size: .9rem; }
.slot label { min-width: 86px; font-weight: 600; color: #1d3557; }
select { font: 600 14px ui-monospace, monospace; padding: .3rem .5rem; border: 1px solid #adb1b8;
border-radius: 6px; text-transform: uppercase; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Crossword construction: fit words from a fixed list into a grid so that every crossing square agrees on one letter. Checking a fill is a quick scan of the crossings; finding one is NP-complete (Garey & Johnson, 1979) — a backtracking search over the tree of word choices, pruning whenever two letters clash.
const WORDS = ['CAT','DOG','CAD','TUG','BAT','GUM','RID','SUN'];
// Slots: each is a list of [row,col] cells (all length 3). Two slots that share a cell impose an equality constraint on the letters placed there.
const SLOTS = {
A1: { cells: [[0,0],[0,1],[0,2]] }, // across, top row
A2: { cells: [[2,0],[2,1],[2,2]] }, // across, bottom row
D1: { cells: [[0,0],[1,0],[2,0]] }, // down, left column
D2: { cells: [[0,2],[1,2],[2,2]] }, // down, right column
};
const ORDER = ['A1','A2','D1','D2'];
const LABEL = { A1:'Across 1', A2:'Across 2', D1:'Down 1', D2:'Down 2' };
const key = (r,c) => r + ',' + c;
// Cells that are part of some slot; everything else is a black block.
const CELLS = new Set();
ORDER.forEach(s => SLOTS[s].cells.forEach(([r,c]) => CELLS.add(key(r,c))));
const choice = {}; // slot -> word (or '')
ORDER.forEach(s => choice[s] = '');
// Letter each slot places on a cell, given current choices.
function letterAt(r,c){
const out = [];
for(const s of ORDER){
const w = choice[s]; if(!w) continue;
SLOTS[s].cells.forEach(([cr,cc],i) => { if(cr===r && cc===c) out.push(w[i]); });
}
return out;
}
function buildSelectors(){
const host = document.getElementById('slots'); host.innerHTML='';
for(const s of ORDER){
const row = document.createElement('div'); row.className='slot';
const lab = document.createElement('label'); lab.textContent = LABEL[s] + ':';
const sel = document.createElement('select');
const blank = document.createElement('option'); blank.value=''; blank.textContent='— pick —'; sel.appendChild(blank);
WORDS.forEach(w => { const o=document.createElement('option'); o.value=w; o.textContent=w; sel.appendChild(o); });
sel.value = choice[s];
sel.addEventListener('change', () => { choice[s]=sel.value; render(); setStatus('Choose a word for every slot.',''); });
row.appendChild(lab); row.appendChild(sel); host.appendChild(row);
}
}
function render(){
const g = document.getElementById('grid'); g.innerHTML='';
for(let r=0;r<3;r++)for(let c=0;c<3;c++){
const d=document.createElement('div'); const k=key(r,c);
if(!CELLS.has(k)){ d.className='sq block'; g.appendChild(d); continue; }
const ls = letterAt(r,c);
const clash = ls.length>1 && ls.some(x => x!==ls[0]);
d.className='sq fill'+(clash?' clash':'');
d.textContent = ls.length ? ls[0] : '';
g.appendChild(d);
}
// refresh selector values (auto-solve changes them)
const sels = document.querySelectorAll('#slots select');
sels.forEach((sel,i) => sel.value = choice[ORDER[i]]);
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
// A fill is valid iff every slot has a word and no crossing square clashes.
function valid(assign){
for(const s of ORDER) if(!assign[s]) return false;
for(let r=0;r<3;r++)for(let c=0;c<3;c++){
if(!CELLS.has(key(r,c))) continue;
const ls=[];
for(const s of ORDER){ const w=assign[s]; SLOTS[s].cells.forEach(([cr,cc],i)=>{ if(cr===r&&cc===c) ls.push(w[i]); }); }
if(ls.length>1 && ls.some(x=>x!==ls[0])) return false;
}
return true;
}
// Partial check: do the slots already chosen agree on shared cells?
function consistent(assign){
for(let r=0;r<3;r++)for(let c=0;c<3;c++){
if(!CELLS.has(key(r,c))) continue;
const ls=[];
for(const s of ORDER){ const w=assign[s]; if(!w) continue; SLOTS[s].cells.forEach(([cr,cc],i)=>{ if(cr===r&&cc===c) ls.push(w[i]); }); }
if(ls.length>1 && ls.some(x=>x!==ls[0])) return false;
}
return true;
}
function check(){
if(valid(choice)) setStatus('✓ Every crossing agrees — valid crossword!','ok');
else setStatus('✗ A slot is empty or two letters clash. Keep fitting.','bad');
}
// Backtracking search: assign words slot by slot, pruning the moment a crossing clashes.
function solve(){
let nodes=0; const assign={};
function bt(i){
if(i===ORDER.length) return consistent(assign) && Object.keys(assign).length===ORDER.length;
const s=ORDER[i];
for(const w of WORDS){ nodes++; assign[s]=w;
if(consistent(assign)){ if(bt(i+1)) return true; } }
delete assign[s]; return false;
}
if(bt(0)){ ORDER.forEach(s=>choice[s]=assign[s]); render();
setStatus(fmt('Solved by backtracking after %nodes% word placements.', { nodes: nodes }),'ok');
console.log('Solution:', ORDER.map(s=>s+'='+choice[s]).join(' '), '| placements', nodes); }
else { setStatus('No fill exists for this grid and word list.','bad'); }
}
document.getElementById('check').addEventListener('click',check);
document.getElementById('solve').addEventListener('click',solve);
document.getElementById('reset').addEventListener('click',()=>{ ORDER.forEach(s=>choice[s]=''); render(); setStatus('Choose a word for every slot.',''); });
buildSelectors(); render();
<p class="hint">Pick a 3-letter word for each slot. Wherever an Across and a Down slot share a square, their letters <b>must</b> match. Checking a fill is instant — finding one means backtracking through a tree of choices.</p>
<div id="grid" class="grid"></div>
<div class="slots" id="slots"></div>
<div class="status" id="status">Choose a word for every slot.</div>
<div class="btns">
<button id="check" type="button">Check fill</button>
<button id="solve" type="button">Auto-solve</button>
<button id="reset" type="button" class="ghost">Clear</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.grid { display: grid; grid-template-columns: repeat(3, 44px); gap: 3px; margin: .4rem 0; }
.sq { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center;
font: 700 20px ui-monospace, monospace; border-radius: 6px; user-select: none; text-transform: uppercase; }
.sq.fill { background: #fff; border: 1px solid #1d3557; color: #1d3557; }
.sq.fill.clash { background: #ffe0e3; border-color: #c92f3c; color: #c92f3c; }
.sq.block { background: #1d3557; }
.slots { display: flex; flex-direction: column; gap: .4rem; margin: .6rem 0; }
.slot { display: flex; align-items: center; gap: .5rem; font-size: .9rem; }
.slot label { min-width: 86px; font-weight: 600; color: #1d3557; }
select { font: 600 14px ui-monospace, monospace; padding: .3rem .5rem; border: 1px solid #adb1b8;
border-radius: 6px; text-transform: uppercase; }
.status { font-size: 1rem; font-weight: 600; margin: .5rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Crossword construction: fit words from a fixed list into a grid so that every crossing square agrees on one letter. Checking a fill is a quick scan of the crossings; finding one is NP-complete (Garey & Johnson, 1979) — a backtracking search over the tree of word choices, pruning whenever two letters clash.
const WORDS = ['CAT','DOG','CAD','TUG','BAT','GUM','RID','SUN'];
// Slots: each is a list of [row,col] cells (all length 3). Two slots that share a cell impose an equality constraint on the letters placed there.
const SLOTS = {
A1: { cells: [[0,0],[0,1],[0,2]] }, // across, top row
A2: { cells: [[2,0],[2,1],[2,2]] }, // across, bottom row
D1: { cells: [[0,0],[1,0],[2,0]] }, // down, left column
D2: { cells: [[0,2],[1,2],[2,2]] }, // down, right column
};
const ORDER = ['A1','A2','D1','D2'];
const LABEL = { A1:'Across 1', A2:'Across 2', D1:'Down 1', D2:'Down 2' };
const key = (r,c) => r + ',' + c;
// Cells that are part of some slot; everything else is a black block.
const CELLS = new Set();
ORDER.forEach(s => SLOTS[s].cells.forEach(([r,c]) => CELLS.add(key(r,c))));
const choice = {}; // slot -> word (or '')
ORDER.forEach(s => choice[s] = '');
// Letter each slot places on a cell, given current choices.
function letterAt(r,c){
const out = [];
for(const s of ORDER){
const w = choice[s]; if(!w) continue;
SLOTS[s].cells.forEach(([cr,cc],i) => { if(cr===r && cc===c) out.push(w[i]); });
}
return out;
}
function buildSelectors(){
const host = document.getElementById('slots'); host.innerHTML='';
for(const s of ORDER){
const row = document.createElement('div'); row.className='slot';
const lab = document.createElement('label'); lab.textContent = LABEL[s] + ':';
const sel = document.createElement('select');
const blank = document.createElement('option'); blank.value=''; blank.textContent='— pick —'; sel.appendChild(blank);
WORDS.forEach(w => { const o=document.createElement('option'); o.value=w; o.textContent=w; sel.appendChild(o); });
sel.value = choice[s];
sel.addEventListener('change', () => { choice[s]=sel.value; render(); setStatus('Choose a word for every slot.',''); });
row.appendChild(lab); row.appendChild(sel); host.appendChild(row);
}
}
function render(){
const g = document.getElementById('grid'); g.innerHTML='';
for(let r=0;r<3;r++)for(let c=0;c<3;c++){
const d=document.createElement('div'); const k=key(r,c);
if(!CELLS.has(k)){ d.className='sq block'; g.appendChild(d); continue; }
const ls = letterAt(r,c);
const clash = ls.length>1 && ls.some(x => x!==ls[0]);
d.className='sq fill'+(clash?' clash':'');
d.textContent = ls.length ? ls[0] : '';
g.appendChild(d);
}
// refresh selector values (auto-solve changes them)
const sels = document.querySelectorAll('#slots select');
sels.forEach((sel,i) => sel.value = choice[ORDER[i]]);
}
function setStatus(t,cls){ const s=document.getElementById('status'); s.textContent=t; s.className='status '+cls; }
// A fill is valid iff every slot has a word and no crossing square clashes.
function valid(assign){
for(const s of ORDER) if(!assign[s]) return false;
for(let r=0;r<3;r++)for(let c=0;c<3;c++){
if(!CELLS.has(key(r,c))) continue;
const ls=[];
for(const s of ORDER){ const w=assign[s]; SLOTS[s].cells.forEach(([cr,cc],i)=>{ if(cr===r&&cc===c) ls.push(w[i]); }); }
if(ls.length>1 && ls.some(x=>x!==ls[0])) return false;
}
return true;
}
// Partial check: do the slots already chosen agree on shared cells?
function consistent(assign){
for(let r=0;r<3;r++)for(let c=0;c<3;c++){
if(!CELLS.has(key(r,c))) continue;
const ls=[];
for(const s of ORDER){ const w=assign[s]; if(!w) continue; SLOTS[s].cells.forEach(([cr,cc],i)=>{ if(cr===r&&cc===c) ls.push(w[i]); }); }
if(ls.length>1 && ls.some(x=>x!==ls[0])) return false;
}
return true;
}
function check(){
if(valid(choice)) setStatus('✓ Every crossing agrees — valid crossword!','ok');
else setStatus('✗ A slot is empty or two letters clash. Keep fitting.','bad');
}
// Backtracking search: assign words slot by slot, pruning the moment a crossing clashes.
function solve(){
let nodes=0; const assign={};
function bt(i){
if(i===ORDER.length) return consistent(assign) && Object.keys(assign).length===ORDER.length;
const s=ORDER[i];
for(const w of WORDS){ nodes++; assign[s]=w;
if(consistent(assign)){ if(bt(i+1)) return true; } }
delete assign[s]; return false;
}
if(bt(0)){ ORDER.forEach(s=>choice[s]=assign[s]); render();
setStatus(fmt('Solved by backtracking after %nodes% word placements.', { nodes: nodes }),'ok');
console.log('Solution:', ORDER.map(s=>s+'='+choice[s]).join(' '), '| placements', nodes); }
else { setStatus('No fill exists for this grid and word list.','bad'); }
}
document.getElementById('check').addEventListener('click',check);
document.getElementById('solve').addEventListener('click',solve);
document.getElementById('reset').addEventListener('click',()=>{ ORDER.forEach(s=>choice[s]=''); render(); setStatus('Choose a word for every slot.',''); });
buildSelectors(); render();
Comments
Loading comments...