// CHESS — full rules (castling, en passant, promotion), SVG pieces, two players on one board.
// Board index 0 = a8 … 63 = h1: exactly the order an 8x8 grid lays its cells out, so a square
// index IS its cell index — no mapping anywhere.
// ---- pieces ------------------------------------------------------------------------------
// One small SVG body per type in a 45x45 box; colours are injected, so 6 shapes serve both sides.
const string PawnSvg =
"<circle cx='22.5' cy='13' r='6.2'/>" +
"<path d='M16 34c0-7 5-8 5-13h3c0 5 5 6 5 13z'/>" +
"<rect x='12' y='32.5' width='21' height='5.5' rx='2.6'/>";
const string RookSvg =
"<path d='M11 12h5v4h4v-4h5v4h4v-4h5v10l-3 3v8l4 4v2h-25v-2l4-4v-8l-3-3z'/>" +
"<rect x='9' y='34' width='27' height='5.5' rx='2.6'/>";
const string KnightSvg =
"<path d='M11 39h24v-4c0-12-3-19-11-23l1-7-4 5c-5 1-9 5-12 9l-3 5 4 1 3-3c0 6 2 9 5 11-4 2-6 4-7 6z'/>" +
"<circle cx='17' cy='16' r='1.4' stroke='none'/>";
const string BishopSvg =
"<circle cx='22.5' cy='8' r='2.6'/>" +
"<path d='M22.5 11c6 4 9 9 9 13 0 4-4 6-9 6s-9-2-9-6c0-4 3-9 9-13z'/>" +
"<path d='M18.5 20h8M22.5 16v8' fill='none' stroke-width='1.4'/>" +
"<rect x='13' y='29.5' width='19' height='4' rx='2'/>" +
"<rect x='9' y='33.5' width='27' height='5.5' rx='2.6'/>";
const string QueenSvg =
"<path d='M9 31 6 14l4 7 4-12 4 11 4.5-14 4.5 14 4-11 4 12 4-7-3 17z'/>" +
"<circle cx='6' cy='13' r='2.2'/><circle cx='14' cy='8' r='2.2'/><circle cx='22.5' cy='5' r='2.4'/>" +
"<circle cx='31' cy='8' r='2.2'/><circle cx='39' cy='13' r='2.2'/>" +
"<rect x='9' y='30' width='27' height='4' rx='2'/>" +
"<rect x='7' y='34' width='31' height='5.5' rx='2.6'/>";
const string KingSvg =
"<path d='M20.8 3h3.4v4h4v3.4h-4v4h-3.4v-4h-4V7h4z'/>" +
"<path d='M22.5 14c7 0 12 5 12 10l-2 6H12.5l-2-6c0-5 5-10 12-10z'/>" +
"<rect x='10' y='29.5' width='25' height='4' rx='2'/>" +
"<rect x='7' y='33.5' width='31' height='5.5' rx='2.6'/>";
var svgCache = new Dictionary<int, string>();
string SvgOf(int code)
{
if (svgCache.TryGetValue(code, out var cached))
return cached;
var body = Math.Abs(code) switch
{
1 => PawnSvg, 2 => KnightSvg, 3 => BishopSvg,
4 => RookSvg, 5 => QueenSvg, _ => KingSvg,
};
var fill = code > 0 ? "#FAF7F0" : "#25231E";
var line = code > 0 ? "#23201C" : "#0B0A08";
return svgCache[code] =
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 45 45'>" +
$"<g fill='{fill}' stroke='{line}' stroke-width='1.6' stroke-linejoin='round'>{body}</g></svg>";
}
// ---- state -------------------------------------------------------------------------------
const double cell = 44;
var lightSquare = Color.Parse("#EDD7B5");
var darkSquare = Color.Parse("#B58863");
var lightMoved = Color.Parse("#DBD483");
var darkMoved = Color.Parse("#B0A254");
var selectedColor = Color.Parse("#F1E05A");
var checkColor = Color.Parse("#E2645C");
var game = ChessGame.New();
var history = new Stack<ChessGame>();
var cells = new SkiaShape[64];
var pieces = new SkiaSvg[64];
var marks = new SkiaShape[64];
var shown = new int[64]; // SVG currently on each square, so only changed squares re-parse
var targets = new List<ChessMove>(); // legal moves out of the selected square
var selected = -1;
var lastFrom = -1;
var lastTo = -1;
var aiDepth = 3; // 0 = two humans, otherwise how many plies black looks ahead
var thinking = false;
SkiaLabel status = null;
SkiaButton aiButton = null;
void Reset()
{
selected = -1;
lastFrom = lastTo = -1;
targets.Clear();
for (int i = 0; i < 64; i++)
shown[i] = 99; // impossible code: forces every square to reload its SVG
}
void Refresh()
{
var legal = game.Moves();
var check = game.KingAttacked(game.White);
var kingSquare = Array.IndexOf(game.B, game.White ? 6 : -6);
for (int i = 0; i < 64; i++)
{
var isDark = (i / 8 + i % 8) % 2 == 1;
var color = isDark ? darkSquare : lightSquare;
if (i == lastFrom || i == lastTo) color = isDark ? darkMoved : lightMoved;
if (i == selected) color = selectedColor;
if (check && i == kingSquare) color = checkColor;
cells[i].BackgroundColor = color;
var code = game.B[i];
if (shown[i] != code)
{
shown[i] = code;
pieces[i].IsVisible = code != 0;
if (code != 0)
pieces[i].SvgString = SvgOf(code);
}
var isTarget = targets.Any(m => m.To == i);
marks[i].IsVisible = isTarget;
if (isTarget)
{
var capture = game.B[i] != 0; // a ring around the victim, a dot on an empty square
marks[i].WidthRequest = marks[i].HeightRequest = capture ? cell - 5 : 14;
marks[i].BackgroundColor = capture ? Colors.Transparent : Color.Parse("#40151210");
marks[i].StrokeWidth = capture ? 3 : 0;
}
}
var side = game.White ? "White" : "Black";
status.Text = legal.Count == 0
? (check ? $"Checkmate — {(game.White ? "Black" : "White")} wins" : "Stalemate — draw")
: (check ? $"{side} to move — CHECK" : $"{side} to move");
}
// Black's turn, driven by the engine. async so the canvas paints the human move BEFORE the
// search blocks the only thread this app has.
async void PlayAi()
{
thinking = true;
status.Text = "Black is thinking…";
try
{
await System.Threading.Tasks.Task.Delay(40);
var clock = System.Diagnostics.Stopwatch.StartNew();
var move = ChessAi.Best(game, aiDepth);
Console.WriteLine($"ai: depth {aiDepth}, {clock.ElapsedMilliseconds} ms");
if (move != null)
{
history.Push(game.Clone());
game.Apply(move.Value);
lastFrom = move.Value.From;
lastTo = move.Value.To;
}
}
finally
{
thinking = false;
Refresh();
}
}
void Tap(int square)
{
if (thinking) return;
if (aiDepth > 0 && !game.White) return; // black belongs to the engine
if (selected >= 0 && targets.Any(m => m.To == square))
{
history.Push(game.Clone());
game.Apply(targets.First(m => m.To == square));
lastFrom = selected;
lastTo = square;
selected = -1;
targets.Clear();
}
else if (game.Mine(square))
{
selected = square;
targets = game.Moves().Where(m => m.From == square).ToList();
}
else
{
selected = -1;
targets.Clear();
}
Refresh();
if (aiDepth > 0 && !game.White && game.Moves().Count > 0)
PlayAi();
}
// ---- board -------------------------------------------------------------------------------
return new SkiaLayer
{
Padding = new Thickness(16),
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
new SkiaStack
{
Spacing = 10,
UseCache = SkiaCacheType.ImageCompositeGPU,
HorizontalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
new SkiaLabel("CHESS")
{
FontSize = 17,
FontWeight = 800,
TextColor = Color.Parse("#E8C87A"),
}.CenterX(),
new SkiaLabel("White to move")
{
FontSize = 13,
TextColor = Color.Parse("#9FB0CC"),
}.CenterX().Assign(out status),
// frame around the board
new SkiaShape
{
CornerRadius = 6,
BackgroundColor = Color.Parse("#3A2A1C"),
Padding = new Thickness(6),
HorizontalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
// 64 squares placed absolutely: the square index already carries row and
// column, so Left/Top is cheaper and shorter than grid definitions.
new SkiaLayer
{
WidthRequest = cell * 8,
HeightRequest = cell * 8,
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Start,
Children = Enumerable.Range(0, 64).Select(i => (SkiaControl)new SkiaShape
{
WidthRequest = cell,
HeightRequest = cell,
Left = i % 8 * cell,
Top = i / 8 * cell,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
BackgroundColor = (i / 8 + i % 8) % 2 == 1 ? darkSquare : lightSquare,
Children = new List<SkiaControl>
{
// coordinates: rank down the a-file, letter along rank 1
new SkiaLabel(i % 8 == 0 ? $"{8 - i / 8}" : i / 8 == 7 ? $"{(char)('a' + i % 8)}" : "")
{
FontSize = 9,
FontWeight = 700,
TextColor = (i / 8 + i % 8) % 2 == 1 ? lightSquare : darkSquare,
Margin = new Thickness(3, 2, 4, 2),
HorizontalOptions = i % 8 == 0 ? LayoutOptions.Start : LayoutOptions.End,
VerticalOptions = i % 8 == 0 ? LayoutOptions.Start : LayoutOptions.End,
},
new SkiaSvg
{
WidthRequest = cell - 6,
HeightRequest = cell - 6,
IsVisible = false,
}.Center().Assign(out pieces[i]),
new SkiaShape
{
Type = ShapeType.Circle,
WidthRequest = 14,
HeightRequest = 14,
StrokeColor = Color.Parse("#55151210"),
IsVisible = false,
}.Center().Assign(out marks[i]),
}
}
.Assign(out cells[i])
.OnTapped(me => Tap(i))).ToList()
}
}
},
new SkiaRow
{
Spacing = 8,
HorizontalOptions = LayoutOptions.Center,
UseCache = SkiaCacheType.Operations,
Children = new List<SkiaControl>
{
new SkiaButton("New game")
{
WidthRequest = 110,
HeightRequest = 34,
CornerRadius = 8,
FontSize = 13,
BackgroundColor = Color.Parse("#2C3342"),
TextColor = Color.Parse("#DCE4F2"),
}.OnTapped(me => { game = ChessGame.New(); history.Clear(); Reset(); Refresh(); }),
new SkiaButton("Undo")
{
WidthRequest = 90,
HeightRequest = 34,
CornerRadius = 8,
FontSize = 13,
BackgroundColor = Color.Parse("#2C3342"),
TextColor = Color.Parse("#DCE4F2"),
}.OnTapped(me =>
{
if (thinking || history.Count == 0) return;
game = history.Pop();
// against the engine one "move" is two plies, so rewind to a white turn
while (aiDepth > 0 && !game.White && history.Count > 0)
game = history.Pop();
Reset();
Refresh();
}),
new SkiaButton("Black: engine")
{
WidthRequest = 124,
HeightRequest = 34,
CornerRadius = 8,
FontSize = 13,
BackgroundColor = Color.Parse("#2C3342"),
TextColor = Color.Parse("#DCE4F2"),
}
.Assign(out aiButton)
.OnTapped(me =>
{
if (thinking) return;
// 3 plies answers in ~0.1s, 4 costs ~0.5s and freezes the canvas while it thinks
aiDepth = aiDepth == 3 ? 4 : aiDepth == 4 ? 2 : aiDepth == 2 ? 0 : 3;
me.Text = aiDepth == 0 ? "Black: human"
: aiDepth == 2 ? "Black: quick"
: aiDepth == 3 ? "Black: engine" : "Black: strong";
if (aiDepth > 0 && !game.White && game.Moves().Count > 0)
PlayAi();
}),
}
},
}
}
}
}
.Initialize(me => { Reset(); Refresh(); });
// ---- rules -------------------------------------------------------------------------------
// Piece codes: 1 pawn, 2 knight, 3 bishop, 4 rook, 5 queen, 6 king. Positive = white.
record struct ChessMove(int From, int To, int Promo);
class ChessGame
{
public int[] B = new int[64];
public bool White = true;
public bool WK = true, WQ = true, BK = true, BQ = true; // castling rights
public int Ep = -1; // en passant target square
const string Layout = "rnbqkbnrpppppppp................................PPPPPPPPRNBQKBNR";
const string Codes = ".pnbrqk";
static readonly (int dr, int dc)[] Jumps =
{ (1, 2), (2, 1), (2, -1), (1, -2), (-1, -2), (-2, -1), (-2, 1), (-1, 2) };
static readonly (int dr, int dc)[] Diagonal = { (1, 1), (1, -1), (-1, 1), (-1, -1) };
static readonly (int dr, int dc)[] Straight = { (1, 0), (-1, 0), (0, 1), (0, -1) };
static readonly (int dr, int dc)[] Around =
{ (1, 1), (1, -1), (-1, 1), (-1, -1), (1, 0), (-1, 0), (0, 1), (0, -1) };
public static ChessGame New()
{
var g = new ChessGame();
for (int i = 0; i < 64; i++)
{
var c = Layout[i];
if (c == '.') continue;
var type = Codes.IndexOf(char.ToLowerInvariant(c));
g.B[i] = char.IsUpper(c) ? type : -type;
}
return g;
}
public ChessGame Clone() => new ChessGame
{
B = (int[])B.Clone(), White = White, WK = WK, WQ = WQ, BK = BK, BQ = BQ, Ep = Ep,
};
static int Row(int i) => i / 8;
static int Col(int i) => i % 8;
static bool In(int r, int c) => r >= 0 && r < 8 && c >= 0 && c < 8;
static int Sq(int r, int c) => r * 8 + c;
public bool Mine(int i) => B[i] != 0 && B[i] > 0 == White;
/// Every pseudo-legal move, filtered by "does it leave my own king attacked".
public List<ChessMove> Moves()
{
var legal = new List<ChessMove>();
foreach (var move in Pseudo())
{
var after = Clone();
after.Apply(move);
if (!after.KingAttacked(White))
legal.Add(move);
}
return legal;
}
public IEnumerable<ChessMove> Pseudo()
{
var sign = White ? 1 : -1;
for (int i = 0; i < 64; i++)
{
if (!Mine(i)) continue;
int r = Row(i), c = Col(i), piece = Math.Abs(B[i]);
if (piece == 1)
{
var dir = White ? -1 : 1;
var promo = White ? 0 : 7;
var start = White ? 6 : 1;
if (In(r + dir, c) && B[Sq(r + dir, c)] == 0)
{
yield return new ChessMove(i, Sq(r + dir, c), r + dir == promo ? 5 : 0);
if (r == start && B[Sq(r + 2 * dir, c)] == 0)
yield return new ChessMove(i, Sq(r + 2 * dir, c), 0);
}
foreach (var dc in new[] { -1, 1 })
{
if (!In(r + dir, c + dc)) continue;
var to = Sq(r + dir, c + dc);
if (B[to] * sign < 0 || to == Ep)
yield return new ChessMove(i, to, r + dir == promo ? 5 : 0);
}
}
else if (piece == 2)
{
foreach (var (dr, dc) in Jumps)
if (In(r + dr, c + dc) && B[Sq(r + dr, c + dc)] * sign <= 0)
yield return new ChessMove(i, Sq(r + dr, c + dc), 0);
}
else if (piece == 6)
{
foreach (var (dr, dc) in Around)
if (In(r + dr, c + dc) && B[Sq(r + dr, c + dc)] * sign <= 0)
yield return new ChessMove(i, Sq(r + dr, c + dc), 0);
// castling: rights, empty path, and the king neither in check nor crossing one
var home = White ? 7 : 0;
if (r == home && c == 4 && !KingAttacked(White))
{
if ((White ? WK : BK) && B[Sq(home, 5)] == 0 && B[Sq(home, 6)] == 0
&& !Attacked(Sq(home, 5), !White))
yield return new ChessMove(i, Sq(home, 6), 0);
if ((White ? WQ : BQ) && B[Sq(home, 1)] == 0 && B[Sq(home, 2)] == 0 && B[Sq(home, 3)] == 0
&& !Attacked(Sq(home, 3), !White))
yield return new ChessMove(i, Sq(home, 2), 0);
}
}
else
{
var rays = piece == 3 ? Diagonal : piece == 4 ? Straight : Around;
foreach (var (dr, dc) in rays)
{
for (int step = 1; step < 8; step++)
{
int rr = r + dr * step, cc = c + dc * step;
if (!In(rr, cc)) break;
var to = Sq(rr, cc);
if (B[to] * sign > 0) break; // own piece blocks
yield return new ChessMove(i, to, 0);
if (B[to] != 0) break; // a capture ends the ray
}
}
}
}
}
public void Apply(ChessMove m)
{
int piece = B[m.From], type = Math.Abs(piece), row = Row(m.From), col = Col(m.To);
var nextEp = -1;
if (type == 1 && m.To == Ep && B[m.To] == 0)
B[Sq(row, col)] = 0; // en passant: the victim is beside, not under
if (type == 1 && Math.Abs(Row(m.To) - row) == 2)
nextEp = Sq((row + Row(m.To)) / 2, col);
if (type == 6 && Math.Abs(col - Col(m.From)) == 2) // castling drags the rook across
{
if (col == 6) { B[Sq(row, 5)] = B[Sq(row, 7)]; B[Sq(row, 7)] = 0; }
else { B[Sq(row, 3)] = B[Sq(row, 0)]; B[Sq(row, 0)] = 0; }
}
B[m.To] = m.Promo != 0 ? (piece > 0 ? m.Promo : -m.Promo) : piece;
B[m.From] = 0;
// rights die when the king moves, or when a rook leaves or is taken on its home square
if (type == 6) { if (piece > 0) WK = WQ = false; else BK = BQ = false; }
if (m.From == 56 || m.To == 56) WQ = false;
if (m.From == 63 || m.To == 63) WK = false;
if (m.From == 0 || m.To == 0) BQ = false;
if (m.From == 7 || m.To == 7) BK = false;
Ep = nextEp;
White = !White;
}
public bool KingAttacked(bool white)
{
var king = Array.IndexOf(B, white ? 6 : -6);
return king >= 0 && Attacked(king, !white);
}
/// Instead of generating every enemy move, look outward from the square for an attacker of each kind.
public bool Attacked(int square, bool byWhite)
{
int sign = byWhite ? 1 : -1, r = Row(square), c = Col(square);
var pawnRow = r + (byWhite ? 1 : -1); // a white pawn attacks upward, so it stands below
foreach (var dc in new[] { -1, 1 })
if (In(pawnRow, c + dc) && B[Sq(pawnRow, c + dc)] == sign)
return true;
foreach (var (dr, dc) in Jumps)
if (In(r + dr, c + dc) && B[Sq(r + dr, c + dc)] == sign * 2)
return true;
foreach (var (dr, dc) in Around)
if (In(r + dr, c + dc) && B[Sq(r + dr, c + dc)] == sign * 6)
return true;
foreach (var (dr, dc) in Diagonal)
if (Ray(r, c, dr, dc, sign * 3, sign * 5))
return true;
foreach (var (dr, dc) in Straight)
if (Ray(r, c, dr, dc, sign * 4, sign * 5))
return true;
return false;
}
bool Ray(int r, int c, int dr, int dc, int a, int b)
{
for (int step = 1; step < 8; step++)
{
int rr = r + dr * step, cc = c + dc * step;
if (!In(rr, cc)) return false;
var piece = B[Sq(rr, cc)];
if (piece != 0) return piece == a || piece == b;
}
return false;
}
}
// ---- engine ------------------------------------------------------------------------------
// Plain 1980s recipe: score the position by material plus a nudge, look a few plies ahead with
// alpha-beta, and keep searching captures at the leaves so it stops hanging pieces.
static class ChessAi
{
static readonly int[] Value = { 0, 100, 320, 330, 500, 900, 20000 };
public static ChessMove? Best(ChessGame game, int depth)
{
var best = int.MinValue;
ChessMove? pick = null;
foreach (var move in Ordered(game))
{
var after = game.Clone();
after.Apply(move);
if (after.KingAttacked(!after.White)) continue; // pseudo-legal: it left its own king en prise
var score = -Search(after, depth - 1, int.MinValue + 1, int.MaxValue - 1);
if (score > best)
{
best = score;
pick = move;
}
}
return pick;
}
static int Search(ChessGame game, int depth, int alpha, int beta)
{
if (depth == 0)
return Quiesce(game, alpha, beta, 2);
var moved = false;
foreach (var move in Ordered(game))
{
var after = game.Clone();
after.Apply(move);
if (after.KingAttacked(!after.White)) continue;
moved = true;
var score = -Search(after, depth - 1, -beta, -alpha);
if (score >= beta) return beta;
if (score > alpha) alpha = score;
}
if (!moved) // deeper mates score lower, so it takes the fastest one it sees
return game.KingAttacked(game.White) ? -30000 - depth : 0;
return alpha;
}
/// Leaf search over captures only: without it a 3-ply search happily grabs a pawn and loses a queen.
static int Quiesce(ChessGame game, int alpha, int beta, int depth)
{
var stand = Eval(game);
if (stand >= beta) return beta;
if (stand > alpha) alpha = stand;
if (depth == 0) return alpha;
foreach (var move in Ordered(game))
{
if (game.B[move.To] == 0) continue;
var after = game.Clone();
after.Apply(move);
if (after.KingAttacked(!after.White)) continue;
var score = -Quiesce(after, -beta, -alpha, depth - 1);
if (score >= beta) return beta;
if (score > alpha) alpha = score;
}
return alpha;
}
/// Fat victim first, cheap attacker first — the single cheapest thing that makes alpha-beta pay off.
static IEnumerable<ChessMove> Ordered(ChessGame game) =>
game.Pseudo().OrderByDescending(m => game.B[m.To] == 0
? 0
: Value[Math.Abs(game.B[m.To])] - Value[Math.Abs(game.B[m.From])] / 10);
/// Material, plus knights/bishops liking the centre and pawns liking the centre and the far rank.
/// Always from the side to move's point of view, which is what negamax expects.
static int Eval(ChessGame game)
{
var score = 0;
for (int i = 0; i < 64; i++)
{
var piece = game.B[i];
if (piece == 0) continue;
var type = Math.Abs(piece);
var value = Value[type];
int row = i / 8, col = i % 8;
var centre = 3 - Math.Max(Math.Abs(row * 2 - 7), Math.Abs(col * 2 - 7)) / 2;
if (type == 2 || type == 3) value += centre * 6;
if (type == 1) value += centre * 3 + (piece > 0 ? 6 - row : row - 1) * 4;
score += piece > 0 ? value : -value;
}
return game.White ? score : -score;
}
}