The code behind the board.
The Chess960 experiment plays by chessops, which is licensed under the GNU GPL-3.0, so the experiment’s code that runs in your browser is published under the same licence. It is collected from the site’s repository every time the site is built, so what you read here is what runs.
<script setup lang="ts">
import type { NormalMove, Role } from 'chessops/types'
import { isDarkSquare, NAMES, squareGlyph, squareName, type ChessFont } from '#core/domain/chess960'
import type { Ply } from '~/composables/useChessGame'
// The playable board: 64 squares in the chess font, each glyph a piece (or empty) already shaded for its square.
// Click, drag, or use the keyboard (arrows move a roving focus; Enter or Space picks and places).
const props = defineProps<{
squares: string[]
font: ChessFont
flipped: boolean
interactive: boolean
targets: (from: number) => Map<number, NormalMove>
needsPromotion: (move: NormalMove) => boolean
check?: number
last?: Ply
plyCount: number
/** While an engine thinks, the person's colour: their pieces stay movable, and a move becomes a premove. */
premoveColor?: 'white' | 'black'
premove?: { from: number; to: number }
}>()
const emit = defineEmits<{ move: [NormalMove]; premove: [{ from: number; to: number }]; cancelPremove: [] }>()
const family = computed(() => `'Chess ${props.font}', var(--font-mono)`)
const order = computed(() => Array.from({ length: 64 }, (_, i) => {
const row = i >> 3, col = i & 7
return props.flipped ? row * 8 + (7 - col) : (7 - row) * 8 + col
}))
const selected = ref<number>()
const legal = computed(() => (selected.value === undefined ? new Map<number, NormalMove>() : props.targets(selected.value)))
const promoting = ref<NormalMove>()
const hidden = ref(new Set<number>())
const focus = ref(12)
const board = ref<HTMLElement>()
const ownPiece = (sq: number, white: boolean) => { const p = props.squares[sq]; return !!p && (p === p.toUpperCase()) === white }
const whiteToMove = computed(() => props.plyCount % 2 === 0)
const premoving = computed(() => !props.interactive && !!props.premoveColor)
const mover = computed(() => (props.interactive ? (whiteToMove.value ? 'white' : 'black') : props.premoveColor))
function pick(sq: number) {
if (premoving.value) {
// any square will do for a premove; it is checked once the engine has moved
if (selected.value !== undefined && sq !== selected.value) { emit('premove', { from: selected.value, to: sq }); selected.value = undefined; return }
if (selected.value === undefined && !ownPiece(sq, mover.value === 'white')) { emit('cancelPremove'); return }
selected.value = selected.value === sq ? undefined : sq
return
}
if (!props.interactive) return
const move = legal.value.get(sq)
if (move) {
selected.value = undefined
if (props.needsPromotion(move)) { promoting.value = move; return }
emit('move', move)
return
}
selected.value = selected.value !== sq && ownPiece(sq, mover.value === 'white') && props.targets(sq).size ? sq : undefined
}
function promote(role: Role) {
if (!promoting.value) return
emit('move', { ...promoting.value, promotion: role })
promoting.value = undefined
}
watch(() => props.plyCount, () => { if (!premoving.value) selected.value = undefined; promoting.value = undefined })
function label(sq: number) {
const p = props.squares[sq]
const piece = p ? `${p === p.toUpperCase() ? 'white' : 'black'} ${NAMES[p.toUpperCase()]!.toLowerCase()}` : 'empty'
const extra = sq === selected.value ? ', selected' : legal.value.has(sq) ? ', move here' : ''
return `${squareName(sq)}, ${piece}${extra}`
}
function key(e: KeyboardEvent) {
const step: Record<string, [number, number]> = { ArrowUp: [0, 1], ArrowDown: [0, -1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] }
const d = step[e.key]
if (!d) return
e.preventDefault()
const [df, dr] = props.flipped ? [-d[0], -d[1]] : d
const f = Math.min(7, Math.max(0, (focus.value & 7) + df)), r = Math.min(7, Math.max(0, (focus.value >> 3) + dr))
focus.value = r * 8 + f
board.value?.querySelector<HTMLElement>(`[data-sq="${focus.value}"]`)?.focus()
}
// A piece in flight (animation and drag). A White glyph is only an outline, so the board would show through it:
// the same piece's solid Black glyph goes underneath in Paper to fill the body.
function makeTile(piece: string, size: number) {
const tile = document.createElement('span')
tile.className = 'board__tile'
tile.append(
Object.assign(document.createElement('span'), { className: 'board__fill', textContent: squareGlyph(piece.toLowerCase(), false, props.font) }),
Object.assign(document.createElement('span'), { textContent: squareGlyph(piece, false, props.font) }),
)
Object.assign(tile.style, { width: `${size}px`, height: `${size}px`, fontFamily: family.value })
board.value!.append(tile)
return tile
}
// A drag starts only after a few pixels of movement, so a press without movement stays a click.
let drag: { from: number; x: number; y: number; tile?: HTMLElement; size: number } | undefined
let dropped = false
let swallowClick = false
const movable = (sq: number) => (props.interactive || premoving.value) && !!mover.value && ownPiece(sq, mover.value === 'white')
function down(sq: number, e: PointerEvent) {
if (!movable(sq) || e.button !== 0) return
const size = board.value!.querySelector<HTMLElement>(`[data-sq="${sq}"]`)!.getBoundingClientRect().width
drag = { from: sq, x: e.clientX, y: e.clientY, size }
window.addEventListener('pointermove', moveDrag)
window.addEventListener('pointerup', endDrag, { once: true })
window.addEventListener('pointercancel', cancelDrag, { once: true })
}
function place(tile: HTMLElement, x: number, y: number, size: number) {
const o = board.value!.getBoundingClientRect()
tile.style.left = `${x - o.left - size / 2}px`
tile.style.top = `${y - o.top - size / 2}px`
}
function moveDrag(e: PointerEvent) {
if (!drag) return
if (!drag.tile) {
if (Math.hypot(e.clientX - drag.x, e.clientY - drag.y) < 5) return
selected.value = drag.from
hidden.value = new Set([drag.from])
drag.tile = makeTile(props.squares[drag.from]!, drag.size)
drag.tile.classList.add('is-dragging')
}
place(drag.tile, e.clientX, e.clientY, drag.size)
}
function cleanup() {
window.removeEventListener('pointermove', moveDrag)
drag?.tile?.remove()
hidden.value = new Set()
drag = undefined
}
function endDrag(e: PointerEvent) {
window.removeEventListener('pointercancel', cancelDrag)
if (!drag?.tile) return cleanup()
swallowClick = true
const target = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLElement>('[data-sq]')
const to = target ? Number(target.dataset.sq) : undefined
const from = drag.from
cleanup()
if (premoving.value) { selected.value = undefined; if (to !== undefined && to !== from) emit('premove', { from, to }); return }
if (to !== undefined && legal.value.has(to)) { dropped = true; pick(to) }
else selected.value = undefined
}
function cancelDrag() { window.removeEventListener('pointerup', endDrag); cleanup() }
function clicked(sq: number) {
if (swallowClick) { swallowClick = false; return }
focus.value = sq
pick(sq)
}
onBeforeUnmount(cleanup)
// Pieces are hidden on their new squares while their glyphs slide in from the old ones.
async function animate(ply: Ply) {
if (dropped) { dropped = false; return }
if (useReducedMotion() || !board.value) return
const paths: [number, number][] = ply.castle
? [[ply.move.from, ply.castle.kingTo], [ply.castle.rookFrom, ply.castle.rookTo]]
: [[ply.move.from, ply.move.to]]
hidden.value = new Set(paths.map(([, to]) => to))
await nextTick()
const rect = (sq: number) => board.value!.querySelector<HTMLElement>(`[data-sq="${sq}"]`)!.getBoundingClientRect()
const origin = board.value.getBoundingClientRect()
await Promise.all(paths.map(([from, to]) => {
const a = rect(from), b = rect(to)
const tile = makeTile(props.squares[to]!, a.width)
Object.assign(tile.style, { left: `${a.left - origin.left}px`, top: `${a.top - origin.top}px` })
const dx = b.left - a.left, dy = b.top - a.top
return tile.animate(
[{ transform: 'translate(0, 0)' }, { transform: `translate(${dx}px, ${dy}px)` }],
{ duration: 240, easing: 'cubic-bezier(.2, .7, .1, 1)' },
).finished.finally(() => tile.remove())
}))
hidden.value = new Set()
}
watch(() => props.last, (ply, prev) => { if (ply && ply !== prev && props.plyCount > 0) void animate(ply) })
</script>
<template>
<div ref="board" class="board__grid" role="grid" aria-label="Chess board" :style="{ fontFamily: family }" @keydown="key">
<button
v-for="sq in order" :key="sq" type="button" class="board__sq" :data-sq="sq"
:class="{
'is-selected': sq === selected, 'is-target': legal.has(sq), 'is-capture': legal.has(sq) && !!squares[sq],
'is-last': last && (sq === last.move.from || sq === (last.castle?.kingTo ?? last.move.to)), 'is-check': sq === check,
'is-movable': movable(sq), 'is-premove': premove && (sq === premove.from || sq === premove.to),
}"
:tabindex="sq === focus ? 0 : -1" :aria-label="label(sq)" :aria-disabled="!interactive"
@click="clicked(sq)" @pointerdown="down(sq, $event)"
>{{ squareGlyph(hidden.has(sq) ? '' : squares[sq]!, isDarkSquare(sq), font) }}</button>
<div v-if="promoting" class="board__promote" role="dialog" aria-label="Promote to">
<button v-for="r in (['queen', 'rook', 'bishop', 'knight'] as Role[])" :key="r" type="button" :aria-label="`Promote to ${r}`" @click="promote(r)">
{{ squareGlyph(({ queen: 'Q', rook: 'R', bishop: 'B', knight: 'N' } as Record<string, string>)[r]![whiteToMove ? 'toUpperCase' : 'toLowerCase'](), false, font) }}
</button>
</div>
</div>
</template>
<style scoped>
.board__grid { position: relative; display: grid; grid-template-columns: repeat(8, var(--sq)); grid-auto-rows: var(--sq); border: 2px solid var(--color-ink); padding: 2px; color: var(--color-ink); }
.board__sq { position: relative; display: grid; place-items: center; width: var(--sq); height: var(--sq); padding: 0; font: inherit; font-size: var(--sq); line-height: 1; color: inherit; background: none; border: 0; cursor: pointer; }
.board__sq[aria-disabled='true'] { cursor: default; }
/* only squares holding a movable piece stop the page scrolling on touch */
.board__sq.is-movable { cursor: grab; touch-action: none; }
.board__sq:focus-visible { outline: 2px solid var(--color-amber); outline-offset: -2px; z-index: 1; }
.board__sq.is-last { background: color-mix(in srgb, var(--color-highlight) 55%, transparent); }
.board__sq.is-selected { background: var(--color-highlight); }
.board__sq.is-check { box-shadow: inset 0 0 0 3px var(--color-amber); }
.board__sq.is-premove { background: color-mix(in srgb, var(--color-amber) 45%, transparent); }
.board__sq.is-target::after { content: ''; position: absolute; width: 22%; height: 22%; background: var(--color-ink); opacity: .55; }
.board__sq.is-capture::after { width: 100%; height: 100%; background: none; box-shadow: inset 0 0 0 3px var(--color-ink); }
.board__sq:hover:not([aria-disabled='true']) { background: color-mix(in srgb, var(--color-highlight) 70%, transparent); }
.board__grid :deep(.board__tile) { position: absolute; z-index: 3; display: grid; place-items: center; font-size: var(--sq); line-height: 1; pointer-events: none; }
.board__grid :deep(.board__tile) > span { grid-area: 1 / 1; }
.board__grid :deep(.board__fill) { color: var(--color-paper); }
.board__grid :deep(.board__tile.is-dragging) { cursor: grabbing; }
.board__promote { position: absolute; inset: 50% auto auto 50%; translate: -50% -50%; z-index: 4; display: flex; border: 2px solid var(--color-ink); background: var(--color-paper); }
.board__promote button { width: var(--sq); height: var(--sq); padding: 0; font: inherit; font-size: var(--sq); line-height: 1; color: var(--color-ink); background: none; border: 0; cursor: pointer; }
.board__promote button:hover, .board__promote button:focus-visible { background: var(--color-highlight); outline: none; }
</style>