Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ gamesung/
- Reference the issue: "Closes #<number>"
- Keep PRs small and focused

### 7. Push

- Push the working branch with `git push origin <branch-name>` (NOT `git push dev-rkb`, which is the contributor's personal fork and requires separate credentials).
- Stop after pushing. Do not open a PR, do not merge, do not push to other remotes.

## Forbidden Actions

- Do not commit secrets, API keys, or credentials
Expand Down
123 changes: 98 additions & 25 deletions packages/server/bot/bot.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,58 @@ var pieceValues = map[chess.PieceType]int{
chess.King: 20000,
}

// Piece-square tables (white-perspective, rank 8 at index 0 .. rank 1 at index 7).
// Black mirrors the rank index. Values are added to the base material value.
// Source: simplified evaluation tables (Tomasz Michniewski / Chess Programming Wiki).
var pawnPST = [8][8]int{
{0, 0, 0, 0, 0, 0, 0, 0},
{50, 50, 50, 50, 50, 50, 50, 50},
{10, 10, 20, 30, 30, 20, 10, 10},
{5, 5, 10, 25, 25, 10, 5, 5},
{0, 0, 0, 20, 20, 0, 0, 0},
{5, -5, -10, 0, 0, -10, -5, 5},
{5, 10, 10, -20, -20, 10, 10, 5},
{0, 0, 0, 0, 0, 0, 0, 0},
}

var knightPST = [8][8]int{
{-50, -40, -30, -30, -30, -30, -40, -50},
{-40, -20, 0, 0, 0, 0, -20, -40},
{-30, 0, 10, 15, 15, 10, 0, -30},
{-30, 5, 15, 20, 20, 15, 5, -30},
{-30, 0, 15, 20, 20, 15, 0, -30},
{-30, 5, 10, 15, 15, 10, 5, -30},
{-40, -20, 0, 5, 5, 0, -20, -40},
{-50, -40, -30, -30, -30, -30, -40, -50},
}

// King PST for midgame: encourage castled position (corners), penalize center.
var kingPST = [8][8]int{
{-30, -40, -40, -50, -50, -40, -40, -30},
{-30, -40, -40, -50, -50, -40, -40, -30},
{-30, -40, -40, -50, -50, -40, -40, -30},
{-30, -40, -40, -50, -50, -40, -40, -30},
{-20, -30, -30, -40, -40, -30, -30, -20},
{-10, -20, -20, -20, -20, -20, -20, -10},
{20, 20, 0, 0, 0, 0, 20, 20},
{20, 30, 10, 0, 0, 10, 30, 20},
}

const (
mateScore = 99999
mobilityMul = 2
)

type Bot struct {
Depth int
rng *rand.Rand
}

func NewBot(depth int) *Bot {
if depth < 1 {
depth = 2
}
return &Bot{Depth: depth}
return &Bot{Depth: depth, rng: rand.New(rand.NewSource(rand.Int63()))}
}

func (b *Bot) GetMove(game *chess.Game) *chess.Move {
Expand All @@ -36,27 +79,28 @@ func (b *Bot) GetMove(game *chess.Game) *chess.Move {
return b.pickBestMove(game, validMoves)
}

var bestMove *chess.Move
bestScore := -999999
bestScore := -mateScore - 1
candidates := []*chess.Move{}

for _, move := range validMoves {
cloned := game.Clone()
cloned := game.Clone()
if err := cloned.Move(move); err != nil {
continue
}

score := -b.minimax(cloned, b.Depth-1, -999999, 999999)
score := -b.minimax(cloned, b.Depth-1, -mateScore, mateScore)
if score > bestScore {
bestScore = score
bestMove = move
candidates = []*chess.Move{move}
} else if score == bestScore {
candidates = append(candidates, move)
}
}

if bestMove == nil {
return validMoves[rand.Intn(len(validMoves))]
if len(candidates) == 0 {
return validMoves[b.rng.Intn(len(validMoves))]
}

return bestMove
return candidates[b.rng.Intn(len(candidates))]
}

func (b *Bot) minimax(game *chess.Game, depth int, alpha, beta int) int {
Expand All @@ -69,7 +113,7 @@ func (b *Bot) minimax(game *chess.Game, depth int, alpha, beta int) int {
return b.evaluate(game)
}

bestScore := -999999
bestScore := -mateScore - 1
for _, move := range validMoves {
cloned := game.Clone()
if err := cloned.Move(move); err != nil {
Expand Down Expand Up @@ -97,19 +141,20 @@ func (b *Bot) evaluate(game *chess.Game) int {

var score int

for square := chess.A1; square <= chess.H8; square++ {
piece := board.Piece(square)
for sq := chess.A1; sq <= chess.H8; sq++ {
piece := board.Piece(sq)
if piece == chess.NoPiece {
continue
}

value := pieceValues[piece.Type()]
score += pieceScore(piece, sq)
}

if piece.Color() == chess.White {
score += value
} else {
score -= value
}
moves := pos.ValidMoves()
if pos.Turn() == chess.White {
score += len(moves) * mobilityMul
} else {
score -= len(moves) * mobilityMul
}

if pos.Turn() == chess.Black {
Expand All @@ -120,8 +165,8 @@ func (b *Bot) evaluate(game *chess.Game) int {
}

func (b *Bot) pickBestMove(game *chess.Game, moves []*chess.Move) *chess.Move {
var bestMove *chess.Move
bestScore := -999999
bestScore := -mateScore - 1
candidates := []*chess.Move{}

for _, move := range moves {
cloned := game.Clone()
Expand All @@ -132,13 +177,41 @@ func (b *Bot) pickBestMove(game *chess.Game, moves []*chess.Move) *chess.Move {
score := b.evaluate(cloned)
if score > bestScore {
bestScore = score
bestMove = move
candidates = []*chess.Move{move}
} else if score == bestScore {
candidates = append(candidates, move)
}
}

if bestMove == nil {
return moves[rand.Intn(len(moves))]
if len(candidates) == 0 {
return moves[b.rng.Intn(len(moves))]
}
return candidates[b.rng.Intn(len(candidates))]
}

return bestMove
func pieceScore(piece chess.Piece, sq chess.Square) int {
base := pieceValues[piece.Type()]

var pst int
rank := sq.Rank()
file := sq.File()
rankIdx := 7 - int(rank)
if piece.Color() == chess.Black {
rankIdx = int(rank)
}

switch piece.Type() {
case chess.Pawn:
pst = pawnPST[rankIdx][int(file)]
case chess.Knight:
pst = knightPST[rankIdx][int(file)]
case chess.King:
pst = kingPST[rankIdx][int(file)]
}

value := base + pst
if piece.Color() == chess.White {
return value
}
return -value
}
38 changes: 38 additions & 0 deletions packages/server/bot/bot_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package bot

import (
"testing"

"github.com/notnil/chess"
)

func TestGetMoveReturnsNonKnightFromStart(t *testing.T) {
notKnight := 0
for i := 0; i < 50; i++ {
g := chess.NewGame()
b := NewBot(2)
m := b.GetMove(g)
if m == nil {
t.Fatalf("expected a move, got nil")
}
p := g.Position().Board().Piece(m.S1())
if p.Type() != chess.Knight {
notKnight++
}
}
if notKnight == 0 {
t.Fatalf("expected bot to play at least one non-knight move across 50 trials")
}
}

func TestGetMoveReturnsValidMove(t *testing.T) {
g := chess.NewGame()
b := NewBot(2)
m := b.GetMove(g)
if m == nil {
t.Fatalf("expected a move, got nil")
}
if err := g.Move(m); err != nil {
t.Fatalf("bot returned an illegal move: %v", err)
}
}
4 changes: 2 additions & 2 deletions packages/server/game/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func (g *Game) MakeMove(moveStr string) (string, error) {
return "", ErrGameNotPlaying
}

move, err := chess.AlgebraicNotation{}.Decode(g.Board.Position(), moveStr)
move, err := chess.UCINotation{}.Decode(g.Board.Position(), moveStr)
if err != nil {
return "", ErrInvalidMove
}
Expand Down Expand Up @@ -143,7 +143,7 @@ func (g *Game) GetBotMove() (string, error) {
return "", ErrNoValidMoves
}

moveStr := chess.AlgebraicNotation{}.Encode(g.Board.Position(), move)
moveStr := chess.UCINotation{}.Encode(g.Board.Position(), move)

if err := g.Board.Move(move); err != nil {
return "", ErrInvalidMove
Expand Down
7 changes: 5 additions & 2 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
"lint": "eslint src/",
"typecheck": "tsc --noEmit",
"test:e2e": "playwright test"
},
"dependencies": {
"clsx": "^2.1.1",
Expand All @@ -19,6 +20,7 @@
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@playwright/test": "^1.61.0",
"@tailwindcss/postcss": "^4.3.1",
"@types/node": "^22.15.3",
"@types/react": "19.2.17",
Expand All @@ -27,6 +29,7 @@
"@typescript-eslint/parser": "^8.32.0",
"eslint": "^9.27.0",
"eslint-config-next": "^15.3.3",
"playwright": "^1.61.0",
"tailwindcss": "^4.3.1",
"typescript": "^5.8.3"
}
Expand Down
20 changes: 20 additions & 0 deletions packages/web/playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./tests",
fullyParallel: false,
retries: 0,
workers: 1,
reporter: [["list"]],
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
headless: true,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});
Loading
Loading