Skip to content

Instantly share code, notes, and snippets.

@tkellogg
Created August 8, 2025 20:58
Show Gist options
  • Save tkellogg/09709ee2936c8b8f1857f0a9c648d07c to your computer and use it in GitHub Desktop.
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."
<!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