NAME

Game::Oware::Bot - an opponent, on a node budget

VERSION

Version 0.01

SYNOPSIS

use Game::Oware::Bot;

my $bot   = Game::Oware::Bot->new(level => 3, seed => $bytes);
my $house = $bot->choose($game, 'p1');

$game->play('p1', $house);
$bot->last_search;    # { depth => 5, nodes => 18211, score => 312, move => 4 }

DESCRIPTION

Negamax with alpha-beta over a state that fits in fourteen integers, which is the cheapest search on any board this author has written an engine for.

choose returns one of the moves the game already offered

It asks $game->legal($seat) and picks from what comes back. It never builds a candidate list of its own, so it cannot play an illegal move however wrong its judgement is.

That matters more here than in most games. The feeding obligation means the legal list is not "the houses with seeds in them", so a bot that generated its own candidates would offer refused moves in exactly the positions a human finds most confusing: the ones where the opponent has been starved.

The budget is in nodes, never in seconds

A loaded machine must choose the same move as an idle one, or a bot game stops replaying and the whole verification story goes with it. The budget is spent through iterative deepening, so running out always leaves a complete search of a shallower depth rather than half of a deeper one.

The counter is a plain hashref carried down the recursion rather than a property. An accessor call per node would be most of the cost of the bot.

Capturing is the right primary term here, which is the opposite of Reversi

Game::Reversi::Bot warns that maximising discs is the beginner's mistake, because a disc can be flipped back. An Oware capture cannot be undone: a seed in a store never returns to the board. So the captured difference is the objective rather than a proxy for it, and it dominates the evaluation.

That contrast is worth stating because the Reversi bot is the nearest template in this author's tree, and copying its warning across would produce an Oware bot that ignores the only thing that scores.

The other three terms are what stop it being a counting machine

$MATERIAL

Seeds in your own row, weighted low. They are not yours until captured and a sow gives them away, so this is a tiebreak rather than a goal.

$VULNERABLE

Houses of one or two seeds on your own side, weighted negative. Those are precisely what an opponent captures by bringing them to two or three, so counting them is how the bot learns to defend without being told the rule.

$LOADED

Houses of twelve or more, weighted slightly positive. A house that laps the board is a real threat and is hard to answer, and it is also the one thing the origin-skip rule exists for, so a bot blind to it has never met the hardest rule in the game.

They are our variables rather than constants on purpose: use constant is inlined at compile time, so a caller sweeping the weights to tune them would silently measure the same value however many times it ran.

The search ignores the cycle rule, and that is a documented limitation

The value of a position under D2 depends on its history: the same board is a draw if it is the third occurrence and is not otherwise. The search does not carry that, so it evaluates lines past a point where the cycle rule would already have swept the board.

It is bounded rather than dangerous: the ply cap guarantees the game terminates whatever the bot believes, and t/19-bot-terminates.t asserts that a bot-against-bot game always ends. Do not add a transposition table keyed on the twelve houses to fix this. That key is unsound near a cycle for the same reason, and it collides across positions with different stores, which is where the endgame is decided.

Tie-breaks come from the seed AND the seat

No rand, ever. The choice among equally-scored moves is drawn from a hash of the bot's seed, the seat, and the ply.

The seat is not decoration. Without it both bots in a game are the same bot, open the same way every time, and a human reads the rule off two games. Game::Goofspiel on the site this engine feeds shipped with exactly that fault and every bot game finished level.

blunder is how a low rung loses gently

Levels 1 and 2 take the second or third best move some of the time, drawn from the same hash. A bot that always plays its best move at depth one does not play badly, it plays predictably badly, and a human reads it in two games.

There is no blind gate here, because there is nothing to hide

Oware is perfect information. The hidden-information engines in this author's tree carry a test proving choose cannot see what a seat should not; this one does not need one, and its absence is a fact about the game rather than a missing test.

PROPERTIES

level

One to five. BUILD dies on anything else.

seed

Mixed with the seat and the ply for every tie-break. Defaults to the empty string, which is deterministic but makes both seats behave alike, so a real consumer passes one.

{ depth, nodes, score, move } from the most recent choose.

METHODS

levels

Every level, ascending.

setting_for

A copy of one level's table, so a caller can see what a rung means without reaching into the module.

choose

my $house = $bot->choose($game, $seat);

A house index, or undef when the game is over, when it is not that seat's turn, or when the seat has nothing to play.

SEE ALSO

Game::Oware, Game::Oware::Rules

AUTHOR

LNATION <email@lnation.org>

LICENSE AND COPYRIGHT

This software is Copyright (c) 2026 by LNATION <email@lnation.org>.

This is free software, licensed under the Artistic License 2.0.