Skip to content
wannacode

Drawing in the console

In short: Drawing in the console happens in a buffer of cells (a character and a color) that is filled with text, frames and bars and shown at once by a terminal: plain, with ANSI colors, or with only the cells that changed.

Problem

The inventory ended up under the map

The first version of every console screen prints as the game goes: the map, then the life bar, then the messages. The player presses i, and the inventory should appear over the map, like a window over a window:

foreach (string row in MAP) Console.WriteLine(row);
Console.WriteLine("L 24/30 M 5/12");
// The player presses i: the inventory should be over the map.
Console.WriteLine("+- Inventory --+");
Console.WriteLine("| Potion x2 |");
Console.WriteLine("| Axe |");
Console.WriteLine("+--------------+");
Output
##############################
#......#..........~~~........#
#......+..........~~~...>....#
#......#..........~~~........#
####.#####...................#
#............................#
##############################
L 24/30 M 5/12
+- Inventory --+
| Potion x2 |
| Axe |
+--------------+

It came out underneath. The rows of the map are already in the terminal, and printing only moves forward. That is the first problem. There are three more:

  • The frame tears. The combat code prints "The wolf bites you" the moment it happens, and that can be in the middle of the map.
  • The colors are in the logic. The combat code knows which code turns the text red:
// In the combat code:
if (plain) Console.WriteLine("The wolf bites you: -3");
else Console.WriteLine("\u001b[31mThe wolf bites you: -3\u001b[0m");
  • Two outputs, double the code. The game's check wants plain text so it can compare it line by line, and the player wants colors. With a check like this in every line that prints, the two outputs drift apart sooner or later.

All four have one root: the game decides what to show and how to show it in the same place, at the same moment.

The idea

A buffer of cells

We split the two. The game draws into a buffer: a table with one cell for every place on the screen. A cell holds a character and a color. Drawing only writes into the table, and nothing reaches the screen. When the frame is ready, the whole buffer is shown at once.

This way the drawing order decides what is on top: the inventory, drawn after the map, simply overwrites its cells. And since nothing is shown before the frame is ready, nothing tears either.

The buffer is two two-dimensional arrays, one for the characters and one for the colors. The first index is the row (y), the second is the column (x), because rows are printed one after another:

class CellBuffer
{
public readonly int Width;
public readonly int Height;
public readonly char[][] Chars;
public readonly string[][] Colors;
public CellBuffer(int width, int height)
{
Width = width;
Height = height;
Chars = new char[height][];
Colors = new string[height][];
for (int y = 0; y < height; y++)
{
Chars[y] = new char[width];
Colors[y] = new string[width];
Array.Fill(Chars[y], ' ');
Array.Fill(Colors[y], "default");
}
}
public void Put(int x, int y, char ch, string color)
{
if (x < 0 || y < 0 || x >= Width || y >= Height) return;
Chars[y][x] = ch;
Colors[y][x] = color;
}
public void Text(int x, int y, string text, string color)
{
for (int i = 0; i < text.Length; i++) Put(x + i, y, text[i], color);
}
public string RowText(int y) => new string(Chars[y]).TrimEnd(' ');
}
  • Put writes one cell. If the place is outside the buffer, it does nothing.
  • Text writes a string character by character, each with Put.
  • RowText returns one row as a string, without the spaces at the end: they are invisible and only make the output longer.

A color is just a name: "red", "gray". What it means is decided by the terminal later.

Why does Put stay quiet instead of stopping with an error? The hero's name is long, a frame sits at the edge, a message is wider than the screen. Clipped text is better than a game that crashes because of a name:

var buffer = new CellBuffer(12, 2);
buffer.Text(0, 0, "L 24/30", "red");
buffer.Text(8, 1, "Warden", "red");
for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));
Output
L 24/30
Ward

The check is x >= width, not x > width. The indexes go from 0 to width - 1, so cell width is already outside the row.

More precisely, if you are curious

