<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/button.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/dialog.html"></import-element>
<import-element from="https://cdn.jsdelivr.net/npm/@realmorg/ui@alpha/components/switch.html"></import-element>
<custom-element name="chess-game">
<script type="module">
import { Chess, NAMES, START, SYMBOLS, squareName } from "./chess.js";
const props = defineProps({ fen: prop.string() });
const engine = new Chess(props.fen || START);
const STATUS = {
checkmate: (winner) => `Checkmate. ${winner} wins.`,
stalemate: () => "Stalemate. The game is a draw.",
"fifty-move": () => "Draw by the fifty-move rule.",
insufficient: () => "Draw by insufficient material.",
repetition: () => "Draw by threefold repetition.",
};
const game = useState({
version: 0,
selected: -1,
flipped: false,
computer: true,
thinking: false,
pending: null,
last: null,
get state() {
void this.version;
return engine.status();
},
get over() {
return !["playing", "check"].includes(this.state);
},
get targets() {
void this.version;
return this.selected === -1 ? [] : engine.moves(this.selected).map((move) => move.to);
},
get squares() {
void this.version;
const checkSquare = engine.inCheck() ? engine.king(engine.turn) : -1;
const order = Array.from({ length: 64 }, (_, i) => (this.flipped ? 63 - i : i));
return order.map((index, position) => {
const piece = engine.board[index];
const name = squareName(index);
const row = Math.floor(position / 8);
const col = position % 8;
return {
index,
name,
light: (Math.floor(index / 8) + (index % 8)) % 2 === 0,
symbol: piece ? SYMBOLS[piece.type] : "",
color: piece?.color ?? null,
label: piece ? `${name}, ${piece.color === "w" ? "white" : "black"} ${NAMES[piece.type]}` : name,
movable: Boolean(piece && piece.color === engine.turn && !this.over),
selected: index === this.selected,
target: this.targets.includes(index),
last: this.last !== null && (index === this.last.from || index === this.last.to),
check: index === checkSquare,
rank: col === 0 ? name[1] : "",
file: row === 7 ? name[0] : "",
};
});
},
get status() {
const state = this.state;
const mover = engine.turn === "w" ? "White" : "Black";
if (state === "checkmate") return STATUS.checkmate(engine.turn === "w" ? "Black" : "White");
if (STATUS[state]) return STATUS[state]();
if (this.thinking) return "Computer is thinking…";
return state === "check" ? `${mover} is in check.` : `${mover} to move.`;
},
get moves() {
void this.version;
const sans = engine.history.map((entry) => entry.san);
const rows = [];
for (let i = 0; i < sans.length; i += 2) rows.push({ number: i / 2 + 1, white: sans[i], black: sans[i + 1] ?? "" });
return rows;
},
get canUndo() {
void this.version;
return engine.history.length > 0 && !this.thinking;
},
});
const refresh = () => {
game.version++;
};
const play = (from, to, promotion) => {
const move = engine.move({ from, to, promotion });
game.last = { from, to };
game.selected = -1;
refresh();
host.dispatchEvent(new CustomEvent("move", { bubbles: true, detail: { san: move.san, fen: engine.fen() } }));
if (game.computer && engine.turn === "b" && !game.over) {
game.thinking = true;
setTimeout(() => {
const reply = engine.bestMove(2);
game.thinking = false;
if (reply) play(reply.from, reply.to, reply.promotion);
}, 150);
}
};
const attempt = (from, to) => {
const options = engine.moves(from).filter((move) => move.to === to);
if (!options.length) return false;
if (options.some((move) => move.promotion)) {
game.pending = { from, to };
refs.promotion.showModal();
} else play(from, to);
return true;
};
function press(square) {
if (game.thinking || game.over) return;
if (game.selected !== -1 && attempt(game.selected, square.index)) return;
game.selected = square.movable && game.selected !== square.index ? square.index : -1;
}
function dragStart(square, event) {
if (!square.movable || game.thinking) return event.preventDefault();
game.selected = square.index;
event.dataTransfer.setData("text/plain", String(square.index));
event.dataTransfer.effectAllowed = "move";
}
function dragOver(square, event) {
if (game.targets.includes(square.index)) event.preventDefault();
}
function drop(square, event) {
event.preventDefault();
attempt(Number(event.dataTransfer.getData("text/plain")), square.index);
}
function promote(type) {
const { from, to } = game.pending;
game.pending = null;
refs.promotion.close();
play(from, to, type);
}
function undo() {
engine.undo();
if (game.computer && engine.turn === "b") engine.undo();
game.selected = -1;
game.last = null;
refresh();
}
function restart() {
engine.load(props.fen || START);
Object.assign(game, { selected: -1, last: null, thinking: false });
refresh();
}
function flip() {
game.flipped = !game.flipped;
}
function toggleComputer(field) {
game.computer = field.checked;
}
defineMethods({
reset: restart,
get position() {
return engine.fen();
},
});
</script>
<style>
:host {
--light: oklch(0.92 0.03 85);
--dark: oklch(0.62 0.07 60);
display: grid;
grid-template-columns: minmax(0, calc(var(--spacing) * 144)) calc(var(--spacing) * 64);
gap: calc(var(--spacing) * 6);
align-items: start;
}
.board {
display: grid;
grid-template-columns: repeat(8, 1fr);
aspect-ratio: 1;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-lg);
user-select: none;
}
.square {
all: unset;
position: relative;
display: grid;
place-items: center;
aspect-ratio: 1;
background: var(--dark);
cursor: pointer;
}
.square[data-light] {
background: var(--light);
}
.square[data-last]::before {
content: "";
position: absolute;
inset: 0;
background: oklch(0.85 0.15 100 / 0.45);
}
.square[data-selected]::before {
content: "";
position: absolute;
inset: 0;
background: oklch(0.7 0.15 150 / 0.5);
}
.square[data-check] {
background: radial-gradient(circle, oklch(0.65 0.22 25) 0%, oklch(0.65 0.22 25 / 0.3) 60%, transparent 75%), var(--dark);
}
.square[data-target]::after {
content: "";
position: absolute;
width: 30%;
height: 30%;
border-radius: 50%;
background: rgb(0 0 0 / 0.2);
}
.square:focus-visible {
box-shadow: inset 0 0 0 3px var(--ring);
}
.piece {
position: relative;
z-index: 1;
font-size: min(4.2vw, 2.6rem);
line-height: 1;
}
.piece[data-color="w"] {
color: #fff;
text-shadow: 0 0 1px #000, 0 0 1px #000, 0 1px 2px rgb(0 0 0 / 0.6);
}
.piece[data-color="b"] {
color: #1a1a1a;
}
.piece[draggable="true"] {
cursor: grab;
}
.rank,
.file {
position: absolute;
z-index: 1;
font-size: var(--text-xs);
font-weight: var(--font-weight-semibold);
color: rgb(0 0 0 / 0.8);
}
.rank::before,
.file::before {
content: attr(data-label);
}
.rank {
top: calc(var(--spacing) * 0.8);
left: calc(var(--spacing) * 1);
}
.file {
right: calc(var(--spacing) * 1);
bottom: calc(var(--spacing) * 0.6);
}
aside {
display: grid;
gap: calc(var(--spacing) * 4);
}
h1 {
margin: 0;
font-size: var(--text-xl);
}
.status {
margin: 0;
font-weight: var(--font-weight-medium);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: calc(var(--spacing) * 2);
}
ol {
max-height: calc(var(--spacing) * 80);
margin: 0;
padding: calc(var(--spacing) * 2) 0;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
list-style: none;
font-family: var(--font-mono);
font-size: var(--text-sm);
}
li {
display: grid;
grid-template-columns: calc(var(--spacing) * 10) 1fr 1fr;
padding: calc(var(--spacing) * 0.6) calc(var(--spacing) * 3);
}
li span:first-child {
color: var(--muted-foreground);
}
.choices {
display: flex;
justify-content: center;
gap: calc(var(--spacing) * 2);
font-size: var(--text-3xl);
}
@media (max-width: 760px) {
:host {
grid-template-columns: minmax(0, 1fr);
}
.piece {
font-size: 9vw;
}
}
</style>
<div class="board" role="group" aria-label="Chess board">
<repeat-list of="#game.squares" as="square" key="name">
<button
type="button"
class="square"
_aria-label="#square.label"
_data-light="#square.light"
_data-selected="#square.selected"
_data-target="#square.target"
_data-last="#square.last"
_data-check="#square.check"
_data-square="#square.name"
onclick="press(square)"
ondragover="dragOver(square, event)"
ondrop="drop(square, event)">
<span class="piece" aria-hidden="true" _data-color="#square.color" _draggable="#square.movable" ondragstart="dragStart(square, event)"><slot name="#square.symbol"></slot></span>
<span class="rank" _data-label="#square.rank"></span>
<span class="file" _data-label="#square.file"></span>
</button>
</repeat-list>
</div>
<aside>
<h1>Chess</h1>
<p class="status" role="status"><slot name="#game.status"></slot></p>
<ui-switch _checked="#game.computer" onchange="toggleComputer(this)">Play against the computer</ui-switch>
<div class="controls">
<ui-button size="sm" variant="outline" _disabled="!#game.canUndo" onclick="undo()">Undo</ui-button>
<ui-button size="sm" variant="outline" onclick="flip()">Flip board</ui-button>
<ui-button size="sm" onclick="restart()">New game</ui-button>
</div>
<ol aria-label="Moves">
<li repeat="#game.moves" as="row" key="number"><span><slot name="#row.number"></slot>.</span><span><slot name="#row.white"></slot></span><span><slot name="#row.black"></slot></span></li>
</ol>
</aside>
<ui-dialog ref="promotion" persistent hide-close>
<ui-dialog-header>
<ui-dialog-title>Promote pawn</ui-dialog-title>
<ui-dialog-description>Choose the piece your pawn becomes.</ui-dialog-description>
</ui-dialog-header>
<div class="choices">
<ui-button variant="outline" size="icon-lg" aria-label="Queen" onclick="promote('q')">♛</ui-button>
<ui-button variant="outline" size="icon-lg" aria-label="Rook" onclick="promote('r')">♜</ui-button>
<ui-button variant="outline" size="icon-lg" aria-label="Bishop" onclick="promote('b')">♝</ui-button>
<ui-button variant="outline" size="icon-lg" aria-label="Knight" onclick="promote('n')">♞</ui-button>
</div>
</ui-dialog>
</custom-element>
export const START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
const FILES = "abcdefgh";
const KNIGHT = [-17, -15, -10, -6, 6, 10, 15, 17];
const KING = [-9, -8, -7, -1, 1, 7, 8, 9];
const DIAGONAL = [-9, -7, 7, 9];
const STRAIGHT = [-8, -1, 1, 8];
const VALUES = { p: 100, n: 320, b: 330, r: 500, q: 900, k: 0 };
export const squareName = (index) => `${FILES[index % 8]}${8 - Math.floor(index / 8)}`;
export const squareIndex = (name) => (8 - Number(name[1])) * 8 + FILES.indexOf(name[0]);
const file = (index) => index % 8;
const opponent = (color) => (color === "w" ? "b" : "w");
const fileDistance = (a, b) => Math.abs(file(a) - file(b));
export class Chess {
constructor(fen = START) {
this.load(fen);
}
load(fen) {
const [placement, turn, castling, enPassant, halfmove = "0", fullmove = "1"] = fen
.trim()
.split(/\s+/);
this.board = Array(64).fill(null);
let index = 0;
for (const char of placement) {
if (char === "/") continue;
if (/\d/.test(char)) index += Number(char);
else {
this.board[index] = {
type: char.toLowerCase(),
color: char === char.toUpperCase() ? "w" : "b",
};
index++;
}
}
this.turn = turn;
this.castling = {
K: castling.includes("K"),
Q: castling.includes("Q"),
k: castling.includes("k"),
q: castling.includes("q"),
};
this.enPassant = enPassant === "-" ? -1 : squareIndex(enPassant);
this.halfmove = Number(halfmove);
this.fullmove = Number(fullmove);
this.history = [];
this.positions = new Map([[this.key(), 1]]);
}
fen() {
let placement = "";
for (let rank = 0; rank < 8; rank++) {
let empty = 0;
for (let f = 0; f < 8; f++) {
const piece = this.board[rank * 8 + f];
if (!piece) {
empty++;
continue;
}
if (empty) placement += empty;
empty = 0;
placement += piece.color === "w" ? piece.type.toUpperCase() : piece.type;
}
if (empty) placement += empty;
if (rank < 7) placement += "/";
}
const castling = ["K", "Q", "k", "q"].filter((right) => this.castling[right]).join("") || "-";
const enPassant = this.enPassant === -1 ? "-" : squareName(this.enPassant);
return `${placement} ${this.turn} ${castling} ${enPassant} ${this.halfmove} ${this.fullmove}`;
}
key() {
return this.fen().split(" ").slice(0, 4).join(" ");
}
king(color) {
return this.board.findIndex((piece) => piece?.type === "k" && piece.color === color);
}
attacked(square, by) {
const pawnSources = by === "w" ? [7, 9] : [-7, -9];
for (const offset of pawnSources) {
const from = square + offset;
const piece = this.board[from];
if (
from >= 0 &&
from < 64 &&
fileDistance(from, square) === 1 &&
piece?.color === by &&
piece.type === "p"
)
return true;
}
for (const offset of KNIGHT) {
const from = square + offset;
const piece = this.board[from];
if (
from >= 0 &&
from < 64 &&
fileDistance(from, square) <= 2 &&
piece?.color === by &&
piece.type === "n"
)
return true;
}
for (const offset of KING) {
const from = square + offset;
const piece = this.board[from];
if (
from >= 0 &&
from < 64 &&
fileDistance(from, square) <= 1 &&
piece?.color === by &&
piece.type === "k"
)
return true;
}
const slide = (directions, types) => {
for (const step of directions) {
let from = square;
for (;;) {
const next = from + step;
if (next < 0 || next >= 64 || fileDistance(next, from) > 1) break;
from = next;
const piece = this.board[from];
if (!piece) continue;
if (piece.color === by && types.includes(piece.type)) return true;
break;
}
}
return false;
};
return slide(DIAGONAL, ["b", "q"]) || slide(STRAIGHT, ["r", "q"]);
}
inCheck(color = this.turn) {
return this.attacked(this.king(color), opponent(color));
}
pseudoMoves(color = this.turn) {
const moves = [];
const add = (from, to, extra = {}) =>
moves.push({
from,
to,
piece: this.board[from].type,
captured: this.board[to]?.type,
...extra,
});
for (let from = 0; from < 64; from++) {
const piece = this.board[from];
if (!piece || piece.color !== color) continue;
if (piece.type === "p") {
const dir = color === "w" ? -8 : 8;
const startRank = color === "w" ? 6 : 1;
const lastRank = color === "w" ? 0 : 7;
const pushTo = (to, extra = {}) => {
if (Math.floor(to / 8) === lastRank)
for (const promotion of ["q", "r", "b", "n"]) add(from, to, { ...extra, promotion });
else add(from, to, extra);
};
const one = from + dir;
if (one >= 0 && one < 64 && !this.board[one]) {
pushTo(one);
const two = from + dir * 2;
if (Math.floor(from / 8) === startRank && !this.board[two])
add(from, two, { double: true });
}
for (const side of [-1, 1]) {
const to = from + dir + side;
if (to < 0 || to >= 64 || fileDistance(from, to) !== 1) continue;
if (this.board[to] && this.board[to].color !== color) pushTo(to);
else if (to === this.enPassant) add(from, to, { enPassant: true, captured: "p" });
}
continue;
}
if (piece.type === "n" || piece.type === "k") {
const offsets = piece.type === "n" ? KNIGHT : KING;
const reach = piece.type === "n" ? 2 : 1;
for (const offset of offsets) {
const to = from + offset;
if (to < 0 || to >= 64 || fileDistance(from, to) > reach) continue;
if (this.board[to]?.color === color) continue;
add(from, to);
}
if (piece.type === "k") this.#castles(from, color, add);
continue;
}
const directions =
piece.type === "b" ? DIAGONAL : piece.type === "r" ? STRAIGHT : [...DIAGONAL, ...STRAIGHT];
for (const step of directions) {
let current = from;
for (;;) {
const to = current + step;
if (to < 0 || to >= 64 || fileDistance(to, current) > 1) break;
if (this.board[to]?.color === color) break;
add(from, to);
if (this.board[to]) break;
current = to;
}
}
}
return moves;
}
#castles(from, color, add) {
const home = color === "w" ? 60 : 4;
if (from !== home || this.inCheck(color)) return;
const enemy = opponent(color);
const [kingSide, queenSide] = color === "w" ? ["K", "Q"] : ["k", "q"];
if (
this.castling[kingSide] &&
!this.board[home + 1] &&
!this.board[home + 2] &&
this.board[home + 3]?.type === "r"
) {
if (!this.attacked(home + 1, enemy) && !this.attacked(home + 2, enemy))
add(from, home + 2, { castle: "king" });
}
if (
this.castling[queenSide] &&
!this.board[home - 1] &&
!this.board[home - 2] &&
!this.board[home - 3] &&
this.board[home - 4]?.type === "r"
) {
if (!this.attacked(home - 1, enemy) && !this.attacked(home - 2, enemy))
add(from, home - 2, { castle: "queen" });
}
}
moves(from) {
const color = this.turn;
return this.pseudoMoves(color).filter((move) => {
if (from !== undefined && move.from !== from) return false;
this.#apply(move);
const legal = !this.inCheck(color);
this.#revert();
return legal;
});
}
#apply(move) {
const piece = this.board[move.from];
this.history.push({
move,
piece,
captured: move.enPassant
? this.board[move.to + (piece.color === "w" ? 8 : -8)]
: this.board[move.to],
castling: { ...this.castling },
enPassant: this.enPassant,
halfmove: this.halfmove,
fullmove: this.fullmove,
});
this.board[move.to] = move.promotion ? { type: move.promotion, color: piece.color } : piece;
this.board[move.from] = null;
if (move.enPassant) this.board[move.to + (piece.color === "w" ? 8 : -8)] = null;
if (move.castle) {
const [rookFrom, rookTo] =
move.castle === "king" ? [move.from + 3, move.from + 1] : [move.from - 4, move.from - 1];
this.board[rookTo] = this.board[rookFrom];
this.board[rookFrom] = null;
}
if (piece.type === "k") {
if (piece.color === "w") this.castling.K = this.castling.Q = false;
else this.castling.k = this.castling.q = false;
}
for (const [square, right] of [
[63, "K"],
[56, "Q"],
[7, "k"],
[0, "q"],
]) {
if (move.from === square || move.to === square) this.castling[right] = false;
}
this.enPassant = move.double ? (move.from + move.to) / 2 : -1;
this.halfmove = piece.type === "p" || move.captured ? 0 : this.halfmove + 1;
if (piece.color === "b") this.fullmove++;
this.turn = opponent(piece.color);
}
#revert() {
const entry = this.history.pop();
const { move, piece, captured } = entry;
this.board[move.from] = piece;
this.board[move.to] = null;
if (move.enPassant) this.board[move.to + (piece.color === "w" ? 8 : -8)] = captured;
else this.board[move.to] = captured;
if (move.castle) {
const [rookFrom, rookTo] =
move.castle === "king" ? [move.from + 3, move.from + 1] : [move.from - 4, move.from - 1];
this.board[rookFrom] = this.board[rookTo];
this.board[rookTo] = null;
}
this.castling = entry.castling;
this.enPassant = entry.enPassant;
this.halfmove = entry.halfmove;
this.fullmove = entry.fullmove;
this.turn = piece.color;
return entry;
}
san(move) {
if (move.castle) return this.#suffix(move, move.castle === "king" ? "O-O" : "O-O-O");
const piece = move.piece;
const capture = move.captured ? "x" : "";
const to = squareName(move.to);
if (piece === "p") {
const base = `${capture ? `${FILES[file(move.from)]}x` : ""}${to}${move.promotion ? `=${move.promotion.toUpperCase()}` : ""}`;
return this.#suffix(move, base);
}
const rivals = this.moves().filter(
(other) => other.piece === piece && other.to === move.to && other.from !== move.from,
);
let disambiguation = "";
if (rivals.length) {
const sameFile = rivals.some((other) => file(other.from) === file(move.from));
const sameRank = rivals.some(
(other) => Math.floor(other.from / 8) === Math.floor(move.from / 8),
);
const name = squareName(move.from);
disambiguation = !sameFile ? name[0] : !sameRank ? name[1] : name;
}
return this.#suffix(move, `${piece.toUpperCase()}${disambiguation}${capture}${to}`);
}
#suffix(move, text) {
this.#apply(move);
const check = this.inCheck();
const mate = check && this.moves().length === 0;
this.#revert();
return `${text}${mate ? "#" : check ? "+" : ""}`;
}
move({ from, to, promotion = "q" }) {
const move = this.moves(from).find(
(candidate) =>
candidate.to === to && (!candidate.promotion || candidate.promotion === promotion),
);
if (!move) return null;
const san = this.san(move);
this.#apply(move);
this.history.at(-1).san = san;
const key = this.key();
this.positions.set(key, (this.positions.get(key) ?? 0) + 1);
return { ...move, san };
}
undo() {
if (!this.history.length) return null;
const key = this.key();
this.positions.set(key, this.positions.get(key) - 1);
return this.#revert().move;
}
insufficientMaterial() {
const pieces = this.board
.map((piece, index) => piece && { ...piece, index })
.filter((piece) => piece && piece.type !== "k");
if (pieces.length === 0) return true;
if (pieces.length === 1 && (pieces[0].type === "b" || pieces[0].type === "n")) return true;
if (pieces.every((piece) => piece.type === "b")) {
const colors = new Set(
pieces.map((piece) => (Math.floor(piece.index / 8) + file(piece.index)) % 2),
);
return colors.size === 1;
}
return false;
}
status() {
const moves = this.moves();
const check = this.inCheck();
if (!moves.length) return check ? "checkmate" : "stalemate";
if (this.halfmove >= 100) return "fifty-move";
if (this.insufficientMaterial()) return "insufficient";
if ((this.positions.get(this.key()) ?? 0) >= 3) return "repetition";
return check ? "check" : "playing";
}
perft(depth) {
if (depth === 0) return 1;
let nodes = 0;
for (const move of this.moves()) {
this.#apply(move);
nodes += this.perft(depth - 1);
this.#revert();
}
return nodes;
}
evaluate() {
let score = 0;
this.board.forEach((piece, index) => {
if (!piece) return;
const center =
3.5 - Math.abs(3.5 - file(index)) + (3.5 - Math.abs(3.5 - Math.floor(index / 8)));
const value = VALUES[piece.type] + (piece.type === "k" ? 0 : center * 4);
score += piece.color === "w" ? value : -value;
});
return score;
}
#negamax(depth, alpha, beta) {
const moves = this.moves();
if (!moves.length) return this.inCheck() ? -100000 - depth : 0;
if (depth === 0) return (this.turn === "w" ? 1 : -1) * this.evaluate();
let best = -Infinity;
for (const move of moves) {
this.#apply(move);
const score = -this.#negamax(depth - 1, -beta, -alpha);
this.#revert();
best = Math.max(best, score);
alpha = Math.max(alpha, score);
if (alpha >= beta) break;
}
return best;
}
bestMove(depth = 2) {
let choice = null;
let best = -Infinity;
const ordered = this.moves().sort(
(a, b) => (VALUES[b.captured] ?? 0) - (VALUES[a.captured] ?? 0),
);
for (const move of ordered) {
this.#apply(move);
const score = -this.#negamax(depth - 1, -Infinity, Infinity);
this.#revert();
if (score > best) {
best = score;
choice = move;
}
}
return choice;
}
}
export const SYMBOLS = { k: "♚", q: "♛", r: "♜", b: "♝", n: "♞", p: "♟" };
export const NAMES = { k: "king", q: "queen", r: "rook", b: "bishop", n: "knight", p: "pawn" };