Skip to main content

Overview

Chessops is designed for high performance in JavaScript environments, particularly web browsers. This guide covers the performance characteristics of the library and best practices for optimization.

Bitboard Advantages

Chessops uses bitboards (64-bit integers represented as two 32-bit numbers) for representing sets of squares. This provides significant performance benefits:

Memory Efficiency

Traditional array-based representation:
// 64 squares × 4 bytes per boolean = 256 bytes minimum
const squares: boolean[] = new Array(64).fill(false);
Bitboard representation:
// 2 × 4 bytes = 8 bytes total
class SquareSet {
  readonly lo: number;  // 4 bytes
  readonly hi: number;  // 4 bytes
}
Bitboards use 32x less memory than array-based representations, improving cache locality and reducing garbage collection pressure.

Operation Speed

Bitwise operations are extremely fast on modern CPUs:
// Union of two square sets: Single OR operation
const combined = set1.union(set2);
// Equivalent to: new SquareSet(set1.lo | set2.lo, set1.hi | set2.hi)

// Intersection: Single AND operation
const overlap = set1.intersect(set2);
// Equivalent to: new SquareSet(set1.lo & set2.lo, set1.hi & set2.hi)

// Check if empty: Two comparisons
const isEmpty = set.isEmpty();
// Equivalent to: set.lo === 0 && set.hi === 0
These operations are typically 1-2 CPU cycles, compared to array iterations that would require 64 iterations.

Parallelism

Bitwise operations process multiple squares simultaneously:
// Check if any of 64 squares overlap between two sets
// Array-based: O(n) iterations
function hasOverlapArray(set1: boolean[], set2: boolean[]): boolean {
  for (let i = 0; i < 64; i++) {
    if (set1[i] && set2[i]) return true;
  }
  return false;
}

// Bitboard: O(1) operation
const hasOverlap = set1.intersects(set2);
// Processes all 64 squares in parallel via bitwise AND

Attack Calculation Performance

Hyperbola Quintessence vs Magic Bitboards

Chessops uses Hyperbola Quintessence for computing sliding piece attacks. Here’s why: Hyperbola Quintessence:
  • ✅ Minimal initialization time (~instant)
  • ✅ Small memory footprint (~4KB attack tables)
  • ✅ Predictable performance
  • ⚠️ Slightly slower lookup (but still very fast)
Magic Bitboards:
  • ❌ Initialization overhead (several milliseconds)
  • ❌ Large memory footprint (hundreds of KB)
  • ✅ Marginally faster lookups
  • ⚠️ Complex implementation
For web applications, initialization time matters. Users notice the delay before interaction, but a few nanoseconds difference in move generation is imperceptible.

Attack Computation Benchmarks

Typical performance characteristics (modern JavaScript engine):
import { kingAttacks, knightAttacks, bishopAttacks } from 'chessops/attacks';

// Non-sliding pieces: ~1-2 nanoseconds (table lookup)
const king = kingAttacks(square);     // O(1) array access
const knight = knightAttacks(square);  // O(1) array access
const pawn = pawnAttacks('white', square); // O(1) array access

// Sliding pieces: ~10-20 nanoseconds (bitwise operations)
const bishop = bishopAttacks(square, occupied); // O(1) with Hyperbola Quintessence
const rook = rookAttacks(square, occupied);     // O(1) with Hyperbola Quintessence
const queen = queenAttacks(square, occupied);   // O(1), combines bishop + rook
Actual performance depends on JavaScript engine, CPU architecture, and JIT optimization. These are relative comparisons.

SquareSet Operations

Fast Operations (O(1))

These operations are pure bitwise manipulations:
import { SquareSet } from 'chessops/squareSet';

const set1 = SquareSet.fromRank(0);
const set2 = SquareSet.fromFile(0);

// All O(1) bitwise operations:
set1.union(set2)      // OR
set1.intersect(set2)  // AND
set1.diff(set2)       // AND NOT
set1.xor(set2)        // XOR
set1.complement()     // NOT
set1.isEmpty()        // Zero check
set1.nonEmpty()       // Non-zero check
set1.has(square)      // Bit test

Iterator Operations

Iterating over squares in a set:
const squares = SquareSet.fromRank(0);

// Fast: Direct iteration (O(k) where k = number of set bits)
for (const square of squares) {
  // Process each square
  // Uses bit scanning (bsf/bsr operations)
}

// Also fast: Built-in methods
squares.size()        // Population count (popcnt instruction)
squares.first()       // Bit scan forward
squares.last()        // Bit scan reverse
Population count implementation:
// From squareSet.ts
const popcnt32 = (n: number): number => {
  n = n - ((n >>> 1) & 0x5555_5555);
  n = (n & 0x3333_3333) + ((n >>> 2) & 0x3333_3333);
  return Math.imul((n + (n >>> 4)) & 0x0f0f_0f0f, 0x0101_0101) >> 24;
};
This uses the Hamming weight algorithm for counting set bits in O(1) time.
Prefer iterating directly over SquareSet rather than converting to arrays. The iterator uses efficient bit scanning.

Move Generation Optimization

Best Practices

❌ Inefficient: Regenerating moves repeatedly
// DON'T: Compute legal moves multiple times
function isMoveLegal(pos: Position, move: Move): boolean {
  const legalMoves = Array.from(pos.allLegalMoves());
  return legalMoves.some(m => m.from === move.from && m.to === move.to);
}
✅ Efficient: Check legality directly
// DO: Use the direct legality check
function isMoveLegal(pos: Position, move: Move): boolean {
  return pos.isLegal(move);
}
✅ Efficient: Cache destinations when needed
// DO: Compute once, use multiple times
const ctx = pos.ctx();
const dests = new Map();
for (const [from, squares] of pos.allDests(ctx)) {
  dests.set(from, squares);
}

// Now use cached destinations
const e2Dests = dests.get(makeSquare('e2'));

Context Reuse

Compute position context once:
import { Chess } from 'chessops/chess';

const pos = Chess.default();

// ❌ DON'T: Recompute context for each operation
for (const move of pos.allLegalMoves()) {
  // pos.allLegalMoves() computes context internally each time
}

// ✅ DO: Compute context once
const ctx = pos.ctx();
for (const [from, dests] of pos.allDests(ctx)) {
  for (const to of dests) {
    // Process move
  }
}

Immutability and Memory

Chessops uses immutable data structures, which has both benefits and costs.

Benefits

// Safe to share without copying
const pos1 = Chess.default();
const pos2 = pos1.play(move);
// pos1 is unchanged, pos2 is new position

// Great for undo/redo
const history: Position[] = [];
let pos = Chess.default();
for (const move of moves) {
  history.push(pos);
  pos = pos.play(move);
}
// Can go back to any historical position instantly

Memory Considerations

// Each position is ~100-200 bytes
// For a 50-move game with full history:
// 100 positions × 200 bytes = 20KB (acceptable)

// ❌ DON'T: Keep unnecessary position copies
const positions = [];
for (let i = 0; i < 10000; i++) {
  positions.push(pos.play(move)); // 2MB of positions!
}

// ✅ DO: Only keep what you need
let pos = Chess.default();
for (let i = 0; i < 10000; i++) {
  pos = pos.play(move); // Previous positions get garbage collected
}
Modern JavaScript engines optimize immutable structures well. The garbage collector efficiently reclaims unused positions.

FEN Parsing Performance

Optimized Parsing

import { parseFen, makeFen } from 'chessops/fen';

// Parsing is fast but not free
const setup = parseFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1');
// ~1-5 microseconds on modern hardware

// ✅ DO: Parse once, reuse
const startingSetup = parseFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1').unwrap();
function newGame() {
  return Chess.fromSetup(startingSetup).unwrap();
}

// ❌ DON'T: Parse repeatedly in loops
for (let i = 0; i < 1000; i++) {
  const setup = parseFen('rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1');
  // Wasted parsing time
}

FEN Generation

// Making FEN is also fast but not instant
const fen = makeFen(pos.toSetup());
// ~1-5 microseconds

// ✅ DO: Cache FEN when displaying same position multiple times
let cachedFen: string | undefined;
function getFen(pos: Position): string {
  if (!cachedFen) {
    cachedFen = makeFen(pos.toSetup());
  }
  return cachedFen;
}

// Invalidate cache when position changes
function playMove(pos: Position, move: Move): Position {
  cachedFen = undefined;
  return pos.play(move);
}

Benchmarking Your Code

Simple Performance Test

import { Chess } from 'chessops/chess';
import { parseUci } from 'chessops';

function benchmark() {
  const iterations = 10000;
  const pos = Chess.default();
  const move = parseUci('e2e4')!;
  
  const start = performance.now();
  for (let i = 0; i < iterations; i++) {
    pos.play(move);
  }
  const end = performance.now();
  
  console.log(`${iterations} moves in ${end - start}ms`);
  console.log(`${(end - start) / iterations}ms per move`);
}

benchmark();

Perft Testing

Perft (performance test) counts leaf nodes at a given depth:
function perft(pos: Position, depth: number): number {
  if (depth === 0) return 1;
  
  let nodes = 0;
  const ctx = pos.ctx();
  
  for (const [from, dests] of pos.allDests(ctx)) {
    for (const to of dests) {
      const move = { from, to };
      if (pos.isLegal(move, ctx)) {
        const newPos = pos.play(move);
        nodes += perft(newPos, depth - 1);
      }
    }
  }
  
  return nodes;
}

// Benchmark move generation speed
const start = performance.now();
const nodes = perft(Chess.default(), 4);
const end = performance.now();

console.log(`Nodes: ${nodes}`);
console.log(`Time: ${end - start}ms`);
console.log(`Nodes/sec: ${(nodes / (end - start) * 1000).toFixed(0)}`);
Perft is the standard benchmark for chess engines. Compare your results with known perft values to verify correctness.

Web Worker Optimization

For expensive computations, use Web Workers:
// worker.ts
import { Chess } from 'chessops/chess';
import { parseFen } from 'chessops/fen';

self.onmessage = (e) => {
  const { fen, depth } = e.data;
  const setup = parseFen(fen).unwrap();
  const pos = Chess.fromSetup(setup).unwrap();
  
  // Expensive computation in worker
  const result = analyzePosition(pos, depth);
  
  self.postMessage(result);
};
// main.ts
const worker = new Worker('worker.ts');

worker.onmessage = (e) => {
  console.log('Analysis complete:', e.data);
};

worker.postMessage({ 
  fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
  depth: 6 
});
Web Workers allow expensive chess computations without blocking the UI thread, keeping your application responsive.

General Tips

  1. Use direct methods: pos.isLegal(move) is faster than generating all legal moves and searching
  2. Cache when appropriate: Context, destinations, and FEN strings can be cached
  3. Prefer SquareSet operations: Native bitboard operations are faster than arrays
  4. Reuse objects: Don’t parse the same FEN repeatedly
  5. Profile your code: Use browser DevTools to find actual bottlenecks
  6. Consider Web Workers: For deep analysis that might freeze the UI
Premature optimization is the root of all evil. Profile first, optimize second. Chessops is already fast for most use cases.

Build docs developers (and LLMs) love