Skip to main content

Overview

Obsidian Chess Studio provides comprehensive opening analysis with ECO (Encyclopedia of Chess Openings) classification, opening name detection, performance statistics, and variation tree visualization.

Opening Database

ECO Classification

The application includes a database of 3,000+ opening positions organized by ECO code:
  • A codes (A00-A99): Flank openings, English, Reti, etc.
  • B codes (B00-B99): Semi-open games (Sicilian, French, Caro-Kann, etc.)
  • C codes (C00-C99): Open games (1.e4 e5) and French Defense
  • D codes (D00-D99): Closed games and Queen’s Gambit
  • E codes (E00-E99): Indian Defenses (King’s Indian, Nimzo-Indian, etc.)

Database Structure

// src-tauri/src/opening.rs:12
struct Opening {
    eco: String,        // "C50", "B90", etc.
    name: String,       // "Italian Game: Giuoco Piano"
    setup: Setup,       // Position after opening moves
    pgn: Option<String>, // Move sequence (e.g., "1. e4 e5 2. Nf3 Nc6 3. Bc4")
}
The database is embedded in the application binary:
pub const TSV_DATA: [&[u8]; 5] = [
    include_bytes!("../data/a.tsv"),
    include_bytes!("../data/b.tsv"),
    include_bytes!("../data/c.tsv"),
    include_bytes!("../data/d.tsv"),
    include_bytes!("../data/e.tsv"),
];

Position Lookup

Get Opening from FEN

Identify the opening from a position:
const openingName = await invoke('get_opening_from_fen', {
  fen: 'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq -'
});

console.log(openingName);  // "Italian Game"

Get Opening Info

Retrieve detailed opening information:
interface OpeningInfo {
  eco: string;       // ECO code (e.g., "C50")
  opening: string;   // Opening name (e.g., "Italian Game")  
  variation: string; // Variation name (e.g., "Giuoco Piano")
}

const info = await invoke('get_opening_info_from_fen', { fen });
console.log(`${info.eco}: ${info.opening} - ${info.variation}`);
// "C50: Italian Game - Giuoco Piano"

Position Matching

The lookup uses lenient matching with three levels:
// src-tauri/src/opening.rs:140
pub fn get_opening_info_from_fen(fen: &str) -> Result<OpeningInfo, Error> {
    let setup = normalize_fen_to_setup(fen)?;
    
    // Level 1: Exact match (board + turn + castling + ep + counters)
    let mut opening = openings.iter().find(|o| o.setup == setup);
    
    // Level 2: Lenient match (board + turn + counters, ignore castling/ep)
    if opening.is_none() {
        opening = openings.iter().find(|o| {
            o.setup.board == setup.board
                && o.setup.turn == setup.turn
                && o.setup.fullmoves == setup.fullmoves
                && o.setup.halfmoves == setup.halfmoves
        });
    }
    
    // Level 3: Most lenient (board + turn only)
    if opening.is_none() {
        opening = openings.iter().find(|o| {
            o.setup.board == setup.board && o.setup.turn == setup.turn
        });
    }
    
    // ...
}
The multi-level matching ensures that transpositions and move-order variations are correctly identified.

Search by Name

Find openings by name with fuzzy matching:
const results = await invoke('search_opening_name', {
  query: 'sicilian'
});

// Returns up to 15 matches
interface OutOpening {
  name: string;  // "Sicilian Defense: Najdorf Variation"
  fen: string;   // Position FEN
}

Similarity Algorithm

Search uses two string similarity metrics:
let sorenson_score = sorensen_dice(&lower_query, &lower_name);
let jaro_score = jaro_winkler(&lower_query, &lower_name);
let score = sorenson_score.max(jaro_score);

// Filter results with score > 0.8
let matches = openings
    .filter(|(_, score)| *score > 0.8)
    .take(15);  // Limit to top 15 results

Get Moves from Name

Retrieve the move sequence for an opening:
const pgn = await invoke('get_opening_from_name', {
  name: 'Sicilian Defense: Najdorf Variation'
});

console.log(pgn);  // "1. e4 c5 2. Nf3 d6 3. d4 cxd4 4. Nxd4 Nf6 5. Nc3 a6"

Opening Performance

Statistics by Opening

Track performance in specific openings:
interface OpeningStats {
  name: string;   // Opening name
  games: number;  // Total games
  won: number;    // Games won
  draw: number;   // Games drawn
  lost: number;   // Games lost
}

// Aggregate openings for White
const whiteOpenings = aggregate_openings(games, true);

// Aggregate openings for Black  
const blackOpenings = aggregate_openings(games, false);

Score Rate Calculation

Calculate performance score (1 for win, 0.5 for draw, 0 for loss):
pub fn get_score_rate(opening: &OpeningStats) -> f64 {
    if opening.games == 0 {
        return 0.0;
    }
    (opening.won as f64 + opening.draw as f64 * 0.5) / opening.games as f64
}

Sorting Options

