Checkers
One or two players, forced captures, multi-jumps, kings and a quiescence-searching AI.
Pick a mode, then click pieces. U undo · R restart · M menu
Source: examples/checkers/main.cr
examples/checkers/main.cr
require "../../src/eagle"
require "./checkers"
include Eagle
# Checkers. Red (bottom) moves first. Captures are mandatory, multi-jumps supported.
# Menu: pick 1 player (vs AI) or 2 players. Keys: R restart, M menu, U undo, 1-8 AI depth.
class CheckersGame < App
SIZE = 70
ORIGIN = v2(50, 50)
@board = Checkers::Board.new
@ai = Checkers::AI.new(6)
@selected : Int32? = nil
@legal = [] of Checkers::Move
@mode : Symbol = :menu
@last : Checkers::Move? = nil
@menu = CanvasLayer.new
@status = ""
@snd : Sound? = nil
def load
@snd = Sound.tone(330, 0.07, Sound::Wave::Triangle, 0.35)
SceneTree.root.add(@menu)
build_menu
# `checkers ai` / `checkers two` skips the menu
start(:ai) if ARGV.includes?("ai")
start(:two) if ARGV.includes?("two")
end
def build_menu
@menu.clear_children
panel = Panel.new(size: v2(360, 0))
panel.anchor = Anchor::Center
panel.fit_content = true
box = VBox.new(size: v2(340, 0))
box.position = v2(10, 10)
box.fit_content = true
title = Label.new("Checkers", align: TextAlign::Center)
title.font_scale = 1.5
box.add(title)
box.add(Button.new("1 player (vs AI)") { start(:ai) })
box.add(Button.new("2 players") { start(:two) })
depth_label = Label.new("AI depth #{@ai.depth}")
depth = Slider.new(1, 8, @ai.depth, step: 1)
depth.on_value_changed { |v| @ai.depth = v.to_i; depth_label.text = "AI depth #{v.to_i}" }
box.add(depth_label, depth)
panel.add(box)
@menu.add(panel)
@menu.visible = true
end
def start(mode : Symbol)
@mode = mode
@board = Checkers::Board.new
@selected = nil; @legal.clear; @last = nil
@menu.visible = false
update_status
end
def square_at(p : Vec2) : Int32?
l = p - ORIGIN
return nil if l.x < 0 || l.y < 0 || l.x >= SIZE * 8 || l.y >= SIZE * 8
(7 - (l.y / SIZE).to_i) * 8 + (l.x / SIZE).to_i
end
def square_pos(i : Int32) : Vec2
ORIGIN + v2((i % 8) * SIZE, (7 - i // 8) * SIZE)
end
def human_turn? : Bool
@mode == :two || @board.turn.red?
end
def input(e : Event)
return if @mode == :menu
if e.is_a?(MouseButtonEvent) && e.pressed? && e.button.left? && human_turn? && !@board.game_over?
sq = square_at(e.position)
return unless sq
if @selected && (mv = @legal.find { |m| m.to == sq })
@board.play(mv)
@last = mv
@snd.try(&.play)
@selected = nil; @legal.clear
update_status
elsif (c = @board[sq]) && c.side == @board.turn
@selected = sq
@legal = @board.legal_moves_from(sq)
else
@selected = nil; @legal.clear
end
elsif e.is_a?(KeyEvent) && e.pressed?
case e.key
when Key::R then start(@mode)
when Key::M then @mode = :menu; build_menu
when Key::U then undo
end
end
end
def undo
n = (@mode == :ai && @board.history.size >= 2) ? 2 : 1
return if @board.history.size < n
hist = @board.history[0...-n]
@board = Checkers::Board.new
hist.each { |m| @board.apply(m) }
@last = hist.last?
@selected = nil; @legal.clear
update_status
end
def update(dt : Float32)
if @mode == :ai && @board.turn.black? && !@board.game_over?
if mv = @ai.best_move(@board)
@board.apply(mv)
@last = mv
@snd.try(&.play(pitch: 0.8))
update_status
end
end
Eagle.quit if Input.pressed?(Key::Escape)
end
def update_status
@status = case @board.status
when .red_wins? then "Red wins!"
when .black_wins? then "Black wins!"
when .draw? then "Draw"
else "#{@board.turn.red? ? "Red" : "Black"} to move#{@board.legal_moves.first?.try(&.capture?) ? ", must capture" : ""}"
end
end
def draw(g : Graphics)
return if @mode == :menu
64.times do |i|
p = square_pos(i)
c = Checkers.dark?(i) ? Color.hex("#6b4f3a") : Color.hex("#e8d8c0")
c = c.lerp(Color::YELLOW, 0.35) if (l = @last) && (l.path.includes?(i))
c = c.lerp(Color::GREEN, 0.4) if @selected == i
g.rect(p.x, p.y, SIZE, SIZE, color: c)
end
@legal.each { |m| p = square_pos(m.to) + v2(SIZE / 2, SIZE / 2); g.circle(p, 10, color: Color.new(0, 0, 0, 0.3)) }
64.times do |i|
c = @board[i]
next unless c
p = square_pos(i) + v2(SIZE / 2, SIZE / 2)
base = c.side.red? ? Color.hex("#c0392b") : Color.hex("#2c2c2c")
g.circle(p + v2(2, 3), SIZE * 0.38, color: Color.new(0, 0, 0, 0.35))
g.circle(p, SIZE * 0.38, color: base)
g.circle(p, SIZE * 0.3, DrawMode::Line, color: base.lighten(0.3))
g.print("K", p.x, p.y - 8, Color::YELLOW, align: TextAlign::Center) if c.king?
end
g.print(@status, ORIGIN.x, ORIGIN.y + SIZE * 8 + 14, Color::WHITE)
g.print("red #{@board.count(Checkers::Side::Red)} black #{@board.count(Checkers::Side::Black)} #{@mode == :ai ? "vs AI depth #{@ai.depth}, nodes #{@ai.nodes}" : "two players"} U undo R restart M menu", ORIGIN.x, ORIGIN.y + SIZE * 8 + 38, Color::GRAY)
end
end
Eagle.run(CheckersGame, title: "Eagle Checkers", width: 900, height: 700)
examples/checkers/checkers.cr
# Checkers (English draughts) rules + AI, independent of rendering.
# 8x8 board, dark squares only, forced captures, multi-jumps, kings.
module Checkers
enum Side : Int8
Red = 0 # moves up (increasing row)
Black = 1 # moves down
def other : Side; red? ? Black : Red; end
end
struct Cell
getter side : Side
getter? king : Bool
def initialize(@side, @king = false); end
def crowned : Cell; Cell.new(@side, true); end
end
struct Move
getter path : Array(Int32) # squares visited (from ... to)
getter captures : Array(Int32) # captured squares
def initialize(@path, @captures = [] of Int32); end
def from : Int32; @path.first; end
def to : Int32; @path.last; end
def capture? : Bool; !@captures.empty?; end
def to_s : String; @path.map { |i| Checkers.name(i) }.join(capture? ? "x" : "-"); end
def ==(o : Move) : Bool; @path == o.path; end
end
def self.name(i : Int32) : String; "#{('a'.ord + i % 8).chr}#{i // 8 + 1}"; end
def self.index(name : String) : Int32; (name[0].ord - 'a'.ord) + (name[1].to_i - 1) * 8; end
def self.dark?(i : Int32) : Bool; (i % 8 + i // 8).even?; end
enum Status
Playing
RedWins
BlackWins
Draw
end
class Board
getter cells : Array(Cell?)
getter turn : Side
getter history = [] of Move
getter quiet_moves = 0 # moves without capture/crowning (draw at 40 each side)
def initialize
@cells = Array(Cell?).new(64, nil)
@turn = Side::Red
setup
end
protected def initialize(@cells, @turn, @history, @quiet_moves); end
def clone : Board; Board.new(@cells.dup, @turn, @history.dup, @quiet_moves); end
def setup : Nil
@cells.fill(nil)
64.times do |i|
next unless Checkers.dark?(i)
r = i // 8
@cells[i] = Cell.new(Side::Red) if r < 3
@cells[i] = Cell.new(Side::Black) if r > 4
end
end
# Layout from 8 text rows, top row first: r/R red (R = king), b/B black, . empty
def self.from_layout(rows : Array(String), turn : Side = Side::Red) : Board
b = new
b.cells.fill(nil)
rows.each_with_index do |row, ri|
r = 7 - ri
row.each_char.with_index do |c, f|
i = r * 8 + f
case c
when 'r' then b.cells[i] = Cell.new(Side::Red)
when 'R' then b.cells[i] = Cell.new(Side::Red, true)
when 'b' then b.cells[i] = Cell.new(Side::Black)
when 'B' then b.cells[i] = Cell.new(Side::Black, true)
end
end
end
b.set_turn(turn)
b
end
# :nodoc:
def set_turn(@turn); end
def [](i : Int32) : Cell?; @cells[i]; end
def count(side : Side) : Int32; @cells.count { |c| c && c.side == side }; end
private def dirs(c : Cell) : Array({Int32, Int32})
return [{1, 1}, {-1, 1}, {1, -1}, {-1, -1}] if c.king?
c.side.red? ? [{1, 1}, {-1, 1}] : [{1, -1}, {-1, -1}]
end
# All legal moves (captures are mandatory).
def legal_moves : Array(Move)
captures = [] of Move
simple = [] of Move
64.times do |i|
c = @cells[i]
next unless c && c.side == @turn
jumps(i, c, [i], [] of Int32, captures)
next unless captures.empty?
dirs(c).each do |(df, dr)|
f = i % 8 + df; r = i // 8 + dr
next unless (0 <= f <= 7) && (0 <= r <= 7)
t = r * 8 + f
simple << Move.new([i, t]) if @cells[t].nil?
end
end
captures.empty? ? simple : captures
end
private def jumps(i, c, path, caps, out_moves)
found = false
dirs(c).each do |(df, dr)|
f = i % 8 + df; r = i // 8 + dr
f2 = f + df; r2 = r + dr
next unless (0 <= f2 <= 7) && (0 <= r2 <= 7)
mid = r * 8 + f; t = r2 * 8 + f2
m = @cells[mid]
next unless m && m.side != c.side && !caps.includes?(mid)
next unless @cells[t].nil? || t == path.first
found = true
# stop multi-jump when a man reaches the king row
last_row = c.side.red? ? 7 : 0
if !c.king? && r2 == last_row
out_moves << Move.new(path + [t], caps + [mid])
else
jumps(t, c, path + [t], caps + [mid], out_moves)
end
end
out_moves << Move.new(path, caps) if !found && caps.size > 0
end
def legal_moves_from(i : Int32) : Array(Move)
legal_moves.select { |m| m.from == i }
end
def apply(m : Move) : Nil
c = @cells[m.from].not_nil!
@cells[m.from] = nil
m.captures.each { |x| @cells[x] = nil }
last_row = c.side.red? ? 7 : 0
crowned = !c.king? && m.to // 8 == last_row
@cells[m.to] = crowned ? c.crowned : c
@quiet_moves = (m.capture? || crowned) ? 0 : @quiet_moves + 1
@history << m
@turn = @turn.other
end
def play(m : Move) : Bool
real = legal_moves.find { |lm| lm == m }
return false unless real
apply(real)
true
end
def play(path : String) : Bool
squares = path.split(/[-x]/).map { |n| Checkers.index(n) }
play(Move.new(squares))
end
def status : Status
return Status::Draw if @quiet_moves >= 80
if legal_moves.empty?
return @turn.red? ? Status::BlackWins : Status::RedWins
end
Status::Playing
end
def game_over? : Bool; !status.playing?; end
def to_s(io : IO) : Nil
7.downto(0) do |r|
8.times do |f|
c = @cells[r * 8 + f]
io << (c ? (c.side.red? ? (c.king? ? 'R' : 'r') : (c.king? ? 'B' : 'b')) : (Checkers.dark?(r * 8 + f) ? '.' : ' ')) << ' '
end
io << '\n'
end
end
end
class AI
property depth : Int32
getter nodes = 0
def initialize(@depth = 6); end
def evaluate(b : Board) : Int32
score = 0
64.times do |i|
c = b[i]
next unless c
v = c.king? ? 300 : 100
# advancement bonus for men
r = i // 8
v += (c.side.red? ? r : 7 - r) * 3 unless c.king?
# edge safety
v += 4 if i % 8 == 0 || i % 8 == 7
score += c.side.red? ? v : -v
end
b.turn.red? ? score : -score
end
def best_move(b : Board) : Move?
@nodes = 0
moves = b.legal_moves
return nil if moves.empty?
return moves[0] if moves.size == 1
best = moves[0]; best_score = Int32::MIN
alpha = -1_000_000; beta = 1_000_000
moves.each do |m|
c = b.clone; c.apply(m)
s = -search(c, @depth - 1, -beta, -alpha)
if s > best_score
best_score = s; best = m
end
alpha = Math.max(alpha, s)
end
best
end
private def search(b : Board, depth : Int32, alpha : Int32, beta : Int32) : Int32
@nodes += 1
moves = b.legal_moves
return -100_000 - depth if moves.empty?
# extend search while captures are available (quiescence)
return evaluate(b) if depth <= 0 && !moves[0].capture?
return evaluate(b) if depth <= -4
moves.each do |m|
c = b.clone; c.apply(m)
s = -search(c, depth - 1, -beta, -alpha)
return beta if s >= beta
alpha = Math.max(alpha, s)
end
alpha
end
end
end