A small, pure-Swift toolkit for turn-based, two-player games. Describe your game as a value type, and AdversarialKit finds the best move with minimax search and alpha-beta pruning, or with Monte Carlo tree search when the game is too big to search exhaustively.
- Plain Swift, no dependencies, works on any platform Swift runs on
- Value-type game models: the search never mutates shared state
- Swift 6 ready (
Sendablethroughout) - Includes ready-made Tic-Tac-Toe, Nim and Hex models
Recording of swift test --filter TicTacToeGameplayTests: two minimax players play Tic-Tac-Toe to a draw. Full-quality video.
Add the package in Package.swift:
dependencies: [
.package(url: "https://github.com/tkausch/AdversarialKit.git", branch: "main")
],
targets: [
.target(name: "MyGame", dependencies: ["AdversarialKit"])
]Conform to GameModel (a struct works best), with your own ModelMove type:
import AdversarialKit
struct Place: ModelMove { let index: Int }
struct MyGame: GameModel {
var activePlayer: ModelPlayer { ... } // .first or .second
var outcome: Outcome? { ... } // nil while running; .win(player) or .draw when over
func legalMoves() -> [Place] { ... } // any Sequence works; lazy ones avoid allocations
func applying(_ move: Place) -> MyGame { ... } // return a new state
}let strategist = MinimaxStrategist<MyGame>()
if let move = await strategist.bestMove(for: game) {
game = game.applying(move)
}bestMove(for:) is async. The search always runs concurrently, off the calling executor, so it never blocks the caller. It can't be cancelled; use maxDepth (below) to bound how long it takes.
For games too large to search to the end, set a depth limit and let your model rate the positions where the search stops. Implement heuristicValue(for:) on your GameModel (it returns nil by default, meaning no heuristic):
extension MyGame {
func heuristicValue(for player: ModelPlayer) -> Int? {
// Rate the position for `player`; higher is better.
score(of: self, for: player)
}
}
let strategist = MinimaxStrategist<MyGame>(maxDepth: 6)To override the model's heuristic, pass a closure: MinimaxStrategist<MyGame>(maxDepth: 6) { model, player in ... }. HexModel ships with a heuristic, so MinimaxStrategist<HexModel>(maxDepth: 2) works out of the box.
For games that are too big to search exhaustively and hard to score with a heuristic, use MonteCarloStrategist. It plays many random games and picks the most promising move. More iterations play stronger and take longer. Set a seed for reproducible results.
let strategist = MonteCarloStrategist<MyGame>(iterations: 5_000)
let move = await strategist.bestMove(for: game)Use Arena to play strategists against one another, for example to see whether a change made one stronger:
let arena = Arena<TicTacToeModel>()
let result = await arena.match(
MinimaxStrategist<TicTacToeModel>(),
against: MonteCarloStrategist<TicTacToeModel>(iterations: 1_000),
rounds: 20,
startingFrom: TicTacToeModel()
)
print(result.firstWins, result.secondWins, result.draws)match alternates who moves first each round, so the first-move advantage doesn't skew the comparison. Use playGame directly for a single game.
| Type | Purpose |
|---|---|
GameModel |
Your game: active player, outcome, legal moves, applying a move |
ModelPlayer |
The two players: .first and .second |
ModelMove |
A legal action (Hashable, Sendable) |
Outcome |
.win(player) or .draw |
Strategist |
Protocol for anything that picks a move |
MinimaxStrategist |
Minimax with alpha-beta pruning, optional depth limit and heuristic |
MonteCarloStrategist |
Monte Carlo tree search: plays random games, needs no heuristic |
Arena |
Plays two strategists against each other and tallies the results |
TicTacToeModel, NimModel, HexModel |
Ready-made games |
TicTacToeModel, NimModel and HexModel are ready-made games that double as references for writing your own. Two perfect players always draw at Tic-Tac-Toe (see the test run above):
let strategist = MinimaxStrategist<TicTacToeModel>()
var game = TicTacToeModel()
while let move = await strategist.bestMove(for: game) {
game = game.applying(move)
}
// game.outcome == .drawIn Nim, players take 1 to maxTake stones from a pile and whoever takes the last stone wins:
var game = NimModel(stones: 10, maxTake: 3)
let move = await strategist.bestMove(for: game) // take 2, leaving a multiple of 4Hex is played on a rhombus of hexagonal cells. The first player connects the top and bottom edges, the second player the left and right edges, and a game never ends in a draw. Its branching factor is high, so MonteCarloStrategist suits it better than exhaustive minimax:
let strategist = MonteCarloStrategist<HexModel>(iterations: 2_000)
var game = HexModel(size: 7) // the default size is 11
while let move = await strategist.bestMove(for: game) {
game = game.applying(move)
}
// game.outcome is .win(.first) or .win(.second)swift testAdversarialKit is available under the Apache License 2.0. See LICENSE for details.
