A complete step-by-step guide to creating an interactive 3D chess replay viewer using Babylon.js, React, and TypeScript. Watch chess games come to life with smooth animations, glowing highlights, and WebGPU-accelerated rendering!
🎯 What You’ll Build
An interactive 3D chess board that can:
- Parse chess games in PGN notation
- Replay games move by move with smooth 3D animations
- Switch between different chess piece sets
- Auto-play games with pause/resume controls
- Support both WebGPU (modern) and WebGL (fallback) rendering
Live Demo Features:
- ⚡ WebGPU rendering with automatic WebGL fallback
- 🎨 Glowing square highlights for move visualization
- 📱 Interactive camera controls (rotate, zoom, pan)
- ♟️ Multiple chess piece sets (Lewis chess pieces included!)
- ⏯️ Play/Pause, step forward/backward controls
📋 Prerequisites
- Node.js 18+ and npm installed
- Basic knowledge of TypeScript/React
- A code editor (VS Code recommended)
- 10-30 minutes of your time
Step 1: Create Your Project Repository
1.1 Initialize the Project
Open your terminal and run:
# Create project directory
mkdir webgpu-chess-replay
cd webgpu-chess-replay
# Initialize git repository
git init
# Initialize npm project
npm init -y
💡 AI Prompt for Project Setup:
Create a package.json for a TypeScript React project using Vite as the build tool.
Include these dependencies:
- Babylon.js core, GUI, and loaders (version 8.47.0)
- React 19
- chess.js for chess logic
- TypeScript 5.9+
Include scripts for dev, build, and preview.
1.2 Install Dependencies
# Install core dependencies
npm install @babylonjs/core@^8.47.0 @babylonjs/gui@^8.47.0 @babylonjs/loaders@^8.47.0
npm install chess.js@^1.4.0
npm install react@^19.2.3 react-dom@^19.2.3
# Install dev dependencies
npm install -D @types/react@^19.2.9 @types/react-dom@^19.2.3
npm install -D @vitejs/plugin-react@^5.1.2
npm install -D typescript@^5.9.3 vite@^7.3.1
1.3 Create Configuration Files
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
Create vite.config.ts:
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
base: '/',
plugins: [react()],
server: { port: 5173 }
});
1.4 Create Project Structure
# Create directories
mkdir -p src public/models/set1 public/models/set2
# Create .gitignore
cat > .gitignore << 'EOF'
node_modules
dist
.DS_Store
*.log
.vite
*.local
EOF
Step 2: Download and Prepare 3D Models
This is the most important step - getting high-quality 3D chess pieces that work well in your scene.
2.1 Find Free 3D Chess Models
Recommended Sources:
- Sketchfab (https://sketchfab.com) - Search “chess set downloadable”
- Poly Pizza (https://poly.pizza) - Free 3D models
- TurboSquid Free - Some free chess models available
💡 AI Prompt for Finding Models:
I need to find free downloadable 3D chess models in GLB or GLTF format.
The models should be:
- Individual pieces OR a complete set in one file
- Optimized for web (under 5MB total for all pieces)
- With clean geometry (not too many polygons)
- Preferably with separate white and black pieces
Where can I find good quality free chess 3D models?
2.2 Download Models
For this tutorial, we’ll use the Lewis Chess Pieces setup:
Option A: Individual Files (Recommended for beginners)
- Download 12 separate GLB files (6 white + 6 black pieces)
- Place in
public/models/set2/ - File naming convention:
Pawn_white.glb, Pawn_black.glb Knight_white.glb, Knight_black.glb Bishop_white.glb, Bishop_black.glb Castle_white.glb, Castle_black.glb Queen_white.glb, Queen_black.glb King_white.glb, King_black.glb
Option B: Combined File (Advanced)
- Download a single
chess_set.glbwith all pieces - Place in
public/models/set1/ - You’ll need to identify mesh names (covered in Step 3)
2.3 Optimize Models (Optional but Recommended)
If your models are large (>1MB per file), optimize them:
Using glTF-Transform (command line):
npm install -g @gltf-transform/cli
# Optimize each model
gltf-transform optimize input.glb output.glb --texture-compress webp
Or use online tools:
- https://gltf.report/ - Drag and drop GLB files
- Check file size, polygon count
- Export optimized version
💡 Target Sizes:
- Individual piece: 100-500KB
- Complete set: Under 5MB total
- Polygon count: 5,000-20,000 per piece
Step 3: Finding Mesh Names in Your 3D Models
This step is crucial! You need to know the exact names of meshes in your GLB files.
3.1 Create a Quick Inspection Tool
Create inspect-glb.html in your project root:
<!DOCTYPE html>
<html>
<head>
<title>GLB Inspector</title>
<script src="https://cdn.babylonjs.com/babylon.js"></script>
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
<style>
body { margin: 0; font-family: monospace; }
canvas { width: 100%; height: 70vh; }
#info { padding: 20px; background: #f0f0f0; }
#info pre { background: white; padding: 10px; overflow: auto; }
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div id="info">
<h3>GLB Inspector - Drag and Drop GLB file</h3>
<input type="file" id="fileInput" accept=".glb,.gltf" />
<pre id="meshList">No file loaded</pre>
</div>
<script>
const canvas = document.getElementById('canvas');
const engine = new BABYLON.Engine(canvas, true);
let scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color4(0.2, 0.2, 0.3, 1);
const camera = new BABYLON.ArcRotateCamera('cam', 0, Math.PI/3, 5,
BABYLON.Vector3.Zero(), scene);
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight('light', new BABYLON.Vector3(0, 1, 0), scene);
engine.runRenderLoop(() => scene.render());
window.addEventListener('resize', () => engine.resize());
document.getElementById('fileInput').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
// Clear previous scene
scene.dispose();
scene = new BABYLON.Scene(engine);
scene.clearColor = new BABYLON.Color4(0.2, 0.2, 0.3, 1);
const camera = new BABYLON.ArcRotateCamera('cam', 0, Math.PI/3, 5,
BABYLON.Vector3.Zero(), scene);
camera.attachControl(canvas, true);
new BABYLON.HemisphericLight('light', new BABYLON.Vector3(0, 1, 0), scene);
// Load GLB
const url = URL.createObjectURL(file);
const result = await BABYLON.SceneLoader.ImportMeshAsync('', '', url, scene);
// Display mesh information
const meshInfo = result.meshes.map((m, i) => {
return `[${i}] "${m.name}" - vertices: ${m.getTotalVertices()}`;
}).join('\n');
document.getElementById('meshList').textContent =
`Loaded: ${file.name}\n\nMeshes found (${result.meshes.length}):\n${meshInfo}`;
});
</script>
</body>
</html>
3.2 Inspect Your Models
- Open
inspect-glb.htmlin your browser - Click “Choose File” and select a GLB file
- You’ll see a list like:
[0] "__root__" - vertices: 0 [1] "Pawn_mesh" - vertices: 1234 [2] "Pawn_collider" - vertices: 56 - Take note of the mesh names - you’ll need them in your code!
💡 AI Prompt for Understanding Mesh Names:
I loaded my chess GLB file and got these mesh names:
[paste your mesh list here]
Which mesh names should I use for my chess pieces?
How do I identify the main visual mesh vs colliders or other helper meshes?
3.3 Document Your Mesh Names
Create a public/models/README.md:
# Chess Models
## Set 2 (Lewis Pieces)
- Individual GLB files
- Main mesh in each file: Usually index [1] or first non-__root__ mesh
- File structure:
- Pawn_white.glb → Main mesh: "Pawn_mesh" or similar
- Knight_white.glb → Main mesh: "Knight_mesh"
- etc.
## Naming Convention
When inspected, look for meshes with highest vertex count (actual geometry)
Ignore: __root__, colliders, helpers
Step 4: Setting Up the Core Files
4.1 Create Type Definitions
Create src/types.ts:
export interface Ply {
index: number;
san: string;
}
export interface MoveMeta {
from: string;
to: string;
san: string;
uci: string;
color: "w" | "b";
piece: string;
captured?: string;
promotion?: string;
flags: string;
}
export interface ReplayData {
plies: Ply[];
fens: string[];
metas: MoveMeta[];
}
4.2 Create PGN Parser
Create src/parser.ts:
import { Chess } from "chess.js";
import type { Ply, ReplayData, MoveMeta } from "./types";
export function buildReplayData(input: string, startFen?: string): ReplayData {
const text = input.trim();
if (!text) throw new Error("Input is empty.");
const chess = new Chess();
if (startFen && startFen.trim()) {
chess.load(startFen.trim());
}
const plies = parseIntoPlies(text);
const fens: string[] = [chess.fen()];
const metas: MoveMeta[] = [];
for (let i = 0; i < plies.length; i++) {
const ply = plies[i];
const move = chess.move(ply.san, { sloppy: true });
if (!move) {
throw new Error(`Illegal move at ply ${ply.index}: "${ply.san}"`);
}
metas.push({
from: move.from,
to: move.to,
san: move.san,
uci: `${move.from}${move.to}${move.promotion ?? ""}`,
color: move.color,
piece: move.piece,
captured: move.captured,
promotion: move.promotion,
flags: move.flags
});
fens.push(chess.fen());
}
return { plies, fens, metas };
}
function parseIntoPlies(text: string): Ply[] {
// Remove comments {like this} and (like this)
let cleaned = text.replace(/\{[^}]*\}/g, "");
cleaned = cleaned.replace(/\([^)]*\)/g, "");
// Remove move numbers like "1." or "12..."
cleaned = cleaned.replace(/\d+\.\s*/g, " ");
// Split by whitespace and filter
const tokens = cleaned.split(/\s+/).filter(t => {
t = t.trim();
if (!t) return false;
if (t === "1-0" || t === "0-1" || t === "1/2-1/2" || t === "*") return false;
return true;
});
return tokens.map((san, i) => ({ index: i + 1, san }));
}
💡 AI Prompt for Parser:
Create a chess PGN parser that:
1. Takes PGN notation text input
2. Handles standard algebraic notation (SAN)
3. Validates moves using chess.js
4. Returns an array of FEN positions for each move
5. Strips comments and result markers
How should I structure this?
4.3 Create Main HTML File
Create index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebGPU Chess Replay</title>
<style>
html, body {
height: 100%;
margin: 0;
background: #0b0f14;
overflow: hidden;
}
#root { height: 100%; }
canvas { display: block; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Create src/main.tsx:
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
Step 5: Changing Offsets and Camera Settings
The trickiest part! Each chess set has different pivot points and scales.
5.1 Understanding the Coordinate System
Babylon.js 3D Space:
Y (up)
|
|_____ X (right)
/
Z (forward)
Chess Board Layout:
- Board center: (0, 0, 0)
- Each square: 1.0 x 1.0 units
- Files (a-h): X from -3.5 to +3.5
- Ranks (1-8): Z from -3.5 to +3.5
5.2 Create Babylon.js Chess View (Part 1: Setup)
Create src/babylonChess.ts:
import {
Engine,
WebGPUEngine,
Scene,
ArcRotateCamera,
Vector3,
HemisphericLight,
DirectionalLight,
MeshBuilder,
Color3,
Color4,
StandardMaterial,
GlowLayer,
TransformNode,
Mesh,
Animation,
CubicEase,
EasingFunction,
SceneLoader
} from "@babylonjs/core";
import "@babylonjs/loaders"; // GLB support
import { Chess } from "chess.js";
import type { AbstractMesh } from "@babylonjs/core";
type SquareKey = string; // "a1" .. "h8"
const FILES = ["a", "b", "c", "d", "e", "f", "g", "h"];
const RANKS = ["1", "2", "3", "4", "5", "6", "7", "8"];
function isWebGPUAvailable(): boolean {
return typeof navigator !== "undefined" && !!(navigator as any).gpu;
}
export class BabylonChessView {
private canvas: HTMLCanvasElement;
private engine!: Engine | WebGPUEngine;
private scene!: Scene;
private glow!: GlowLayer;
private root!: TransformNode;
private squareMeshes = new Map<SquareKey, Mesh>();
private pieceMeshes = new Map<SquareKey, Mesh>();
private pieceRoot!: TransformNode;
private pieceLibrary = new Map<string, AbstractMesh>();
private piecesReady = false;
private modelRoot: AbstractMesh[] = [];
private currentChessSet: 'set1' | 'set2' = 'set2';
private matDark!: StandardMaterial;
private matLight!: StandardMaterial;
private matGlowFrom!: StandardMaterial;
private matGlowTo!: StandardMaterial;
private currentFen: string | null = null;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
}
async init(): Promise<void> {
// Try WebGPU first, fallback to WebGL
if (isWebGPUAvailable()) {
try {
const wgpu = new WebGPUEngine(this.canvas, { adaptToDeviceRatio: true });
await wgpu.initAsync();
this.engine = wgpu;
} catch (error) {
console.warn("WebGPU failed, using WebGL:", error);
this.engine = new Engine(this.canvas, true);
}
} else {
this.engine = new Engine(this.canvas, true);
}
this.scene = new Scene(this.engine);
this.scene.clearColor = new Color4(0.04, 0.06, 0.08, 1);
// Camera setup - IMPORTANT for good viewing angle
const camera = new ArcRotateCamera(
"cam",
Math.PI / 4, // Alpha: rotation around Y axis (45°)
Math.PI / 3.2, // Beta: elevation angle (~35°)
18, // Radius: distance from target
new Vector3(0, 0, 0), // Target: board center
this.scene
);
// Camera limits - prevents awkward angles
camera.lowerRadiusLimit = 10; // Can't zoom in too close
camera.upperRadiusLimit = 26; // Can't zoom out too far
camera.lowerBetaLimit = 0.9; // Can't go too low
camera.upperBetaLimit = 1.4; // Can't flip upside down
camera.attachControl(this.canvas, true);
// Lighting setup
new HemisphericLight("ambient", new Vector3(0, 1, 0), this.scene).intensity = 0.35;
const dirLight = new DirectionalLight("sun", new Vector3(-1, -2, -1), this.scene);
dirLight.intensity = 1.0;
// Glow effect for highlights
this.glow = new GlowLayer("glow", this.scene);
this.glow.intensity = 0.8;
this.root = new TransformNode("root", this.scene);
this.pieceRoot = new TransformNode("pieces", this.scene);
this.pieceRoot.parent = this.root;
this.buildMaterials();
this.buildBoard();
await this.loadPieceModels();
this.engine.runRenderLoop(() => this.scene.render());
window.addEventListener("resize", () => this.engine.resize());
}
dispose(): void {
this.scene?.dispose();
this.engine?.dispose();
}
getEngineKindLabel(): string {
return this.engine instanceof WebGPUEngine ? "WebGPU" : "WebGL";
}
// More methods to follow...
}
💡 AI Prompt for Camera Settings:
I'm building a 3D chess viewer with Babylon.js ArcRotateCamera.
The board is centered at (0,0,0) and is 8x8 units.
What alpha, beta, and radius values give a good "over the shoulder" chess view?
How do I set camera limits to prevent the user from going upside down or too close?
5.3 Critical Offset Settings
Add these methods to babylonChess.ts:
private buildMaterials(): void {
// Dark squares
this.matDark = new StandardMaterial("dark", this.scene);
this.matDark.diffuseColor = new Color3(0.3, 0.25, 0.2);
this.matDark.specularColor = new Color3(0.1, 0.1, 0.1);
// Light squares
this.matLight = new StandardMaterial("light", this.scene);
this.matLight.diffuseColor = new Color3(0.9, 0.85, 0.7);
this.matLight.specularColor = new Color3(0.2, 0.2, 0.2);
// Highlight materials (glow effect)
this.matGlowFrom = new StandardMaterial("glowFrom", this.scene);
this.matGlowFrom.emissiveColor = new Color3(0.3, 0.6, 1.0);
this.matGlowTo = new StandardMaterial("glowTo", this.scene);
this.matGlowTo.emissiveColor = new Color3(1.0, 0.8, 0.2);
}
private buildBoard(): void {
for (let r = 0; r < 8; r++) {
for (let f = 0; f < 8; f++) {
const sq = MeshBuilder.CreateBox(
`sq_${FILES[f]}${RANKS[r]}`,
{ width: 1, height: 0.2, depth: 1 },
this.scene
);
// Position: center the board at origin
const x = f - 3.5; // -3.5 to +3.5
const z = r - 3.5; // -3.5 to +3.5
sq.position.set(x, -0.1, z);
// Checkerboard pattern
const isDark = (r + f) % 2 === 1;
sq.material = isDark ? this.matDark : this.matLight;
sq.parent = this.root;
this.squareMeshes.set(`${FILES[f]}${RANKS[r]}`, sq);
}
}
}
5.4 The Critical Part: Piece Offsets
Different chess sets have different pivot points! Add this to babylonChess.ts:
private async loadPieceModels(): Promise<void> {
// Set 2: Individual Lewis pieces
const pieceFiles = {
w_p: "Pawn_white.glb",
w_n: "Knight_white.glb",
w_b: "Bishop_white.glb",
w_r: "Castle_white.glb",
w_q: "Queen_white.glb",
w_k: "King_white.glb",
b_p: "Pawn_black.glb",
b_n: "Knight_black.glb",
b_b: "Bishop_black.glb",
b_r: "Castle_black.glb",
b_q: "Queen_black.glb",
b_k: "King_black.glb",
};
for (const [key, fileName] of Object.entries(pieceFiles)) {
try {
const result = await SceneLoader.ImportMeshAsync(
null,
"/models/set2/",
fileName,
this.scene
);
this.modelRoot.push(...result.meshes);
result.meshes.forEach(m => {
m.setEnabled(false);
m.position.setAll(0);
m.rotationQuaternion = null;
m.rotation.setAll(0);
m.scaling.setAll(1);
});
// Find main mesh (skip __root__)
const mainMesh = result.meshes.find(m => m.name !== "__root__") || result.meshes[0];
if (mainMesh) {
this.pieceLibrary.set(key, mainMesh);
console.log(`✓ Loaded ${key} from ${fileName}`);
}
} catch (error) {
console.error(`Failed to load ${fileName}:`, error);
}
}
this.piecesReady = this.pieceLibrary.size >= 12;
console.log(`Pieces ready: ${this.pieceLibrary.size}/12`);
}
setPositionFromFen(fen: string): void {
if (this.currentFen === fen) return;
this.currentFen = fen;
// Clear existing pieces
for (const m of this.pieceMeshes.values()) {
m.dispose();
}
this.pieceMeshes.clear();
const chess = new Chess();
try {
chess.load(fen);
} catch {
return;
}
const board = chess.board(); // 8x8 from rank 8 to 1
for (let r = 0; r < 8; r++) {
for (let f = 0; f < 8; f++) {
const piece = board[r][f];
if (!piece) continue;
const file = FILES[f];
const rank = String(8 - r);
const sq = `${file}${rank}` as SquareKey;
const mesh = this.createPieceMesh(piece.type, piece.color);
// ⚠️ CRITICAL: Y-offset for each piece type
// These values depend on your 3D model's pivot point!
const yOffsets: Record<string, number> = {
p: 0.05, // Pawn - sits on board
n: 0.05, // Knight - adjust if hovering/sinking
b: 0.10, // Bishop - slightly taller
r: 0.05, // Rook/Castle
q: 0.05, // Queen
k: 0.15, // King - tallest piece
};
const yOffset = yOffsets[piece.type] || 0.05;
const x = f - 3.5;
const z = r - 3.5;
mesh.position.set(x, yOffset, z);
mesh.parent = this.pieceRoot;
this.pieceMeshes.set(sq, mesh);
}
}
}
private createPieceMesh(type: string, color: "w" | "b"): Mesh {
const key = `${color}_${type}`;
const template = this.pieceLibrary.get(key);
if (!template) {
// Fallback: create a simple box if model not found
const box = MeshBuilder.CreateBox("fallback", { size: 0.5 }, this.scene);
box.position.y = 0.25;
return box;
}
// Clone the template mesh
const instance = template.clone(`piece_${key}_${Date.now()}`, null)!;
instance.setEnabled(true);
return instance as Mesh;
}
💡 AI Prompt for Finding Correct Offsets:
My 3D chess pieces are floating above the board or sinking into it.
The board squares are at Y=0 with height 0.2.
How do I determine the correct Y-offset for each piece type?
My piece models have their pivot at [describe where - bottom, center, etc.]
Should I:
1. Adjust Y-offset in code?
2. Modify the 3D model's pivot point?
3. Use parent transformations?
Testing Offsets:
// Add to your code temporarily to test different offsets
const TEST_OFFSET = 0.5; // Change this value and reload
mesh.position.set(x, TEST_OFFSET, z);
// Try values: -0.5, 0, 0.05, 0.1, 0.2, 0.5, 1.0
// Watch the pieces - find the value where they sit naturally on the board
Step 6: Adding the Play Button and Controls
Now for the fun part - making pieces move!
6.1 Create the React UI Component
Create src/App.tsx:
import React, { useEffect, useRef, useState } from "react";
import { BabylonChessView } from "./babylonChess";
import { buildReplayData } from "./parser";
import type { ReplayData } from "./types";
const DEFAULT_PGN = `1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8 10. d4 Nbd7`;
export default function App() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const viewRef = useRef<BabylonChessView | null>(null);
const [engineLabel, setEngineLabel] = useState<string>("...");
const [text, setText] = useState(DEFAULT_PGN);
const [startFen, setStartFen] = useState("");
const [error, setError] = useState<string>("");
const [replay, setReplay] = useState<ReplayData | null>(null);
const [ply, setPly] = useState<number>(0);
const [busy, setBusy] = useState(false);
const [playing, setPlaying] = useState(false);
const playIntervalRef = useRef<NodeJS.Timeout | null>(null);
// Initialize Babylon.js scene
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const view = new BabylonChessView(canvas);
viewRef.current = view;
(async () => {
await view.init();
setEngineLabel(view.getEngineKindLabel());
tryLoad(); // Load default game
})();
return () => {
view.dispose();
viewRef.current = null;
};
}, []);
const maxPly = replay ? replay.plies.length : 0;
function tryLoad() {
setError("");
try {
const data = buildReplayData(text, startFen);
setReplay(data);
setPly(0);
viewRef.current?.setPositionFromFen(data.fens[0]);
viewRef.current?.clearSquareHighlights();
} catch (e: any) {
setReplay(null);
setPly(0);
setError(e?.message ?? String(e));
}
}
async function next() {
if (!replay || busy || ply >= maxPly) return;
setBusy(true);
try {
const nextPly = ply + 1;
const meta = replay.metas[nextPly - 1];
await viewRef.current?.animateMove(meta.from, meta.to);
viewRef.current?.setPositionFromFen(replay.fens[nextPly]);
setPly(nextPly);
} finally {
setBusy(false);
}
}
async function back() {
if (!replay || busy || ply <= 0) return;
setBusy(true);
try {
const prevPly = ply - 1;
viewRef.current?.clearSquareHighlights();
viewRef.current?.setPositionFromFen(replay.fens[prevPly]);
setPly(prevPly);
} finally {
setBusy(false);
}
}
function reset() {
if (!replay || busy) return;
stopAutoPlay();
setPly(0);
viewRef.current?.clearSquareHighlights();
viewRef.current?.setPositionFromFen(replay.fens[0]);
}
function toggleAutoPlay() {
if (playing) {
stopAutoPlay();
} else {
startAutoPlay();
}
}
function startAutoPlay() {
if (!replay || ply >= maxPly) return;
setPlaying(true);
playIntervalRef.current = setInterval(async () => {
setPly(current => {
if (current >= maxPly) {
stopAutoPlay();
return current;
}
const nextPly = current + 1;
const meta = replay.metas[nextPly - 1];
viewRef.current?.animateMove(meta.from, meta.to);
return nextPly;
});
}, 1500); // Play one move every 1.5 seconds
}
function stopAutoPlay() {
setPlaying(false);
if (playIntervalRef.current) {
clearInterval(playIntervalRef.current);
playIntervalRef.current = null;
}
}
useEffect(() => {
if (playing && ply >= maxPly) {
stopAutoPlay();
}
}, [ply, maxPly, playing]);
return (
<div style={styles.shell}>
<div style={styles.left}>
<div style={styles.brandRow}>
<div style={styles.brand}>WebGPU Chess Replay</div>
<div style={styles.badge}>{engineLabel}</div>
</div>
<div style={styles.sectionTitle}>Input PGN</div>
<textarea
style={styles.textarea}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Paste PGN notation here..."
/>
<div style={{ display: "flex", gap: 10 }}>
<input
style={styles.input}
value={startFen}
onChange={(e) => setStartFen(e.target.value)}
placeholder="Optional start FEN"
/>
<button style={styles.btn} onClick={tryLoad} disabled={busy}>
Load
</button>
</div>
{error && <div style={styles.error}>{error}</div>}
<div style={styles.sectionTitle}>Controls</div>
<div style={{ display: "flex", gap: 10 }}>
<button style={styles.btn} onClick={back} disabled={busy || ply <= 0 || playing}>
◀ Back
</button>
<button style={styles.btnPrimary} onClick={next} disabled={busy || !replay || ply >= maxPly || playing}>
Next ▶
</button>
<button style={styles.btn} onClick={reset} disabled={busy || ply === 0 || playing}>
Reset
</button>
<button
style={{...styles.btn, ...(playing ? styles.btnActive : {})}}
onClick={toggleAutoPlay}
disabled={busy || !replay || ply >= maxPly}
>
{playing ? "⏸ Pause" : "▶ Play"}
</button>
</div>
<div style={styles.metaRow}>
<div style={styles.meta}>
Move: <b>{ply}</b> / {maxPly}
</div>
<div style={styles.meta}>
Engine: <b>{engineLabel}</b>
</div>
</div>
</div>
<div style={styles.right}>
<canvas ref={canvasRef} style={styles.canvas} />
</div>
</div>
);
}
// Styles
const styles: Record<string, React.CSSProperties> = {
shell: {
display: "flex",
height: "100vh",
background: "#0b0f14",
color: "#e0e0e0",
fontFamily: "system-ui, -apple-system, sans-serif",
},
left: {
width: 380,
padding: 20,
background: "#161b22",
borderRight: "1px solid #30363d",
overflowY: "auto",
display: "flex",
flexDirection: "column",
gap: 15,
},
right: {
flex: 1,
position: "relative",
},
canvas: {
width: "100%",
height: "100%",
display: "block",
},
brandRow: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
},
brand: {
fontSize: 18,
fontWeight: 700,
color: "#58a6ff",
},
badge: {
background: "#238636",
color: "white",
padding: "4px 8px",
borderRadius: 4,
fontSize: 11,
fontWeight: 600,
},
sectionTitle: {
fontSize: 13,
fontWeight: 600,
color: "#8b949e",
textTransform: "uppercase",
marginTop: 10,
},
textarea: {
width: "100%",
height: 120,
background: "#0d1117",
border: "1px solid #30363d",
borderRadius: 6,
color: "#e0e0e0",
padding: 10,
fontSize: 13,
fontFamily: "monospace",
resize: "vertical",
},
input: {
flex: 1,
background: "#0d1117",
border: "1px solid #30363d",
borderRadius: 6,
color: "#e0e0e0",
padding: "8px 12px",
fontSize: 13,
},
btn: {
background: "#21262d",
border: "1px solid #30363d",
borderRadius: 6,
color: "#e0e0e0",
padding: "8px 16px",
cursor: "pointer",
fontSize: 13,
fontWeight: 500,
transition: "all 0.2s",
},
btnPrimary: {
background: "#238636",
border: "1px solid #2ea043",
borderRadius: 6,
color: "white",
padding: "8px 16px",
cursor: "pointer",
fontSize: 13,
fontWeight: 600,
},
btnActive: {
background: "#1f6feb",
borderColor: "#388bfd",
},
error: {
background: "#da3633",
color: "white",
padding: 10,
borderRadius: 6,
fontSize: 13,
},
metaRow: {
display: "flex",
gap: 20,
padding: 10,
background: "#0d1117",
borderRadius: 6,
},
meta: {
fontSize: 13,
color: "#8b949e",
},
};
6.2 Add Animation to babylonChess.ts
Add these methods to make pieces move smoothly:
async animateMove(from: SquareKey, to: SquareKey): Promise<void> {
// Highlight squares
this.clearSquareHighlights();
const sqFrom = this.squareMeshes.get(from);
const sqTo = this.squareMeshes.get(to);
if (sqFrom) sqFrom.material = this.matGlowFrom;
if (sqTo) sqTo.material = this.matGlowTo;
// Get piece at 'from' square
const piece = this.pieceMeshes.get(from);
if (!piece) return;
// Calculate target position
const [toFile, toRank] = [to[0], to[1]];
const toX = FILES.indexOf(toFile) - 3.5;
const toZ = RANKS.indexOf(toRank) - 3.5;
// Animate piece movement
return new Promise<void>(resolve => {
const fps = 60;
const duration = 30; // frames (0.5 seconds at 60fps)
const startPos = piece.position.clone();
const endPos = new Vector3(toX, piece.position.y, toZ);
// Create animation
const anim = new Animation(
"moveAnim",
"position",
fps,
Animation.ANIMATIONTYPE_VECTOR3,
Animation.ANIMATIONLOOPMODE_CONSTANT
);
// Easing for smooth motion
const ease = new CubicEase();
ease.setEasingMode(EasingFunction.EASINGMODE_EASEINOUT);
anim.setEasingFunction(ease);
// Keyframes
anim.setKeys([
{ frame: 0, value: startPos },
{ frame: duration, value: endPos }
]);
piece.animations = [anim];
this.scene.beginAnimation(piece, 0, duration, false, 1, () => {
resolve();
});
});
}
clearSquareHighlights(): void {
for (let r = 0; r < 8; r++) {
for (let f = 0; f < 8; f++) {
const sq = this.squareMeshes.get(`${FILES[f]}${RANKS[r]}`);
if (sq) {
const isDark = (r + f) % 2 === 1;
sq.material = isDark ? this.matDark : this.matLight;
}
}
}
}
💡 AI Prompt for Animations:
I want to animate chess piece movement in Babylon.js.
Requirements:
- Smooth easing (not linear)
- Takes 0.5 seconds
- Piece should slide across the board
- Should return a Promise when animation completes
- Use Babylon.js Animation API
How do I implement this?
Step 7: Future Enhancements - Multiple Boards & Themes
7.1 Add Chess Set Switcher
This is already in the code above, but here’s how to expand it:
// In App.tsx, add state for chess set
const [chessSet, setChessSet] = useState<'set1' | 'set2'>('set2');
// Add buttons in the UI
<div style={styles.sectionTitle}>Chess Set</div>
<div style={{ display: "flex", gap: 10 }}>
<button
style={{...styles.btn, ...(chessSet === 'set1' ? styles.btnActive : {})}}
onClick={async () => {
setChessSet('set1');
await viewRef.current?.switchChessSet('set1');
}}
disabled={busy}
>
Glass Set
</button>
<button
style={{...styles.btn, ...(chessSet === 'set2' ? styles.btnActive : {})}}
onClick={async () => {
setChessSet('set2');
await viewRef.current?.switchChessSet('set2');
}}
disabled={busy}
>
Lewis Set
</button>
</div>
7.2 Multiple Boards (Side-by-Side Comparison)
💡 AI Prompt for Multi-Board:
I want to show two chess boards side-by-side to compare two games.
Each board needs:
- Its own Babylon.js scene
- Its own canvas element
- Independent controls
- Synchronized play option
Should I:
1. Create two BabylonChessView instances?
2. Use one scene with two camera viewports?
3. Create separate scenes?
What's the best architecture?
Here’s a quick implementation:
// MultiBoard.tsx
export function MultiBoardApp() {
const canvas1Ref = useRef<HTMLCanvasElement>(null);
const canvas2Ref = useRef<HTMLCanvasElement>(null);
const view1Ref = useRef<BabylonChessView | null>(null);
const view2Ref = useRef<BabylonChessView | null>(null);
useEffect(() => {
if (canvas1Ref.current) {
view1Ref.current = new BabylonChessView(canvas1Ref.current);
view1Ref.current.init();
}
if (canvas2Ref.current) {
view2Ref.current = new BabylonChessView(canvas2Ref.current);
view2Ref.current.init();
}
return () => {
view1Ref.current?.dispose();
view2Ref.current?.dispose();
};
}, []);
return (
<div style={{ display: 'flex' }}>
<div style={{ flex: 1 }}>
<h3>Game 1</h3>
<canvas ref={canvas1Ref} style={{ width: '100%', height: '600px' }} />
</div>
<div style={{ flex: 1 }}>
<h3>Game 2</h3>
<canvas ref={canvas2Ref} style={{ width: '100%', height: '600px' }} />
</div>
</div>
);
}
7.3 Export/Share Feature
// Add to App.tsx
function exportGame() {
if (!replay) return;
const data = {
pgn: text,
startFen: startFen || "startpos",
moveCount: replay.plies.length,
timestamp: new Date().toISOString()
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chess-replay-${Date.now()}.json`;
a.click();
}
// Add button
<button style={styles.btn} onClick={exportGame} disabled={!replay}>
📥 Export
</button>
Step 8: Testing and Optimization
8.1 Test Your Build Locally
# Development mode (hot reload)
npm run dev
# Open http://localhost:5173
# Test all features:
# ✓ Load PGN
# ✓ Click Next/Back
# ✓ Click Play/Pause
# ✓ Camera controls work
# ✓ Pieces animate smoothly
# ✓ No console errors
8.2 Build for Production
# Create optimized build
npm run build
# Preview production build
npm run preview
# Open http://localhost:4173
8.3 Optimize for Size
Check your build size:
# After npm run build
ls -lh dist/
# Target sizes:
# - dist/index.html: ~1-2 KB
# - dist/assets/*.js: 500KB - 1.5MB (gzipped: ~200-400KB)
# - dist/models/: <5MB total
If bundle is too large:
// vite.config.ts - Add build optimizations
export default defineConfig({
base: '/',
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: {
'babylon': ['@babylonjs/core', '@babylonjs/loaders'],
'react-vendor': ['react', 'react-dom']
}
}
},
minify: 'terser',
terserOptions: {
compress: {
drop_console: true, // Remove console.logs
drop_debugger: true
}
}
}
});
8.4 Performance Tips
// In babylonChess.ts - Optimize rendering
this.scene.autoClear = false;
this.scene.autoClearDepthAndStencil = false;
// Freeze meshes that don't move
this.root.freezeWorldMatrix();
for (const sq of this.squareMeshes.values()) {
sq.freezeWorldMatrix();
}
// Use instances instead of clones for pieces
const instance = template.createInstance(`piece_${key}`);
Step 9: Deployment
9.1 Deploy to GitHub Pages
# Install gh-pages
npm install -D gh-pages
# Add to package.json scripts
{
"scripts": {
"deploy": "vite build && gh-pages -d dist"
}
}
# Deploy
npm run deploy
9.2 Deploy to Netlify
# Create netlify.toml
cat > netlify.toml << 'EOF'
[build]
command = "npm run build"
publish = "dist"
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
EOF
# Push to GitHub, connect to Netlify
# Or use Netlify CLI:
npx netlify-cli deploy --prod
9.3 Deploy to Your Own Server
# Build
npm run build
# Upload dist/ folder via FTP/SSH
scp -r dist/* [email protected]:/var/www/html/chess/
# Or use rsync
rsync -avz dist/ [email protected]:/var/www/html/chess/
🎓 Learning Resources & Prompts
For Babylon.js
💡 Useful AI Prompts:
"How do I load a GLB file in Babylon.js and list all mesh names?"
"Explain Babylon.js coordinate system - where is origin, which way is up?"
"How do I create smooth camera animations in Babylon.js?"
"What's the difference between clone() and createInstance() for meshes?"
"How do I optimize Babylon.js performance for web deployment?"
For Chess Logic
💡 Useful AI Prompts:
"Parse PGN notation in JavaScript - extract moves, handle comments, validate"
"Convert FEN to 8x8 board array in JavaScript"
"How do I validate chess moves using chess.js library?"
"Explain chess move notation: SAN vs UCI vs LAN"
For 3D Model Issues
💡 Useful AI Prompts:
"My 3D model is too big/small in Babylon.js - how do I scale it?"
"3D model rotated wrong way - how to fix orientation in code vs in Blender?"
"How do I find the pivot point of a GLB model?"
"Best practices for optimizing GLB files for web - reduce file size"
🐛 Common Issues & Solutions
Issue: Pieces Floating or Sinking
// Solution: Adjust Y offset
const yOffsets = {
p: 0.05, // Start here
n: 0.05,
// ... test each piece type
};
// Debug helper - press 'D' to show axis
window.addEventListener('keydown', (e) => {
if (e.key === 'd') {
const axes = new BABYLON.AxesViewer(this.scene, 2);
}
});
Issue: Models Not Loading
// Add detailed logging
console.log("Attempting to load:", fileName);
console.log("Full path:", `/models/set2/${fileName}`);
// Check browser Network tab
// Make sure files are in public/models/ not src/models/
// Vite serves public/ at root URL
Issue: WebGPU Not Working
// Check browser support
console.log("GPU available:", navigator.gpu !== undefined);
// Force WebGL for testing
// In BabylonChessView.init():
this.engine = new Engine(this.canvas, true); // Always use WebGL
Issue: Animations Choppy
// Increase FPS or duration
const fps = 60;
const duration = 60; // 1 second instead of 0.5
// Or use built-in easing
import { BackEase } from "@babylonjs/core";
const ease = new BackEase();
ease.setEasingMode(EasingFunction.EASINGMODE_EASEOUT);
Issue: Large Bundle Size
# Analyze bundle
npm install -D rollup-plugin-visualizer
# Add to vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
plugins: [
react(),
visualizer({ open: true })
]
# Build and check stats.html
📊 Project Checklist
Before deploying, verify:
- All 12 chess pieces load correctly
- Pieces sit properly on board (not floating/sinking)
- Next/Back buttons work
- Play/Pause works with proper timing
- PGN parser handles comments and variations
- Camera limits prevent upside-down views
- WebGPU fallback to WebGL works
- Build size under 10MB total
- No console errors in production
- Mobile responsive (if needed)
- Loading indicators show during async operations
🚀 Next Steps & Advanced Features
- Add Opening Book: Highlight opening names as users step through
- Engine Analysis: Integrate Stockfish.js for move evaluation
- Multiple Games: Load PGN file with multiple games
- Custom Themes: Let users change board colors, lighting
- VR Support: Add WebXR support for VR headsets
- Multiplayer: Add real-time game sharing with WebSockets
- Mobile Controls: Add touch gestures for camera
- Puzzle Mode: Tactical puzzle solver with hints
💡 AI Prompt for Advanced Features:
I have a working 3D chess replay viewer. I want to add [feature].
Current tech stack: Babylon.js, React, TypeScript, Vite.
What's the best approach? Show me:
1. Which libraries to install
2. Code structure changes needed
3. Example implementation
📝 Summary
You’ve built a complete 3D chess replay viewer! Here’s what we covered:
- ✅ Project Setup - Vite + React + TypeScript + Babylon.js
- ✅ 3D Models - Finding, downloading, optimizing GLB files
- ✅ Mesh Discovery - Inspecting GLB files to find mesh names
- ✅ Configuration - TypeScript, Vite, camera settings
- ✅ Critical Offsets - Positioning pieces correctly on board
- ✅ Play Controls - Next, Back, Play/Pause, Reset
- ✅ Future Features - Multiple boards, themes, export
Time Investment:
- Initial setup: 5-10 minutes
- Finding models: 10-20 minutes
- Adjusting offsets: 5-15 minutes (trial and error)
- Testing & polish: 10-30 minutes
Total: ~30-75 minutes for a working prototype!
💾 Complete File Structure
webgpu-chess-replay/
├── public/
│ └── models/
│ ├── set1/
│ │ └── chess_set.glb
│ └── set2/
│ ├── Pawn_white.glb
│ ├── Pawn_black.glb
│ ├── Knight_white.glb
│ ├── Knight_black.glb
│ ├── Bishop_white.glb
│ ├── Bishop_black.glb
│ ├── Castle_white.glb
│ ├── Castle_black.glb
│ ├── Queen_white.glb
│ ├── Queen_black.glb
│ ├── King_white.glb
│ └── King_black.glb
├── src/
│ ├── App.tsx (React UI component)
│ ├── babylonChess.ts (3D rendering engine)
│ ├── parser.ts (PGN parser)
│ ├── types.ts (TypeScript types)
│ └── main.tsx (Entry point)
├── index.html
├── package.json
├── tsconfig.json
├── vite.config.ts
├── inspect-glb.html (Dev tool)
└── README.md
🎉 You Did It!
You now have a fully functional 3D chess replay viewer with:
- ⚡ WebGPU-accelerated rendering
- 🎨 Beautiful animations
- 📊 Interactive controls
- 🎯 Production-ready code
Share your creation!
- Post on Twitter with #BabylonJS #WebGPU
- Submit to Babylon.js showcase
- Add to your portfolio
Questions? Use these AI prompts to debug or extend further!
Happy Coding! ♟️
Part 2: AI-Powered Lesson Generator
Added March 2026 — how the Tutorial tab went from static TypeScript files to a fully automated AI pipeline.
After shipping the 3D replay viewer, the natural next question was: can I generate chess lessons automatically? Upload a plain-text lesson outline, get a structured interactive lesson back — no manual TypeScript required.
Here’s exactly how it was built, entirely through prompts.
🏗️ Architecture Overview
User uploads .txt file via /admin
↓
Express server (server.js)
↓
LiteLLM → GPT-4o
↓
JSON lesson (steps, FENs computed server-side)
↓
Postgres (chess_lessons table)
↓
GET /api/lesson/generated
↓
TutorialView fetches at runtime — no rebuild needed
Key design decision: The AI returns JSON with move sequences (e.g. ["e2e4", "e7e5"]), never raw FEN strings. The server computes FENs using chess.js. This prevents hallucinated positions — the AI only handles human-readable move notation.
Step 1: Express Backend (server.js)
The backend runs as a separate Docker container on the same VPS, reachable internally as chess_backend:3010.
require("dotenv").config();
const express = require("express");
const multer = require("multer");
const { Pool } = require("pg");
const { Chess } = require("chess.js");
// LiteLLM config — all from env, never hardcoded
const LITELLM_URL = process.env.LITELLM_URL;
const LITELLM_KEY = process.env.LITELLM_KEY;
const MODEL = process.env.LITELLM_MODEL || "gpt-4o";
Upload endpoint — multi-file support:
const upload = multer({ dest: PENDING_DIR, limits: { files: 20 } });
app.post("/api/lesson/upload", upload.array("files", 20), (req, res) => {
for (const file of req.files) {
processLesson(lessonId, file.path).catch(console.error);
}
res.json({ queued: req.files.length });
});
💡 Prompt used to build this:
Create an Express server that:
1. Accepts multi-file uploads via multer (max 20 .txt/.md files)
2. Queues each file for async processing
3. Has GET /api/lesson/status to poll job progress
4. Stores results in Postgres
5. Exposes GET /api/lesson/generated returning all lessons as JSON
Use dotenv for secrets. Never hardcode credentials.
Step 2: LiteLLM as the AI Gateway
LiteLLM runs as a self-hosted container (litellm_litellm_1) on the same VPS. It proxies to OpenAI (or any other provider) and handles rate limiting, logging, and key management centrally.
Why LiteLLM instead of calling OpenAI directly?
- One API key to manage across all projects
- Can swap models (GPT-4o → Claude → local) without changing application code
- Built-in usage tracking via Prometheus
Internal Docker networking:
Containers communicate by name — chess_backend calls http://litellm_litellm_1:4000/v1/chat/completions. Port 4000 is never exposed publicly.
const response = await fetch("http://litellm_litellm_1:4000/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${LITELLM_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "gpt-4o",
messages: [{ role: "system", content: SYSTEM_PROMPT }, { role: "user", content: lessonText }],
temperature: 0.2,
}),
});
💡 Prompt used to debug network connectivity:
docker inspect chess_backend shows it's on npm_default network.
docker inspect litellm_litellm_1 shows it's on litellm_default network.
fetch() calls to litellm_litellm_1 fail with "fetch failed".
How do I connect chess_backend to litellm_default without restarting?
→ docker network connect litellm_default chess_backend — then persist it in docker-compose.yml.
Step 3: The System Prompt
The AI receives a schema and must return a JSON array of lesson objects. The server computes FENs — the AI only provides move sequences.
You are an expert chess educator and JSON generator.
Convert the chess lesson document into a JSON array of TutorialLesson objects.
Output ONLY valid JSON — no markdown fences, no prose.
=== TutorialStep schema ===
{
"type": "demo" | "challenge",
"moves": string[], // UCI moves from startFen, e.g. ["e2e4","e7e5"]
"startFen": string, // optional — omit for standard starting position
"title": string,
"explanation": string,
"arrows": [{"from", "to", "color"}],
"challengePiece": string,
"expectedSquare": string,
"hint": string
}
RULES:
1. NEVER provide a "fen" field — server computes FENs from "moves"
2. End every lesson with a demo titled "🏁 Lesson Complete!"
Keys to getting good output:
- Low temperature (0.2) — deterministic, consistent structure
- Explicit schema — reduces hallucination
- No FENs from the AI — eliminates the biggest source of errors
Step 4: Postgres Storage
Lessons are stored as JSONB in a chess_lessons table. The existing litellm_db Postgres container is reused — no new infrastructure needed.
CREATE TABLE chess_lessons (
id TEXT PRIMARY KEY,
data JSONB NOT NULL,
sort_order INTEGER DEFAULT 9999,
created_at TIMESTAMPTZ DEFAULT NOW()
);
sort_order keeps static (hand-authored) lessons at the top, AI-generated ones appended below.
Seeding static lessons into Postgres:
// scripts/seed-static-lessons.ts
import { ALL_LESSONS } from "../src/tutorialData";
import { Pool } from "pg";
for (let i = 0; i < ALL_LESSONS.length; i++) {
await pool.query(
`INSERT INTO chess_lessons (id, data, sort_order)
VALUES ($1, $2, $3)
ON CONFLICT (id) DO UPDATE SET data = EXCLUDED.data, sort_order = EXCLUDED.sort_order`,
[ALL_LESSONS[i].id, ALL_LESSONS[i], i]
);
}
Run once: npm run seed — all lessons normalised in one place.
Step 5: Frontend Fetches at Runtime
The TutorialView component fetches lessons from the API when the tab opens — no rebuild or redeploy needed when new lessons are added.
const [allGroups, setAllGroups] = useState<LessonGroup[]>([]);
useEffect(() => {
fetch("/api/lesson/generated")
.then(r => r.json())
.then((data: TutorialLesson[]) => {
// Group by category, sort by order
const groups = groupAndSort(data);
setAllGroups(groups);
});
}, []);
The /api/lesson/generated endpoint is proxied through nginx:
location /api/ {
proxy_pass http://chess_backend:3010/api/;
}
Key fix: localhost inside chess_static (nginx container) resolves to itself, not chess_backend. Always use the container name.
Step 6: Docker + CI/CD
The full pipeline deploys on every push to main via GitHub Actions:
- Build frontend on GitHub runner →
scp dist/to VPS - SSH:
docker cp nginx.conf chess_static:/etc/nginx/conf.d/default.conf && nginx -s reload - SSH:
git pull→ write.envfrom GitHub Secrets →docker-compose rm -sf chess-backend && docker-compose up -d chess-backend
docker-compose.yml (relevant excerpt):
chess-backend:
build: { context: ., target: backend }
container_name: chess_backend
env_file: .env
networks:
- default # npm_default — shared with nginx proxy manager
- litellm_net # litellm_default — to reach LiteLLM + Postgres
networks:
litellm_net:
name: litellm_default
external: true
Dockerfile (backend stage):
FROM node:20-alpine AS backend
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY server.js .
EXPOSE 3010
CMD ["node", "server.js"]
No TypeScript compilation — server.js is plain CommonJS, keeping the backend image small and fast to build.
🐛 Debugging Journey (the real learning)
| Problem | Root Cause | Fix |
|---|---|---|
405 Not Allowed from OpenResty | LITELLM_URL was bare IP, hit port 80 | Add http:// prefix + full path |
fetch failed | Containers on different Docker networks | docker network connect litellm_default chess_backend |
EXDEV: cross-device link | Separate Docker volumes = separate filesystems | Single volume + copyFileSync fallback |
env (0) from dotenv | No .env inside container (correct!) | env_file: in docker-compose injects vars before process starts |
docker restart didn’t pick up new .env | Restart reuses existing container env | docker-compose rm -sf then up to recreate |
nginx /api/ returning 404 | proxy_pass http://localhost:3010 | Changed to proxy_pass http://chess_backend:3010 |
Every single one of these was diagnosed and fixed through prompts.
✅ End Result
- Upload a
.txtchess lesson outline atchess.anmious.cloud/admin - GPT-4o converts it to structured JSON with move sequences, arrows, challenges
- Server computes FENs, stores in Postgres
- Tutorial tab fetches and displays instantly — no code change, no rebuild, no redeploy
- Static hand-authored lessons also live in Postgres, seeded once
Live: chess.anmious.cloud → 🎓 Tutorial tab
Happy Coding! ♟️