NAME
Game::Reversi::Terminal - a playable game on two filehandles
VERSION
Version 0.04
SYNOPSIS
my $terminal = Game::Reversi::Terminal->new(
level => 3, variant => 'historic', colour => 'b');
my $result = $terminal->start; # returns, never exits
# and in a test, with no pipe and no subprocess:
my $terminal = Game::Reversi::Terminal->new(in => $in, out => $out);
DESCRIPTION
A person against Game::Reversi::Bot, on a board drawn in text.
The distribution ships something playable because a rules engine nobody can play is a rules engine nobody has checked. This one found two faults during phase 06 that the suite had not.
It is separate from the engine on purpose
Game::Cribbage put about a thousand lines of escape codes in the top level namespace module, which is why only its ::Board ever turned out to be reusable. The top level module here is the engine, and this is a consumer of it exactly as a web adapter would be. The suite asserts that the engine can be loaded without pulling 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/reversi, which is the only part that knows it is a program.
Taking a turn back
u takes your last turn back, with every reply the bot made to it, and r plays them again; typed, the same are undo and redo. A move made after u forgets what r would have played. With nothing to take back or play again a note says so and the board stays as it is. The first turn of a game, or of a game given to the terminal, has nothing before it, even when the bot opened. The line under the board names u undo and r redo while each has something to do.
The board
a b c d e f g h
┌───┬───┬───┬───┬───┬───┬───┬───┐
8 │ │ │ │ │ │ │ │ │ 8
├───┼───┼───┼───┼───┼───┼───┼───┤
...
5 │ │ │ │ ● │ ○ │ * │ │ │ 5
├───┼───┼───┼───┼───┼───┼───┼───┤
4 │ │ │ * │ ○ │ ● │ │ │ │ 4
...
└───┴───┴───┴───┴───┴───┴───┴───┘
a b c d e f g h
A ruled grid with the discs sitting in it, because a reversi board is a grid and eight rows of dots are a list. ascii draws the same board as X and O on +-|, for a terminal that cannot manage the rest.
Two glyphs, not one glyph in two colours
Two discs told apart only by an escape code are indistinguishable in half the terminals in the world and unreadable to a good fraction of the people using the other half. A filled disc and a hollow one are as far apart on a screen as they are on a table, and they are still that far apart with no colour at all. So the glyphs carry all of the information and colour carries none: strip the escapes and the board reads exactly the same.
Colour is off unless the output is a terminal, and off regardless if NO_COLOR is set to anything non-empty. An explicit ansi option overrides the terminal check but not NO_COLOR, because somebody who set that meant it.
Every option is validated in the constructor, including the level
This distribution is the cautionary tale four of its siblings' POD cite: it validated its variant three calls deeper than the constructor and shipped a --variant draughts that died with a raw Perl message and a useless exit status.
That was fixed for the variant and left for the level, which lived in Game::Reversi::Bot and so was not checked until start built one, outside bin/reversi's eval. --level 9 died with a file and a line number at exit 25, one option along from the bug this dist is quoted for. Both are checked in BUILD now, and t/20-terminal.t asserts the level refusal carries no file and no line.
What the last move did, in characters
Every cell is three characters wide, which is a glyph and a character either side of it to spend. They are spent rather than left as padding: the square the last move was placed on is bracketed, [●], and the discs it turned are drawn ringed, ◉ and ◎, against ● and ○ for discs that were already there.
That is not decoration in this game. The flipped discs are the move. The narration said "turning 3 discs" and the board that followed said neither which disc was new nor which three had changed hands, so the one thing a Reversi player needs to see was the one thing not drawn.
Both marks are characters, so they survive --ascii, NO_COLOR and a redirected handle. Colour is laid over them as a second telling, never as the only one.
You point at the move you mean
On a terminal with Term::ReadKey installed the arrow keys walk a cursor over the board, return plays the square it is on, t goes back to typing, ? lists the keys and q gives up. Without Term::ReadKey, off a terminal, or under --nopick, the squares are typed as before.
The cursor walks the starred squares and nothing else, in reading order. That is the difference from Game::Go::Terminal, where every point is a candidate and the cursor is free: measured over 2400 turns, Reversi offers between one and fifteen legal moves and eight or fewer on 71% of turns. They are already marked on the board, so a cursor crawling the other fifty squares would spend most of its time on squares that refuse.
What the square under the cursor says
One line, from the engine rather than from arithmetic here:
d3: turns 3 discs, and you would be 5 to 2
The square is played on a clone and the clone is asked, so nothing moves and the numbers are the ones the game will really produce. A clone is 0.003 ms, so the line is recomputed on every keystroke without anybody noticing, and t/20-terminal.t checks both halves against a second game that plays the move for real.
METHODS
new
in, out, level, variant, colour, seed, quiet, ansi, ascii.
ansi
Whether colour will be used.
in, out, game
The handles, and the game being played: given to new or set before start it is the one played on, otherwise start builds one. A turn taken back replaces it with a new game. All three are read and write: a test hands this a tied handle and reads the game back out of it.
The ansi option is held privately, because "ansi" is a question about the handle and the environment as well as the option, and there should be one public answer to it rather than two.
level, variant, colour, seed, quiet, ascii
The options, as properties. colour is the side the person is playing.
glyph
The disc a colour is drawn as, which ascii decides.
last_move, remember, since
The move just played, the setter for it, and that move as the last and flips pair "board_text" marks from.
interactive, clear
Whether out is a terminal, and a clear of the window and of what has scrolled off it, which does nothing off a terminal.
history
The Game::Terminal::Kit::History the turns are taken back through, which keeps each of the person's turns as the game's to_text.
keyboard
The Game::Terminal::Kit::Keys the keys and the typed lines are read through, built from in, keysource and pending.
screen
The Game::Terminal::Kit::Screen everything is printed through, built from out, ascii and "ansi".
picking
Whether a square is pointed at rather than typed. Settable, because a failed enter_raw and the t key turn it off for the rest of the game. Unset, it follows "keys_available".
keys_available, enter_raw, leave_raw, raw
Whether there is anything to read keys with, a key at a time on and off, and whether it is on. An interrupt stays an interrupt while it is on. "run" turns it off on every way out, a signal included, because a program that dies with it on leaves the shell with no echo.
keysource, pending
A coderef returning ONE character in place of Term::ReadKey, and characters read and given back. keysource is what lets t/20 drive the key loop with no terminal and no Term::ReadKey installed; one character a call, because a source handing back "\e[C" whole never becomes a right arrow.
read_key
One keystroke, as "read_key" in Game::Terminal::Kit::Keys names it: the character itself, or a name such as up, enter or escape. A name is always longer than one character so it never collides with one.
pick_square, pick_help
The cursor loop and its key list. pick_square returns true to give up and false when a turn was taken, the same contract as the typed turn, and swap when u or r replaced the game. The cursor starts on the legal square nearest the last move and goes round at the ends: right, down and tab go on a square, left, up and shift-tab go back one, home and end go to the first and the last. On a terminal each frame is drawn over the one before.
square_line
What playing a square would do, as the one line printed under the board. Played on a clone; the game is not touched.
board_text
The board as lines of text: a ruled grid, files across the top and the bottom, ranks down both sides, and the legal moves for a colour marked with *. Marking them is not decoration: working out which squares outflank something is the engine's job, and a person doing it by hand every turn will get it wrong.
start
Plays the game given to the terminal, or a new one when none was, and returns its Game::Reversi::Result.
options
my $fields = Game::Reversi::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: how hard the opponent is.
variant-
One of
historic,othello: place the first four discs yourselves, or find them down. colour-
One of
b,w: b plays dark and moves first, w plays light. seed-
Text: makes a game reproducible.
run
exit Game::Reversi::Terminal->run(@ARGV);
The whole command line program as a class method: parses the options bin/reversi documents, builds the terminal, plays, and returns the exit status rather than calling exit: 0 if you won or tied, 1 if you lost, 2 for a bad option, a leftover word or a value the terminal refuses, with the reason and the usage on STDERR. --help prints the usage on STDOUT and returns 0. Signal handlers are local to the call and the terminal is left in cooked mode 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 when NO_COLOR is set, as it is off a terminal.
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.