NAME

Game::Oware::Terminal - a playable game on two filehandles

VERSION

Version 0.04

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, u takes back your last move and the reply to it, r plays them again, ? 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 keys are read by Game::Terminal::Kit::Keys and the list is a Game::Terminal::Kit::Picker, the same as in this author's other terminal games, so that two of them reading the same keyboard do not disagree about what Home is. The rows are numbered and a digit jumps to that row.

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. quiet does 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.

keysource

A coderef called for each character and returning it, or undef for none, 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.

keyboard

The Game::Terminal::Kit::Keys the keys and the typed lines are read through, built from "in", "keysource" and "pending" unless one is given, and kept in step with those three when they are set later.

screen

The Game::Terminal::Kit::Screen the game is printed through, built from "out", "interactive", "ansi" and "ascii" unless one is given, and kept in step with the first three.

log

A Game::Terminal::Kit::Log holding the last two plies, oldest first, each as the lines that narrate it in the past tense, and whatever the picker has to say under the list: the rules, a house that cannot be played, a key that did nothing. A ply is recorded whether or not it was narrated: the picker redraws the screen on every keystroke, so this is the only surviving copy of the round.

last_move

The Game::Oware::Move played last, by either seat. Its marks are the ones v puts on the board as it stands. Undef after a take back or a play again.

history

The Game::Terminal::Kit::History the turns are kept in, as the game's text. A turn is kept each time the move passes to you, and when the game ends; the bot's opening, when it moves first, is never a turn to take back. "start" begins it again.

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 drawing a frame over the last one 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.

raw

Whether the terminal is being read a key at a time: true between "enter_raw" and "leave_raw".

enter_raw

Starts reading the terminal a key at a time and returns the object, or undef if it cannot. An interrupt stays an interrupt rather than becoming a key.

leave_raw

Puts it back. "run" does this on every way out, a signal included, because a program that dies while reading a key at a time leaves the shell it came from with no echo.

read_key

One keystroke, as "read_key" in Game::Terminal::Kit::Keys names it. 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.

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.

picker

my $picker = $terminal->picker($legal, $at);

The Game::Terminal::Kit::Picker for a list of houses: a row for each with its letter and what sowing it would do, the cursor on the first or on the index given. Not windowed, because six is the most there can ever be. The house letters, v, u, r, ?, t and : are keys it hands back to the game.

choice_lines

my @lines = $terminal->choice_lines($legal, $at);

The option list, one numbered line per legal move, with the one under the cursor marked.

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, undo, redo, again for an empty line, or the text itself. undo takes back your last move and the reply to it, and redo plays them again.

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.

options

my $fields = Game::Oware::Terminal->options;

The options a person would set before a game, in the order a launcher shows them. Each is a hash reference with a name, a type and a help, and min and max or choices where the type has them. A new list is returned each time.

level

A whole number from 1 to 5: bot level.

seat

One of p1, p2: the seat you play.

variant

One of abapa, awari: the rules.

seed

Text: so a game can be replayed.

run

exit Game::Oware::Terminal->run(@ARGV);

The whole command line program as a class method: parses the options bin/oware documents, builds the terminal, plays, and returns the exit status rather than calling exit: 0 if you won, drew or quit, 1 if you lost, 2 for a bad option, a leftover word or a refused value, with the reason on STDERR. --help and --version print to STDOUT and return 0. Options are read from the arguments passed in, never from @ARGV, and the Getopt configuration the parse needs is restored before it returns. Signal handlers are local to the call and the terminal is given back on every way out, so a launcher such as Game::Terminal can call it in its own process and carry on afterwards. Colour is off under NO_COLOR as well as off a terminal.

start

Plays the game and returns its Game::Oware::Result, or undef if the player quit.

person_turn

Called by "start" when the turn passes to you, and when the game ends: keeps the game in "history" as a turn.

take_back

The undo command and the u key: your last move and every reply to it. Says so, and returns 1; with nothing to take back, says that and returns 0.

play_again

The redo command and the r key: the turn "take_back" took back, played again. Returns 1, or 0 when there is nothing to play again.

swap

$terminal->swap($game);

Puts another game in place of this one, and forgets the last move and the plies the picker reprints.

notify

$terminal->notify($line);

A line under the picker's list while the keys are read one at a time, and printed otherwise.

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.