NAME

Game::Dominoes::Terminal - the interactive game, and the only module here that does I/O

VERSION

Version 0.02

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 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 same key tables as Game::Checkers::Terminal and Game::Oware::Terminal, deliberately: three of this author's terminals reading the same keyboard should not disagree about what Home is.

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.

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 clearing the screen on every keystroke 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, 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. COLUMNS from the environment, 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.

raw

Whether the terminal is in cbreak mode. Set by "enter_raw", cleared by "leave_raw".

keysource

$term->keysource(sub { shift @character });

A coderef taking a wait flag and returning one character, 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

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.

FUNCTIONS

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. The picker prints these in its header: it clears the screen on every keystroke, 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.

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/dominoes 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" holds, 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 as up, down, home and so on; 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 goes back on "pending" rather than being lost, because the escape key and the first byte of an arrow key are the same byte.

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)