02.1 - OOP 1: Classes, Inheritance, Interfaces
Recap
In 01.3 - Console Programming the whole game lived in one Program.cs: top-level statements, a few static helper methods and Console.ReadLine loops. That is fine for a script, but our board game grows into several projects (MenuSystem, GameEngine, ConsoleUI, later WebApp) that must share one engine. To organise that code we need classes, objects and contracts between them.
By the end of this lecture you should be able to:
- Write a class with fields, properties (
init,required), constructors (including primary constructors), object initialisers and static members. - Explain the access modifiers and how namespaces and assemblies (projects) group code.
- Use inheritance with
virtual,override,abstractandnew, and explain the difference between overriding and hiding. - Define an interface such as
IMoveProviderand let a human and an AI implementation be used interchangeably (polymorphism).
Lecture demos: csharp-2026-fall
Why OOP for a game engine
- The game consists of clear things: a configuration, a board state, a brain that knows the rules, a player who produces moves. Each thing becomes a type that keeps its data and its behaviour together.
- The same engine must run under a console UI this month and under a web UI a few weeks later. Objects with a small public surface let us swap the UI without touching the rules.
- Human vs AI player, JSON vs database storage — these are interchangeable implementations of the same contract. Interfaces make the swap a one-line change.
The running example for the whole course is "N in a row on a W×H grid". A board cell holds an enum value (enums in depth in the next lecture):
public enum EGamePiece { Empty, X, O }
Class vs object
Class and object are not the same thing. The class is the code — the blueprint. An object is a usable instance built from that blueprint at run time. One class, any number of objects.
public class GameConfiguration
{
public string Name { get; set; } = "Tic-Tac-Toe";
public int BoardWidth { get; set; } = 3;
public int BoardHeight { get; set; } = 3;
public int WinLength { get; set; } = 3;
public int CellCount() => BoardWidth * BoardHeight;
}
Creating an object from a class is called instantiation:
var classic = new GameConfiguration();
var big = new GameConfiguration { BoardWidth = 10 };
Console.WriteLine(classic.CellCount()); // 9
Console.WriteLine(big.CellCount()); // 30 — independent objects
In the next lecture GameConfiguration becomes a record; here it stays a plain class so every member type is visible.
Class members
Fields and properties
A field is a variable that belongs to the object. A property looks like a field from the outside but is a pair of methods (get, set) — it can validate, compute or restrict access.
public class GameState
{
// field: private, underscore prefix by convention
private int _moveCount;
// auto-implemented property with an initialiser
public EGamePiece NextMoveBy { get; set; } = EGamePiece.X;
// property over a backing field — setter usable only inside the class
public int MoveCount
{
get => _moveCount;
private set => _moveCount = value >= 0 ? value : throw new ArgumentOutOfRangeException(nameof(value));
}
}
Rule of thumb: fields are private; everything the outside world sees is a property or a method. Rider/VS: type prop + Tab.
init setters and required members
Two modifiers remove the most common "half-initialised object" bugs:
init— the property can be assigned only while the object is being created (constructor or object initialiser). Afterwards it is read-only.required— the compiler refuses to create the object unless the caller assigns the property.
public class GameState
{
public Guid Id { get; init; } = Guid.NewGuid();
public required GameConfiguration Config { get; init; }
public required EGamePiece[][] Board { get; init; }
public EGamePiece NextMoveBy { get; set; } = EGamePiece.X;
}
Object initialisers
An object initialiser assigns properties right after new, in one expression. It is the way to satisfy required members without writing a constructor:
var state = new GameState
{
Config = new GameConfiguration(),
Board = [[EGamePiece.Empty, EGamePiece.Empty], [EGamePiece.Empty, EGamePiece.Empty]],
};
state.NextMoveBy = EGamePiece.O; // ok, ordinary setter
// state.Config = other; // error: init-only
// var broken = new GameState(); // error: required members not set
Every non-nullable property (string, GameConfiguration, arrays...) must be initialised — by required, an initialiser, a constructor, or as a last resort = default!. The full story is in 02.2 - OOP 2; the project settings that enforce it are in 01.1 - Course Intro & Tooling.
Methods and overloads
A method is an action the object can perform. Several methods may share a name if their parameter lists differ — overloads: MakeMove(int row, int col) and MakeMove((int row, int col) move) can live side by side, and the second one usually just forwards to the first.
Constructors
A constructor runs exactly once, when the object is created. Constructors can be overloaded and can chain to each other with this(...). If you write no constructor at all, the compiler generates an empty parameterless one.
public class GameConfiguration
{
public string Name { get; init; }
public int BoardWidth { get; init; }
public int BoardHeight { get; init; }
public int WinLength { get; init; }
public GameConfiguration(string name, int boardWidth, int boardHeight, int winLength)
{
Name = name;
BoardWidth = boardWidth;
BoardHeight = boardHeight;
WinLength = winLength;
}
// chained constructor: square board
public GameConfiguration(string name, int size, int winLength) : this(name, size, size, winLength) { }
}
Primary constructors
Since C# 12 you can declare constructor parameters directly on the class header. They are in scope in every member; a parameter used in a method body is captured by the compiler into a hidden field.
public class GameBrain(GameConfiguration config)
{
// parameter used in an initialiser
public GameState State { get; private set; } = CreateEmptyState(config);
public bool MakeMove(int row, int col)
{
// parameter used in a method body — captured
if (row < 0 || row >= config.BoardHeight || col < 0 || col >= config.BoardWidth) return false;
if (State.Board[row][col] != EGamePiece.Empty) return false;
State.Board[row][col] = State.NextMoveBy;
State.NextMoveBy = State.NextMoveBy == EGamePiece.X ? EGamePiece.O : EGamePiece.X;
return true;
}
private static GameState CreateEmptyState(GameConfiguration config)
{
var board = new EGamePiece[config.BoardHeight][];
for (var row = 0; row < config.BoardHeight; row++)
{
board[row] = new EGamePiece[config.BoardWidth]; // every cell is Empty (0)
}
return new GameState { Config = config, Board = board };
}
}
A primary constructor parameter is not a property. If the outside world needs it, expose it explicitly: public GameConfiguration Config { get; } = config;. Primary constructors are ideal for services that receive their dependencies — you will see this everywhere in ASP.NET Core.
Static members and static classes
A static member belongs to the class, not to an object — one copy shared by everybody. A static class contains only static members and cannot be instantiated. Use static for shared constants and for pure helpers that depend only on their parameters.
public class GameBrain(GameConfiguration config)
{
public const int MinBoardSize = 3;
public static readonly GameConfiguration DefaultConfig = new("Tic-Tac-Toe", 3, 3, 3);
// static: needs only its parameters, no object state
public static List<(int row, int col)> GetLegalMoves(GameState state)
{
List<(int row, int col)> moves = [];
for (var row = 0; row < state.Board.Length; row++)
for (var col = 0; col < state.Board[row].Length; col++)
{
if (state.Board[row][col] == EGamePiece.Empty) moves.Add((row, col));
}
return moves;
}
// instance: uses State
public bool IsDraw() => GetLegalMoves(State).Count == 0;
}
A static class in ConsoleUI that draws the board — note that a static member cannot touch instance members such as State, there is no object to read them from:
public static class BoardPrinter
{
public static void Print(GameState state)
{
foreach (var row in state.Board)
{
foreach (var cell in row) Console.Write(cell switch { EGamePiece.X => " X", EGamePiece.O => " O", _ => " ." });
Console.WriteLine();
}
}
}
Destructors — just use IDisposable
C# has finalizers (~GameBrain() { }) but the garbage collector decides when they run, so you almost never write one. If a class holds an unmanaged resource (file handle, database connection), implement IDisposable and let callers write using var brain = ...; — deterministic cleanup at the end of the block.
Nested classes and anonymous types
A class declared inside another class is nested and private by default — useful for a small helper nobody else should see. Anonymous types (new { Row = 1, Col = 2 }) create a nameless, read-only type on the fly; you will meet them in LINQ projections, rarely elsewhere.
Access modifiers
| Modifier | Who can access |
|---|---|
public | Anyone, in any assembly. |
private | Only code inside the same class. The default for members. |
protected | The same class and classes derived from it. |
internal | Any code in the same assembly (project). The default for top-level types. |
protected internal | Same assembly or derived classes anywhere. |
private protected | Derived classes in the same assembly only. |
Keep the surface small: start with private, widen only when another class actually needs it. internal is the right choice for engine helpers that ConsoleUI should never call directly. (There is also file scope, C# 11+, mostly for generated helpers.)
Assemblies and namespaces
- Assembly — the physical unit: one project compiles into one
.dll. It is the unit of deployment, versioning and ofinternalvisibility. - Namespace — the logical unit: a prefix that keeps type names unique. By convention it mirrors the project and folder names, declared file-scoped at the top of each file:
namespace GameEngine;.
Our solution has one assembly per concern:
NInARow.sln
├── GameEngine/ GameConfiguration, GameState, GameBrain, IMoveProvider, RandomAiMoveProvider
├── MenuSystem/ Menu, MenuItem, EMenuLevel
├── ConsoleUI/ Program.cs, BoardPrinter, HumanMoveProvider
├── DAL.Json/ (week 4)
├── DAL.EF/ (week 5)
├── WebApp/ (week 8)
└── Tests/
ConsoleUI.csproj adds a ProjectReference to GameEngine.csproj, and Program.cs says using GameEngine; — now GameBrain is visible there. Tests needs to see internal members of GameEngine? Add [assembly: InternalsVisibleTo("Tests")] to the engine instead of making everything public.
Inheritance
A derived class reuses, extends and modifies the members of its base class. Every class derives from object implicitly — that is where ToString(), Equals() and GetHashCode() come from.
public class Player
{
public required string Name { get; init; }
public required EGamePiece Piece { get; init; }
public virtual string Describe() => $"{Name} plays {Piece}";
}
public class AiPlayer : Player
{
public int Difficulty { get; init; } = 1;
public override string Describe() => base.Describe() + $" (AI level {Difficulty})";
}
Player p = new AiPlayer { Name = "Bot", Piece = EGamePiece.O, Difficulty = 2 };
Console.WriteLine(p.Describe()); // Bot plays O (AI level 2) — the override runs, even through a Player variable
The base constructor runs first. With primary constructors you pass arguments up in the header: public class AiPlayer(string name, EGamePiece piece) : Player(name, piece).
sealed class— cannot be used as a base class. Seal by default unless you designed for inheritance.abstract class— can only be a base class; cannot be instantiated.
virtual, override, abstract, new
| Modifier | Meaning |
|---|---|
virtual | This member may be overridden in a derived class. Has an implementation. |
override | Replaces a virtual or abstract member of the base. Called through the base type, the derived version runs (polymorphism). |
abstract | Must be overridden; has no body. Only inside an abstract class. Implicitly virtual. |
new | Hides the base member instead of overriding it. No polymorphism. |
public abstract class MoveProviderBase
{
public abstract (int row, int col) GetMove(GameState state); // no body — every provider must implement it
public virtual string DisplayName => GetType().Name; // sensible default — may be overridden
}
new hides — a base-typed variable still sees the base member:
public class Player { public string Describe() => "player"; }
public class AiPlayer : Player { public new string Describe() => "AI player"; }
Player p = new AiPlayer();
Console.WriteLine(p.Describe()); // "player" — hidden, not overridden
Console.WriteLine(((AiPlayer)p).Describe()); // "AI player"
override extends, new hides. If you find yourself writing new on a method, you almost certainly wanted virtual in the base and override in the derived class. Hiding without new is a compiler warning — and in our setup warnings are errors.
Interfaces
An interface is a contract: a set of members without state. A class (or struct, or record) that implements the interface promises to provide every member. Unlike inheritance, a type can implement many interfaces. Conventions: names start with I; members are public without writing the modifier; a class must implement every member unless the interface provides a default; code that uses the object should depend on the interface, not on the concrete class.
Our first contract — "something that can produce a move for the current state" — lives in GameEngine:
public interface IMoveProvider
{
(int row, int col) GetMove(GameState state);
// default implementation — implementers may keep it or supply their own
string DisplayName => GetType().Name;
}
Two very different implementations of the same contract. The human asks the console, so it lives in ConsoleUI:
public class HumanMoveProvider : IMoveProvider
{
public string DisplayName => "Human";
public (int row, int col) GetMove(GameState state)
{
while (true)
{
Console.Write($"{state.NextMoveBy}, enter row,col: ");
var parts = (Console.ReadLine() ?? "").Split(',');
if (parts.Length == 2 && int.TryParse(parts[0], out var row) && int.TryParse(parts[1], out var col))
{
return (row, col);
}
Console.WriteLine("Two numbers separated by a comma, for example 1,2");
}
}
}
The AI picks a random legal move and needs no console at all, so it lives in GameEngine:
public class RandomAiMoveProvider : IMoveProvider
{
private readonly Random _random = new();
public (int row, int col) GetMove(GameState state)
{
var legal = GameBrain.GetLegalMoves(state);
return legal[_random.Next(legal.Count)];
}
}
Polymorphism in the game loop
The game loop never mentions HumanMoveProvider or RandomAiMoveProvider. It holds IMoveProvider references and calls GetMove — whichever object is behind the reference answers. Swapping human for AI, or adding a minimax AI in 09.1 - Game AI, does not change this code.
var brain = new GameBrain(GameBrain.DefaultConfig);
// dictionaries in detail in lecture 03.1 — here: piece -> who moves for it
Dictionary<EGamePiece, IMoveProvider> players = new()
{
[EGamePiece.X] = new HumanMoveProvider(),
[EGamePiece.O] = new RandomAiMoveProvider(),
};
while (true)
{
BoardPrinter.Print(brain.State);
var mover = brain.State.NextMoveBy;
IMoveProvider provider = players[mover]; // human or AI — the loop does not care
var (row, col) = provider.GetMove(brain.State);
if (!brain.MakeMove(row, col)) { Console.WriteLine("Illegal move, try again."); continue; }
if (brain.CheckWin(mover)) { Console.WriteLine($"{provider.DisplayName} ({mover}) wins!"); break; }
if (brain.IsDraw()) { Console.WriteLine("Draw."); break; }
}
CheckWin(EGamePiece piece) walks from every cell in four directions (right, down, both diagonals) and returns true once it counts WinLength equal pieces in a row. The twenty lines are in the demo repository; you write your own version in the homework and 09.1 - Game AI builds the minimax evaluation on top of it.
Default implementations
DisplayName above has a body inside the interface. A class that does not define it gets the default; a class that does define it wins. If two interfaces supply conflicting defaults for the same member, the compiler forces you to implement it yourself (the diamond problem). Use defaults sparingly — they exist to evolve a published interface without breaking implementers, not as a substitute for a base class.
Abstract class or interface?
MoveProviderBase from the inheritance section and IMoveProvider describe the same idea. An abstract class can hold fields and constructors and expresses "is a kind of", but a class gets only one base. An interface holds no state, expresses "can do", and a type can implement any number of them. Prefer the interface: it does not force a class hierarchy, it can be faked in Tests, and it is what dependency injection in ASP.NET Core works with. Reach for an abstract class only when derived classes genuinely share fields or constructor logic.
Generic interfaces such as IGameRepository<T> and generic constraints are the topic of 03.1 - Collections, Generics, LINQ.
Self preparation QA
Be prepared to explain topics like these:
- What is the difference between a class and an object? — The class is the compiled blueprint; an object is one instance of it created with
newat run time. One class, many independent objects. - When do you use a property instead of a field? — Whenever the member is visible outside the class: a property can validate, compute, be read-only or
init-only, and can change implementation later without breaking callers. Fields stayprivate. - What do
requiredandinitguarantee? —requiredmakes the compiler refuse object creation unless the member is assigned;initallows assignment only during creation. Together they give fully initialised, immutable-after-creation objects without a long constructor. - Is a primary constructor parameter a property? — No. It is in scope for all members and is captured into a hidden field when a method uses it; expose it explicitly with a property if callers need it.
- What is the difference between
overrideandnew? —overridereplaces a virtual member so the derived version runs even through a base-typed reference (polymorphism);newmerely hides the base member, and a base-typed reference still calls the base version. - Why does the game loop hold
IMoveProviderinstead ofHumanMoveProvider? — So the loop depends only on the contract. Any implementation (human, random AI, minimax, a network player) can be plugged in without changing the loop, and the loop can be tested with a fake provider. - Abstract class or interface for a move provider? — Interface: it needs no shared state, a class can implement several interfaces, and interfaces are what DI and test doubles work with. An abstract class is justified only when derived classes share fields or constructor logic.