Skip to main content

01.3 - Console Programming

Recap

01.2 - C# Basics covered the language: types, strings, TryParse, control flow, methods, enums and jagged arrays. Now we put it to work in the only UI we have until Week 11: the terminal. Everything here lands in ConsoleUI, and the menu part is the seed of the MenuSystem library you start in A1 (set up in 01.1 - Course Intro & Tooling).

By the end of this lecture you should be able to:

  • Read and validate keyboard input with ReadLine/TryParse loops and ReadKey.
  • Control the screen: clear, position and hide the cursor, use colours and Unicode glyphs.
  • Draw a W×H board from EGamePiece[][] and highlight the last move.
  • Write a hot-seat game loop and an arrow-key cell picker.
  • Sketch the A1 menu loop and list the cross-platform traps of the Console API.
Demo code

Lecture demos: csharp-2026-fall

The Console class

Console.Write("Name: ");                       // no newline
var name = Console.ReadLine(); // string? — null at end of input (Ctrl+D / Ctrl+Z)
Console.WriteLine($"Hello, {name ?? "stranger"}");

var key = Console.ReadKey(intercept: true); // waits for ONE key; true = do not echo it
Console.WriteLine($"{key.Key} char='{key.KeyChar}' mods={key.Modifiers}");

ReadLine blocks until Enter and returns the line without the newline. Because it returns string?, nullable analysis forces you to deal with null??, is null, or passing it straight into a TryParse that accepts null.

Input validation loop

The pattern you will write a hundred times: ask, parse, range-check, repeat.

int width;
do
{
Console.Write("Board width (3-20): ");
}
while (!int.TryParse(Console.ReadLine(), out width) || width is < 3 or > 20);

Wrap it once and reuse it everywhere:

static int AskInt(string prompt, int min, int max)
{
while (true)
{
Console.Write($"{prompt} ({min}-{max}): ");
if (int.TryParse(Console.ReadLine(), out var value) && value >= min && value <= max)
{
return value;
}
Console.WriteLine($"Not a number between {min} and {max}, try again.");
}
}

Screen control

Console.Clear();                                  // wipe the screen, cursor to (0, 0)
Console.SetCursorPosition(left: 10, top: 2); // column 10, row 2 — (0, 0) is top-left
Console.Write("*");
Console.CursorVisible = false; // hide the blinking cursor while drawing; GetCursorPosition() reads it back

Redrawing a whole screen with Clear() produces a visible blank frame. For animation or after every move, put the cursor back at (0, 0) and draw over the old frame instead — same output, no flicker.

Colours

Console.ForegroundColor = ConsoleColor.Red;
Console.BackgroundColor = ConsoleColor.DarkBlue;
Console.Write(" X ");
Console.ResetColor(); // ALWAYS — otherwise the shell keeps your colours

ConsoleColor has 16 members (Black, DarkBlue, ..., Gray, DarkGray, Blue, ..., White). Set, write, reset — wrap the three lines in a small WriteColored(text, fg, bg) helper so the board-drawing code stays readable.

Unicode pieces and box-drawing characters

By default .NET may write the console in the OS code page. Switch it to UTF-8 as the first statement of Program.cs, then any glyph in the terminal's font works:

using System.Text;

Console.OutputEncoding = Encoding.UTF8;

Console.WriteLine("● ○ ✕ ◯ ■ □ ▲ ▼");
Console.WriteLine("┌───┬───┐");
Console.WriteLine("│ X │ O │");
Console.WriteLine("└───┴───┘");

System.Text is not part of the implicit usings — the using line is required. The full box-drawing set is ─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼; copy it into a comment at the top of your drawing class.

warning

Stick to single-width glyphs. Emoji and many CJK characters are double-width in most terminals and shift every column to the right of them. Keep an ASCII fallback (X, O, +-|) behind a flag — a TA's terminal may not have the font you have.

Window size

if (Console.WindowWidth < 3 * width + 4 || Console.WindowHeight < 2 * height + 4)
{
Console.WriteLine($"Terminal too small ({Console.WindowWidth}x{Console.WindowHeight}). Resize and press a key.");
Console.ReadKey(true);
}

