NAME
Game::Dominoes::Terminal - the interactive game, and the only module here that does I/O
VERSION
Version 0.04
SYNOPSIS
use Game::Dominoes;
use Game::Dominoes::Bot;
use Game::Dominoes::Terminal;
Game::Dominoes::Terminal->new(
game => Game::Dominoes->new(seed => $bytes, players => 4),
bots => { 2 => $bot_a, 3 => $bot_b, 4 => $bot_c },
)->start;
DESCRIPTION
Everything in this distribution that reads a handle or writes to one is here. Game::Dominoes and the modules under it do no input and no output at all, and nothing in the engine loads this module: the dominoes script does.
The handles are properties. in and out default to STDIN and STDOUT, and a test hands it two in-memory filehandles and plays a whole game in process, which is the reason for the split. start returns the Game::Dominoes::Result and never calls exit, so the script owns the exit status.
Game::Cribbage is a thousand lines of escape codes in its top level namespace module, which is why only Game::Cribbage::Board was ever reusable by anything. The top level module here is the engine.
The table
┌───┬───┐┌───┬───┐
│● ●│ ││ │● ●│
│ ● │ ● ││ ● │ │
│● ●│ ││ │● ●│
└───┴───┘└───┴───┘
│
┌───┐
│● ●│
┌───┬───┐│ ● │┌───┬───┐
│● │● ●││● ●││● ●│● │
│ ● │ ● │├───┤│ ● │ │
│ ●│● ●││● ●││● ●│ ●│
└───┴───┘│ ● │└───┴───┘
│● ●│
└───┘
Tiles are drawn: nine columns by five rows lying along their run, five by nine standing across it, with the pips where a domino has them. A double is laid crosswise, so it stands where the tiles either side of it lie, and that is the whole shape of All Fives on the screen rather than in the rules. Turning a domino turns its pips with it, so a tile standing across its run has the two-spot and the three-spot on the other diagonal.
The arms hang off the spinner: each starts at the spinner's own column with a stroke joining it to the tile it grew out of. They are drawn as runs across the screen rather than as columns down it, because a tile standing on end is nine rows tall and two of them would be a screenful.
A run that does not fit continues on the rows below it. "width" says how much it may use, and "compact" goes back to [6|4] on one line for a small window. "ascii" draws the same tiles in +-| and *.
The hotseat problem, which checkers did not have
Checkers is perfect information, so its hotseat mode just redraws the board. Every hand here is a secret, and a hotseat game on one screen would show seat 2 what seat 1 was just looking at. That is not cosmetic: it makes the mode useless for playing, and useless for testing too, because a bug in view is invisible when everything is on screen anyway.
So the mode is built around a hand-over. Between seats the terminal clears, says whose turn it is and nothing else, and waits for a keypress.
And the table is drawn from a view, never from the game object. If this reached into $game->hand(2) to draw, the clear-screen would be the only thing protecting a hand and a scrollback buffer would defeat it. Rendering from the view means the secret was never printed. It also makes this a second test of view, and a good one, because a leak shows up as something visible on a screen rather than as a key in a hashref nobody inspected.
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 table and the up and down keys walk them. Return plays the one under the cursor, a number jumps to it, v shows the table as it stands, ? prints the commands, t goes back to typing, u and r take back and play again, and q stops. Off a terminal, or without Term::ReadKey, the moves are typed out as before, and --nopick 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 Game::Checkers::Terminal, deliberately: this author's terminals reading the same keyboard should not disagree about what Home is. Left and right, tab and shift-tab walk the list as up and down do, home and end go to its ends, and the frame is drawn over the last one rather than after a clear.
The table above the list is the one the move would make, with the count it would leave and what it would score, rather than the live table with the candidate's path drawn over it. Game::Oware::Terminal shipped the second form first, copied from the Checkers picker, and on a board of counts it contradicted itself: a house under the cursor was marked as emptied beside the number it held before the move. The question here is where a tile goes and what the count becomes, so a preview that is not the result answers neither half.
A dominoes move needs no building up, which is why there is no step at a time here. "candidates" in Game::Dominoes::Rules returns one entry per tile and arm already, so the list is flat: Game::Backgammon::Terminal has to offer the next move of each matching turn because its turns are combinations, and this one does not.
Undo and redo
u takes back your last move and every reply the bots made to it, up to three at four seats, and r plays them again, until another move is made; typed, they are the words undo and redo. A lone u or r typed at the prompt is neither: the arms are only letters after a tile. Between people at one keyboard each move is one step. With nothing to take back or play again, a line says so and nothing changes. With nobody at the keys nothing is kept.
Nothing before a tile you drew from the boneyard can be taken back. Going back past it would show which tile the boneyard gives next, so a draw that reaches your hand forgets every step before it, and a note says so the first time. A bot's draw shows you nothing and forgets nothing.
Nothing before a new hand can be taken back either. The end of a hand scores the pips left in the other hands, and the new deal shows you your next tiles, so going back into the hand before would replay it knowing both. A note says so the first time.
The table's marks for the last round are those of the game taken back to, or played again.
The option list is windowed at eight, which is a measured number
Over 6750 turns of four-seat bot games the list held at most fourteen entries, and 99.5% of turns offered eight or fewer: 29% held one, 29% two, 18% three. So eight is the window and anything past it is a count of what is above and below rather than a scrollbar.
What landed while you were away
At four seats three tiles appear between your turns, and before this the only record of them was three lines of narration that had already scrolled, so the table simply grew by three tiles nobody could point at.
Every play is remembered whether or not it was printed, and the tiles of the last full round are marked on the table. The picker also reprints that round's narration in its own header, because a picker that takes the whole screen destroys the lines the player is answering. That was a real bug in the Oware picker before it was one here.
A mark is a frame, not a colour, and there are three of them
A settled tile is drawn in light box drawing, a tile played since you last looked in double, and the candidate under the cursor in heavy. In --ascii that is +---+, #===# and +===+.
Three sets and not two. The candidate and the just-played tiles are on screen together, so at four seats a single marked style would give four tiles that all look equally special and no way to tell which one you were about to play. And they are frames rather than colours because the whole point of marking a tile is that it is the one thing on the table the player has not read yet, so it has to survive --nocolour, NO_COLOR and a redirected handle. Every glyph is one column wide, so a marked tile fills exactly the cell an unmarked one does and nothing in the wrapping shifts.
--compact has no frame to thicken, so there the mark is the brackets: {6|4} rather than [6|4]. Still a character and still not a colour.
The picker is the second place a hand can leak
The hand-over and rendering from the view are what protect a hotseat game, and a full-screen picker draws far more of the screen far more often than the typed prompt did. It also draws a position no game has been in.
So "preview" clones the layout out of the seat's own view and carries nothing that view did not already carry. Build it from the game object instead and the preview becomes the hole in the invariant. t/22-pick.t greps a whole picked hotseat session for every tile in another seat's hand, the way t/20 does for the typed one.
Why this phase is not last
A hotseat game at a prompt is the only cheap way to play three and four seat dominoes before a website can host it. Rules bugs found here are found before they are tangled up with a database and a migration.
PROPERTIES
game
$term->game;
The Game::Dominoes being played.
bots
$term->bots; # { 2 => $bot }
Which seats a bot plays, keyed by seat. Any seat not named is played by a person. An empty hashref is a full hotseat game; every seat named is a demonstration nobody has to sit through.
in, out
$term->in;
$term->out;
The handles, defaulting to STDIN and STDOUT. Hand it two in-memory handles and a whole game runs in process.
colour
$term->colour;
Whether to use colour. On only when out is a terminal and NO_COLOR is unset or empty, unless it is set explicitly.
ascii
$term->ascii;
Draw without anything but plain ASCII.
compact
$term->compact;
Tiles as [6|4] on one line instead of drawn, for a window too small for the drawn table.
width
$term->width;
What the drawn table may use before a run wraps onto the rows below it. Left at 0, that is the width of the terminal when it can be measured, COLUMNS from the environment when it cannot, or 80.
handover
$term->handover;
Whether to stop and clear between seats. On by default whenever more than one person is playing.
quit
$term->quit;
Set when the player asked to stop.
recent
$term->recent; # [ { seat => 2, play => $play }, ... ]
The plays of the last full round, oldest first, recorded whether or not they were printed. One round and not one round less your own turn, so your own last tile is marked too: at two seats that is your play and the reply to it, which is the pair you are reasoning about.
keysource
Game::Dominoes::Terminal->new(keysource => sub { shift @character });
A coderef given to new, called for each character and returning it or undef, used in place of Term::ReadKey. 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 described in POD.
pending
An array reference given to new, of characters to be read before any others.
keyboard
$term->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.
screen
$term->screen;
The Game::Terminal::Kit::Screen everything is printed through, built from "out", "colour" and "ascii" unless one is given.
log
$term->log;
The Game::Terminal::Kit::Log holding what the picker reprints: the narration of the round just played, a line a play, and whatever a key had to say for one frame, such as the commands after ?.
history
$term->history;
The Game::Terminal::Kit::History the turns are kept in, each as the game's full text with its seed, which is never printed: the history command prints the moves alone. A turn is kept each time the move passes to a person; a draw from the boneyard into a person's hand, or a new hand, forgets every step before it. "start" begins it again.
FUNCTIONS
options
my $fields = Game::Dominoes::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.
players-
A whole number from 2 to 4: seats at the table.
seat-
A whole number from 1 to 4: which seat you take.
level-
A whole number from 1 to 5: the bots' level.
variant-
One of
all_fives,draw,block: the rules. target-
A whole number from 50 to 500: the score that wins.
hotseat-
A switch: every seat is a person.
bot-vs-bot-
A switch: nobody is a person.
seed-
Text: reproduce a game exactly.
run
exit Game::Dominoes::Terminal->run(@ARGV);
The whole command line program as a class method: parses the options bin/dominoes documents, deals the game, seats the bots, plays, and returns the exit status rather than calling exit: 0 if your seat won or nobody was playing a seat, 1 if you lost or stopped early, 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 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 under NO_COLOR as well as off a terminal.
start
my $result = $term->start;
Plays the game and returns the Game::Dominoes::Result, or undef if it was abandoned. Never calls exit.
show
$term->show($view);
Draws one screen from a view: the scores, the tile counts, the table, the open ends with what would score, and the seat's own tiles.
table
$term->table($view);
$term->table($view, { $tile->id => 'preview' });
The table as a list of lines, drawn from a view. The main line runs across, a double stands across the run it is in, and the spinner's two arms hang above and below it from its own column.
The second argument marks tiles by id: new and recent for the round just played, preview for a candidate. Left out, "table_marks" supplies the round.
table_marks
What "recent" says to mark, as a hashref of tile id to mark name. The newest is new and the rest are recent; the two share a frame and differ only in colour, because which is the very latest is a nicety rather than something that has to be readable in black and white.
recent_lines
The round just played, a line a seat, in the past tense, as "log" holds it. The picker prints these in its header: it takes the whole screen, so the narration printed when those plays happened is already gone.
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".
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
$term->raw;
Whether the terminal is being read a key at a time. "enter_raw" makes it so and "leave_raw" ends it.
enter_raw
Starts reading a key at a time and returns the object, or undef if it cannot.
An interrupt stays an interrupt rather than becoming a key this module has to know about.
leave_raw
Puts it back. "run" does this on every way out, a signal included, because a program that dies reading a key at a time leaves the shell it came from with no echo.
read_key
One keystroke, as a name. A character comes back as itself; an escape sequence as up, down, home and so on, or unknown for one that has no name; a control character as enter, tab, backspace, interrupt or eof; the escape key alone as escape. So a name is always longer than one character and a key never collides with one. An escape followed by a key that does not continue a sequence leaves that key to be read next.
person_turn
$term->person_turn;
What happens when the move passes to a person: the game is kept as a step, and if a person drew from the boneyard or a hand was dealt since the last one, every step before it is forgotten.
take_back
$term->take_back;
Undo: the game as it was at your last turn, before the replies to it. True if it went back, false with a note when there was nothing to take back.
play_again
$term->play_again;
Redo: what "take_back" took back, played again. True if it went forward, false with a note when there was nothing.
swap
$term->swap($game);
Puts another game in play, and the last round's plays from it in "recent".
picker
my $picker = $term->picker($moves);
my $picker = $term->picker($moves, $at);
A Game::Terminal::Kit::Picker over a list of legal moves, in the order they are listed, with the cursor on the first or on the index given. Its values are the moves themselves, and v, ?, t, :, u and r come back from it as the keys live, help, type, command, undo and redo.
pick
$term->pick($view);
Draws the table, the hand, the option list and the cursor, and reads keys until something is played or the player stops. Returns true to keep going and false to stop, the same contract as "command", and hands over to the typed prompt if the keys turn out not to be available after all.
preview
my ($peek, $play) = $term->preview($view, $move);
What a move would do: a view of the table it would make, and the Game::Dominoes::Play it would be. The layout is cloned, so the real one is untouched and nothing is played.
The returned view is built from the one passed in and carries nothing that view did not already carry, which is what keeps a hand secret through the picker as well as through "show".
choice_lines
The option list, one line a move, with the one under the cursor marked and a count of what is above and below when there are more than eight.
legend
The keys, as lines to print under the list.
remember
$term->remember($seat, $play);
Records a play for "recent", keeping one round.
hand_lines
$term->hand_lines($view);
The seat's own tiles, drawn, with what to type under each one so that nobody has to count pips to name a tile.
command
$term->command($line, $view);
Applies one line of input. Returns true to keep going, false to stop. Undefined input is end of file and stops cleanly.
help
$term->help;
The command list, as lines.
SEE ALSO
Game::Dominoes, the engine; Game::Dominoes::Bot, the opponent.
AUTHOR
LNATION <email@lnation.org>
BUGS
Please report any bugs or feature requests to bug-game-dominoes at rt.cpan.org, or through the web interface at https://rt.cpan.org/NoAuth/ReportBug.html?Queue=Game-Dominoes.
SUPPORT
You can find documentation for this module with the perldoc command.
perldoc Game::Dominoes::Terminal
ACKNOWLEDGEMENTS
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 (GPL Compatible)