A character is a char: one UTF-16 unit. Latin letters, accented letters like é and the characters #, +, | are one char each, so one character of the string is one cell. When this stops being true is in "More precisely" at the bottom.

You could also use an array of "cell" objects with a character and a color. Two separate arrays are simpler to copy and compare, and we will need that at the end of the lesson.

The idea

Frames and bars

On top of Put and Text come the things the game draws all the time: an empty rectangle, a frame with a title and a life bar:

public void FillRect(int x, int y, int width, int height)
{
for (int row = y; row < y + height; row++)
{
for (int col = x; col < x + width; col++) Put(col, row, ' ', "default");
}
}
public void Box(int x, int y, int width, int height, string title)
{
FillRect(x, y, width, height);
for (int col = x; col < x + width; col++)
{
Put(col, y, '-', "gray");
Put(col, y + height - 1, '-', "gray");
}
for (int row = y; row < y + height; row++)
{
Put(x, row, '|', "gray");
Put(x + width - 1, row, '|', "gray");
}
Put(x, y, '+', "gray");
Put(x + width - 1, y, '+', "gray");
Put(x, y + height - 1, '+', "gray");
Put(x + width - 1, y + height - 1, '+', "gray");
if (title.Length > 0) Text(x + 2, y, " " + title + " ", "yellow");
}
public void Bar(int x, int y, int width, int value, int max, string color)
{
int filled = max > 0 ? value * width / max : 0;
if (value > 0 && filled == 0) filled = 1;
if (filled > width) filled = width;
Put(x, y, '[', "gray");
for (int i = 0; i < width; i++)
{
if (i < filled) Put(x + 1 + i, y, '#', color);
else Put(x + 1 + i, y, '-', "gray");
}
Put(x + width + 1, y, ']', "gray");
}

Box first clears its area with FillRect: otherwise the map underneath would show between the letters. Then it draws the edge: - at the top and bottom, | on the sides, + in the corners. The title sits on the top edge, with one space on each side.

Bar works out how many places to fill: value × width / maximum, with integer division. Division rounds down, so the bar is full only at full life: 29 of 30 does not look like 30 of 30. On top of that there is one rule: a living hero never sees an empty bar. At 1 of 30 the division gives 0, and the bar shows one place.

