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.
Opening Search
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"
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"
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.