// ─────────────────────────────────────────────────────────────────────────────
// Chess vs MCTS — a guided tour
// ─────────────────────────────────────────────────────────────────────────────
//
// This example is a complete, playable chess game where the visitor plays
// Black against an MCTS engine driving White. It exercises a vertical slice
// of the Meep engine, end-to-end, in a single file. Read top to bottom; each
// section explains the engine pieces it leans on.
//
// §1 Rules engine A pure-data board model and the legal-move
// generator. Used both to validate the player's
// clicks AND to expand MCTS nodes — single source
// of truth for legality.
//
// §2 Engine bootstrap EngineHarness.bootstrap / buildBasics, the
// systems we need (SGMesh, Highlight, Decal),
// shadows + SSAO + an HDR sky/environment map.
//
// §3 World setup Board GLTF, twelve piece GLTFs, captured-piece
// graveyards either side of the board.
//
// §4 Move-arc animation AnimationCurve / Keyframe per axis (X, Y, Z)
// with 4 keyframes per move: lift, glide, touch.
//
// §5 Selection & input Click → raycast (ShadedGeometrySystem) +
// board-plane fallback. Highlight on selection,
// Decal markers on every legal destination.
//
// §6 MCTS AI MonteCarloTreeSearch wrapped in a Task on
// engine.executor so search never blocks render.
//
// §7 Turn flow + UI Banner on checkmate, HUD with FPS and AI
// progress, lock input during AI / animation.
//
// Simplifications we accept on purpose: no castling, no en passant, no
// pawn promotion. The MCTS implementation in the engine doesn't flip
// perspective per ply, so the AI relies on a material heuristic and is
// qualitatively weak — fine for a wiring demo.
// ─── Imports ────────────────────────────────────────────────────────────────
import {
plane3_compute_ray_intersection
} from "@woosh/meep-engine/src/core/geom/3d/plane/plane3_compute_ray_intersection.js";
import { SurfacePoint3 } from "@woosh/meep-engine/src/core/geom/3d/SurfacePoint3.js";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import Task from "@woosh/meep-engine/src/core/process/task/Task.js";
import { TaskSignal } from "@woosh/meep-engine/src/core/process/task/TaskSignal.js";
import { AnimationCurve } from "@woosh/meep-engine/src/engine/animation/curve/AnimationCurve.js";
import { Keyframe } from "@woosh/meep-engine/src/engine/animation/curve/Keyframe.js";
import { GameAssetType } from "@woosh/meep-engine/src/engine/asset/GameAssetType.js";
import { GLTFAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/GLTFAssetLoader.js";
import { ImageRGBADataLoader } from "@woosh/meep-engine/src/engine/asset/loaders/image/ImageRGBADataLoader.js";
import { TextureAssetLoader } from "@woosh/meep-engine/src/engine/asset/loaders/texture/TextureAssetLoader.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { ParentEntity } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import { Camera } from "@woosh/meep-engine/src/engine/graphics/ecs/camera/Camera.js";
import { Decal } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/Decal.js";
import { FPDecalSystem } from "@woosh/meep-engine/src/engine/graphics/ecs/decal/v2/FPDecalSystem.js";
import Highlight from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/Highlight.js";
import {
ShadedGeometryHighlightSystem
} from "@woosh/meep-engine/src/engine/graphics/ecs/highlight/system/ShadedGeometryHighlightSystem.js";
import { Light } from "@woosh/meep-engine/src/engine/graphics/ecs/light/Light.js";
import { LightType } from "@woosh/meep-engine/src/engine/graphics/ecs/light/LightType.js";
import { SGMesh } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMesh.js";
import {
SGMeshHighlightSystem
} from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshHighlightSystem.js";
import { SGMeshSystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/aggregate/SGMeshSystem.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import {
AmbientOcclusionPostProcessEffect
} from "@woosh/meep-engine/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js";
import { MonteCarloTreeSearch } from "@woosh/meep-engine/src/engine/intelligence/mcts/MonteCarlo.js";
import { MoveEdge } from "@woosh/meep-engine/src/engine/intelligence/mcts/MoveEdge.js";
import { StateType } from "@woosh/meep-engine/src/engine/intelligence/mcts/StateNode.js";
// Three.js bits we use directly for HDR loading (the engine's helpers cover
// cube + EXR; for a single equirectangular HDR we just need RGBELoader and
// PMREMGenerator from Three).
import { EquirectangularReflectionMapping, PMREMGenerator } from "three";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
// ─── §1 Rules engine ───────────────────────────────────────────────────────
//
// Everything in this section is pure JS — no engine, no DOM, no Three. It's
// the kind of code you'd write whether you were targeting Meep or anything
// else. We isolate it on purpose so it can be shared between the player's UI
// (move validation) and the MCTS (state expansion).
// --- Piece encoding --------------------------------------------------------
//
// Each square in the board is a single byte:
//
// 0 empty
// 1..6 white piece, by type (KING..PAWN)
// 9..14 black piece, by type (KING..PAWN; same low bits, with bit 3 set)
//
// The TYPE lives in the low 3 bits; the TEAM is bit 3 (value 8). Encoding a
// piece is a bitwise OR — `WHITE | ROOK`, `BLACK | KNIGHT`, etc. — and
// reading either field is a single mask.
const EMPTY = 0;
const KING = 1;
const QUEEN = 2;
const ROOK = 3;
const BISHOP = 4;
const KNIGHT = 5;
const PAWN = 6;
const WHITE = 0;
const BLACK = 8;
const TEAM_MASK = 8;
const TYPE_MASK = 7;
const pieceType = (code) => code & TYPE_MASK;
const pieceTeam = (code) => code & TEAM_MASK;
const isEmpty = (code) => code === EMPTY;
const opponent = (team) => team ^ TEAM_MASK;
// Square index 0..63 packs (rank, file) so consecutive ranks are contiguous.
const sq = (file, rank) => rank * 8 + file;
const fileOf = (idx) => idx & 7;
const rankOf = (idx) => (idx >> 3) & 7;
const inBoard = (f, r) => f >= 0 && f < 8 && r >= 0 && r < 8;
// --- Material values (used by the MCTS heuristic) --------------------------
//
// Classical chess values. KING is intentionally 0 — checkmate is signalled
// by the terminal flag, not by counting material.
const PIECE_VALUE = new Float32Array(8);
PIECE_VALUE[PAWN] = 1;
PIECE_VALUE[KNIGHT] = 3;
PIECE_VALUE[BISHOP] = 3;
PIECE_VALUE[ROOK] = 5;
PIECE_VALUE[QUEEN] = 9;
PIECE_VALUE[KING] = 0;
// Total non-king material per side = 1·8 + 3·2 + 3·2 + 5·2 + 9 = 39, so the
// max absolute material balance is 39. We normalise into ~[-1, 1].
const MATERIAL_NORM = 1 / 39;
// --- Initial position + cloning -------------------------------------------
/**
* Build a fresh starting position. The `squares` Uint8Array is cheap to
* clone (slice) and cheap to mutate in place — perfect for MCTS.
*/
function makeInitialState() {
const squares = new Uint8Array(64);
squares[sq(0, 0)] = WHITE | ROOK;
squares[sq(1, 0)] = WHITE | KNIGHT;
squares[sq(2, 0)] = WHITE | BISHOP;
squares[sq(3, 0)] = WHITE | QUEEN;
squares[sq(4, 0)] = WHITE | KING;
squares[sq(5, 0)] = WHITE | BISHOP;
squares[sq(6, 0)] = WHITE | KNIGHT;
squares[sq(7, 0)] = WHITE | ROOK;
for (let f = 0; f < 8; f++) squares[sq(f, 1)] = WHITE | PAWN;
for (let f = 0; f < 8; f++) squares[sq(f, 6)] = BLACK | PAWN;
squares[sq(0, 7)] = BLACK | ROOK;
squares[sq(1, 7)] = BLACK | KNIGHT;
squares[sq(2, 7)] = BLACK | BISHOP;
squares[sq(3, 7)] = BLACK | QUEEN;
squares[sq(4, 7)] = BLACK | KING;
squares[sq(5, 7)] = BLACK | BISHOP;
squares[sq(6, 7)] = BLACK | KNIGHT;
squares[sq(7, 7)] = BLACK | ROOK;
return { squares, sideToMove: WHITE };
}
function cloneState(state) {
return { squares: state.squares.slice(), sideToMove: state.sideToMove };
}
// --- Pseudo-legal move generation ------------------------------------------
//
// Moves are packed into a single 12-bit integer:
//
// move = (from << 6) | to // from, to ∈ [0, 63]
//
// Packed integers let MoveEdge.move() pass a number around the MCTS hot
// path without allocating an object per legal move.
const RAY_DIRECTIONS_ROOK = [[1, 0], [-1, 0], [0, 1], [0, -1]];
const RAY_DIRECTIONS_BISHOP = [[1, 1], [1, -1], [-1, 1], [-1, -1]];
const RAY_DIRECTIONS_QUEEN = RAY_DIRECTIONS_ROOK.concat(RAY_DIRECTIONS_BISHOP);
const KNIGHT_DELTAS = [[1, 2], [2, 1], [-1, 2], [-2, 1], [1, -2], [2, -1], [-1, -2], [-2, -1]];
const KING_DELTAS = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]];
const moveFrom = (move) => (move >> 6) & 0x3F;
const moveTo = (move) => move & 0x3F;
const makeMove = (from, to) => (from << 6) | to;
/**
* Push every pseudo-legal move for the piece at `from` into `out`. "Pseudo"
* because we don't yet check whether the move leaves our own king in check
* — that filter happens once, in enumerateLegalMoves.
*/
function pushPseudoMoves(out, state, from) {
const piece = state.squares[from];
const team = pieceTeam(piece);
const type = pieceType(piece);
const f0 = fileOf(from), r0 = rankOf(from);
switch (type) {
case PAWN: {
const dir = team === WHITE ? +1 : -1;
const startRank = team === WHITE ? 1 : 6;
// Single push.
const r1 = r0 + dir;
if (inBoard(f0, r1) && isEmpty(state.squares[sq(f0, r1)])) {
out.push(makeMove(from, sq(f0, r1)));
// Double push from starting rank, but only if the in-between
// square is also empty (handled by the outer `if`).
if (r0 === startRank) {
const r2 = r0 + 2 * dir;
if (isEmpty(state.squares[sq(f0, r2)])) {
out.push(makeMove(from, sq(f0, r2)));
}
}
}
// Diagonal captures — only valid if there's an opponent there.
for (const df of [-1, +1]) {
const fc = f0 + df, rc = r0 + dir;
if (!inBoard(fc, rc)) continue;
const target = state.squares[sq(fc, rc)];
if (target !== EMPTY && pieceTeam(target) !== team) {
out.push(makeMove(from, sq(fc, rc)));
}
}
return;
}
case KNIGHT: {
for (const [df, dr] of KNIGHT_DELTAS) {
const f1 = f0 + df, r1 = r0 + dr;
if (!inBoard(f1, r1)) continue;
const target = state.squares[sq(f1, r1)];
if (target === EMPTY || pieceTeam(target) !== team) {
out.push(makeMove(from, sq(f1, r1)));
}
}
return;
}
case KING: {
for (const [df, dr] of KING_DELTAS) {
const f1 = f0 + df, r1 = r0 + dr;
if (!inBoard(f1, r1)) continue;
const target = state.squares[sq(f1, r1)];
if (target === EMPTY || pieceTeam(target) !== team) {
out.push(makeMove(from, sq(f1, r1)));
}
}
return;
}
case BISHOP:
pushRayMoves(out, state, from, team, RAY_DIRECTIONS_BISHOP);
return;
case ROOK:
pushRayMoves(out, state, from, team, RAY_DIRECTIONS_ROOK);
return;
case QUEEN:
pushRayMoves(out, state, from, team, RAY_DIRECTIONS_QUEEN);
return;
}
}
/**
* Sliding-piece move generation: walk along each direction until we hit the
* edge of the board, our own piece (stop without capturing), or an opponent
* (capture, then stop).
*/
function pushRayMoves(out, state, from, team, directions) {
const f0 = fileOf(from), r0 = rankOf(from);
for (const [df, dr] of directions) {
let f = f0 + df, r = r0 + dr;
while (inBoard(f, r)) {
const idx = sq(f, r);
const target = state.squares[idx];
if (target === EMPTY) {
out.push(makeMove(from, idx));
} else {
if (pieceTeam(target) !== team) out.push(makeMove(from, idx));
break;
}
f += df;
r += dr;
}
}
}
function pseudoLegalMovesFor(state, team) {
const out = [];
for (let i = 0; i < 64; i++) {
const piece = state.squares[i];
if (piece === EMPTY) continue;
if (pieceTeam(piece) !== team) continue;
pushPseudoMoves(out, state, i);
}
return out;
}
// --- Attack queries (used for check detection) -----------------------------
function findKing(state, team) {
const target = team | KING;
for (let i = 0; i < 64; i++) if (state.squares[i] === target) return i;
return -1;
}
/**
* True if any piece of `attackerTeam` attacks square `idx`. We don't build a
* full attack table — we just check, square-by-square, whether any of the
* attacker's piece types could be standing on a square from which they'd
* threaten the target.
*/
function isSquareAttackedBy(state, idx, attackerTeam) {
const tf = fileOf(idx), tr = rankOf(idx);
// Pawn attacks. A pawn attacks diagonally forward; sitting on the target,
// we look one rank "back" from the attacker's POV.
const pawnDir = attackerTeam === WHITE ? +1 : -1;
for (const df of [-1, +1]) {
const f = tf + df, r = tr - pawnDir;
if (!inBoard(f, r)) continue;
if (state.squares[sq(f, r)] === (attackerTeam | PAWN)) return true;
}
// Knight attacks.
for (const [df, dr] of KNIGHT_DELTAS) {
const f = tf + df, r = tr + dr;
if (!inBoard(f, r)) continue;
if (state.squares[sq(f, r)] === (attackerTeam | KNIGHT)) return true;
}
// King attacks (one-square radius).
for (const [df, dr] of KING_DELTAS) {
const f = tf + df, r = tr + dr;
if (!inBoard(f, r)) continue;
if (state.squares[sq(f, r)] === (attackerTeam | KING)) return true;
}
// Sliding attacks — walk outwards from the target. The FIRST occupied
// square in each direction decides: if it's the right piece type for the
// attacker, we're under attack; otherwise the ray is blocked.
function rayHasAttacker(directions, type1, type2) {
for (const [df, dr] of directions) {
let f = tf + df, r = tr + dr;
while (inBoard(f, r)) {
const p = state.squares[sq(f, r)];
if (p !== EMPTY) {
if (pieceTeam(p) === attackerTeam) {
const t = pieceType(p);
if (t === type1 || t === type2) return true;
}
break;
}
f += df;
r += dr;
}
}
return false;
}
if (rayHasAttacker(RAY_DIRECTIONS_ROOK, ROOK, QUEEN)) return true;
if (rayHasAttacker(RAY_DIRECTIONS_BISHOP, BISHOP, QUEEN)) return true;
return false;
}
function isInCheck(state, team) {
const kingIdx = findKing(state, team);
if (kingIdx === -1) return false;
return isSquareAttackedBy(state, kingIdx, opponent(team));
}
// --- Move application (apply / undo) --------------------------------------
//
// applyMoveInPlace and undoMoveInPlace operate destructively on the state
// AND return enough information to perfectly reverse the move. This pairing
// is how enumerateLegalMoves filters in-check moves without ever cloning the
// state — the inner loop only touches three bytes per move.
function applyMoveInPlace(state, move) {
const from = moveFrom(move);
const to = moveTo(move);
const moving = state.squares[from];
const captured = state.squares[to];
state.squares[to] = moving;
state.squares[from] = EMPTY;
state.sideToMove = opponent(state.sideToMove);
return captured;
}
function undoMoveInPlace(state, move, captured) {
const from = moveFrom(move);
const to = moveTo(move);
state.squares[from] = state.squares[to];
state.squares[to] = captured;
state.sideToMove = opponent(state.sideToMove);
}
// --- Legal-move enumeration -----------------------------------------------
/** All pseudo-legal moves that don't leave the side-to-move's king in check. */
function enumerateLegalMoves(state) {
const team = state.sideToMove;
const pseudo = pseudoLegalMovesFor(state, team);
const legal = [];
for (let i = 0; i < pseudo.length; i++) {
const move = pseudo[i];
const captured = applyMoveInPlace(state, move);
const safe = !isInCheck(state, team);
undoMoveInPlace(state, move, captured);
if (safe) legal.push(move);
}
return legal;
}
/** Early-exit variant — cheaper when we only need yes/no. */
function hasAnyLegalMove(state) {
const team = state.sideToMove;
const pseudo = pseudoLegalMovesFor(state, team);
for (let i = 0; i < pseudo.length; i++) {
const move = pseudo[i];
const captured = applyMoveInPlace(state, move);
const safe = !isInCheck(state, team);
undoMoveInPlace(state, move, captured);
if (safe) return true;
}
return false;
}
// --- End-of-game detection ------------------------------------------------
//
// Called AFTER a move has been made. Asks the player whose turn it now is
// whether they have any reply. If not, the previous move was either a
// checkmate (game ends, opposite side wins) or a stalemate (we ignore it
// per the example's spec — only mate triggers the banner).
function computeOutcome(state) {
if (hasAnyLegalMove(state)) return { result: null };
if (isInCheck(state, state.sideToMove)) {
return { result: state.sideToMove === WHITE ? "BLACK_WINS" : "WHITE_WINS" };
}
return { result: "STALEMATE" };
}
// ─── §2 Engine bootstrap ───────────────────────────────────────────────────
//
// EngineHarness handles the boilerplate of constructing the Engine,
// applying the configuration, mounting the viewstack to the DOM and so on.
// We hand it a configuration callback that registers the asset loaders,
// the ECS systems we plan to use, and one or two plugins.
// World scale: the board GLTF is authored with squares ≈ 0.063 world units
// each. Rather than scaling the model up, we shrink the placement grid and
// the camera to match. Bump this if your assets have a different scale.
const WORLD_PER_SQUARE = 0.063;
// Camera — player's-eye view from the Black side (yaw = π points the
// camera toward -Z; positive Z is the player's near edge of the board).
const CAMERA_PITCH = 0.95;
const CAMERA_YAW = Math.PI;
const CAMERA_DISTANCE = 14 * WORLD_PER_SQUARE;
const CAMERA_FOV = 38;
const CAMERA_FOCUS = new Vector3(
0,
0.3 * WORLD_PER_SQUARE,
0.8 * WORLD_PER_SQUARE,
);
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
// Asset loaders the systems below will reach for.
const gltfLoader = new GLTFAssetLoader();
config.addLoader(GameAssetType.ModelGLTF, gltfLoader);
config.addLoader(GameAssetType.ModelGLTF_JSON, gltfLoader);
config.addLoader(GameAssetType.Texture, new TextureAssetLoader());
config.addLoader(GameAssetType.Image, new ImageRGBADataLoader());
// The mesh pipeline. SGMeshSystem reacts to SGMesh components by
// loading the referenced GLTF and spawning the per-primitive
// ShadedGeometry child entities that ShadedGeometrySystem actually
// batches and draws.
config.addSystem(new SGMeshSystem(engine));
config.addSystem(new ShadedGeometrySystem(engine));
// Outline highlighting. SGMeshHighlightSystem propagates a Highlight
// attached to the top-level SGMesh entity down to its ShadedGeometry
// children; ShadedGeometryHighlightSystem does the actual rendering.
config.addSystem(new SGMeshHighlightSystem());
config.addSystem(new ShadedGeometryHighlightSystem(engine));
// Decals — our circular legal-move markers project onto the board.
config.addSystem(new FPDecalSystem(engine));
// SSAO — adds soft contact shadows under the pieces.
config.addPlugin(AmbientOcclusionPostProcessEffect);
},
});
// Once the AO plugin is initialised we can dial its intensity.
engine.plugins.acquire(AmbientOcclusionPostProcessEffect).then((plugin) => {
plugin.getValue().intensity = 1;
});
// buildBasics constructs the camera + lights + (optionally) shadows. We
// keep cameraController disabled because chess is a static scene.
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: false,
enableShadows: true,
focus: CAMERA_FOCUS,
distance: CAMERA_DISTANCE,
pitch: CAMERA_PITCH,
yaw: CAMERA_YAW,
cameraFieldOfView: CAMERA_FOV,
cameraFarDistance: 2,
cameraController: false,
showFps: false,
});
await EngineHarness.buildLights({
engine,
sunDirection: new Vector3(-0.5, -1, -0.5),
ambientIntensity: 0,
});
const ecd = engine.entityManager.dataset;
// Highlight and Decal are component types we attach at runtime, so they need
// to be registered before any add/remove call.
ecd.registerComponentType(Highlight);
ecd.registerComponentType(Decal);
// ─── §2b Environment (HDR sky + PBR env map) ───────────────────────────────
//
// One HDR texture, used two ways:
// - scene.background → drawn behind everything by the renderer.
// - scene.environment → PMREM-filtered, sampled by every PBR material on
// the scene (this is what gives the pieces their
// plausible specular reflections and skylight).
//
// This pattern mirrors load_environment_map in the engine, which handles
// cubemap + EXR cases — for a single equirectangular .hdr we just call
// RGBELoader and PMREMGenerator.fromEquirectangular directly.
const HDR_URL = "./photo_studio_loft_hall_2k.hdr";
new RGBELoader().load(HDR_URL, (hdrTexture) => {
hdrTexture.mapping = EquirectangularReflectionMapping;
engine.graphics.scene.background = hdrTexture;
const pmrem = new PMREMGenerator(engine.graphics.renderer);
const filtered = pmrem.fromEquirectangular(hdrTexture).texture;
engine.graphics.scene.environment = filtered;
pmrem.dispose();
});
// ─── §3 World setup ────────────────────────────────────────────────────────
const MODEL_URL_BASE = "./models/chess/";
const BOARD_TOP_Y = 0.02; // world-Y of the board's playing surface
// World position of a square's centre, on the board.
function squareWorld(file, rank, y = BOARD_TOP_Y) {
return [
(file - 3.5) * WORLD_PER_SQUARE,
y,
(rank - 3.5) * WORLD_PER_SQUARE,
];
}
// --- Board mesh ------------------------------------------------------------
//
// The board is a single GLTF object; individual squares aren't separately
// pickable. Empty-square clicks get resolved by a ray/plane intersection
// in §5 below.
new Entity()
.add(Transform.fromJSON({ position: { x: 0, y: 0, z: 0 } }))
.add(SGMesh.fromURL(MODEL_URL_BASE + "board.gltf"))
.build(ecd);
// --- Piece spawning -------------------------------------------------------
/** Build a relative URL like "./models/chess/black-knight.gltf". */
function pieceUrl(piece) {
const color = pieceTeam(piece) === WHITE ? "white" : "black";
const type = ({
[KING]: "king", [QUEEN]: "queen", [ROOK]: "rook",
[BISHOP]: "bishop", [KNIGHT]: "knight", [PAWN]: "pawn",
})[pieceType(piece)];
return `${MODEL_URL_BASE}${color}-${type}.gltf`;
}
/** Half-turn so White's pieces face away from the camera, toward Black. */
function pieceYawFor(piece) {
return pieceTeam(piece) === WHITE ? Math.PI : 0;
}
// Per-square entity bookkeeping. We need both directions:
// pieceEntityBySquare[idx] → SGMesh entity id (or -1 if empty)
// squareByPieceEntity.get(id) → square index (or undefined for non-pieces)
//
// The reverse map is consulted when a ray-cast lands on a piece's mesh: we
// walk to the SGMesh ancestor and ask "which square is this piece on?".
const pieceEntityBySquare = new Int32Array(64);
pieceEntityBySquare.fill(-1);
const squareByPieceEntity = new Map();
function spawnPieceAt(piece, squareIdx) {
if (piece === EMPTY) return -1;
const [x, , z] = squareWorld(fileOf(squareIdx), rankOf(squareIdx));
const halfYaw = pieceYawFor(piece) / 2;
const ent = new Entity()
.add(Transform.fromJSON({
position: { x, y: BOARD_TOP_Y, z },
rotation: { x: 0, y: Math.sin(halfYaw), z: 0, w: Math.cos(halfYaw) },
}))
.add(SGMesh.fromURL(pieceUrl(piece)))
.build(ecd);
pieceEntityBySquare[squareIdx] = ent;
squareByPieceEntity.set(ent, squareIdx);
return ent;
}
// --- Live game state ------------------------------------------------------
const game = makeInitialState();
let gameOver = false;
let inputLocked = false; // true while the AI is thinking
let animationActive = false; // true while any piece is in flight
let selectedSquare = -1;
let selectedEntity = -1;
const moveMarkerEntities = [];
for (let i = 0; i < 64; i++) spawnPieceAt(game.squares[i], i);
// --- Captured-piece graveyards --------------------------------------------
//
// Captured pieces don't disappear — they fly to a row beside the board so
// the player can see what they've taken. White's captures go off the left
// (-X) side; black's go off the right (+X) side. Each team fills its row
// in capture order along the rank axis.
const GRAVEYARD_OFFSET = 5.0 * WORLD_PER_SQUARE;
const GRAVEYARD_SPACING = 0.5 * WORLD_PER_SQUARE;
const nextGraveyardSlot = { [WHITE]: 0, [BLACK]: 0 };
function graveyardSlotPosition(team, slotIdx) {
const sideX = (team === WHITE ? -1 : +1) * GRAVEYARD_OFFSET;
const z = (slotIdx - 7.5) * GRAVEYARD_SPACING;
return [sideX, BOARD_TOP_Y, z];
}
// ─── §4 Move-arc animation ─────────────────────────────────────────────────
//
// Each move is a 3-segment trajectory built from three cubic-Hermite
// AnimationCurves, one per spatial axis:
//
// Y (height)
// ▲
// │ ╭───────────────────╮ ← KF1, KF2 are at apex
// │ ╱ ╲
// │ ╱ ╲
// │╱ ╲
// base ──┼──────────────────────────►──── time
// KF0 KF3
// ↑lift glide↑ touchdown↑
//
// X and Z carry tangent = 0 at every keyframe, so the only horizontal
// motion happens between KF1 (above source) and KF2 (above destination).
// The cubic between them is a smoothstep — eases in and out.
//
// Y has a positive outTangent at KF0 (liftoff speed) and a negative
// inTangent at KF3 (touchdown speed). At the two apex keyframes both
// tangents are zero, which gives a brief, flat hover at the top of the
// arc and prevents the piece from "ringing" past the apex height.
const ARC_DURATION_SEC = 0.45; // total animation length per move
const ARC_LIFT_HEIGHT = 0.06; // apex sits this far above the board (world units)
const ARC_LIFT_FRACTION = 0.2; // fraction of total time spent lifting (also touching down)
const ARC_LIFT_DURATION = ARC_LIFT_FRACTION * ARC_DURATION_SEC;
const ARC_TRAVEL_END = ARC_DURATION_SEC - ARC_LIFT_DURATION;
const ARC_LIFT_SLOPE = ARC_LIFT_HEIGHT / ARC_LIFT_DURATION; // dY/dT during lift / touchdown
function makeArcCurves(fromX, fromZ, toX, toZ) {
const apexY = BOARD_TOP_Y + ARC_LIFT_HEIGHT;
// X & Z curves: stay at source through the lift, smoothstep to
// destination during the glide, then stay put through the touchdown.
const xCurve = AnimationCurve.from([
Keyframe.from(0, fromX, 0, 0),
Keyframe.from(ARC_LIFT_DURATION, fromX, 0, 0),
Keyframe.from(ARC_TRAVEL_END, toX, 0, 0),
Keyframe.from(ARC_DURATION_SEC, toX, 0, 0),
]);
const zCurve = AnimationCurve.from([
Keyframe.from(0, fromZ, 0, 0),
Keyframe.from(ARC_LIFT_DURATION, fromZ, 0, 0),
Keyframe.from(ARC_TRAVEL_END, toZ, 0, 0),
Keyframe.from(ARC_DURATION_SEC, toZ, 0, 0),
]);
// Y curve: liftoff (positive outTangent), hover (zero tangents),
// touchdown (negative inTangent).
const yCurve = AnimationCurve.from([
Keyframe.from(0, BOARD_TOP_Y, 0, ARC_LIFT_SLOPE),
Keyframe.from(ARC_LIFT_DURATION, apexY, 0, 0),
Keyframe.from(ARC_TRAVEL_END, apexY, 0, 0),
Keyframe.from(ARC_DURATION_SEC, BOARD_TOP_Y, -ARC_LIFT_SLOPE, 0),
]);
return { xCurve, yCurve, zCurve };
}
/**
* Drive one entity along a freshly-built arc, resolving when the animation
* completes. We attach a per-animation listener to the engine's postRender
* signal and detach it on completion — concurrent animations don't share
* state.
*/
function animateEntityAlongArc(entity, fromX, fromZ, toX, toZ) {
return new Promise((resolve) => {
const transform = ecd.getComponent(entity, Transform);
if (transform === undefined) {
resolve();
return;
}
const { xCurve, yCurve, zCurve } = makeArcCurves(fromX, fromZ, toX, toZ);
const startTimeMs = performance.now();
const tick = () => {
const elapsedSec = (performance.now() - startTimeMs) / 1000;
const t = Math.min(elapsedSec, ARC_DURATION_SEC);
transform.position.set(
xCurve.evaluate(t),
yCurve.evaluate(t),
zCurve.evaluate(t),
);
if (elapsedSec >= ARC_DURATION_SEC) {
engine.graphics.on.postRender.remove(tick);
resolve();
}
};
engine.graphics.on.postRender.add(tick);
});
}
// ─── §5 Selection & input ──────────────────────────────────────────────────
// --- Move-marker decals ---------------------------------------------------
//
// When a piece is selected, we drop a soft green dot onto each legal
// destination square. The dot is a procedurally-generated PNG fed to the
// engine as a blob: URL, so no asset file is required.
function makeMarkerTextureURL() {
return new Promise((resolve) => {
const size = 128;
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d");
const cx = size / 2, cy = size / 2;
const r = size * 0.42;
const grad = ctx.createRadialGradient(cx, cy, r * 0.2, cx, cy, r);
grad.addColorStop(0, "rgba(78, 240, 168, 0.95)");
grad.addColorStop(0.65, "rgba(78, 240, 168, 0.75)");
grad.addColorStop(1, "rgba(78, 240, 168, 0)");
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
canvas.toBlob((blob) => resolve(URL.createObjectURL(blob)), "image/png");
});
}
const MARKER_URI = await makeMarkerTextureURL();
// The FP decal shader projects along the decal's local Z axis. We rotate
// 90° around X so local Z aligns with world -Y → the decal sprays straight
// down onto the board. After the rotation: local X → world X, local Y →
// world Z, local Z → world -Y.
const DECAL_DOWN_QUAT = {
x: Math.sin(Math.PI / 4),
y: 0,
z: 0,
w: Math.cos(Math.PI / 4),
};
const MARKER_WIDTH_FRACTION = 0.85; // fraction of a square the marker occupies
const MARKER_DEPTH_FRACTION = 0.6; // projection volume depth, in board-square units
function placeMoveMarker(squareIdx) {
const [x, , z] = squareWorld(fileOf(squareIdx), rankOf(squareIdx));
const decal = Decal.fromJSON({ uri: MARKER_URI, priority: 1, color: "#FFFFFF" });
const ent = new Entity()
.add(Transform.fromJSON({
position: { x, y: BOARD_TOP_Y + 0.01 * WORLD_PER_SQUARE, z },
rotation: DECAL_DOWN_QUAT,
scale: {
x: MARKER_WIDTH_FRACTION * WORLD_PER_SQUARE,
y: MARKER_WIDTH_FRACTION * WORLD_PER_SQUARE,
z: MARKER_DEPTH_FRACTION * WORLD_PER_SQUARE,
},
}))
.add(decal)
.build(ecd);
moveMarkerEntities.push(ent);
}
function clearMoveMarkers() {
for (const ent of moveMarkerEntities) {
if (ecd.entityExists(ent)) ecd.removeEntity(ent);
}
moveMarkerEntities.length = 0;
}
// --- Selection / highlight ------------------------------------------------
const HIGHLIGHT_COLOR = Highlight.fromOne(0.31, 0.94, 0.66, 1);
function setSelectedSquare(idx) {
if (idx === selectedSquare) return;
// Tear down the previous selection (if any) before installing the new.
if (selectedEntity !== -1 && ecd.entityExists(selectedEntity)) {
ecd.removeComponentFromEntity(selectedEntity, Highlight);
}
clearMoveMarkers();
selectedSquare = idx;
selectedEntity = idx === -1 ? -1 : pieceEntityBySquare[idx];
if (selectedEntity !== -1) {
ecd.addComponentToEntity(selectedEntity, HIGHLIGHT_COLOR);
// Drop a marker on every legal destination from the newly-selected square.
const legal = enumerateLegalMoves(game);
for (const m of legal) {
if (moveFrom(m) === idx) placeMoveMarker(moveTo(m));
}
}
}
// --- Click pick ------------------------------------------------------------
//
// A click resolves to a square via two independent reads from the same ray:
//
// raycastPieceSquare() : the tracked piece the ray hits first, if any
// projectPlaneSquare() : the board square at ray ∩ playing-surface plane
//
// The two are combined by the tap handler below depending on whether the
// player is making a selection or executing a move.
const PLAYER_TEAM = BLACK;
const cameraEntry = ecd.getAnyComponent(Camera);
const camera = cameraEntry.component;
const shadedGeometrySystem = engine.entityManager.getSystem(ShadedGeometrySystem);
const pickContact = new SurfacePoint3();
const pickOrigin = new Vector3();
const pickDirection = new Vector3();
const planeHitPoint = new Vector3();
/**
* Walk up the ECS parent chain from `entity` until we find one with an
* SGMesh component (the top-level piece). This is needed because the BVH
* raycast returns the leaf ShadedGeometry child of the SGMesh tree, not
* the SGMesh root we track in `squareByPieceEntity`.
*
* Note: ecd.getComponent returns `undefined` (not null) when the component
* isn't attached; the check below treats both as "missing".
*/
function findSGMeshAncestor(entity) {
let current = entity;
for (let safety = 0; safety < 16; safety++) {
if (ecd.getComponent(current, SGMesh) !== undefined) return current;
const parentRef = ecd.getComponent(current, ParentEntity);
if (parentRef === undefined || parentRef === null) return -1;
current = parentRef.entity;
}
return -1;
}
/**
* Square of the tracked piece directly under the cursor, or -1 if the ray
* missed every piece. Caller must have populated pickOrigin / pickDirection
* via camera.projectRay first.
*/
function raycastPieceSquare() {
const hit = shadedGeometrySystem.raycastNearest(
pickContact,
pickOrigin.x, pickOrigin.y, pickOrigin.z,
pickDirection.x, pickDirection.y, pickDirection.z,
);
if (hit === undefined || hit === null) return -1;
const meshEnt = findSGMeshAncestor(hit.entity);
if (meshEnt === -1) return -1;
const sqIdx = squareByPieceEntity.get(meshEnt);
return sqIdx === undefined ? -1 : sqIdx;
}
/**
* Square the ray lands on when intersected with the playing-surface plane,
* or -1 if it misses the board entirely. Caller must have populated
* pickOrigin / pickDirection.
*/
function projectPlaneSquare() {
const ok = plane3_compute_ray_intersection(
planeHitPoint,
pickOrigin.x, pickOrigin.y, pickOrigin.z,
pickDirection.x, pickDirection.y, pickDirection.z,
0, 1, 0, -BOARD_TOP_Y,
);
if (!ok) return -1;
const f = Math.round(planeHitPoint.x / WORLD_PER_SQUARE + 3.5);
const r = Math.round(planeHitPoint.z / WORLD_PER_SQUARE + 3.5);
if (!inBoard(f, r)) return -1;
return sq(f, r);
}
function isPlayerPieceAt(squareIdx) {
if (squareIdx === -1) return false;
const p = game.squares[squareIdx];
return p !== EMPTY && pieceTeam(p) === PLAYER_TEAM;
}
engine.devices.pointer.on.tap.add(async (position) => {
if (inputLocked || gameOver || animationActive) return;
const ndcX = (position.x / window.innerWidth) * 2 - 1;
const ndcY = -(position.y / window.innerHeight) * 2 + 1;
camera.projectRay(ndcX, ndcY, pickOrigin, pickDirection);
const pieceSquare = raycastPieceSquare();
const planeSquare = projectPlaneSquare();
// SELECT routing: a direct hit on a player piece wins. If the ray hits
// an opponent piece instead, we IGNORE that intersection and consult
// the board-plane square. We only ever select a player piece.
const selectCandidate =
isPlayerPieceAt(pieceSquare) ? pieceSquare :
isPlayerPieceAt(planeSquare) ? planeSquare :
-1;
// Phase 1: no piece in hand → try to pick one up.
if (selectedSquare === -1) {
if (selectCandidate !== -1) setSelectedSquare(selectCandidate);
return;
}
// Phase 2: a piece is selected → maybe execute a move. The destination
// is the piece-hit square if any (so clicking on an opponent piece
// captures it), otherwise the board-plane square.
const moveTarget = pieceSquare !== -1 ? pieceSquare : planeSquare;
if (moveTarget !== -1) {
const legalMoves = enumerateLegalMoves(game).filter(
(m) => moveFrom(m) === selectedSquare && moveTo(m) === moveTarget,
);
if (legalMoves.length > 0) {
const move = legalMoves[0];
setSelectedSquare(-1);
await commitMove(move);
startAITurn();
return;
}
}
// Phase 3: click wasn't a legal destination → treat as a re-selection
// attempt (applies the same player-piece rules as Phase 1).
if (selectCandidate !== -1) setSelectedSquare(selectCandidate);
else setSelectedSquare(-1);
});
// ─── §5b Directional-light dim during AI's turn ────────────────────────────
//
// We tween the key/directional light's intensity to a fraction of its
// authored value while the AI is thinking, and back up when control returns
// to the player. AnimationCurve.easeInOut gives the transition a soft start
// and end — both ramps share the same curve, just different endpoints.
const LIGHT_DIM_FRACTION = 0.5; // reduction during AI's turn
const LIGHT_TWEEN_DURATION_SEC = 0.3;
// Pull the directional light's intensity (a Vector1) out of the lights that
// EngineHarness.buildLights spawned for us. We grab a reference once and
// mutate it via `.set(...)` from then on.
let directionalLightIntensity = null;
let directionalLightBaseIntensity = 1;
ecd.traverseEntities([Light], (light) => {
if (light.type.getValue() === LightType.DIRECTION && directionalLightIntensity === null) {
directionalLightIntensity = light.intensity;
directionalLightBaseIntensity = light.intensity.getValue();
}
});
let activeLightTween = null; // postRender handler, if a tween is in flight
function tweenDirectionalLightTo(targetIntensity) {
if (directionalLightIntensity === null) return;
// Cancel any in-flight tween so consecutive transitions chain cleanly.
if (activeLightTween !== null) {
engine.graphics.on.postRender.remove(activeLightTween);
activeLightTween = null;
}
const startIntensity = directionalLightIntensity.getValue();
if (startIntensity === targetIntensity) return;
// easeInOut gives a single AnimationCurve that interpolates from
// (0, startIntensity) to (DURATION, targetIntensity) with soft tangents.
const curve = AnimationCurve.easeInOut(
0, startIntensity,
LIGHT_TWEEN_DURATION_SEC, targetIntensity,
);
const startTimeMs = performance.now();
const tick = () => {
const elapsedSec = (performance.now() - startTimeMs) / 1000;
const t = Math.min(elapsedSec, LIGHT_TWEEN_DURATION_SEC);
directionalLightIntensity.set(curve.evaluate(t));
if (elapsedSec >= LIGHT_TWEEN_DURATION_SEC) {
engine.graphics.on.postRender.remove(tick);
if (activeLightTween === tick) activeLightTween = null;
}
};
activeLightTween = tick;
engine.graphics.on.postRender.add(tick);
}
function dimLightForAI() {
tweenDirectionalLightTo(directionalLightBaseIntensity * LIGHT_DIM_FRACTION);
}
function restoreLightForPlayer() {
tweenDirectionalLightTo(directionalLightBaseIntensity);
}
// ─── §6 MCTS AI ────────────────────────────────────────────────────────────
//
// MonteCarloTreeSearch expects four callbacks: a state-mutating move
// applier (wrapped in a MoveEdge), a terminal-flag computer, a state
// cloner, and an optional heuristic. We feed all four from the rules
// engine in §1 and a material-balance heuristic.
const AI_TIME_BUDGET_MS = 2000;
const AI_PLAYOUT_BUDGET = 40_000;
const AI_BATCH_PER_CYCLE = 32; // playouts per Task slice; keep slices short
const AI_MAX_DEPTH = 64; // plies; beyond this MCTS falls back on heuristic
/** Adapter: turns the rules engine's legal-move list into MoveEdges. */
function computeValidMoves(state) {
const moves = enumerateLegalMoves(state);
const edges = new Array(moves.length);
for (let i = 0; i < moves.length; i++) {
const m = moves[i];
const edge = new MoveEdge();
edge.move = (s) => {
applyMoveInPlace(s, m);
return s;
};
edge.__chessMove = m; // stash so we can read the chosen move back at the end
edges[i] = edge;
}
return edges;
}
/**
* MCTS asks: "is this state terminal, and if so what's the outcome FROM
* THE ROOT PLAYER'S PERSPECTIVE?" Our root player is White, so:
* - side to move (just-moved-against) is BLACK and they have no replies:
* White just delivered mate → Win
* - side to move is WHITE and they have no replies:
* Black just delivered mate → Loss
* - neither side has any reply but nobody is in check: Tie (stalemate)
* - otherwise: Undecided
*/
function computeTerminalFlag(state) {
if (hasAnyLegalMove(state)) return StateType.Undecided;
if (isInCheck(state, state.sideToMove)) {
return state.sideToMove === BLACK ? StateType.Win : StateType.Loss;
}
return StateType.Tie;
}
/** Material balance from White's perspective, normalised into ~[-1, 1]. */
function materialHeuristic(_node, state) {
let balance = 0;
for (let i = 0; i < 64; i++) {
const p = state.squares[i];
if (p === EMPTY) continue;
const v = PIECE_VALUE[pieceType(p)];
balance += pieceTeam(p) === WHITE ? v : -v;
}
return balance * MATERIAL_NORM;
}
const aiProgressEl = document.getElementById("ai-progress");
const turnEl = document.getElementById("turn");
/** Initialise an MCTS search rooted at the current game state. */
function buildSearch() {
const search = new MonteCarloTreeSearch();
search.maxExplorationDepth = AI_MAX_DEPTH;
search.initialize({
rootState: cloneState(game),
computeValidMoves,
computeTerminalFlag,
cloneState,
heuristic: materialHeuristic,
});
return search;
}
/**
* Hand the search to engine.executor as a Task. The executor time-slices
* the cycleFunction against rendering, so AI work never blocks the page.
* Each cycle runs AI_BATCH_PER_CYCLE playouts; we stop when either the
* wall-clock budget or the playout budget is reached.
*/
function runAISearchAsTask(search, onDone) {
const deadlineMs = performance.now() + AI_TIME_BUDGET_MS;
let playouts = 0;
const cycle = () => {
for (let i = 0; i < AI_BATCH_PER_CYCLE; i++) {
search.playout();
playouts++;
}
aiProgressEl.textContent = `${playouts} playouts`;
const timeUp = performance.now() >= deadlineMs;
const playoutsUp = playouts >= AI_PLAYOUT_BUDGET;
return (timeUp || playoutsUp) ? TaskSignal.EndSuccess : TaskSignal.Continue;
};
const task = new Task({
name: "chess.ai.mcts",
cycleFunction: cycle,
computeProgress: () => Math.min(1, playouts / AI_PLAYOUT_BUDGET),
estimatedDuration: AI_TIME_BUDGET_MS / 1000,
});
task.on.completed.add(() => onDone(playouts));
engine.executor.run(task);
}
/** Kick off the AI's move. Resolves when the AI's piece is on its square. */
function startAITurn() {
// The player might have just delivered checkmate; if so, freeze.
const outcomeAfterPlayer = computeOutcome(game);
if (outcomeAfterPlayer.result !== null) {
showBanner(outcomeAfterPlayer.result);
gameOver = true;
return;
}
inputLocked = true;
turnEl.textContent = "White (AI)";
turnEl.className = "value thinking";
dimLightForAI();
const search = buildSearch();
// No legal moves for White → already terminal (caught above). Defensive.
if (computeValidMoves(search.rootState).length === 0) {
inputLocked = false;
return;
}
runAISearchAsTask(search, async (playouts) => {
const best = search.root.pickBestMoves();
if (best.length === 0) {
aiProgressEl.textContent = "no move";
finalizeAITurn();
return;
}
// Tie-break ties randomly so MCTS doesn't always play the same line.
const chosen = best[(Math.random() * best.length) | 0];
aiProgressEl.textContent = `done (${playouts})`;
await commitMove(chosen.__chessMove);
finalizeAITurn();
});
}
function finalizeAITurn() {
inputLocked = false;
restoreLightForPlayer();
const outcome = computeOutcome(game);
if (outcome.result !== null) {
showBanner(outcome.result);
gameOver = true;
turnEl.textContent = "—";
turnEl.className = "value";
aiProgressEl.textContent = "—";
return;
}
turnEl.textContent = "Black (you)";
turnEl.className = "value you";
aiProgressEl.textContent = "idle";
}
// ─── §7 Move commit (animation + bookkeeping) ──────────────────────────────
//
// commitMove is the only function that mutates BOTH the logical board AND
// the visual world. It runs in three phases:
//
// 1. Update the rules state + entity-tracking maps synchronously, so any
// code that runs after `await commitMove(...)` sees the new board.
// 2. Build per-piece animations (mover + optional captured piece) and
// run them in parallel via Promise.all.
// 3. Clear the animation lock once both motions are finished.
//
// Captured pieces are NOT removed from the world — they're animated off to
// the appropriate graveyard slot and left there.
async function commitMove(move) {
const from = moveFrom(move);
const to = moveTo(move);
const movingEnt = pieceEntityBySquare[from];
const capturedEnt = pieceEntityBySquare[to];
// Snapshot the captured piece BEFORE applying the move — we need its
// team to pick a graveyard slot.
const capturedPieceCode = game.squares[to];
// Phase 1 — bookkeeping.
if (capturedEnt !== -1) squareByPieceEntity.delete(capturedEnt);
pieceEntityBySquare[to] = movingEnt;
pieceEntityBySquare[from] = -1;
if (movingEnt !== -1) squareByPieceEntity.set(movingEnt, to);
applyMoveInPlace(game, move);
// Phase 2 — animations.
animationActive = true;
const animations = [];
if (movingEnt !== -1) {
const [fromX, , fromZ] = squareWorld(fileOf(from), rankOf(from));
const [toX, , toZ] = squareWorld(fileOf(to), rankOf(to));
animations.push(animateEntityAlongArc(movingEnt, fromX, fromZ, toX, toZ));
}
if (capturedEnt !== -1 && capturedPieceCode !== EMPTY) {
const team = pieceTeam(capturedPieceCode);
const slot = nextGraveyardSlot[team]++;
const [fromX, , fromZ] = squareWorld(fileOf(to), rankOf(to));
const [graveX, , graveZ] = graveyardSlotPosition(team, slot);
animations.push(animateEntityAlongArc(capturedEnt, fromX, fromZ, graveX, graveZ));
}
await Promise.all(animations);
// Phase 3 — release the lock.
animationActive = false;
}
// ─── §8 End-game banner ────────────────────────────────────────────────────
function showBanner(result) {
if (result === "STALEMATE") return; // example only flags mate
const banner = document.getElementById("banner");
const who = document.getElementById("banner-who");
who.textContent = result === "WHITE_WINS" ? "White won" : "Black won";
banner.hidden = false;
}
// ─── §9 HUD ────────────────────────────────────────────────────────────────
const fpsEl = document.getElementById("fps");
let fpsWindow = 0;
let fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = (nowMs - lastFrameMs) / 1000;
lastFrameMs = nowMs;
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});
// ─── §10 Kickoff ───────────────────────────────────────────────────────────
//
// White (AI) moves first as in standard chess. We wait a frame so the
// engine has processed system startups (in particular FPDecalSystem
// acquiring the ForwardPlusRenderingPlugin) before handing it a Task.
requestAnimationFrame(() => {
startAITurn();
});