WindowWidth/WindowHeight are readable everywhere; setting them works only on Windows. When output is redirected to a file (dotnet run > out.txt) cursor and colour calls throw — Console.IsOutputRedirected tells you in advance.

Drawing the board

The board is EGamePiece[][] — rows first, then columns. The drawing method takes the board, an optional last move to highlight, and nothing else: no game rules, no configuration object.

static void DrawBoard(EGamePiece[][] board, (int Row, int Col)? lastMove = null)
{
var height = board.Length;
var width = board[0].Length;

Console.Write(" ");
for (var c = 0; c < width; c++) Console.Write($"{c,2} ");
Console.WriteLine();

Console.Write(" ┌");
for (var c = 0; c < width; c++) Console.Write(c == width - 1 ? "──┐" : "──┬");
Console.WriteLine();

for (var r = 0; r < height; r++)
{
Console.Write($"{r,2} │");
for (var c = 0; c < width; c++)
{
if (lastMove == (r, c)) Console.BackgroundColor = ConsoleColor.DarkYellow;
Console.ForegroundColor = board[r][c] switch
{
EGamePiece.X => ConsoleColor.Red,
EGamePiece.O => ConsoleColor.Cyan,
_ => ConsoleColor.DarkGray
};
Console.Write($"{PieceToString(board[r][c])} ");
Console.ResetColor();
Console.Write("│");
}
Console.WriteLine();

if (r < height - 1)
{
Console.Write(" ├");
for (var c = 0; c < width; c++) Console.Write(c == width - 1 ? "──┤" : "──┼");
Console.WriteLine();
}
}

Console.Write(" └");
for (var c = 0; c < width; c++) Console.Write(c == width - 1 ? "──┘" : "──┴");
Console.WriteLine();
}

static string PieceToString(EGamePiece piece) => piece switch
{
EGamePiece.X => "✕",
EGamePiece.O => "○",
_ => " "
};

The result is a boxed grid with row and column indexes on the outside, each cell three characters wide, and the last move on a dark-yellow background.

Highlighting the last move

lastMove is a nullable tuple. lastMove == (r, c) is a lifted comparison: false when lastMove is null, so the same method serves the first draw and every later one. The same trick highlights the cursor in the arrow-key picker below, and in Week 3 a set of legal moves from GameBrain.GetLegalMoves() can be passed in the same way — for Reversi that is the difference between a playable game and a guessing game.

Hot-seat loop skeleton

Two humans, one keyboard, taking turns. The loop below is complete except for win detection, which belongs to GameBrain (Week 3) — the console must never contain game rules.

using System.Text;

Console.OutputEncoding = Encoding.UTF8;

const int boardWidth = 7;
const int boardHeight = 6;
var board = new EGamePiece[boardHeight][];
for (var r = 0; r < boardHeight; r++) board[r] = new EGamePiece[boardWidth];

var nextMoveBy = EGamePiece.X;
(int Row, int Col)? lastMove = null;
var movesMade = 0;

while (true)
{
Console.Clear();
DrawBoard(board, lastMove);
Console.Write($"Player {nextMoveBy}, your move (row,col) or Q to quit: ");
var input = Console.ReadLine();

if (input?.Trim().ToUpperInvariant() == "Q") break;

if (!TryParseMove(input, boardWidth, boardHeight, out var move) || board[move.Row][move.Col] != EGamePiece.Empty)
{
Console.WriteLine("Illegal move — press any key.");
Console.ReadKey(true);
continue;
}

board[move.Row][move.Col] = nextMoveBy;
lastMove = move;
movesMade++;

// TODO Week 3: brain.CheckWin(...) and brain.IsDraw() live in GameEngine, not here
if (movesMade == boardWidth * boardHeight) break; // board full — draw

nextMoveBy = nextMoveBy == EGamePiece.X ? EGamePiece.O : EGamePiece.X;
}

// TryParseMove(string? input, int width, int height, out (int Row, int Col) move)
// is the worked example from lecture 01.2, taking width/height instead of a GameConfiguration.

enum EGamePiece
{
Empty,
X,
O
}

