Created
August 8, 2025 20:58
-
-
Save tkellogg/09709ee2936c8b8f1857f0a9c648d07c to your computer and use it in GitHub Desktop.
GPT-5 generated (and ideated!) chess game, "A chess game on a Möbius board where capturing a piece also erases its past moves from history."
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="utf-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<title>Möbius Chess</title> | |
<style> | |
:root { | |
--bg: #0f1220; | |
--panel: #13172a; | |
--ink: #e9ecf1; | |
--muted: #9aa3b2; | |
--accent: #7aa2ff; | |
--good: #7fe6a2; | |
--bad: #ff6b7a; | |
--sq-a: #2e3357; | |
--sq-b: #3b416b; | |
--sq-h: #6a73b6; | |
--sq-m: #2a7755; | |
} | |
html, body { height: 100%; } | |
body { | |
margin: 0; | |
background: radial-gradient(1200px 800px at 20% 20%, #1a1f3c 0, #0b0e1a 60%, #070910 100%); | |
color: var(--ink); | |
font: 15px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; | |
display: grid; | |
grid-template-columns: 1fr 360px; | |
gap: 14px; | |
padding: 16px; | |
box-sizing: border-box; | |
} | |
header { | |
grid-column: 1 / -1; | |
display: flex; | |
align-items: center; | |
justify-content: space-between; | |
padding: 10px 12px; | |
background: linear-gradient(180deg, #171b33, #0f1326); | |
border: 1px solid #20264a; | |
border-radius: 14px; | |
box-shadow: 0 10px 30px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.05); | |
} | |
header h1 { | |
font-size: 18px; | |
margin: 0; | |
letter-spacing: 0.3px; | |
} | |
header .controls { display: flex; gap: 8px; align-items: center; } | |
button, .toggle { | |
background: var(--panel); | |
color: var(--ink); | |
border: 1px solid #2a335f; | |
padding: 8px 10px; | |
border-radius: 10px; | |
cursor: pointer; | |
transition: transform .05s ease, background .2s ease, border-color .2s ease; | |
} | |
button:hover, .toggle:hover { background: #1a2042; } | |
button:active { transform: translateY(1px); } | |
.toggle { display: inline-flex; align-items: center; gap: 8px; user-select: none; } | |
.toggle input { accent-color: var(--accent); width: 16px; height: 16px; } | |
.wrap { | |
display: grid; | |
grid-template-columns: 1fr 360px; | |
gap: 14px; | |
grid-column: 1 / -1; | |
} | |
#boardWrap { | |
background: linear-gradient(180deg, #171b33, #0f1326); | |
border: 1px solid #20264a; | |
border-radius: 14px; | |
padding: 12px; | |
display: grid; | |
grid-template-rows: auto 1fr auto; | |
gap: 8px; | |
} | |
#board { | |
width: min(80vh, 80vw); | |
aspect-ratio: 1 / 1; | |
max-width: 840px; | |
border-radius: 12px; | |
overflow: hidden; | |
border: 1px solid #30386a; | |
position: relative; | |
box-shadow: 0 20px 60px rgba(0,0,0,.25); | |
} | |
.grid { | |
display: grid; | |
grid-template-columns: repeat(8, minmax(0, 1fr)); | |
grid-template-rows: repeat(8, minmax(0, 1fr)); | |
height: 100%; | |
} | |
.sq { position: relative; display: grid; place-items: center; font-size: clamp(24px, 6vh, 56px); line-height: 1; overflow: hidden; } | |
.sq.a { background: var(--sq-a); } | |
.sq.b { background: var(--sq-b); } | |
.sq.coord { position: absolute; bottom: 4px; right: 6px; color: #c9d0ff33; font-size: 11px; } | |
.sq.sel { outline: 3px solid var(--sq-h); box-shadow: inset 0 0 0 4px rgba(122,162,255,.35), 0 0 18px rgba(122,162,255,.45); z-index: 2; } | |
.sq.move { box-shadow: inset 0 0 0 3px var(--sq-m); } | |
.sq.capture::after { | |
content: ""; position: absolute; inset: 0; background: radial-gradient(circle at 50% 50%, #ff6b7a55 0, #ff6b7a22 40%, transparent 70%); | |
} | |
.mobiusHint { position: absolute; inset: 0; pointer-events:none; } | |
.mobiusHint::before, .mobiusHint::after { | |
content:""; position:absolute; top:0; bottom:0; width:10px; opacity:.35; | |
background: linear-gradient(180deg, #88a1ff22, #88a1ff00 60%); | |
} | |
.mobiusHint::before { left:0; } | |
.mobiusHint::after { right:0; } | |
.mobiusBadge { | |
position: absolute; top: 8px; left: 50%; transform: translateX(-50%); | |
font-size: 12px; color: var(--muted); background: #20264a; border:1px solid #2c3567; padding: 4px 8px; border-radius: 10px; | |
} | |
#side { | |
background: linear-gradient(180deg, #171b33, #0f1326); | |
border: 1px solid #20264a; border-radius: 14px; padding: 12px; display: grid; grid-template-rows: auto 1fr auto; gap: 10px; | |
} | |
#log { overflow: auto; max-height: 70vh; } | |
.moveRow { display: grid; grid-template-columns: 24px 1fr; gap: 8px; align-items: start; padding: 6px 8px; border-radius: 8px; } | |
.moveRow:nth-child(odd) { background: #1a1f3a; } | |
.moveRow s { color: #ff90a0; text-decoration-color: #ff90a0; } | |
.tag { font-size: 11px; color: var(--muted); } | |
.pill { display:inline-block; font-size:11px; padding:2px 6px; border:1px solid #33407b; border-radius:999px; color:#b8c1ff; } | |
.legend { color: var(--muted); font-size: 12px; } | |
.help { font-size: 13px; color: #c7cde6; background:#121634; border:1px solid #2b3468; border-radius:12px; padding:10px; } | |
.kbd { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; font-size: 12px; background:#22284e; border:1px solid #39427a; padding:2px 6px; border-radius:6px; } | |
a { color: var(--accent); text-decoration: none; } | |
</style> | |
</head> | |
<body> | |
<header> | |
<h1>♾️ Möbius Chess <span class="tag">v0.1</span></h1> | |
<div class="controls"> | |
<label class="toggle" title="Rebuild the world after every capture by replaying the timeline with the captured piece's moves erased."> | |
<input type="checkbox" id="temporal" /> Temporal Consistency | |
</label> | |
<label class="toggle" title="Move either color on any turn."><input type="checkbox" id="freeplay" checked /> Free Play</label> | |
<div id="turnBadge" class="pill">Turn: White</div> | |
<button id="undoBtn" title="Undo last move (⌫)">Undo</button> | |
<button id="resetBtn" title="Start over (R)">Reset</button> | |
<button id="helpBtn" title="How does this work?">Help</button> | |
</div> | |
</header> | |
<div id="boardWrap"> | |
<div id="board"> | |
<div class="mobiusHint" aria-hidden="true"></div> | |
<div class="mobiusBadge">Left/Right edges wrap with a flip</div> | |
<div class="grid" id="grid"></div> | |
</div> | |
<div class="legend">Möbius wrap: stepping off the <b>left or right</b> edge re-enters on the opposite side with the <i>row flipped</i> (e.g., A1 → H8). Vertical edges do not wrap.</div> | |
</div> | |
<aside id="side"> | |
<div> | |
<div class="pill">Move Log</div> | |
</div> | |
<div id="log"></div> | |
<div class="help" id="helpBox" hidden> | |
<b>Rules (artist’s cut):</b> | |
<ul> | |
<li>Standard chess move patterns; no castling, en passant, or checks/enforcement (it's a toy).</li> | |
<li><b>Möbius wrap:</b> If a move steps across the left/right boundary, it appears on the opposite file and flips rank (row). Sliders (rook/bishop/queen) wrap step-by-step; <b>knights</b> are treated as doing their <i>two horizontal</i> steps first, then the vertical step. Pawns capture by doing the horizontal step first, then forward.</li> | |
<li><b>Free Play:</b> Move either color on any turn. When off, you can only move the side whose turn it is (see badge).</li> | |
<li><b>History erasure:</b> Capturing a piece removes <i>all of that piece’s prior moves</i> from the visible log. If <span class="kbd">Temporal Consistency</span> is on, the board is re-simulated from the start without those moves. That may make the capturing move turn into a non-capture if, in the edited timeline, the target piece wasn't there.</li> | |
<li>Objective: vibes. (Feel free to decide your own win conditions.)</li> | |
</ul> | |
<div>Shortcuts: <span class="kbd">R</span> reset, <span class="kbd">Backspace</span> undo, click a piece to see moves.</div> | |
</div> | |
</aside> | |
<script> | |
(() => { | |
// --- Utilities --- | |
const $ = sel => document.querySelector(sel); | |
const $$ = sel => [...document.querySelectorAll(sel)]; | |
const grid = $('#grid'); | |
const logBox = $('#log'); | |
const temporalBox = $('#temporal'); | |
const freePlayBox = $('#freeplay'); | |
const WHITE = 'w'; | |
const BLACK = 'b'; | |
const PIECES = { K:'K', Q:'Q', R:'R', B:'B', N:'N', P:'P' }; | |
const GLYPH = { | |
'wK':'\u2654','wQ':'\u2655','wR':'\u2656','wB':'\u2657','wN':'\u2658','wP':'\u2659', | |
'bK':'\u265A','bQ':'\u265B','bR':'\u265C','bB':'\u265D','bN':'\u265E','bP':'\u265F', | |
}; | |
// Board state | |
let board, turn, history, nextId, idMapInitial; | |
function clone(obj){ return JSON.parse(JSON.stringify(obj)); } | |
function algebra(r,c){ return 'abcdefgh'[c] + (8-r); } | |
function reset(initial=false){ | |
board = [...Array(8)].map(()=>Array(8).fill(null)); | |
turn = WHITE; | |
history = []; | |
nextId = 1; | |
// Place starting pieces with stable IDs so timeline replay is deterministic | |
function place(r,c,type,color){ | |
const id = nextId++; | |
board[r][c] = { id, type, color, hasMoved:false }; | |
return id; | |
} | |
const back = [PIECES.R,PIECES.N,PIECES.B,PIECES.Q,PIECES.K,PIECES.B,PIECES.N,PIECES.R]; | |
// Black back rank (r=0), pawns (r=1) | |
for(let c=0;c<8;c++){ place(0,c,back[c],BLACK); place(1,c,PIECES.P,BLACK); } | |
// White back rank (r=7), pawns (r=6) | |
for(let c=0;c<8;c++){ place(7,c,back[c],WHITE); place(6,c,PIECES.P,WHITE); } | |
if(!initial){ render(); renderLog(); updateTurnUI(); } | |
// Save a snapshot of the initial IDs/positions for consistent replay | |
if(initial){ idMapInitial = clone(board); } | |
} | |
// Möbius step: apply a single (dr,dc) step with horizontal wrap+flip | |
function stepMobius(r,c,dr,dc){ | |
let nr = r + dr; | |
let nc = c + dc; | |
if(nc < 0 || nc > 7){ | |
nc = (nc + 8) % 8; // wrap columns | |
nr = 7 - nr; // flip row | |
} | |
if(nr < 0 || nr > 7) return null; // vertical edges are bounds | |
return [nr,nc]; | |
} | |
function inBounds(r,c){ return r>=0 && r<8 && c>=0 && c<8; } | |
function rayMoves(r,c,dr,dc,color){ | |
const moves = []; | |
const seen = new Set(); // avoid infinite cycles around the strip | |
let cr=r, cc=c; | |
while(true){ | |
const key = cr+','+cc; | |
if(seen.has(key)) break; | |
seen.add(key); | |
const nxt = stepMobius(cr,cc,dr,dc); | |
if(!nxt) break; | |
const [nr,nc] = nxt; | |
const occ = board[nr][nc]; | |
if(occ){ | |
if(occ.color !== color) moves.push([nr,nc,'x']); | |
break; | |
} else { | |
moves.push([nr,nc]); | |
} | |
cr = nr; cc = nc; | |
} | |
return moves; | |
} | |
function knightMoves(r,c,color){ | |
// Convention: apply two horizontal steps first (each applying Möbius), then one vertical. | |
const deltas = [ | |
[0, 1, 1], [0, -1, 1], [0, 1, -1], [0, -1, -1], // two horiz right/left then vertical up/down | |
]; | |
const res = []; | |
for(const [hdir, sign, vdir] of deltas){ | |
let pos = [r,c]; | |
// two horizontal steps in chosen direction | |
pos = stepMobius(pos[0], pos[1], 0, sign*1); if(!pos) continue; | |
pos = stepMobius(pos[0], pos[1], 0, sign*1); if(!pos) continue; | |
// one vertical step | |
pos = stepMobius(pos[0], pos[1], vdir*1, 0); if(!pos) continue; | |
const [nr,nc] = pos; | |
const occ = board[nr][nc]; | |
if(!occ || occ.color !== color) res.push([nr,nc, occ ? 'x': undefined]); | |
} | |
// Mirror variants: swap directions (two vertical first then one horizontal) to increase spice | |
// We deliberately DO NOT include these; designer’s rule. | |
// Deduplicate squares | |
return res.filter((v,i,a)=>a.findIndex(u=>u[0]===v[0]&&u[1]===v[1])===i); | |
} | |
function bishopMoves(r,c,color){ | |
return [ | |
...rayMoves(r,c,-1, 1,color), | |
...rayMoves(r,c,-1,-1,color), | |
...rayMoves(r,c, 1, 1,color), | |
...rayMoves(r,c, 1,-1,color), | |
]; | |
} | |
function rookMoves(r,c,color){ | |
return [ | |
...rayMoves(r,c, 0, 1,color), | |
...rayMoves(r,c, 0,-1,color), | |
...rayMoves(r,c,-1, 0,color), | |
...rayMoves(r,c, 1, 0,color), | |
]; | |
} | |
function queenMoves(r,c,color){ return [...rookMoves(r,c,color), ...bishopMoves(r,c,color)]; } | |
function kingMoves(r,c,color){ | |
const res = []; | |
for(const dr of [-1,0,1]) for(const dc of [-1,0,1]){ | |
if(dr===0 && dc===0) continue; | |
const pos = stepMobius(r,c,dr,dc); | |
if(!pos) continue; | |
const [nr,nc] = pos; | |
const occ = board[nr][nc]; | |
if(!occ || occ.color!==color) res.push([nr,nc, occ?'x':undefined]); | |
} | |
return res; | |
} | |
function pawnMoves(r,c,color){ | |
const res = []; | |
const dir = color===WHITE ? -1 : 1; // forward is toward opponent | |
// Forward one (no wrap horizontally) | |
const f1 = stepMobius(r,c,dir,0); | |
if(f1){ const [fr,fc] = f1; if(!board[fr][fc]) res.push([fr,fc]); } | |
// Forward two from start | |
const startRow = color===WHITE ? 6 : 1; | |
if(r===startRow && f1){ const [fr1,fc1]=f1; if(!board[fr1][fc1]){ | |
const f2 = stepMobius(fr1,fc1,dir,0); if(f2){ const [fr2,fc2]=f2; if(!board[fr2][fc2]) res.push([fr2,fc2]); } | |
}} | |
// Captures: designer rule — apply horizontal step first (dc=±1), then vertical step (dr=dir) | |
for(const s of [-1,1]){ | |
let pos = stepMobius(r,c,0,s); // horizontal | |
if(!pos) continue; | |
pos = stepMobius(pos[0],pos[1],dir,0); // then forward | |
if(!pos) continue; | |
const [nr,nc]=pos; const occ = board[nr][nc]; | |
if(occ && occ.color!==color) res.push([nr,nc,'x']); | |
} | |
return res; | |
} | |
function legalMovesAt(r,c){ | |
const cell = board[r][c]; | |
if(!cell) return []; | |
const {type, color} = cell; | |
switch(type){ | |
case PIECES.P: return pawnMoves(r,c,color); | |
case PIECES.N: return knightMoves(r,c,color); | |
case PIECES.B: return bishopMoves(r,c,color); | |
case PIECES.R: return rookMoves(r,c,color); | |
case PIECES.Q: return queenMoves(r,c,color); | |
case PIECES.K: return kingMoves(r,c,color); | |
default: return []; | |
} | |
} | |
// --- Rendering --- | |
function colorName(c){ return c===WHITE? 'White' : 'Black'; } | |
function updateTurnUI(){ | |
const badge = $('#turnBadge'); | |
if(badge){ badge.textContent = 'Turn: ' + colorName(turn) + (freePlayBox && freePlayBox.checked ? ' (Free Play)' : ''); } | |
} | |
let sel = null; // selected square [r,c] | |
function render(){ | |
grid.innerHTML = ''; | |
for(let r=0;r<8;r++){ | |
for(let c=0;c<8;c++){ | |
const sq = document.createElement('div'); | |
sq.className = 'sq ' + (((r+c)&1)?'b':'a'); | |
sq.dataset.r = r; sq.dataset.c = c; | |
const p = board[r][c]; | |
if(p){ | |
const span = document.createElement('span'); | |
span.textContent = GLYPH[p.color+p.type]; | |
span.setAttribute('aria-label', `${p.color==='w'?'White':'Black'} ${p.type} at ${algebra(r,c)}`); | |
sq.appendChild(span); | |
} | |
const coord = document.createElement('div'); | |
coord.className = 'coord'; coord.textContent = algebra(r,c); | |
sq.appendChild(coord); | |
sq.addEventListener('click', onSquareClick); | |
grid.appendChild(sq); | |
} | |
} | |
paintSelection(); | |
} | |
function paintSelection(){ | |
$$('.sq').forEach(s=>s.classList.remove('sel','move','capture')); | |
if(!sel) return; | |
const [sr,sc] = sel; | |
$(`.sq[data-r="${sr}"][data-c="${sc}"]`).classList.add('sel'); | |
const moves = legalMovesAt(sr,sc); | |
for(const [r,c,cap] of moves){ | |
const el = $(`.sq[data-r="${r}"][data-c="${c}"]`); | |
if(!el) continue; el.classList.add(cap?'capture':'move'); | |
} | |
} | |
function onSquareClick(e){ | |
const r = +e.currentTarget.dataset.r; | |
const c = +e.currentTarget.dataset.c; | |
const cell = board[r][c]; | |
if(sel){ | |
const [sr,sc] = sel; | |
const moves = legalMovesAt(sr,sc); | |
const can = moves.find(m=>m[0]===r && m[1]===c); | |
if(can){ | |
if(freePlayBox && !freePlayBox.checked){ | |
const moving = board[sr][sc]; | |
if(moving && moving.color !== turn){ sel=null; paintSelection(); return; } | |
} | |
doMove(sr,sc,r,c); | |
sel = null; render(); renderLog(); | |
return; | |
} | |
} | |
if(cell){ | |
if(freePlayBox && !freePlayBox.checked && cell.color !== turn){ | |
sel = null; // not your turn | |
} else { | |
sel = [r,c]; | |
} | |
} else sel = null; | |
paintSelection(); | |
} | |
function doMove(sr,sc,r,c){ | |
const piece = board[sr][sc]; | |
const target = board[r][c]; | |
const move = { ply: history.length+1, id: piece.id, from:[sr,sc], to:[r,c], capture: target? target.id : null }; | |
if(target){ | |
// History erasure: remove all prior moves by the captured piece from visible history | |
history = history.filter(m => m.id !== target.id); | |
} | |
// Apply move in the current world | |
board[r][c] = piece; board[sr][sc] = null; piece.hasMoved = true; | |
// If temporal consistency is on, rebuild the world from the start without the captured piece's moves | |
if(target && temporalBox.checked){ | |
history.push(move); // include the capturing move in the timeline | |
rebuildFromHistory(); | |
} else { | |
history.push(move); | |
} | |
turn = (turn===WHITE?BLACK:WHITE); | |
updateTurnUI(); | |
} | |
function rebuildFromHistory(){ | |
// Recreate initial position with stable IDs. | |
function snapshotToBoard(snap){ | |
const b = [...Array(8)].map(()=>Array(8).fill(null)); | |
for(let r=0;r<8;r++) for(let c=0;c<8;c++){ | |
const p = snap[r][c]; if(p) b[r][c] = clone(p); | |
} | |
return b; | |
} | |
board = snapshotToBoard(idMapInitial); | |
// Apply surviving moves. If a move references a missing piece (captured earlier in edited timeline), skip it. | |
for(const m of history){ | |
// Find the piece with id m.id somewhere on the board | |
let loc = findPieceById(m.id); | |
if(!loc) continue; // piece no longer exists | |
const [sr,sc] = loc; | |
const [tr,tc] = m.to; | |
const target = board[tr][tc]; | |
// Apply capture if present; if target missing, treat as simple move. | |
if(target && target.id !== m.id) { | |
// As we apply a capture in the new timeline, also remove that target's prior moves from history going forward. | |
// (We won't mutate history array mid-iteration; the history already had those moves removed at capture time.) | |
// Just proceed to remove the target from the board now. | |
} | |
board[tr][tc] = board[sr][sc]; | |
board[sr][sc] = null; | |
} | |
} | |
function findPieceById(id){ | |
for(let r=0;r<8;r++) for(let c=0;c<8;c++){ | |
const p = board[r][c]; if(p && p.id===id) return [r,c]; | |
} | |
return null; | |
} | |
function undo(){ | |
if(history.length===0) return; | |
history.pop(); | |
// Rebuild board by replaying remaining history from start | |
rebuildClean(); | |
turn = (history.length % 2 === 0) ? WHITE : BLACK; | |
render(); renderLog(); | |
} | |
function rebuildClean(){ | |
// Start from initial and apply every move in history as-is. | |
function snapshotToBoard(snap){ | |
const b = [...Array(8)].map(()=>Array(8).fill(null)); | |
for(let r=0;r<8;r++) for(let c=0;c<8;c++){ | |
const p = snap[r][c]; if(p) b[r][c] = clone(p); | |
} | |
return b; | |
} | |
board = snapshotToBoard(idMapInitial); | |
for(const m of history){ | |
const loc = findPieceById(m.id); if(!loc) continue; | |
const [sr,sc] = loc; const [tr,tc] = m.to; | |
board[tr][tc] = board[sr][sc]; | |
board[sr][sc] = null; | |
} | |
} | |
function renderLog(){ | |
logBox.innerHTML = ''; | |
let moveNo = 1; | |
history.forEach((m,i)=>{ | |
const row = document.createElement('div'); | |
row.className = 'moveRow'; | |
const ply = document.createElement('div'); | |
ply.textContent = ((i%2)===0) ? (Math.floor(i/2)+1)+'.' : ''; | |
const txt = document.createElement('div'); | |
const cap = m.capture ? ' x' : ''; | |
const from = algebra(m.from[0],m.from[1]); | |
const to = algebra(m.to[0],m.to[1]); | |
txt.innerHTML = `<span class="tag">${m.id}</span> ${from}→${to}${cap}`; | |
row.appendChild(ply); row.appendChild(txt); | |
logBox.appendChild(row); | |
}); | |
} | |
// --- Controls --- | |
$('#resetBtn').addEventListener('click', ()=>{ reset(); }); | |
$('#undoBtn').addEventListener('click', ()=>{ undo(); }); | |
$('#helpBtn').addEventListener('click', ()=>{ | |
const hb = $('#helpBox'); hb.hidden = !hb.hidden; | |
}); | |
document.addEventListener('keydown', (e)=>{ | |
if(e.key.toLowerCase()==='r'){ reset(); } | |
if(e.key==='Backspace'){ e.preventDefault(); undo(); } | |
}); | |
if(freePlayBox){ freePlayBox.addEventListener('change', updateTurnUI); } | |
// --- Bootstrap --- | |
reset(true); // build idMapInitial | |
render(); | |
renderLog(); | |
updateTurnUI(); | |
})(); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment