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/TryParseloops andReadKey. - 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
ConsoleAPI.
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.
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.
Menu loop teaser for A1
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 |
|---|---|
Main | Exit |
Second | Return to previous, Exit |
Deeper | Return 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.
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,SetCursorPositionand colours misbehave there. Tick Emulate terminal in output console in the run configuration, or rundotnet run --project ConsoleUIin a real terminal. Test in a real terminal before the defence. - Windows-only members throw
PlatformNotSupportedExceptionon macOS/Linux: theCursorVisiblegetter (the setter is fine),Console.Beep(frequency, duration), theWindowWidth/WindowHeight/BufferHeightsetters, theTitlegetter. ParameterlessConsole.Beep()sends the terminal bell, which most terminals mute. - Colours are a palette, not RGB.
ConsoleColor.DarkYellowis 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 afterOutputEncoding = 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.Enteris the same everywhere, butKeyCharis'\r'on Windows and'\n'elsewhere — compareKey, notKeyChar. 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.WriteLineusesEnvironment.NewLine(\r\non Windows,\nelsewhere). Do not hard-code either when writing files.
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
- What does
Console.ReadLine()return at end of input and why does the compiler care? —null; the return type isstring?, so with nullable on you must handle it (??,is null, or aTryParsethat accepts null). - Why
ReadKey(true)rather thanReadKey()for navigation? —trueintercepts the key so it is not echoed to the screen, which would corrupt the drawn board. - What is the first statement of
Program.csin a console app that draws Unicode pieces? —Console.OutputEncoding = Encoding.UTF8;(withusing System.Text;above it). - Why redraw with
SetCursorPosition(0, 0)instead ofClear()during animation? — Overwriting in place avoids the blank frame between clear and redraw, so there is no flicker. - How do you highlight the last move without the board array knowing about it? — Pass the coordinates into
DrawBoardas an optional nullable tuple and switch the background colour for that one cell. - 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.
- 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. - Name two
Consolemembers that throw on macOS/Linux. — TheCursorVisiblegetter andBeep(frequency, duration); theWindowWidth/WindowHeightsetters as well.