var buffer = new CellBuffer(20, 4);
int[] values = { 30, 15, 1, 0 };
for (int i = 0; i < values.Length; i++)
{
buffer.Bar(0, i, 10, values[i], 30, "red");
buffer.Text(13, i, values[i] + "/30", "default");
}
for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));
Output
[##########] 30/30
[#####-----] 15/30
[#---------] 1/30
[----------] 0/30
More precisely, if you are curious

Why not round to the nearest? Then 29 of 30 gives 10 places (a full bar for a wounded hero), and 1 of 30 gives 0 (an empty bar for a living one). The two extremes are exactly where the player looks at the bar. With integer division and the "at least one place" rule, a full bar means full life and an empty bar means death.

The width in Bar is only the fillable part: the bar takes width + 2 cells because of [ and ].

Try it

Predict the bars

The hero has 29 life of 30, and the servant next to them 1 of 30. What does the program print?

var buffer = new CellBuffer(12, 2);
buffer.Bar(0, 0, 10, 29, 30, "red");
buffer.Bar(0, 1, 10, 1, 30, "red");
for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));
Answer

Try it

The right edge

This is the loop in Box that draws the side edges. The left one is in column x. Which column is the right one in?

for (int row = y; row < y + height; row++)
{
    Put(x, row, '|', "gray");
    Put(, row, '|', "gray");
}

See it

The map, the inventory and the bar

Here is the whole frame from the start: the map, the hero, the wolf, the inventory over the map and the status line at the bottom. The buffer is printed only at the end, row by row:

var buffer = new CellBuffer(30, 9);
for (int y = 0; y < MAP.Length; y++) buffer.Text(0, y, MAP[y], "gray");
buffer.Put(3, 2, '@', "white");
buffer.Put(25, 4, 'w', "red");
buffer.Box(7, 1, 15, 5, "Inventory");
buffer.Text(9, 2, "Potion x2", "red");
buffer.Text(9, 3, "Axe", "default");
buffer.Text(9, 4, "Gold 35", "yellow");
buffer.Text(0, 7, "Iva, witch lv2", "white");
buffer.Text(0, 8, "L", "red");
buffer.Bar(2, 8, 10, 24, 30, "red");
buffer.Text(15, 8, "24/30", "default");
buffer.Text(21, 8, "M", "blue");
buffer.Bar(23, 8, 5, 5, 12, "blue");
for (int y = 0; y < buffer.Height; y++) Console.WriteLine(buffer.RowText(y));
Output
##############################
#......+- Inventory -+.......#
#..@...| Potion x2 |..>....#
#......| Axe |.......#
####.##| Gold 35 |...w...#
#......+-------------+.......#
##############################
Iva, witch lv2
L [########--] 24/30 M [##---]

The inventory is over the map, and the wolf is still visible to the right of it, because the frame covers only its own cells. The lines in the code are not in the order of the rows on the screen: the status line could have been drawn first, and the frame would be the same.

The idea

The terminal decides how

What is left is how the buffer reaches the screen. The answer depends on who is looking: the check wants plain text, the player wants colors, the browser wants HTML. So the buffer does not print itself. A terminal prints, and every terminal has one method: Present(buffer), "show this frame".

interface ITerminal
{
void Present(CellBuffer buffer);
}
class PlainTerminal : ITerminal
{
string lastFrame = "";
public void Present(CellBuffer buffer)
{
var lines = new List<string>();
for (int y = 0; y < buffer.Height; y++) lines.Add(buffer.RowText(y));
while (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1);
string frame = string.Join("\n", lines) + "\n----\n";
if (frame == lastFrame) return;
Write(frame);
lastFrame = frame;
}
}
class AnsiTerminal : ITerminal
{
public void Present(CellBuffer buffer)
{
string frame = ESC + "[2J" + ESC + "[H";
for (int y = 0; y < buffer.Height; y++)
{
string row = buffer.RowText(y);
string color = "default";
for (int x = 0; x < row.Length; x++)
{
if (buffer.Colors[y][x] != color)
{
color = buffer.Colors[y][x];
frame += CODES[color];
}
frame += row[x];
}
frame += ESC + "[0m\n";
}
Write(frame);
}
}

PlainTerminal prints the rows with RowText, drops the empty rows at the end and adds ---- to end the frame. If the frame is the same as the last one, it prints nothing: a command that changes nothing (a wrong command, a look at something already seen) should not fill the output with identical frames.

AnsiTerminal sends codes that the terminal carries out instead of showing them. Each one starts with the character ESC (code 27):

  • ESC[2J clears the screen, ESC[H puts the cursor at the top left;
  • ESC[31m means "red from here on", ESC[90m gray, ESC[33m yellow, ESC[0m "back to the normal color".

A color in the terminal is state: it stays until you change it. So AnsiTerminal writes a code only where a cell's color differs from the one before, and returns to the normal color at the end of every row.

The game holds a ITerminal and calls present without knowing which class is underneath. Which one it is gets decided by one line at the start.

In the lesson's programs, ESC, the codes and the output to the terminal are here:

const string ESC = "\u001b";
static readonly Dictionary<string, string> CODES = new()
{
["default"] = ESC + "[0m", ["red"] = ESC + "[31m", ["yellow"] = ESC + "[33m",
["blue"] = ESC + "[34m", ["gray"] = ESC + "[90m", ["white"] = ESC + "[97m",
};
// To the terminal. ESC is printed as \e so that you can see it in the output.
static void Write(string text) => Console.Write(text.Replace(ESC, "\\e"));

Write prints ESC as the text \e so that you can see it in the output on this page. In a real terminal the character itself is there, and instead of \e[31m you see red.

See it

One buffer, two terminals

var buffer = new CellBuffer(30, 9);
for (int y = 0; y < MAP.Length; y++) buffer.Text(0, y, MAP[y], "gray");
buffer.Put(3, 2, '@', "white");
buffer.Put(25, 4, 'w', "red");
buffer.Box(7, 1, 15, 5, "Inventory");
buffer.Text(9, 2, "Potion x2", "red");
buffer.Text(9, 3, "Axe", "default");
buffer.Text(9, 4, "Gold 35", "yellow");
buffer.Text(0, 7, "Iva, witch lv2", "white");
buffer.Text(0, 8, "L", "red");
buffer.Bar(2, 8, 10, 24, 30, "red");
buffer.Text(15, 8, "24/30", "default");
buffer.Text(21, 8, "M", "blue");
buffer.Bar(23, 8, 5, 5, 12, "blue");
ITerminal plain = new PlainTerminal();
ITerminal ansi = new AnsiTerminal();
plain.Present(buffer);
plain.Present(buffer); // the same frame: not printed again
ansi.Present(buffer);
Step by step, in words
  1. An empty buffer: 30 × 9 = 270 cells, each one a space in the color default. Nothing has been printed.
  2. The map: Text writes each row character by character, and each character is one Put into one cell. 7 rows of 30, all gray.
  3. Put writes one character with a color: the hero @ at (3, 2) in white, the wolf w at (25, 4) in red. A cell keeps both.
  4. Box first clears its 15 × 5 cells (FillRect), then draws the edge and the title. The map under it is overwritten in the buffer, not on the screen: the screen is still empty.
  5. The items go inside the frame: Potion x2, Axe, Gold 35. The drawing order decides what is on top: the inventory comes after the map.
  6. The status line: 24 of 30 in 10 places is 8 #, and 5 of 12 in 5 places is 2. The whole picture is ready, and the terminal has not seen any of it.
  7. PlainTerminal: only the characters, row by row, without the spaces at the end of a row (row y = 7 is 14 characters, not 30), and ---- to end the frame. The colors are lost.
  8. The same buffer again: PlainTerminal compares the frame with the last one it printed and prints nothing.
  9. AnsiTerminal: the same buffer, in color. It writes a color code only where the color changes: 35 codes for 9 rows, not one per cell.
  10. The game drew once. Which terminal shows it is decided by one line at the start, and the drawing code does not know.

The program draws the frame from above and hands it to both terminals. PlainTerminal gets it twice but prints it once:

ITerminal plain = new PlainTerminal();
ITerminal ansi = new AnsiTerminal();
plain.Present(buffer);
plain.Present(buffer); // the same frame: not printed again
ansi.Present(buffer);
Output
##############################
#......+- Inventory -+.......#
#..@...| Potion x2 |..>....#
#......| Axe |.......#
####.##| Gold 35 |...w...#
#......+-------------+.......#
##############################
Iva, witch lv2
L [########--] 24/30 M [##---]
----
\e[2J\e[H\e[90m##############################\e[0m
\e[90m#......+-\e[33m Inventory \e[90m-+.......#\e[0m
\e[90m#..\e[97m@\e[90m...|\e[0m \e[31mPotion x2\e[0m \e[90m|..>....#\e[0m
\e[90m#......|\e[0m Axe \e[90m|.......#\e[0m
\e[90m####.##|\e[0m \e[33mGold 35\e[0m \e[90m|...\e[31mw\e[90m...#\e[0m
\e[90m#......+-------------+.......#\e[0m
\e[90m##############################\e[0m
\e[97mIva, witch lv2\e[0m
\e[31mL\e[0m \e[90m[\e[31m########\e[90m--]\e[0m 24/30 \e[34mM\e[0m \e[90m[\e[34m##\e[90m---]\e[0m

See how rarely a code appears in the output of AnsiTerminal: 35 codes for 270 cells. A row of gray map is a single code, and the \e[0m inside the frame marks where the cleared cells have the normal color.

Try it

Find the bug

A colleague touched AnsiTerminal, and the message under the life bar came out gray when it should be in the normal color. Here is what the program sends (\e is ESC):

var buffer = new CellBuffer(30, 2);
buffer.Text(0, 0, "L", "red");
buffer.Bar(2, 0, 10, 24, 30, "red");
buffer.Text(0, 1, "The wolf bites you.", "default");
new AnsiTerminal().Present(buffer);
Output
\e[2J\e[H\e[31mL\e[0m \e[90m[\e[31m########\e[90m--]
The wolf bites you.

Which line has the bug?

Pick the line with the bug.

The idea

Only what changed

PlainTerminal already skips a frame that is the same as the last one. That saves at the level of a frame: all or nothing. But in a turn-based game most turns change two or three cells: the hero takes a step, and the other 250 stay the same. AnsiTerminal still clears the screen and sends everything again.

A better terminal remembers what it has shown (a second buffer, last) and compares cell by cell. For every cell that differs it moves the cursor with ESC[row;columnH and writes only that cell:

class DiffTerminal : ITerminal
{
CellBuffer? last;
public int Cells;
public void Present(CellBuffer buffer) => Write(Changes(buffer));
public string Changes(CellBuffer buffer)
{
string frame = "";
if (last == null)
{
last = new CellBuffer(buffer.Width, buffer.Height);
frame += ESC + "[2J";
}
Cells = 0;
for (int y = 0; y < buffer.Height; y++)
{
for (int x = 0; x < buffer.Width; x++)
{
char ch = buffer.Chars[y][x];
string color = buffer.Colors[y][x];
if (ch == last.Chars[y][x] && color == last.Colors[y][x]) continue;
frame += ESC + "[" + (y + 1) + ";" + (x + 1) + "H";
frame += CODES[color] + ch;
last.Put(x, y, ch, color);
Cells++;
}
}
if (Cells > 0) frame += CODES["default"];
return frame;
}
}

Two details catch everyone who writes this for the first time. The row and column in ESC[row;columnH start at 1, not 0, and the row comes first. And last is updated right away with the cell that was sent: otherwise the next frame sends the same cells again.

The program draws the world without the inventory. Then the hero takes a step and the wolf bites. Show prints how many cells and how many characters each frame sends, and the short updates in full:

var buffer = new CellBuffer(30, 9);
var screen = new DiffTerminal();
DrawWorld(buffer);
Show(1, screen, buffer);
buffer.Put(3, 2, '.', "gray"); // the hero takes a step to the right
buffer.Put(4, 2, '@', "white");
Show(2, screen, buffer);
buffer.Bar(2, 8, 10, 21, 30, "red"); // the wolf bites: 24 → 21
buffer.Text(15, 8, "21/30", "default");
Show(3, screen, buffer);
Show(4, screen, buffer); // nothing has changed
Output
frame 1: 250 cells, 3173 chars
frame 2: 2 cells, 28 chars
\e[3;4H\e[90m.\e[3;5H\e[97m@\e[0m
frame 3: 2 cells, 29 chars
\e[9;11H\e[90m-\e[9;17H\e[0m1\e[0m
frame 4: 0 cells, 0 chars
  • Frame 1 sends 250 cells and 3173 characters: the screen is empty and everything is new. That is more than the whole frame of AnsiTerminal (418 characters), because every cell comes with its own cursor move and its own color.
  • The step is 2 cells and 28 characters: the old place becomes ., the new one @.
  • The bite changes one # in the bar and one digit: from 24 to 21 only the 4 changes.
  • Frame 4 sends nothing. PlainTerminal skipping an identical frame is the same idea, reduced to "0 cells".

Why is it worth it? When the terminal is across a network (ssh, a server where several people play), every character goes over the network: 28 characters instead of the 418 that AnsiTerminal sends for a whole frame is a difference you feel on every turn. Slow terminals draw character by character, and ESC[2J first shows an empty screen and then fills it from the top: the screen flickers. Updating in place does not flicker.

Frame 1 also shows the other side: when almost everything changes (a new screen, the inventory opens), a whole frame is cheaper than cell by cell. That is why terminal libraries (such as curses) send the whole screen for a big change and only the difference for a small one.

The idea

In the real game

The canonical game has the same parts. Its buffer is 60 × 18 and also has Clear(): on every turn the screen stack draws everything again into a cleared buffer.

// The game does not print directly: it draws into a buffer of cells (symbol + color), and the terminal decides how
// to show it. So the same drawing becomes both plain text and a colored ANSI screen.
static partial class Arcanum
{
public const int SCREEN_WIDTH = 60;
public const int SCREEN_HEIGHT = 18;
public const int VIEW_WIDTH = 40;
public const int VIEW_HEIGHT = 13;
}
class CellBuffer
{
public int Width;
public int Height;
public char[][] Chars;
public string[][] Colors;
public CellBuffer(int width, int height)
{
Width = width;
Height = height;
Chars = new char[height][];
Colors = new string[height][];
for (int y = 0; y < height; y++)
{
Chars[y] = new char[width];
Colors[y] = new string[width];
Array.Fill(Chars[y], ' ');
Array.Fill(Colors[y], "default");
}
}
public void Clear()
{
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
Chars[y][x] = ' ';
Colors[y][x] = "default";
}
}
}
public void Put(int x, int y, char ch, string color)
{
if (x < 0 || y < 0 || x >= Width || y >= Height) return;
Chars[y][x] = ch;
Colors[y][x] = color;
}
public void Text(int x, int y, string text, string color)
{
for (int i = 0; i < text.Length; i++) Put(x + i, y, text[i], color);
}
public void FillRect(int x, int y, int width, int height)
{
for (int row = y; row < y + height; row++)
{
for (int col = x; col < x + width; col++) Put(col, row, ' ', "default");
}
}
// A frame with an empty inside: transparent screens (menu, inventory) are drawn in a frame over the map.
public void Box(int x, int y, int width, int height, string title)
{
FillRect(x, y, width, height);
for (int col = x; col < x + width; col++)
{
Put(col, y, '-', "gray");
Put(col, y + height - 1, '-', "gray");
}
for (int row = y; row < y + height; row++)
{
Put(x, row, '|', "gray");
Put(x + width - 1, row, '|', "gray");
}
Put(x, y, '+', "gray");
Put(x + width - 1, y, '+', "gray");
Put(x, y + height - 1, '+', "gray");
Put(x + width - 1, y + height - 1, '+', "gray");
if (title.Length > 0) Text(x + 2, y, " " + title + " ", "yellow");
}
// Bar [#####-----]: the filled part is proportional to the value.
public void Bar(int x, int y, int width, int value, int max, string color)
{
int filled = max > 0 ? (value * width) / max : 0;
if (value > 0 && filled == 0) filled = 1;
if (filled > width) filled = width;
Put(x, y, '[', "gray");
for (int i = 0; i < width; i++)
{
if (i < filled) Put(x + 1 + i, y, '#', color);
else Put(x + 1 + i, y, '-', "gray");
}
Put(x + width + 1, y, ']', "gray");
}
public string RowText(int y)
{
int end = Width;
while (end > 0 && Chars[y][end - 1] == ' ') end--;
return new string(Chars[y], 0, end);
}
}

The two terminals:

// Two terminals with the same interface: Present(buffer) shows the frame, Echo(line) shows the command.
// PlainTerminal prints text (for the checker and the lessons) and skips a frame equal to the previous one.
interface ITerminal
{
void Present(CellBuffer buffer);
void Echo(string line);
}
class PlainTerminal : ITerminal
{
public IGameIo Io;
public string LastFrame;
public PlainTerminal(IGameIo io)
{
Io = io;
LastFrame = "";
}
public void Present(CellBuffer buffer)
{
List<string> lines = new List<string>();
for (int y = 0; y < buffer.Height; y++) lines.Add(buffer.RowText(y));
while (lines.Count > 0 && lines[lines.Count - 1].Length == 0) lines.RemoveAt(lines.Count - 1);
string frame = string.Join("\n", lines) + "\n----\n";
if (frame != LastFrame)
{
Io.Write(frame);
LastFrame = frame;
}
}
public void Echo(string line)
{
Io.Write("> " + line + "\n");
}
}
static partial class Arcanum
{
public static readonly Dictionary<string, string> ANSI_CODES = new Dictionary<string, string>
{
["default"] = "\u001b[0m", ["red"] = "\u001b[31m", ["green"] = "\u001b[32m", ["yellow"] = "\u001b[33m",
["blue"] = "\u001b[34m", ["magenta"] = "\u001b[35m", ["cyan"] = "\u001b[36m", ["white"] = "\u001b[1;37m", ["gray"] = "\u001b[90m",
};
}
// AnsiTerminal clears the screen and adds color: blood is red, magic is blue, poison is green.
class AnsiTerminal : ITerminal
{
public IGameIo Io;
public AnsiTerminal(IGameIo io)
{
Io = io;
}
public void Present(CellBuffer buffer)
{
string output = "\u001b[2J\u001b[H";
for (int y = 0; y < buffer.Height; y++)
{
int end = buffer.RowText(y).Length;
string color = "default";
for (int x = 0; x < end; x++)
{
if (buffer.Colors[y][x] != color)
{
color = buffer.Colors[y][x];
output += "\u001b[0m" + ANSI_CODES[color];
}
output += buffer.Chars[y][x];
}
output += "\u001b[0m\n";
}
Io.Write(output + "> ");
}
public void Echo(string line) { }
}

There are three differences from the lesson, and each has a reason:

  • echo(line). PlainTerminal writes the command (> w) after the frame: that way the playthrough used to check the game reads like a conversation. AnsiTerminal does not write it, because the player sees what they typed.
  • ESC[0m before every color. White in the game is 1;37: bold and white. Without a reset the bold stays on the next color too. In the lesson all codes change only the color, so the reset is not needed.
  • No DiffTerminal. The game is turn-based: one frame per command, and the player waits until they type the next one anyway. If it ever goes over a network, DiffTerminal is a third class, and nothing else changes.

Which terminal it is gets decided by one line at the start of the game loop: with --plain it is PlainTerminal, otherwise AnsiTerminal. The status line is a function that draws into the buffer and knows nothing about the terminal:

// The bottom bar: name, class, level, life and mana with bars, gold, place. The last three messages.
static partial class Arcanum
{
public static void DrawHud(CellBuffer buffer, Game game, int row)
{
Hero hero = game.Hero;
int next = XpForLevel(hero.Level + 1);
buffer.Text(0, row, hero.Name + ", " + Nm(hero.HeroClass()) + " " + T("hud.level", ("level", hero.Level)) + " " +
T("hud.xp", ("xp", hero.Xp), ("next", next)) + " " + T("hud.gold", ("gold", hero.Gold)), "white");
buffer.Text(0, row + 1, T("hud.hp"), "red");
buffer.Bar(2, row + 1, 12, hero.Hp, hero.MaxHp(), "red");
buffer.Text(17, row + 1, hero.Hp + "/" + hero.MaxHp(), "default");
buffer.Text(25, row + 1, T("hud.mana"), "blue");
buffer.Bar(27, row + 1, 8, hero.Mana, hero.MaxMana(), "blue");
buffer.Text(38, row + 1, hero.Mana + "/" + hero.MaxMana(), "default");
buffer.Text(45, row + 1, EffectLabels(hero), "green");
}
public static void DrawMessages(CellBuffer buffer, Game game, int row, int count)
{
int first = Math.Max(0, game.Messages.Count - count);
for (int i = first; i < game.Messages.Count; i++) buffer.Text(0, row + i - first, game.Messages[i], "default");
}
public static string EffectLabels(Creature creature)
{
List<string> labels = new List<string>();
foreach (Effect effect in creature.Effects) labels.Add(effect.Label());
return string.Join(" ", labels);
}
// The digit after a command: "d5" is five steps to the right. Returns null if it is not a number.
public static long? CommandNumber(string command, int start)
{
if (command.Length <= start) return null;
return ParseIntStrict(command.Substring(start));
}
}

At the end of the region there is also a small helper for commands (d5 is five steps to the right): it lives here because both the status line and the commands belong to the map.

More precisely

Buffers, widths, colors

Double buffering. last in DiffTerminal is what graphics calls a double buffer: one buffer is the one on screen, and the next frame is drawn into the other. In games with a graphics card the two are swapped at once so that half a frame is never visible. Here we draw the whole frame before calling present, so the player never sees half a frame.

The width of characters. In a typical terminal, Cyrillic letters and the box-drawing characters (─, │, ┌) take one column each. The Unicode standard, however, gives both an "ambiguous" width: a terminal set up for Chinese or Japanese may draw them in two columns. Emoji and CJK ideographs always take two. There is a second problem too: emoji lie outside the basic plane of Unicode, and in Java, C# and JavaScript the length of a string with one emoji is 2 (two halves in UTF-16), while in Python it is 1. A buffer that writes one character of the string per cell cuts it in two. That is why the canonical game draws its frames in ASCII: +, -, |, # are one column everywhere and one character in every language.

16, 256 and millions of colors. ESC[31m to ESC[37m and ESC[90m to ESC[97m are the 16 basic colors. Their exact shade is chosen by the terminal's theme, so the red stays readable on both a light and a dark background. There is also ESC[38;5;Nm (a palette of 256) and ESC[38;2;R;G;Bm (an exact color), but not every terminal understands them, and an exact color can blend into someone's background. The game uses the 16.

Windows and redirected output. Windows Terminal understands ANSI codes. The old console window understands them from Windows 10 on, but the program has to switch them on (the ENABLE_VIRTUAL_TERMINAL_PROCESSING mode), otherwise the screen shows ←[31m. Separately, when the output is redirected to a file, the codes are junk in the file. Programs check whether they write to a terminal: Console.IsOutputRedirected. The game leaves the choice to the player: --plain.

Exercise

The battle window

Solve this in your own editor, with the CellBuffer from the lesson.

The battle window. In a 36 × 5 buffer, draw a frame around the whole buffer with the title "Battle". Inside it, one row per enemy: the name from column 2, a bar 10 wide from column 17 and the life as current/max from column 30. A dead enemy (life 0) is gray and has no bar and no life, only dead from column 17. Print the buffer row by row.

string[] names = { "blood wolf", "giant rat", "goblin shaman" };
int[] hp = { 20, 1, 0 };
int[] maxHp = { 20, 9, 13 };
Output
+- Battle -------------------------+
| blood wolf [##########] 20/20|
| giant rat [#---------] 1/9 |
| goblin shaman dead |
+----------------------------------+

More tasks

  • A log in a frame. A function that draws the last N combat messages in a frame and cuts the long ones to its width, with ... at the end.
  • Fewer moves. In DiffTerminal, do not send ESC[row;columnH when the changed cell comes right after the previous one on the same row: the cursor is already there. How many characters do you save in the first frame?
  • A third terminal. HtmlTerminal, which returns the frame as a <pre> with a <span style="color: …"> at every color change. The game does not change.

Test

Check yourself

1. Why does the game draw into a buffer instead of printing right away?
2. Why does AnsiTerminal send a color code only where the color changes?
3. You want the game to run in a browser, with the colors as <span> elements. What do you change?
4. The hero takes a step to the right. What does DiffTerminal send?
5. A colleague changed the check in put to x > width. What happens to text that reaches the right edge?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·