NAME
Game::Oware::Terminal - a playable game on two filehandles
VERSION
Version 0.02
SYNOPSIS
use Game::Oware::Terminal;
my $terminal = Game::Oware::Terminal->new(level => 3, seat => 'p1');
my $result = $terminal->start;
DESCRIPTION
It is separate from the engine on purpose
Game::Cribbage is about a thousand lines of escape codes in its top level namespace module, so that only its ::Board was ever reusable by anything else. This is a consumer of the engine exactly as a web adapter would be, and the suite asserts that loading Game::Oware does not pull this in.
in and out are properties
Defaulting to STDIN and STDOUT. That is what makes a terminal testable: the suite drives a whole game in process against in-memory handles, with no pipe, no fork, and no subprocess writing into the TAP stream.
start returns, and never exits
A module that calls exit cannot be tested and cannot be embedded. The exit status belongs to bin/oware, which is the only part that knows it is a program.
Every option is validated in the constructor
An unknown variant, an unknown level or a seat that does not exist all die from BUILD, before a game is built and before anything is printed. Game::Reversi validated its variant three calls deeper and shipped a --variant draughts that died with a raw Perl message and an exit status of 255.
The board is drawn as a board
A store at each end, flanking the two rows of six houses, which is the shape of the thing on the table. The viewer's own store is the right-hand one, as it is when you sit down to play.
Each house carries its seed count and a cluster of seeds, because neither alone is enough. A pile of dots is unreadable past four, so the count is what you play from; but a bare grid of numbers gives no sense of a board at all, and the cluster is what makes a row of ones and twos look as thin as it is. Past $SEED_CAP the cluster stops and takes a +, because the number is right there and five dots against twenty-five dots would be a lie either way.
Counts, not glyphs, is why the letters are the interface
The letters are printed above and below the board and the prompt takes one, matching Game::Oware::Notation exactly. A terminal that invented its own numbering would give the distribution two notations.
The board is turned round for whoever is looking
Your own six houses are always the bottom row, because the whole spatial vocabulary of this game is "your row" and "their row". The letters do not move: A to F is p1's side and a to f is p2's, in both views and in every line of narration, so two people looking at one game can never disagree about where a house is.
The marks are in the text, and the colour is on top of them
Five things happen to a house in a move, and each gets a character inside its cell: - for the house that was emptied, + for a house that gained a seed, * for where the last seed landed, x for a capture and ! for a capture that was forfeited.
That is deliberately not done with colour. Strip every escape from a game and it still says what the last move did, which is the property NO_COLOR, --no-ansi and a redirected handle all depend on, and the suite asserts it byte for byte. Colour here is a second encoding of what the marks already say, plus a reading of the position that carries no history: a house of one or two seeds in red because that is exactly what an opponent captures by bringing it to two or three, and a house of twelve or more drawn bright because it laps the board.
The + marks come from walking the sow rather than from diffing two boards, so a hand of twelve or more shows the origin staying empty while the hand goes past it. That is the one rule in Oware that a player will otherwise be certain is a bug, and here it is visible in the cell rather than only in a sentence.
A move is chosen with the arrow keys, and the preview is the result
On a terminal with Term::ReadKey installed, the legal moves are listed under the board and the up and down keys walk them. Return sows the one under the cursor, a house letter jumps to that house, v shows the position as it stands, ? prints the rules, t goes back to typing and q stops. Off a terminal, or without Term::ReadKey, the moves are typed out as before, and --no-pick asks for that on purpose.
The same key tables as Game::Checkers::Terminal, deliberately: two of this author's terminals reading the same keyboard should not disagree about what Home is.
The board above the list is the position the move would produce. That is where this parts company with the Checkers picker, which highlights the squares a jump passes through. A checkers square holds a piece or nothing, so a highlight is the whole story; an Oware house holds a number, and the first version of this did it the Checkers way with the result that the house under the cursor read - 1 for a house the move had just emptied, and a capture drew x over seeds that were still sitting there. Previewing the result instead means a capture shows the store going up and the status line shows the seeds leaving the board, which is the question a player is actually asking.
The cost of a full-screen picker is that the opponent's move scrolls away, and that was a real bug in the first draft: the narration printed when the bot moved was gone by the time there was anything to choose, so the player answered a move they never read. The last move is recorded whether or not it was printed and is reprinted on every frame, in the past tense, and v puts its marks back on the live board.
The board is drawn every ply, not every round
A human move and a bot reply are two changes, and drawing once after both of them leaves the player diffing a grid of twelve numbers to work out which half of it was their own doing. Each ply draws, so a move and its answer are two pictures.
--ascii is a real second table
$GLYPH{ascii} is + - | and a full stop for a seed, against box drawing and a bullet for the wide form. The first version of this module shipped two identical tables, so --ascii was a flag that changed nothing; there is now a difference to see and t/20-terminal.t asserts it.
This file is not under use utf8, so the wide glyphs are byte strings. Every cell is padded from a count the caller already holds rather than from length, and nothing built from a glyph reaches sprintf's %Ns. A board padded by byte count shears by two columns per seed on a UTF-8 terminal and by none in the C locale, which is a bug that passes every test run on the machine that wrote it.
Four moments have to be said in words as well
Oware's failure mode on a terminal is that the board changes in ways the player cannot account for, so each of these gets a sentence rather than being left to be inferred:
a forfeited slam. A capturing move captures nothing. Unexplained, that is a bug report.
a sow of twelve or more, which skipped its own house on the way past, so the counts do not add up by eye and the player will count them again.
the feeding rule, when it has pruned the move list. The refusal message exists, but a player who never tries the illegal move never sees it.
the cycle ending, named as a house rule with the count that triggered it, because a game ending on a rule no book contains has to say so.
quietdoes not silence that paragraph: it is not narration, and a terse run of this program must not imply a book says what it just did.
The failed-feed ending is a fifth: a seat that cannot move takes its own seeds, which reads backwards to anybody who knows chess.
quiet never silences the result
quiet drops the banner, the narration and the board. It does not drop the line saying who won, which is the one thing a run of this program always has to produce, and the first version of this module dropped that too.
PROPERTIES
in
The handle to read from. Defaults to STDIN.
out
The handle to write to. Defaults to STDOUT.
level
The bot's level, 1 to 5.
variant
abapa or awari.
seat
Which seat the human plays, p1 or p2.
seed
Passed to the game and, with a suffix, to the bot.
quiet
Suppress the banner, the narration and the board. Not the result, and not the cycle rule's disclaimer.
ascii
Plain box drawing and a full stop for a seed.
game
The Game::Oware being played. Built by BUILD unless one is supplied, which is how the suite starts from a constructed position.
bot
The Game::Oware::Bot playing the other seat.
raw
Whether the terminal is currently in cbreak mode. Set by "enter_raw" and cleared by "leave_raw".
keysource
A coderef taking a wait flag and returning one character, used in place of Term::ReadKey:
$terminal->keysource(sub { shift @character });
That is how t/22-pick.t drives the whole key loop with no terminal, no pipe and no Term::ReadKey installed. A key loop with no such seam does not get tested, it gets asserted about in POD.
pending
Characters read and given back, which is how an escape that turns out not to open a sequence does not eat the keystroke behind it.
recent
The last two plies, as { move, seat } hashrefs, oldest first. Recorded whether or not they were narrated: the picker clears the screen on every keystroke, so this is the only surviving copy of the round.
METHODS
ansi
Whether to emit escapes. NO_COLOR wins over everything; an explicit ansi option beats the tty check; otherwise it follows whether out is a terminal.
progress
Whether to print the transient "thinking" line while the bot searches. An explicit progress option beats the tty check; otherwise it follows whether out is a terminal, and quiet turns it off outright.
It is gated on being interactive rather than on ansi, for two reasons: level 5 can spend several seconds on one move and a dead screen is the worst thing this program does, while a line that is erased with a carriage return has no business in a file. Gating it on ansi instead would also put it in a coloured run and not a plain one, which is exactly the difference the suite forbids.
interactive
Whether out is something a player is looking at. An explicit interactive option beats the tty check. It gates the screen clearing and, by default, "progress".
picking
Whether a move is chosen with the keys rather than typed. Settable, because the t key and the type command turn it off for the rest of the game and pick turns it back on. Unset, it follows "keys_available"; quiet turns it off outright, there being no board to put a cursor on.
keys_available
Whether there is anything to read keys with: true when "keysource" is set, or when in is a terminal and Term::ReadKey can be loaded.
enter_raw
Puts the terminal into cbreak and returns the object, or undef if it cannot.
cbreak rather than raw, so an interrupt stays an interrupt rather than becoming a key this module has to know about.
leave_raw
Puts it back. bin/oware calls this from a signal handler and after an eval around the game, because a program that dies in cbreak leaves the shell it came from with no echo.
read_char
One character: whatever "pending" is holding, else "keysource" if it is set, else Term::ReadKey. With a false argument it does not block, and returns undef when there is nothing there.
read_key
One keystroke, as a name. A character comes back as itself; an escape sequence comes back as up, down, home and so on, and a control character as enter, tab, backspace, interrupt or eof. So a name is always longer than one character and a key never collides with one.
read_sequence
The tail of an escape sequence, called once the escape has been read. An opener that turns out not to belong to a sequence is put back on "pending" rather than lost, because the escape key and the first byte of an arrow key are the same byte.
pick
Draws the board, the option list and the cursor, and reads keys until something is played or the player stops. Returns 1 if a move was made and 0 if not, the same contract as the typed turn, and hands over to the typed turn if the keys turn out not to be available after all.
preview
my ($board, $move) = $terminal->preview($house);
What sowing a house would do, resolved against the live position and never played. The board is a new fourteen cells and the game is untouched.
describe
One line on what sowing a house would do: how many seeds, whether it laps the board, what it captures, and whether the capture would be forfeited.
choice_lines
The option list, one line per legal move, with the one under the cursor marked. Not windowed, because six is the most there can ever be.
legend
The keys, as lines to print under the list.
rows_for
The house indices of the top and bottom rows, from a seat's point of view.
marks_for
my $marks = $terminal->marks_for($move);
What a Game::Oware::Move did, as a hashref of house index to mark character. Built by walking the sow, so it is the move's own account of itself rather than a diff of two positions.
board_text
my @lines = $terminal->board_text($game, $seat, $marks);
my @lines = $terminal->board_text($board, $seat, $marks);
The board as nine lines, from a seat's point of view. $marks is optional and comes from "marks_for".
A Game::Oware or a bare fourteen cells, because the picker draws a position no game has been in: the one a candidate move would produce.
status_text
One line: the seeds still in play, and what wins. Seeds in a house belong to nobody, so the difference between the stores and the board is worth a number. Takes a game or a board, for the same reason.
command
One line of input as an instruction: quit, help, board, pick, type, again for an empty line, or the text itself.
There is no single-letter shortcut for board. b and B are houses, and the first draft of this module accepted b as the command, so typing a perfectly ordinary move redrew the board instead of playing it. q and h are safe because no house is called either.
start
Plays the game and returns its Game::Oware::Result, or undef if the player quit.
SEE ALSO
Game::Oware, Game::Oware::Bot, Game::Oware::Notation
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.