Skip to main content

01.2 - C# Basics

Recap

01.1 - Course Intro & Tooling set up the solution, Rider and git. This lecture is the language crash course for people who already program in something else (Java, Python, JavaScript, C++) and need C# syntax fast. OOP proper — classes, records, nullable types, delegates — is next week.

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

  • Read and write a Program.cs with top-level statements and know where the hidden Main is.
  • Choose between value and reference types, pick the right built-in numeric type, and convert user input without exceptions.
  • Use if, loops, switch statements and expressions, and the basic is patterns.
  • Write methods with out/ref/optional/named parameters, return tuples and deconstruct them.
  • Declare enums, jagged arrays and namespaces, and follow the course naming conventions.
Demo code

Lecture demos: csharp-2026-fall

Program.cs and top-level statements

// ConsoleUI/Program.cs — the whole file
Console.WriteLine("Hello, World!");

The compiler wraps this in a hidden Program class with a static Main method. Rules:

  • Only one file per project may contain top-level statements.
  • args (string[]) is available implicitly; return 1; sets the process exit code.
  • using directives go first, statements next, type declarations (class, record, enum) at the bottom of the file — never between statements.
  • Methods declared at top level are local functions of the hidden Main; mark them static and call them from anywhere in the file.

The classic public static void Main(string[] args) inside a class is still valid and is what you see in older code. Libraries have no Main at all — they contain only types.

Value types vs reference types

var a = 5;               // int is a value type: the variable holds the number itself
var b = a; // copy
b++; // a is still 5

int[] xs = [1, 2, 3]; // arrays are reference types: the variable holds a reference
var ys = xs; // both point at the same array on the heap
ys[0] = 99; // xs[0] is 99 too
  • Value types: all numeric types, bool, char, enum, struct, tuples. Copied on assignment and when passed to a method. Cannot be null unless declared nullable (int?).
  • Reference types: class, record, string, arrays, delegates, interfaces. The object lives on the heap; the variable refers to it. null is possible — and with nullable reference types on, the compiler tracks whether it is allowed (string?) or not (string).
  • string is a reference type that behaves like a value: immutable, compared by content with ==.

Built-in types and var

TypeLiteralUse for
int (32-bit)42counters, indexes, board sizes
long (64-bit)42Ltimestamps, big counts
double3.14physics, statistics
decimal9.99mmoney — exact decimal arithmetic
booltrueflags
char (UTF-16)'X'one character
string"X wins"text
byte255raw bytes

var asks the compiler to infer the type from the initialiser. The variable is still statically typed — var n = 5; n = "five"; does not compile, and neither does var x = null;. Use var when the type is obvious from the right-hand side; write the type when it is not.

Default values: numbers are 0, bool is false, references are null. new EGamePiece[3] therefore holds three EGamePiece.Empty — the member with value 0.

Strings

var name = "Ada";
var greeting = $"Hello, {name}! {3 + 4} moves left."; // interpolation: any expression inside braces
var aligned = $"|{name,-6}|{42,4}|"; // alignment: |Ada | 42|
var path = @"C:\games\saves"; // verbatim: backslashes are literal
var rules = """
Connect Four:
drop a piece, "four in a row" wins — no \ escaping needed here
"""; // raw string literal (C# 11)
var banner = $"""
Game: {name}
Board: {7}x{6}
"""; // raw + interpolation