Three variables — board, nextMoveBy, lastMove — are exactly the fields of GameState in Week 3. The loop will shrink to "draw state, ask, brain.MakeMove, repeat".

Arrow-key navigation

Typing coordinates is fine for A2; a cursor you move with arrow keys is nicer and is one of the two accepted input styles for the A1 menu. ReadKey(true) returns a ConsoleKeyInfo; .Key is the layout-independent ConsoleKey, .KeyChar the character (if any), .Modifiers the Shift/Alt/Ctrl state.

static (int Row, int Col)? PickCell(EGamePiece[][] board)
{
var row = 0;
var col = 0;
var maxRow = board.Length - 1;
var maxCol = board[0].Length - 1;
Console.CursorVisible = false;

while (true)
{
Console.SetCursorPosition(0, 0);
DrawBoard(board, (row, col));
Console.WriteLine("Arrows: move Enter: place Esc: cancel");

switch (Console.ReadKey(true).Key)
{
case ConsoleKey.UpArrow: row = Math.Max(0, row - 1); break;
case ConsoleKey.DownArrow: row = Math.Min(maxRow, row + 1); break;
case ConsoleKey.LeftArrow: col = Math.Max(0, col - 1); break;
case ConsoleKey.RightArrow: col = Math.Min(maxCol, col + 1); break;
case ConsoleKey.Enter:
Console.CursorVisible = true;
return (row, col);
case ConsoleKey.Escape:
Console.CursorVisible = true;
return null;
}
}
}

if (PickCell(board) is { } cell) Console.WriteLine($"You chose {cell.Row},{cell.Col}");

Returning null for "cancelled" is honest: the caller is forced by the compiler to handle it. For Connect Four the picker only needs Left/Right and a column; for Nine Men's Morris it needs to skip invalid points — pass in the set of selectable cells and refuse to move the cursor elsewhere.

A1 is a reusable menu library: unlimited nesting, actions supplied by the caller, updateable labels, unique hot keys, and mandatory items per level.

Level (EMenuLevel)Mandatory items
MainExit
SecondReturn to previous, Exit
DeeperReturn to main, Return to previous, Exit

The library knows nothing about games. What a menu item does arrives as a delegate — a method passed as a value — and what it returns is a string the calling code interprets:

// MenuSystem/MenuItem.cs
namespace MenuSystem;

public class MenuItem(string shortcut, string title, Func<string> action)
{
public string Shortcut { get; } = shortcut.ToUpperInvariant();
public string Title { get; set; } = title; // set: labels can change ("Sound: on")
public Func<string> Action { get; } = action; // a method returning string
}
// ConsoleUI/Program.cs — the loop, not yet the library
using MenuSystem;

List<MenuItem> mainItems =
[
new("N", "New game", () => "new-game"),
new("L", "Load game", () => "load-game"),
new("X", "Exit", () => "exit"),
];

var choice = RunMenu("Main menu", mainItems);
Console.WriteLine($"Menu returned: {choice}");

static string RunMenu(string title, List<MenuItem> items)
{
while (true)
{
Console.Clear();
Console.WriteLine(title);
Console.WriteLine(new string('=', title.Length));
foreach (var item in items)
{
Console.WriteLine($"{item.Shortcut}) {item.Title}");
}
Console.Write("> ");

var input = Console.ReadLine()?.Trim().ToUpperInvariant();
var selected = items.Find(i => i.Shortcut == input);
if (selected is null)
{
continue; // unknown shortcut: redraw, ask again
}

var result = selected.Action();
if (result == "exit")
{
return result; // bubbles up through every level
}
}
}

The step from this loop to the library is the design work of A1: a Menu class that owns its items, adds the mandatory items for its EMenuLevel automatically, rejects duplicate shortcuts (including clashes with the mandatory ones) and whose Run() is itself a Func<string> — so a submenu is just new MenuItem("O", "Options", optionsMenu.Run). That is recursion through delegates; lecture 02.2 covers Func, lambdas and closures in depth.

info

Design the return protocol first. "exit" must close every level; "back" closes one; "main" closes all but the first. The strings are yours to choose — constants in the library, not magic literals in the app.

Simple animation

