<p class="hint">Offline Tetris: you know the whole sequence in advance. A well 4 columns wide. Slide the current piece with <b>◀ ▶</b>, then <b>Drop</b>. Your goal: clear <b>both</b> bottom rows. Checking a finished board is one glance per row — finding the placements that clear the most is the hard part.</p>
<div class="wrap">
<div id="well" class="well"></div>
<div class="side">
<div class="label">Next pieces</div>
<div id="queue" class="queue"></div>
<div class="label">Cleared rows: <b id="score">0</b> / 2</div>
<div class="status" id="status">Slide and drop the piece.</div>
</div>
</div>
<div class="btns">
<button id="left" type="button">◀ Left</button>
<button id="right" type="button">Right ▶</button>
<button id="drop" type="button">Drop</button>
</div>
<div class="btns">
<button id="naive" type="button" class="ghost">Auto: naive</button>
<button id="smart" type="button" class="ghost">Auto: smart</button>
<button id="reset" type="button" class="ghost">Reset</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; }
.wrap { display: flex; gap: 1.1rem; align-items: flex-start; flex-wrap: wrap; }
.well { display: grid; grid-template-columns: repeat(4, 34px); grid-auto-rows: 34px;
gap: 2px; background: #d7dde3; padding: 4px; border-radius: 8px; }
.c { width: 34px; height: 34px; border-radius: 5px; background: #eef2f6; }
.c.fill { background: #1d3557; }
.c.cur { background: #4895ef; }
.c.target { box-shadow: inset 0 0 0 2px #adb6c0; }
.side { font-size: .9rem; }
.label { font-weight: 600; margin: .2rem 0 .35rem; color: #333; }
.queue { display: flex; gap: .4rem; margin-bottom: .9rem; }
.mini { display: grid; grid-template-columns: repeat(2, 14px); grid-auto-rows: 14px; gap: 1px; }
.mini .m { width: 14px; height: 14px; border-radius: 3px; background: transparent; }
.mini .m.on { background: #457b9d; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin-top: .3rem; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .7rem; }
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; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// OFFLINE TETRIS (NP-hard, Demaine-Hohenberger-Liben-Nowell 2003).
// Width-4 well; the sequence of pieces is known in advance. Goal: clear the
// two bottom rows. Two square (O) pieces tile both rows perfectly IF you put
// one on the left two columns and one on the right two — but a naive "drop in the leftmost gap" strategy stacks them and clears nothing. Checking a final board is one scan per row; finding the clearing placement is the hard part.
const W = 4, H = 6;
// Each piece: list of [dr,dc] cells with dr measured from the piece top.
const O = [[0,0],[0,1],[1,0],[1,1]];
const SEQUENCE = [O, O]; // the known, fixed sequence
const widthOf = (cells) => Math.max(...cells.map(c => c[1])); // 0-based span
let board, idx, curCol;
function reset(){
board = Array.from({length:H}, () => Array(W).fill(0));
idx = 0; curCol = 0;
setStatus('Slide and drop the piece.', '');
render();
}
// Find the resting top-row for `cells` dropped at column `col`; null if illegal.
function landingRow(b, cells, col){
if (col < 0 || col + widthOf(cells) >= W) return null;
for (let r = H - 1; r >= -3; r--){
let valid = true;
for (const [dr,dc] of cells){
const rr = r+dr, cc = col+dc;
if (rr < 0 || rr >= H || cc < 0 || cc >= W || b[rr][cc]){ valid = false; break; }
}
if (!valid) continue;
const occ = new Set(cells.map(([dr,dc]) => (r+dr)+','+(col+dc)));
let supported = false;
for (const [dr,dc] of cells){
const rr = r+dr+1, cc = col+dc;
if (rr >= H){ supported = true; break; }
if (!occ.has(rr+','+cc) && b[rr][cc]){ supported = true; break; }
}
if (supported) return r;
}
return null;
}
function place(b, cells, col){
const r = landingRow(b, cells, col);
if (r === null) return null;
const nb = b.map(row => row.slice());
for (const [dr,dc] of cells) nb[r+dr][col+dc] = 1;
return nb;
}
// How many of the two bottom rows are completely full.
function clearedBottom2(b){
let n = 0;
for (const r of [H-1, H-2]) if (b[r].every(x => x)) n++;
return n;
}
function setStatus(t, cls){ const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + cls; }
function curCells(){ return SEQUENCE[idx]; }
function render(){
const well = document.getElementById('well');
well.innerHTML = '';
// ghost: where the current piece would land
const ghost = new Set();
if (idx < SEQUENCE.length){
const r = landingRow(board, curCells(), curCol);
if (r !== null) for (const [dr,dc] of curCells()) ghost.add((r+dr)+','+(curCol+dc));
}
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++){
const d = document.createElement('div');
let cls = 'c';
if (board[r][c]) cls += ' fill';
else if (ghost.has(r+','+c)) cls += ' cur';
d.className = cls;
well.appendChild(d);
}
// queue preview
const q = document.getElementById('queue'); q.innerHTML = '';
for (let i = idx; i < SEQUENCE.length; i++){
const m = document.createElement('div'); m.className = 'mini';
const set = new Set(SEQUENCE[i].map(([dr,dc]) => dr+','+dc));
for (let r = 0; r < 2; r++) for (let c = 0; c < 2; c++){
const cell = document.createElement('div');
cell.className = 'm' + (set.has(r+','+c) ? ' on' : '');
m.appendChild(cell);
}
q.appendChild(m);
}
document.getElementById('score').textContent = clearedBottom2(board);
const done = idx >= SEQUENCE.length;
document.getElementById('left').disabled = done;
document.getElementById('right').disabled = done;
document.getElementById('drop').disabled = done;
}
function clampCol(){
const maxCol = W - 1 - widthOf(curCells());
if (curCol < 0) curCol = 0;
if (curCol > maxCol) curCol = maxCol;
}
function dropCurrent(){
if (idx >= SEQUENCE.length) return;
const nb = place(board, curCells(), curCol);
if (nb === null){ setStatus('That column has no room — slide over.', 'bad'); return; }
board = nb; idx++; curCol = 0;
finishIfDone();
render();
}
function finishIfDone(){
if (idx < SEQUENCE.length) return;
const cleared = clearedBottom2(board);
if (cleared === 2) setStatus('Both rows cleared — perfect packing!', 'ok');
else setStatus(fmt('Cleared %cleared% of 2. Reset and try the other arrangement.', { cleared: cleared }), 'bad');
}
// Naive strategy: each piece drops into the leftmost legal column.
function autoNaive(){
reset();
for (let i = 0; i < SEQUENCE.length; i++){
let c = 0; while (c < W && place(board, SEQUENCE[i], c) === null) c++;
const nb = place(board, SEQUENCE[i], c);
if (nb) board = nb;
idx++;
}
curCol = 0;
setStatus(fmt('Naive: each piece in the leftmost gap → cleared %clearedBottom2% of 2.', { clearedBottom2: clearedBottom2(board) }), clearedBottom2(board) === 2 ? 'ok' : 'bad');
render();
}
// Smart strategy: brute-force search over (column for each piece) and keep the
// arrangement clearing the most rows. This is the search that blows up in general.
function autoSmart(){
reset();
let best = null, bestScore = -1;
const cols = [];
function rec(b, i){
if (i === SEQUENCE.length){
const s = clearedBottom2(b);
if (s > bestScore){ bestScore = s; best = cols.slice(); }
return;
}
for (let c = 0; c <= W - 1 - widthOf(SEQUENCE[i]); c++){
const nb = place(b, SEQUENCE[i], c);
if (nb){ cols.push(c); rec(nb, i+1); cols.pop(); }
}
}
rec(board, 0);
if (best){
for (let i = 0; i < SEQUENCE.length; i++){ board = place(board, SEQUENCE[i], best[i]); idx++; }
}
curCol = 0;
setStatus(fmt('Smart: searched all placements → cleared %clearedBottom2% of 2.', { clearedBottom2: clearedBottom2(board) }), clearedBottom2(board) === 2 ? 'ok' : 'bad');
render();
}
document.getElementById('left').addEventListener('click', () => { curCol--; clampCol(); render(); });
document.getElementById('right').addEventListener('click', () => { curCol++; clampCol(); render(); });
document.getElementById('drop').addEventListener('click', dropCurrent);
document.getElementById('naive').addEventListener('click', autoNaive);
document.getElementById('smart').addEventListener('click', autoSmart);
document.getElementById('reset').addEventListener('click', reset);
reset();
<p class="hint">Offline Tetris: you know the whole sequence in advance. A well 4 columns wide. Slide the current piece with <b>◀ ▶</b>, then <b>Drop</b>. Your goal: clear <b>both</b> bottom rows. Checking a finished board is one glance per row — finding the placements that clear the most is the hard part.</p>
<div class="wrap">
<div id="well" class="well"></div>
<div class="side">
<div class="label">Next pieces</div>
<div id="queue" class="queue"></div>
<div class="label">Cleared rows: <b id="score">0</b> / 2</div>
<div class="status" id="status">Slide and drop the piece.</div>
</div>
</div>
<div class="btns">
<button id="left" type="button">◀ Left</button>
<button id="right" type="button">Right ▶</button>
<button id="drop" type="button">Drop</button>
</div>
<div class="btns">
<button id="naive" type="button" class="ghost">Auto: naive</button>
<button id="smart" type="button" class="ghost">Auto: smart</button>
<button id="reset" type="button" class="ghost">Reset</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; }
.wrap { display: flex; gap: 1.1rem; align-items: flex-start; flex-wrap: wrap; }
.well { display: grid; grid-template-columns: repeat(4, 34px); grid-auto-rows: 34px;
gap: 2px; background: #d7dde3; padding: 4px; border-radius: 8px; }
.c { width: 34px; height: 34px; border-radius: 5px; background: #eef2f6; }
.c.fill { background: #1d3557; }
.c.cur { background: #4895ef; }
.c.target { box-shadow: inset 0 0 0 2px #adb6c0; }
.side { font-size: .9rem; }
.label { font-weight: 600; margin: .2rem 0 .35rem; color: #333; }
.queue { display: flex; gap: .4rem; margin-bottom: .9rem; }
.mini { display: grid; grid-template-columns: repeat(2, 14px); grid-auto-rows: 14px; gap: 1px; }
.mini .m { width: 14px; height: 14px; border-radius: 3px; background: transparent; }
.mini .m.on { background: #457b9d; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin-top: .3rem; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .7rem; }
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; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// OFFLINE TETRIS (NP-hard, Demaine-Hohenberger-Liben-Nowell 2003).
// Width-4 well; the sequence of pieces is known in advance. Goal: clear the
// two bottom rows. Two square (O) pieces tile both rows perfectly IF you put
// one on the left two columns and one on the right two — but a naive "drop in the leftmost gap" strategy stacks them and clears nothing. Checking a final board is one scan per row; finding the clearing placement is the hard part.
const W = 4, H = 6;
// Each piece: list of [dr,dc] cells with dr measured from the piece top.
const O = [[0,0],[0,1],[1,0],[1,1]];
const SEQUENCE = [O, O]; // the known, fixed sequence
const widthOf = (cells) => Math.max(...cells.map(c => c[1])); // 0-based span
let board, idx, curCol;
function reset(){
board = Array.from({length:H}, () => Array(W).fill(0));
idx = 0; curCol = 0;
setStatus('Slide and drop the piece.', '');
render();
}
// Find the resting top-row for `cells` dropped at column `col`; null if illegal.
function landingRow(b, cells, col){
if (col < 0 || col + widthOf(cells) >= W) return null;
for (let r = H - 1; r >= -3; r--){
let valid = true;
for (const [dr,dc] of cells){
const rr = r+dr, cc = col+dc;
if (rr < 0 || rr >= H || cc < 0 || cc >= W || b[rr][cc]){ valid = false; break; }
}
if (!valid) continue;
const occ = new Set(cells.map(([dr,dc]) => (r+dr)+','+(col+dc)));
let supported = false;
for (const [dr,dc] of cells){
const rr = r+dr+1, cc = col+dc;
if (rr >= H){ supported = true; break; }
if (!occ.has(rr+','+cc) && b[rr][cc]){ supported = true; break; }
}
if (supported) return r;
}
return null;
}
function place(b, cells, col){
const r = landingRow(b, cells, col);
if (r === null) return null;
const nb = b.map(row => row.slice());
for (const [dr,dc] of cells) nb[r+dr][col+dc] = 1;
return nb;
}
// How many of the two bottom rows are completely full.
function clearedBottom2(b){
let n = 0;
for (const r of [H-1, H-2]) if (b[r].every(x => x)) n++;
return n;
}
function setStatus(t, cls){ const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + cls; }
function curCells(){ return SEQUENCE[idx]; }
function render(){
const well = document.getElementById('well');
well.innerHTML = '';
// ghost: where the current piece would land
const ghost = new Set();
if (idx < SEQUENCE.length){
const r = landingRow(board, curCells(), curCol);
if (r !== null) for (const [dr,dc] of curCells()) ghost.add((r+dr)+','+(curCol+dc));
}
for (let r = 0; r < H; r++) for (let c = 0; c < W; c++){
const d = document.createElement('div');
let cls = 'c';
if (board[r][c]) cls += ' fill';
else if (ghost.has(r+','+c)) cls += ' cur';
d.className = cls;
well.appendChild(d);
}
// queue preview
const q = document.getElementById('queue'); q.innerHTML = '';
for (let i = idx; i < SEQUENCE.length; i++){
const m = document.createElement('div'); m.className = 'mini';
const set = new Set(SEQUENCE[i].map(([dr,dc]) => dr+','+dc));
for (let r = 0; r < 2; r++) for (let c = 0; c < 2; c++){
const cell = document.createElement('div');
cell.className = 'm' + (set.has(r+','+c) ? ' on' : '');
m.appendChild(cell);
}
q.appendChild(m);
}
document.getElementById('score').textContent = clearedBottom2(board);
const done = idx >= SEQUENCE.length;
document.getElementById('left').disabled = done;
document.getElementById('right').disabled = done;
document.getElementById('drop').disabled = done;
}
function clampCol(){
const maxCol = W - 1 - widthOf(curCells());
if (curCol < 0) curCol = 0;
if (curCol > maxCol) curCol = maxCol;
}
function dropCurrent(){
if (idx >= SEQUENCE.length) return;
const nb = place(board, curCells(), curCol);
if (nb === null){ setStatus('That column has no room — slide over.', 'bad'); return; }
board = nb; idx++; curCol = 0;
finishIfDone();
render();
}
function finishIfDone(){
if (idx < SEQUENCE.length) return;
const cleared = clearedBottom2(board);
if (cleared === 2) setStatus('Both rows cleared — perfect packing!', 'ok');
else setStatus(fmt('Cleared %cleared% of 2. Reset and try the other arrangement.', { cleared: cleared }), 'bad');
}
// Naive strategy: each piece drops into the leftmost legal column.
function autoNaive(){
reset();
for (let i = 0; i < SEQUENCE.length; i++){
let c = 0; while (c < W && place(board, SEQUENCE[i], c) === null) c++;
const nb = place(board, SEQUENCE[i], c);
if (nb) board = nb;
idx++;
}
curCol = 0;
setStatus(fmt('Naive: each piece in the leftmost gap → cleared %clearedBottom2% of 2.', { clearedBottom2: clearedBottom2(board) }), clearedBottom2(board) === 2 ? 'ok' : 'bad');
render();
}
// Smart strategy: brute-force search over (column for each piece) and keep the
// arrangement clearing the most rows. This is the search that blows up in general.
function autoSmart(){
reset();
let best = null, bestScore = -1;
const cols = [];
function rec(b, i){
if (i === SEQUENCE.length){
const s = clearedBottom2(b);
if (s > bestScore){ bestScore = s; best = cols.slice(); }
return;
}
for (let c = 0; c <= W - 1 - widthOf(SEQUENCE[i]); c++){
const nb = place(b, SEQUENCE[i], c);
if (nb){ cols.push(c); rec(nb, i+1); cols.pop(); }
}
}
rec(board, 0);
if (best){
for (let i = 0; i < SEQUENCE.length; i++){ board = place(board, SEQUENCE[i], best[i]); idx++; }
}
curCol = 0;
setStatus(fmt('Smart: searched all placements → cleared %clearedBottom2% of 2.', { clearedBottom2: clearedBottom2(board) }), clearedBottom2(board) === 2 ? 'ok' : 'bad');
render();
}
document.getElementById('left').addEventListener('click', () => { curCol--; clampCol(); render(); });
document.getElementById('right').addEventListener('click', () => { curCol++; clampCol(); render(); });
document.getElementById('drop').addEventListener('click', dropCurrent);
document.getElementById('naive').addEventListener('click', autoNaive);
document.getElementById('smart').addEventListener('click', autoSmart);
document.getElementById('reset').addEventListener('click', reset);
reset();
Comments
Loading comments...