In raw string literals the position of the closing """ decides the indentation: that much whitespace is stripped from every line.

Strings are immutable, so every method returns a new string:

var s = "  2,1 \n";
var trimmed = s.Trim(); // "2,1"
var parts = trimmed.Split(','); // ["2", "1"]
var upper = "quit".ToUpperInvariant(); // "QUIT"
var blank = string.IsNullOrWhiteSpace(" "); // true
string[] pieces = ["X", "O"];
var joined = string.Join(" | ", pieces); // "X | O"
var line = new string('-', 10); // "----------"
var second = "abc"[1]; // 'b' — a char, not a string
var found = s.Contains(',') && trimmed.StartsWith("2");

Building a long string with += in a loop copies the whole thing every time; use System.Text.StringBuilder for that (you will, when drawing boards).

Numbers and conversions

int i = 7;
long l = i; // implicit widening: always safe
double d = i / 2; // 3 — integer division happens BEFORE the conversion
double half = i / 2.0; // 3.5
int truncated = (int)3.99; // 3 — explicit cast truncates towards zero
int rounded = (int)Math.Round(2.5); // 2 — banker's rounding; MidpointRounding.AwayFromZero gives 3
var remainder = 7 % 3; // 1
var text = 42.ToString(); // "42"
var big = checked(int.MaxValue + i); // OverflowException instead of silently wrapping

int.Parse("4x") throws FormatException. For anything typed by a human use TryParse, which returns false instead:

Console.Write("Board width: ");
if (int.TryParse(Console.ReadLine(), out var width) && width is >= 3 and <= 20)
{
Console.WriteLine($"Width set to {width}");
}
else
{
Console.WriteLine("Expected a number between 3 and 20");
}

TryParse accepts null, so Console.ReadLine() (which returns string?) can be passed straight in.

warning

Decimal separators follow the current culture. On an Estonian locale double.Parse("3.14") fails and "3,14" succeeds; on a US locale it is the other way round. For files and network data always pass CultureInfo.InvariantCulture (using System.Globalization;).

char

var isDigit = char.IsDigit('7');       // true
var code = (int)'A'; // 65
var next = (char)('A' + 1); // 'B'
var digit = '7' - '0'; // 7 — arithmetic on chars gives int
var upperC = char.ToUpperInvariant('c'); // column letters, single-key menu shortcuts

Control flow

if (piece == EGamePiece.Empty)
{
Console.WriteLine("free");
}
else
{
Console.WriteLine($"taken by {piece}"); // else if (...) chains as usual
}

var label = piece == EGamePiece.Empty ? "free" : "taken"; // conditional operator

for (var r = 0; r < board.Length; r++)
{
if (board[r][0] == EGamePiece.Empty) continue; // next iteration
if (board[r][0] == EGamePiece.X) break; // leave this loop only
}

foreach (var row in board) // read-only iteration, no index
{
foreach (var cell in row) Console.Write(cell);
}

while (!gameOver) gameOver = PlayOneTurn();

string? input;
do
{
Console.Write("> ");
input = Console.ReadLine();
}
while (string.IsNullOrWhiteSpace(input)); // runs at least once — ideal for prompts

switch statement and switch expression

The statement form — switch (piece) { case EGamePiece.X: ...; break; default: ...; break; } — runs code per case; each case ends with break, return or throw, there is no silent fall-through. The expression form produces a value and is what you will write most of the time:

static string PieceSymbol(EGamePiece piece) => piece switch
{
EGamePiece.X => "X",
EGamePiece.O => "O",
EGamePiece.Empty => " ",
_ => throw new ArgumentOutOfRangeException(nameof(piece), piece, null)
};

_ is the discard arm. Without it the compiler warns that the switch is not exhaustive (CS8509) — in our solution that warning is an error.

Pattern matching basics

Patterns test a value's type, shape or range and can bind a variable in the same step:

object o = 42;
if (o is int n && n > 40) Console.WriteLine($"big int {n}"); // type pattern + binding

var sizeClass = width switch // relational patterns
{
< 3 => "too small",
>= 3 and <= 10 => "normal",
_ => "large"
};

if (input is "q" or "Q" or "quit") return; // constant patterns joined with or
if (piece is not EGamePiece.Empty) Console.WriteLine("occupied");
if (move is (0, 0) or (0, 2)) Console.WriteLine("top corner"); // positional pattern on a tuple

Property patterns (is { Length: > 0 }) and list patterns exist too; you will meet them when they become useful.

Methods

static int Area(int width, int height) => width * height;          // expression-bodied

static int Max(params int[] numbers) => numbers.Max(); // params: Max(1, 2, 3) or Max(array)

static string Describe(string name, int width = 3, int height = 3) // optional parameters
=> $"{name} {width}x{height}";
var t = Describe("Tic-Tac-Toe");
var g = Describe("Gomoku", height: 15, width: 15); // named arguments, any order

static bool TryDivide(int a, int b, out int result) // out: method hands a value back
{
result = 0;
if (b == 0) return false;
result = a / b;
return true;
}

static void Swap(ref int a, ref int b) => (a, b) = (b, a); // ref: edits the caller's variable

The rule behind ref/out: value types are copied into the method, so without ref/out the caller never sees a change. out must be assigned inside the method before returning; ref must be assigned by the caller before the call. Reference types come in as a copy of the reference: the method can change the object (board[0][0] = ...) but cannot swap the caller's variable for another object.

Tuples and deconstruction

(int Row, int Col) move = (2, 1);           // named elements
var (row, col) = move; // deconstruction into two locals

static (int Row, int Col) ToRowCol(int index, int width) => (index / width, index % width);

var (r, c) = ToRowCol(10, 7); // (1, 3) — flat index into a 7-wide board
if (ToRowCol(10, 7) is (1, _)) Console.WriteLine("second row");

Tuples are value types (ValueTuple), compared element by element; the element names exist only at compile time. They are perfect for "return two things from a method". The moment the pair needs behaviour, validation or a name that shows up in JSON, it becomes a record (next week).

Scope

Variables live from their declaration to the end of the enclosing block. C# does not allow shadowing a local with the same name in a nested block (unlike JavaScript or C++). One useful exception: out var declared inside an if condition stays in scope after the if:

if (!int.TryParse(input, out var n)) return;
Console.WriteLine(n * 2); // n is in scope here

Enums

enum EGamePiece
{
Empty, // 0 — the default for a freshly allocated array
X, // 1
O // 2
}

var piece = EGamePiece.X;
var asInt = (int)piece; // 1; piece.ToString() is "X"
var all = Enum.GetValues<EGamePiece>(); // [Empty, X, O]
var ok = Enum.TryParse<EGamePiece>("O", out var parsed); // true, parsed == O
var opponent = piece == EGamePiece.X ? EGamePiece.O : EGamePiece.X;
var suspicious = (EGamePiece)7; // compiles! Enum.IsDefined(suspicious) is false

Enums are value types backed by int. Put the "nothing" member first so that it is 0. The E prefix (EGamePiece, EMenuLevel) is a course convention, not a .NET one — it makes enums easy to spot in a big solution.

Arrays — just enough

int[] scores = [10, 20, 30];               // collection expression (C# 12)
var zeros = new int[5]; // five zeros
scores[0] = 11; // index from 0; scores[3] throws IndexOutOfRangeException

var grid = new EGamePiece[6, 7]; // rectangular 2D: [rows, cols]; grid.GetLength(0) is 6
grid[0, 3] = EGamePiece.X;

var board = new EGamePiece[6][]; // jagged: an array of row arrays — the course board
for (var r = 0; r < board.Length; r++)
{
board[r] = new EGamePiece[7];
}
board[0][3] = EGamePiece.X;

GameState.Board is the jagged EGamePiece[][], not [,]: System.Text.Json serialises jagged arrays out of the box (rectangular arrays throw), and row-wise foreach is natural. Lists, dictionaries, LINQ and the trade-offs come in lecture 15.

Namespaces and using

// MenuSystem/MenuItem.cs
namespace MenuSystem; // file-scoped namespace: applies to the whole file

public class MenuItem
{
public required string Shortcut { get; init; }
public required string Title { get; set; }
}

In ConsoleUI/Program.cs a single using MenuSystem; line makes MenuItem resolve without the prefix. using static System.Console; goes one step further and lets you write WriteLine("hi").

ImplicitUsings (on in our Directory.Build.props) adds System, System.IO, System.Linq, System.Collections.Generic, System.Net.Http, System.Threading and System.Threading.Tasks to every file. Anything else — System.Text, System.Globalization, System.Text.Json — still needs an explicit using. A global using System.Text; in any one file applies to the whole project. Keep namespaces equal to project name + folder; Rider warns when they drift.

XML doc comments

Triple-slash comments document the public API and show up as tooltips in Rider for whoever uses your library:

/// <summary>
/// Tries to parse "row,col" into a zero-based board position.
/// </summary>
/// <param name="input">Raw user input; may be null.</param>
/// <param name="move">The parsed position when the method returns true.</param>
/// <returns>True when the input is well formed and inside the board.</returns>
static bool TryParseMove(string? input, GameConfiguration config, out (int Row, int Col) move)

Document every public member of MenuSystem and GameEngine. Turning on GenerateDocumentationFile in the csproj makes missing comments a warning (CS1591) — hence an error for us — so either document all public members or leave the setting off.

Naming conventions, const and readonly

ThingStyleExample
Namespace, class, record, enum, method, propertyPascalCaseGameBrain, MakeMove, BoardWidth
InterfaceI + PascalCaseIGameRepository
Enum type (course convention)E + PascalCaseEGamePiece
Local variable, parametercamelCasenextMoveBy, winLength
Private field_camelCase_config, _movesMade
ConstantPascalCaseMinBoardSize
public class GameBrain(GameConfiguration config)       // primary constructor (C# 12)
{
public const int MinBoardSize = 3; // compile-time constant, implicitly static
private static readonly Random Rng = new(); // runtime constant: objects are allowed
private readonly GameConfiguration _config = config; // assigned once, never again
private int _movesMade; // ordinary field, changes over time
}

const works only for numbers, strings, bool, char and null; readonly works for anything but is fixed once the constructor finishes.

Worked example: parse a "row,col" move safely

The console game asks for moves as text. Everything that can go wrong with user input — null, empty, extra spaces, letters, one number, three numbers, out of range, absurdly long digits — has to end in a polite "try again", never in an exception.

// ConsoleUI/Program.cs
var config = new GameConfiguration("Tic-Tac-Toe", BoardWidth: 3, BoardHeight: 3, WinLength: 3);

Console.Write("Your move (row,col): ");
var input = Console.ReadLine();

if (TryParseMove(input, config, out var move))
{
Console.WriteLine($"Move accepted: row {move.Row}, col {move.Col}");
}
else
{
Console.WriteLine("Bad input. Two numbers separated by a comma, e.g. 2,1");
}

static bool TryParseMove(string? input, GameConfiguration config, out (int Row, int Col) move)
{
move = default; // out must be assigned on every path
if (string.IsNullOrWhiteSpace(input)) return false;

var parts = input.Split(',', StringSplitOptions.TrimEntries);
if (parts.Length != 2) return false;

if (!int.TryParse(parts[0], out var row) || !int.TryParse(parts[1], out var col)) return false;

if (row < 0 || row >= config.BoardHeight) return false;
if (col < 0 || col >= config.BoardWidth) return false;

move = (row, col);
return true;
}

record GameConfiguration(string Name, int BoardWidth, int BoardHeight, int WinLength);

Notes:

  • string? in the signature documents that null is acceptable — the compiler then insists on the IsNullOrWhiteSpace guard before input.Split.
  • StringSplitOptions.TrimEntries makes " 2 , 1 " work; "2,,1" still splits into three parts and is rejected by the length check. Every one of these cases becomes a unit test in Week 3.
  • Coordinates are 0-based internally; if the UI shows 1-based numbers, subtract at the UI edge, never in the engine.
  • The record on the last line is a one-line immutable class with a constructor, properties and value equality — Week 2 explains it. Note the named arguments in the new call: with three int parameters in a row, names prevent the classic width/height swap.

Self preparation QA

  1. Where is Main when you use top-level statements? — The compiler generates a Program class with a Main around your statements; only one file per project may have them.
  2. What happens when you assign one int[] variable to another and change an element? — Both variables reference the same array, so both see the change; arrays are reference types.
  3. Why prefer int.TryParse over int.Parse for user input?Parse throws on bad input; TryParse returns false and hands the value back through an out parameter, so you can loop and re-ask.
  4. What is the difference between a switch statement and a switch expression? — The statement runs code per case; the expression yields a value, must be exhaustive (use _) and pairs naturally with patterns.
  5. When do you need ref or out on a parameter? — When the method must write to the caller's variable; value types are copied otherwise. out must be assigned inside the method, ref before the call.
  6. Why does the course put Empty first in EGamePiece? — Enum value 0 is the default, so a freshly allocated EGamePiece[] is full of Empty with no extra initialisation.
  7. What is the difference between EGamePiece[,] and EGamePiece[][]? — Rectangular vs jagged (array of arrays); the course uses jagged because JSON serialisation and row-wise access work out of the box.
  8. const or readonly for a Random instance?static readonly; const only works for compile-time constants such as numbers and strings.