pub fn sort_openings(openings: &mut [OpeningStats], sort_by: &str) {
    match sort_by {
        "score_asc" => openings.sort_by(|a, b| 
            get_score_rate(a).partial_cmp(&get_score_rate(b))
        ),
        "score_desc" => openings.sort_by(|a, b| 
            get_score_rate(b).partial_cmp(&get_score_rate(a))
        ),
        _ => openings.sort_by(|a, b| b.games.cmp(&a.games)),  // Default: by frequency
    }
}

Opening Parsing

Name Structure

Opening names follow standard formats:
// Format 1: "Opening: Variation" (most common)
"Sicilian Defense: Najdorf Variation"

// Format 2: "Opening, Variation" (rare)
"Queen's Gambit, Accepted"

// Format 3: "Opening" (no variation)
"Italian Game"

Splitting Names

fn split_opening_name(full_name: &str) -> (String, String) {
    let trimmed = full_name.trim();
    
    if let Some((opening, rest)) = trimmed.split_once(':') {
        (opening.trim().to_string(), rest.trim().to_string())
    } else if let Some((opening, rest)) = trimmed.split_once(',') {
        (opening.trim().to_string(), rest.trim().to_string())
    } else {
        (trimmed.to_string(), String::new())
    }
}

Fischer Random Chess (Chess960)

FRC Positions

The database includes all 960 Fischer Random starting positions:
const FISCHER_RANDOM_DATA: &[u8] = include_bytes!("../data/frc.tsv");

struct FischerRandomRecord {
    name: String,  // "Fischer Random #518"
    fen: String,   // Starting position FEN
}

FRC Lookup

Fischer Random positions are identified just like standard openings:
const frcInfo = await invoke('get_opening_info_from_fen', {
  fen: 'rkrbbqnn/pppppppp/8/8/8/8/PPPPPPPP/RKRBBQNN w KQkq -'
});

console.log(frcInfo.eco);      // "FRC"
console.log(frcInfo.opening);  // "Fischer Random #518"

Practical Examples

Example 1: Identify Position

import { invoke } from '@tauri-apps/api/tauri';

async function identifyPosition(fen: string) {
  try {
    const info = await invoke('get_opening_info_from_fen', { fen });
    
    if (info.variation) {
      return `${info.eco}: ${info.opening} - ${info.variation}`;
    } else {
      return `${info.eco}: ${info.opening}`;
    }
  } catch (error) {
    return 'Unknown opening / middlegame position';
  }
}

// Usage
const fen = 'rnbqkb1r/pppp1ppp/5n2/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq -';
const opening = await identifyPosition(fen);
console.log(opening);  // "C44: King's Knight Opening: Normal Variation"

Example 2: Find Best Performing Openings

import { aggregate_openings, sort_openings, get_score_rate } from './player_stats';

function findBestOpenings(games: Game[], color: 'white' | 'black', minGames = 10) {
  const isWhite = color === 'white';
  const openings = aggregate_openings(games, isWhite);
  
  // Filter openings with enough games
  const filtered = openings.filter(o => o.games >= minGames);
  
  // Sort by score descending
  sort_openings(filtered, 'score_desc');
  
  // Show top 5
  return filtered.slice(0, 5).map(o => ({
    name: o.name,
    games: o.games,
    score: (get_score_rate(o) * 100).toFixed(1) + '%'
  }));
}

// Usage
const topWhiteOpenings = findBestOpenings(myGames, 'white');
console.log('Best openings as White:', topWhiteOpenings);

Example 3: Opening Repertoire Coverage

function analyzeRepertoire(games: Game[]) {
  const whiteOpenings = new Set<string>();
  const blackOpenings = new Set<string>();
  
  for (const game of games) {
    if (game.isPlayerWhite) {
      whiteOpenings.add(game.opening);
    } else {
      blackOpenings.add(game.opening);
    }
  }
  
  return {
    whiteRepertoire: Array.from(whiteOpenings).sort(),
    blackRepertoire: Array.from(blackOpenings).sort(),
    totalOpenings: whiteOpenings.size + blackOpenings.size,
    whiteCount: whiteOpenings.size,
    blackCount: blackOpenings.size
  };
}

const repertoire = analyzeRepertoire(myGames);
console.log(`Playing ${repertoire.whiteCount} openings as White`);
console.log(`Playing ${repertoire.blackCount} openings as Black`);

Database Initialization

The opening database is loaded once at startup:
lazy_static! {
    static ref OPENINGS: Vec<Opening> = {
        let mut positions = vec![
            Opening {
                eco: "Extra".to_string(),
                name: "Starting Position".to_string(),
                setup: Setup::default(),
                pgn: None,
            },
        ];
        
        // Load A-E.tsv files (3000+ openings)
        for (tsv_idx, tsv) in TSV_DATA.iter().enumerate() {
            let mut rdr = csv::ReaderBuilder::new()
                .delimiter(b'\t')
                .from_reader(*tsv);
            
            for result in rdr.deserialize() {
                let record: OpeningRecord = result?;
                // Parse PGN and create Opening
                positions.push(/* ... */);
            }
        }
        
        // Load Fischer Random positions
        // ...
        
        positions
    };
}
The database loads in ~50ms on modern hardware, with all 3,000+ openings indexed in memory.

Build docs developers (and LLMs) love