NAME
Game::Gin::Terminal - gin rummy at a prompt
VERSION
Version 0.02
SYNOPSIS
my $ui = Game::Gin::Terminal->new(mode => 'bot', level => 2, seat => 'p1');
$ui->play;
# or with no terminal at all, which is how it is tested
open my $in, '<', \$script;
open my $out, '>', \my $shown;
Game::Gin::Terminal->new(in => $in, out => $out, mode => 'watch')->play(seed => $seed);
DESCRIPTION
Every read and every print in this distribution is in this class. Nothing under Game::Gin:: touches a handle, which is what lets the engine be loaded by a web application, and that is only true because there is exactly one place where it stops being true.
in and out are attributes
So a whole game can be played with no terminal, which is what t/14-terminal.t does. An interface that can only be driven by a human is one that silently rots.
The cards
stock 24 upcard 7♥
┌─────────┐ ┌─────────┐
│░░░░░░░░░│ │7 │
│░░░░░░░░░│ │♥ │
...
┌───┌───┌───┌───┌───┌───┌───┌───┌───┌─────────┐
│7 │7 │7 │3 │4 │5 │A │K │2 │10 │
│♠ │♦ │♣ │♠ │♠ │♠ │♠ │♥ │♦ │♣ │
...
└───set────┘└───run────┘└──────loose 23───────┘
Eleven columns by nine rows, the rank and the suit in opposite corners and a pip in the middle, which is the same card Game::Cribbage deals.
The corner is what makes a hand fannable. Ten cards drawn in full are a hundred and ten columns; ten cards fanned are forty-seven, because a covered card only has to show the corner you would read it by, which is also how a hand is held. Under the fan a bracket marks each meld and says whether it is a set or a run, and what the rest of the hand is costing: grouping a hand by eye is the one chore a screen can take away.
The stock is drawn face down and the upcard face up, because choosing between those two piles is the first half of every turn. A pile with nothing in it is an empty space rather than a gap, so the table does not appear to end there.
"ascii" draws the suits as S H D C in a +-| frame for a terminal without the box characters, and colour is on when out is a terminal and NO_COLOR is unset.
You point at the card you mean
On a terminal with Term::ReadKey installed, the left and right keys walk your own hand and the card under the cursor is the one you would throw. Return discards it, k discards and knocks where that is allowed, v shows the hand remelded without it, g declares big gin, ? lists the keys and q stops. The draw is the same shape one step earlier: u takes the upcard, d draws, n passes it up on the first turn. Off a terminal, or without Term::ReadKey, every move is typed as before, and --nopick asks for that.
Same key tables as Game::Checkers::Terminal, Game::Oware::Terminal and Game::Dominoes::Terminal, on purpose: four of this author's terminals reading the same keyboard should not disagree about what Home is.
The cursor is on the hand, not on a list
The other three terminals here list the legal moves under the board. That is wrong for gin, and measurably so: over 4794 discard turns of level 2 bot games the move list held ten or eleven entries 84% of the time and up to twenty-two, because every card is a discard and 17% of turns offer the same card again with a knock on it. Every single turn offers more than eight, so the eight row window that suits draughts and dominoes never applies, and a twenty-two row list of cards you are already looking at is a worse picture than the hand itself.
So the cursor goes on the fan. That also collapses the knock: the cursor is on a card, and that card either knocks or does not, which is one more key rather than a doubled list.
And it walks the order the fan draws, which is not the order the hand is held in. The fan is grouped into melds, so a cursor stepping through $hand->cards moves to a card somewhere else on the screen: right did not mean right. "choice_cards" and "hand_lines" are both built from "hand_groups" for that reason. One row of cards may only have one order.
The cursor starts on the first loose card, because that is the one you almost always mean, and left or down moves it left while right or up moves it right. A card the fan draws that cannot be thrown is skipped rather than landed on: the card just taken from the pile is the only one, and the rules forbid throwing it straight back.
What the frame tells you before you throw
Under the fan, one line says what the throw would leave: throw 7♥ and your count is 14, and , which lets you knock when it does. That is the whole gin decision stated before it is made, and it is the only thing on the screen the player cannot work out by looking.
It is cheap, which is why it can be on every keystroke: best over ten cards runs in microseconds, so remelding all eleven candidates costs nothing measurable. v shows the remelded hand itself for when the regrouping matters rather than only the count.
Three frames, because there are three things to mark
A settled card is drawn in light box drawing, a card that is new to you in double, and the card under the cursor in heavy. In "ascii" that is +---, #=== and ===>.
Three and not two. The card you just drew and the card you are about to throw are on screen together, so one marked style makes them look alike: the first version of this did exactly that and the two were indistinguishable. And they are frames rather than colours because the point of marking a card is that it is the thing you have not read yet, so it has to survive --nocolour, NO_COLOR and a redirected handle.
The card you drew, and the card they threw
A draw event carries no card, and it must not: everybody sees a card leave the stock, but which card is yours alone, so an event naming it would leak through any spectator view. The terminal therefore learns what you drew by diffing your hand across the move, which it may do because it already holds that hand to draw it.
Two things get marked, and both answer "what is new since I last looked": the card you drew or took, in your hand, and the upcard when your opponent put it there rather than drawing. Before this the terminal said p1 draws and redrew eleven cards, and you found the new one by eye.
The hotseat problem
--mode hotseat is two people at one keyboard, and every hand here is a secret, so one screen carrying both hands makes the mode useless for its own purpose. This distribution shipped exactly that: seat 2's hand was drawn directly under seat 1's with nothing between them.
So the mode is built around a hand-over. Between seats the screen clears, including the scrollback, and nothing is printed but whose turn it is and a wait for a keypress. Off a terminal it is thirty blank lines instead, which is honest rather than secure, and a hotseat game piped to a file was never private anyway.
The test for this cannot be a string grep. A fan draws the ranks on one row and the suits on the row beneath, so 10H is never contiguous on screen and a grep for it finds nothing however badly the hand leaks. t/15-pick.t rebuilds the cards column by column instead, and asserts that no single screen carries a card from both hands, which is a shape that still fails when the hand-over is removed rather than passing because the loop ran zero times.
METHODS
play
$ui->play(seed => $bytes, dealer => 'p1', limit => 20_000);
Plays a match to the end and returns the Game::Gin. With no seed it makes one. Stops if input runs out rather than looping.
render
$ui->render($seat);
The lines shown to a seat, as an arrayref: the deal and the score, the stock and the upcard drawn as cards, the hand fanned with a bracket under each meld, and whether it may knock.
show
render, printed.
table_lines
$ui->table_lines($deal, $seat);
The stock and the upcard, drawn side by side under their labels.
hand_lines
$ui->hand_lines($melding);
A hand from "best" in Game::Gin::Deadwood, fanned, with the brackets under it.
card_art
$ui->card_art($id);
One card as nine rows of eleven columns.
card_back, card_space
A card face down, and the outline of where a card would be.
fan
$ui->fan([ @ids ]);
Cards overlapped so that every one shows its corner and the last shows all of itself.
fan_width
How wide a fan of that many cards is, which is what the brackets are drawn from.
pretty
$ui->pretty($id); # K♥
A card's name for reading. What is typed is still KH.
card_named
$ui->card_named('K♥'); # 26
The card somebody typed, or undef. KH is the engine's spelling, and the two that a drawn card invites are taken as well: the pip instead of the letter, and 10 instead of T.
paint
Wraps text in a colour, or returns it untouched when colour is off.
human_move
Asks the seat on turn for a move and returns it, or undef when input runs out. A discard is named as a card; a knock is the same with ! after it.
bot_move
What Game::Gin::Bot would play for a seat.
is_human
Whether a seat is played from the keyboard, which depends on mode.
announce
One line for an outcome from the engine.
announce_result
The final score.
ask, say_to
The two places this distribution reads and writes.
game, in, out, mode, level, seat, ascii, colour
mode is bot (the default), hotseat or watch. seat is the seat a person plays when the mode is bot. ascii draws the cards without box characters or pips, and colour defaults to on for a terminal with NO_COLOR unset.
quit
Set when the player asked to stop, by q at the keys or quit at a prompt. "play" checks it, which is what the typed game had no way of doing: before this, quit at the discard prompt was simply not a card and asked again.
interactive
Whether out is something a person is looking at. An explicit interactive option beats the tty check. It gates the screen clearing and the shape of the hand-over.
picking
Whether a move is pointed at rather than typed. Settable, because falling back turns it off for the rest of the game. 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.
keysource
$ui->keysource(sub { shift @character });
A coderef taking a wait flag and returning one character, used in place of Term::ReadKey. That is how t/15-pick.t drives the key loop with no terminal and no Term::ReadKey installed. One character: a source handing back "\e[C" whole never becomes a right arrow.
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.
raw
Whether the terminal is in cbreak. Set by "enter_raw", cleared by "leave_raw".
enter_raw, leave_raw
cbreak on and off, cbreak rather than raw so an interrupt stays an interrupt. bin/gin calls leave_raw 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", else Term::ReadKey. With a false argument it does not block.
read_key
One keystroke as a name: a character comes back as itself, an escape sequence as up, left, home and so on, a control character as enter, tab, interrupt or eof. So a name is always longer than one character and never collides with one.
read_sequence
The tail of an escape sequence, once the escape has been read. An opener that turns out not to belong to one goes back on "pending".
clear
Clears the screen and the scrollback, on a terminal only.
handover
Clears, says whose turn it is, and waits. Only in hotseat, and it reads a key or a line to match whichever way the game is being played.
marks
What is new to the seat about to move, as a hashref of card id to drew, took or cursor.
remember
$ui->remember($seat, \%held_before, \@events);
Works out what to mark from the hand a seat held before its move and the events that came back. The drawn card comes from the diff because the draw event does not carry one, and must not.
preview
my $melding = $ui->preview($seat, $card);
What the hand melds to without that card. Nothing is played and the hand is not touched.
knock_line, without_line
The line saying how far off a knock a melding is, and the line saying what throwing the card under the cursor would leave.
hand_groups
The hand split into its melds and its loose cards, each group labelled and flagged loose. "hand_lines" draws from this and "choice_cards" walks it, which is what keeps the cursor and the fan in one order.
choice_cards
The cards a seat may throw, in the order the fan draws them, one entry each, with a knock flag where the same card is also offered as a knock. The engine lists those as two moves; the cursor wants one card.
first_loose
Where the cursor starts: the first card outside a meld, or the first card there is.
pick_move, pick_draw, pick_discard
The key loops. pick_move enters cbreak and sends the turn to whichever of the other two the phase calls for, falling back to the typed prompt for good if the keys turn out not to be available. Each returns a move for "apply" in Game::Gin::Deal, or undef to stop.
show_choice
Draws one frame of the discard picker: the table, the hand with the cursor on it, what the throw would leave, and the keys.
legend, help_lines, typed_help
The keys as a line, the keys in full, and the same for the typed prompt.
SEE ALSO
Game::Gin, and bin/gin.
AUTHOR
LNATION, <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under the Artistic License 2.0.