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.cswith top-level statements and know where the hiddenMainis. - Choose between value and reference types, pick the right built-in numeric type, and convert user input without exceptions.
- Use
if, loops,switchstatements and expressions, and the basicispatterns. - 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.
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.usingdirectives 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 themstaticand 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 benullunless declared nullable (int?). - Reference types:
class,record,string, arrays, delegates, interfaces. The object lives on the heap; the variable refers to it.nullis possible — and with nullable reference types on, the compiler tracks whether it is allowed (string?) or not (string). stringis a reference type that behaves like a value: immutable, compared by content with==.
Built-in types and var
| Type | Literal | Use for |
|---|---|---|
int (32-bit) | 42 | counters, indexes, board sizes |
long (64-bit) | 42L | timestamps, big counts |
double | 3.14 | physics, statistics |
decimal | 9.99m | money — exact decimal arithmetic |
bool | true | flags |
char (UTF-16) | 'X' | one character |
string | "X wins" | text |
byte | 255 | raw 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.
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
| Thing | Style | Example |
|---|---|---|
| Namespace, class, record, enum, method, property | PascalCase | GameBrain, MakeMove, BoardWidth |
| Interface | I + PascalCase | IGameRepository |
| Enum type (course convention) | E + PascalCase | EGamePiece |
| Local variable, parameter | camelCase | nextMoveBy, winLength |
| Private field | _camelCase | _config, _movesMade |
| Constant | PascalCase | MinBoardSize |
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 thatnullis acceptable — the compiler then insists on theIsNullOrWhiteSpaceguard beforeinput.Split.StringSplitOptions.TrimEntriesmakes" 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
recordon 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 thenewcall: with threeintparameters in a row, names prevent the classic width/height swap.
Self preparation QA
- Where is
Mainwhen you use top-level statements? — The compiler generates aProgramclass with aMainaround your statements; only one file per project may have them. - 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. - Why prefer
int.TryParseoverint.Parsefor user input? —Parsethrows on bad input;TryParsereturnsfalseand hands the value back through anoutparameter, so you can loop and re-ask. - What is the difference between a
switchstatement and aswitchexpression? — The statement runs code percase; the expression yields a value, must be exhaustive (use_) and pairs naturally with patterns. - When do you need
reforouton a parameter? — When the method must write to the caller's variable; value types are copied otherwise.outmust be assigned inside the method,refbefore the call. - Why does the course put
Emptyfirst inEGamePiece? — Enum value 0 is the default, so a freshly allocatedEGamePiece[]is full ofEmptywith no extra initialisation. - What is the difference between
EGamePiece[,]andEGamePiece[][]? — Rectangular vs jagged (array of arrays); the course uses jagged because JSON serialisation and row-wise access work out of the box. constorreadonlyfor aRandominstance? —static readonly;constonly works for compile-time constants such as numbers and strings.