Draw, sleep, draw again. Thread.Sleep(ms) blocks the current thread — fine in a single-threaded console app (Week 10 replaces it with await Task.Delay for the AI).

static void DropPiece(EGamePiece[][] board, int col, EGamePiece piece)
{
var target = -1;
for (var r = board.Length - 1; r >= 0; r--) // lowest empty row
{
if (board[r][col] == EGamePiece.Empty)
{
target = r;
break;
}
}
if (target < 0) return; // column full

Console.CursorVisible = false;
for (var r = 0; r <= target; r++)
{
if (r > 0) board[r - 1][col] = EGamePiece.Empty;
board[r][col] = piece;
Console.SetCursorPosition(0, 0); // overwrite in place — no Clear, no flicker
DrawBoard(board, (r, col));
Thread.Sleep(60);
}
Console.CursorVisible = true;
}

The same idea blinks a winning line (draw with highlight, sleep, draw without, three times) or shows Reversi flips one by one. Keep animation in ConsoleUI and keep it optional: the tests and the web app must not depend on it.

Cross-platform caveats

  • Rider's run window is not a terminal. ReadKey, Clear, SetCursorPosition and colours misbehave there. Tick Emulate terminal in output console in the run configuration, or run dotnet run --project ConsoleUI in a real terminal. Test in a real terminal before the defence.
  • Windows-only members throw PlatformNotSupportedException on macOS/Linux: the CursorVisible getter (the setter is fine), Console.Beep(frequency, duration), the WindowWidth/WindowHeight/BufferHeight setters, the Title getter. Parameterless Console.Beep() sends the terminal bell, which most terminals mute.
  • Colours are a palette, not RGB. ConsoleColor.DarkYellow is brown in macOS Terminal and olive in Windows Terminal; light themes swap contrast. Never rely on a single colour to carry meaning — combine it with the glyph — and test on a light and a dark theme.
  • Unicode. Windows Terminal and macOS/Linux terminals render box-drawing and ○ ● ✕ fine after OutputEncoding = UTF8. The legacy Windows console host with a raster font does not; pick a TrueType font such as Cascadia Mono, or fall back to ASCII.
  • Keys. ConsoleKey.Enter is the same everywhere, but KeyChar is '\r' on Windows and '\n' elsewhere — compare Key, not KeyChar. Backspace vs Delete differ between macOS and Windows keyboards. Function keys and Option/Alt combinations are unreliable in some terminals; keep navigation to arrows, Enter, Escape and letters.
  • Line endings. Console.WriteLine uses Environment.NewLine (\r\n on Windows, \n elsewhere). Do not hard-code either when writing files.
danger

Nothing in ConsoleUI may decide whether a move is legal or who has won. Those are GameBrain methods (MakeMove, GetLegalMoves, CheckWin, IsDraw) in GameEngine from Week 3, and the web app in Week 11 reuses them unchanged. The console draws state and collects input — nothing more.

Self preparation QA

  1. What does Console.ReadLine() return at end of input and why does the compiler care?null; the return type is string?, so with nullable on you must handle it (??, is null, or a TryParse that accepts null).
  2. Why ReadKey(true) rather than ReadKey() for navigation?true intercepts the key so it is not echoed to the screen, which would corrupt the drawn board.
  3. What is the first statement of Program.cs in a console app that draws Unicode pieces?Console.OutputEncoding = Encoding.UTF8; (with using System.Text; above it).
  4. Why redraw with SetCursorPosition(0, 0) instead of Clear() during animation? — Overwriting in place avoids the blank frame between clear and redraw, so there is no flicker.
  5. How do you highlight the last move without the board array knowing about it? — Pass the coordinates into DrawBoard as an optional nullable tuple and switch the background colour for that one cell.
  6. Which mandatory items must a third-level menu contain? — Return to main, return to previous and exit, in addition to its own items, all with unique shortcuts.
  7. Why must the menu library not reference the game? — It has to be reusable in any console app; game-specific behaviour is injected through Func<string> actions.
  8. Name two Console members that throw on macOS/Linux. — The CursorVisible getter and Beep(frequency, duration); the WindowWidth/WindowHeight setters as well.