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:

  1. Sketchfab (https://sketchfab.com) - Search “chess set downloadable”
  2. Poly Pizza (https://poly.pizza) - Free 3D models
  3. 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.glb with all pieces
  • Place in public/models/set1/
  • You’ll need to identify mesh names (covered in Step 3)

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

  1. Open inspect-glb.html in your browser
  2. Click “Choose File” and select a GLB file
  3. You’ll see a list like:
    [0] "__root__" - vertices: 0
    [1] "Pawn_mesh" - vertices: 1234
    [2] "Pawn_collider" - vertices: 56
    
  4. 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

  1. Add Opening Book: Highlight opening names as users step through
  2. Engine Analysis: Integrate Stockfish.js for move evaluation
  3. Multiple Games: Load PGN file with multiple games
  4. Custom Themes: Let users change board colors, lighting
  5. VR Support: Add WebXR support for VR headsets
  6. Multiplayer: Add real-time game sharing with WebSockets
  7. Mobile Controls: Add touch gestures for camera
  8. 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:

  1. Project Setup - Vite + React + TypeScript + Babylon.js
  2. 3D Models - Finding, downloading, optimizing GLB files
  3. Mesh Discovery - Inspecting GLB files to find mesh names
  4. Configuration - TypeScript, Vite, camera settings
  5. Critical Offsets - Positioning pieces correctly on board
  6. Play Controls - Next, Back, Play/Pause, Reset
  7. 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:

  1. Build frontend on GitHub runner → scp dist/ to VPS
  2. SSH: docker cp nginx.conf chess_static:/etc/nginx/conf.d/default.conf && nginx -s reload
  3. SSH: git pull → write .env from 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)

ProblemRoot CauseFix
405 Not Allowed from OpenRestyLITELLM_URL was bare IP, hit port 80Add http:// prefix + full path
fetch failedContainers on different Docker networksdocker network connect litellm_default chess_backend
EXDEV: cross-device linkSeparate Docker volumes = separate filesystemsSingle volume + copyFileSync fallback
env (0) from dotenvNo .env inside container (correct!)env_file: in docker-compose injects vars before process starts
docker restart didn’t pick up new .envRestart reuses existing container envdocker-compose rm -sf then up to recreate
nginx /api/ returning 404proxy_pass http://localhost:3010Changed to proxy_pass http://chess_backend:3010

Every single one of these was diagnosed and fixed through prompts.


✅ End Result

  • Upload a .txt chess lesson outline at chess.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! ♟️