Most Dart tutorials throw everything at you at once — generics, mixins, isolates, streams, sealed classes. If your goal is to write your first Flutter app, not earn a computer science degree, that approach is overwhelming and mostly unnecessary.
This crash course is different. It covers only the Dart you need for Flutter — the exact subset of the language that appears in real Flutter code. Every example is pulled from the Flutter Tic-Tac-Toe series on this blog, so you will immediately recognise the patterns when you see them in action.
By the end you will be able to read and write the Dart behind game state, widget constructors, async AI moves, score tracking, and list operations — without being overwhelmed by language features you won’t use for months.
Read this before: Flutter Tutorial for Beginners, the StatefulWidget guide, and the Tic-Tac-Toe series. The Dart you learn here powers everything in those posts.
| ✅ Covered here | ⏭️ Saved for the Dart series |
|---|---|
| Variables, types, var/final/const | Generics, typedefs |
| Null safety: ?, ??, basics | Null safety deep dive: late, !, type promotion |
| Functions with named parameters | Mixins, extensions, abstract classes |
| Basic classes and constructors | Streams, StreamController |
| Lists and Maps | Dart 3.x records and patterns |
| async/await and Future basics | Isolates and compute() |
1. Where to Practice Dart
You do not need to set anything up to start learning Dart. Three options, easiest first:
| Option | How | Best for |
|---|---|---|
| DartPad | Go to dartpad.dev — no install needed | Trying every snippet in this post right now |
| Your Flutter project | Edit lib/main.dart from the Tic-Tac-Toe series | Seeing how concepts connect to real widget code |
| Separate helper file | Create lib/dart_practice.dart, import it from main | Keeping experiments separate from your app code |
Open DartPad in one browser tab and this post in another. Type — don’t paste — every code snippet. Muscle memory matters more than reading when learning a language.
2. Variables and Basic Types
Dart is statically typed — every variable has a type known at compile time. You do not always have to write the type explicitly because Dart can infer it, but the type is always there.
Here are the four primitive types you will use constantly in Flutter apps:
// ── int: whole numbers ────────────────────────────────────────────────────
int scoreX = 0;
int scoreO = 0;
int moveCount = 0;
int boardSize = 9;
// ── double: decimal numbers ───────────────────────────────────────────────
double animationDuration = 0.25; // seconds
double opacity = 1.0;
// ── bool: true or false ───────────────────────────────────────────────────
bool isDraw = false;
bool isAiThinking = false;
bool gameOver = false;
// ── String: text ──────────────────────────────────────────────────────────
String currentPlayer = 'X';
String appTitle = 'Flutter Tic-Tac-Toe';
// You recognise all of these from the Tic-Tac-Toe series —
// every one of these types appeared in the game's state fields
Dart also has two important collection types — List and Map — which get their own sections later because they are fundamental to almost every Flutter app.
3. var, final, and const — When to Use Each
These three keywords control how a variable can change after it is created. Getting them right makes your code clearer and prevents bugs:
// ── var: type inferred, can be reassigned ─────────────────────────────────
var message = 'Player X wins!'; // Dart infers: String
message = 'Player O wins!'; // ✅ reassignment is fine
// message = 42; // ❌ can't reassign a different type
var count = 0; // Dart infers: int
count = count + 1; // ✅
// ── final: set once, can never be reassigned ──────────────────────────────
final int maxCells = 9;
// maxCells = 10; // ❌ compile error — final can't be reassigned
// final is evaluated at RUNTIME, so this is fine:
final DateTime gameStartTime = DateTime.now(); // evaluated when this line runs
// In Flutter, widget fields are almost always final:
// final String title;
// final int scoreX;
// ── const: compile-time constant, deeply immutable ────────────────────────
const int winningLineLength = 3;
// const DateTime now = DateTime.now(); // ❌ DateTime.now() is runtime, not compile-time
// const is used heavily in Flutter for widget construction:
// const Text('Hello') — the Text widget never changes
// const Duration(milliseconds: 400) — the duration never changes
// const EdgeInsets.all(16) — the padding never changes
// ── Practical rule of thumb ───────────────────────────────────────────────
// Use const → when the value is known at compile time and never changes
// Use final → when the value is set once at runtime and never reassigned
// Use var → when you need to reassign the variable later
Here is how these keywords appear inside a real Flutter widget — the ScoreBoard from Tic-Tac-Toe Part 2:
class ScoreBoard extends StatelessWidget {
const ScoreBoard({ // ← const constructor: widget itself is compile-time constant
super.key,
required this.scoreX,
required this.scoreO,
required this.scoreDraws,
this.onResetScores,
});
final int scoreX; // ← final: set once via constructor, never reassigned
final int scoreO;
final int scoreDraws;
final VoidCallback? onResetScores;
@override
Widget build(BuildContext context) {
// Inside build(), var is fine for local temporary values:
var totalGames = scoreX + scoreO + scoreDraws;
return Text('$totalGames games played');
}
}
4. Null Safety: ? and ?? Without the Headache
Dart’s null safety means that by default, a variable cannot be null. The compiler enforces this — if something could be null, you must say so explicitly. This prevents the most common category of runtime crash in mobile apps.
// ── Non-nullable: CANNOT be null ─────────────────────────────────────────
String currentPlayer = 'X';
// currentPlayer = null; // ❌ compile error — String is non-nullable
int score = 0;
// score = null; // ❌ compile error
// ── Nullable: CAN be null, marked with ? ──────────────────────────────────
String? winner; // starts as null — no winner yet
winner = 'X'; // ✅ can be assigned a String
winner = null; // ✅ can be set back to null
int? selectedIndex; // null until user taps something
// ── Reading a nullable value safely ───────────────────────────────────────
String? winner2 = null;
// Option 1: if-null check
if (winner2 != null) {
print('The winner is $winner2'); // safe: Dart knows winner2 is non-null here
}
// Option 2: ?? operator (if-null / null coalescing)
String display = winner2 ?? 'No winner yet';
// If winner2 is null → display = 'No winner yet'
// If winner2 is 'X' → display = 'X'
// If winner2 is 'O' → display = 'O'
print(display);
Here is how null safety looks in the actual Tic-Tac-Toe game state — every field that might not have a value yet is marked with ?:
class _TicTacToePageState extends State<TicTacToePage> {
late List<String> board;
String currentPlayer = 'X'; // non-nullable: always has a value
String? winner; // nullable: null while game is ongoing, 'X' or 'O' when won
bool isDraw = false;
// Using ?? in the status message:
String get statusMessage {
if (winner != null) {
return '$winner wins! 🎉'; // winner is 'X' or 'O' here
}
return isDraw ? "It's a draw!" : "Player $currentPlayer's turn";
}
}
// ── The ?. operator: safe method call on a nullable ───────────────────────
String? name = null;
// Without ?. this would throw: name.length → null has no .length
// With ?. it returns null instead of throwing:
int? nameLength = name?.length; // null (safe)
String? winner3 = 'X';
int? winnerLength = winner3?.length; // 1 (safe)
Dart also has a
! operator that forces a nullable to non-nullable: winner!. If you’re wrong and it is null, the app crashes at runtime. This crash course recommends using if (x != null) or ?? instead. The ! operator is covered in the dedicated Dart series.
5. String Interpolation and Common String Operations
String interpolation is one of Dart’s most-used features. Instead of concatenating with +, embed expressions directly inside a string with $:
String player = 'X';
int score = 3;
// ── $ for simple variables ────────────────────────────────────────────────
print('Player $player wins!'); // → Player X wins!
print('Score: $score'); // → Score: 3
// ── ${} for expressions ───────────────────────────────────────────────────
print('Next player: ${player == 'X' ? 'O' : 'X'}'); // → Next player: O
print('Score + 1: ${score + 1}'); // → Score + 1: 4
print('Name length: ${player.length}'); // → Name length: 1
// ── Multi-line strings with triple quotes ─────────────────────────────────
String instructions = '''
Welcome to Tic-Tac-Toe!
Player $player goes first.
Good luck!
''';
// ── Common string methods used in Flutter apps ────────────────────────────
String input = ' Hello Flutter ';
print(input.trim()); // → 'Hello Flutter' (removes whitespace)
print(input.toLowerCase()); // → ' hello flutter '
print(input.toUpperCase()); // → ' HELLO FLUTTER '
print(input.contains('Flutter')); // → true
print(input.isEmpty); // → false
print(''.isEmpty); // → true
print('X'.isNotEmpty); // → true
// In the Tic-Tac-Toe search AI, this is used everywhere:
String cell = '';
print(cell.isEmpty); // → true (cell not marked yet)
cell = 'X';
print(cell.isEmpty); // → false (cell is marked)
print(cell.isNotEmpty); // → true
6. Operators You’ll See Constantly in Flutter
A handful of operators appear in almost every Flutter code file. Learn these and a huge amount of Flutter code becomes immediately readable:
// ── Arithmetic ────────────────────────────────────────────────────────────
int a = 10, b = 3;
print(a + b); // 13
print(a - b); // 7
print(a * b); // 30
print(a / b); // 3.3333... (always double in Dart)
print(a ~/ b); // 3 ← integer division (truncates decimal)
print(a % b); // 1 ← remainder (modulo)
// ── Comparison ────────────────────────────────────────────────────────────
print(a == b); // false
print(a != b); // true
print(a > b); // true
print(a <= b); // false
// ── Logical ───────────────────────────────────────────────────────────────
bool isOver = true;
bool isAiMode = false;
print(isOver && isAiMode); // false (both must be true)
print(isOver || isAiMode); // true (at least one must be true)
print(!isOver); // false (negation)
// ── Ternary: condition ? valueIfTrue : valueIfFalse ───────────────────────
String player = 'X';
String next = player == 'X' ? 'O' : 'X'; // → 'O'
// This is used constantly in Flutter for switching player turns
// ── Compound assignment ───────────────────────────────────────────────────
int score = 0;
score += 1; // same as: score = score + 1
score -= 1; // same as: score = score - 1
score *= 2; // same as: score = score * 2
score++; // same as: score = score + 1
score--; // same as: score = score - 1
// ── Null-aware operators ──────────────────────────────────────────────────
String? name = null;
name ??= 'Anonymous'; // assign only if name is null
print(name); // → 'Anonymous'
name ??= 'Other'; // name is now 'Anonymous', not null — skipped
print(name); // → 'Anonymous' (unchanged)
7. Functions: Positional vs Named Parameters
Every Flutter widget constructor is a Dart function with named parameters. Understanding how Dart functions work makes the entire Flutter widget API feel far less mysterious.
// ── Basic function: return type, name, positional parameters ─────────────
int add(int a, int b) {
return a + b;
}
print(add(3, 4)); // → 7
// Return type void = the function returns nothing
void printScore(String player, int score) {
print('$player: $score points');
}
printScore('X', 3); // → X: 3 points
// ── Named parameters: wrapped in {} ──────────────────────────────────────
// Named parameters are optional by default (can be omitted at call site)
void showResult({String player = '', bool isDraw = false}) {
if (isDraw) {
print("It's a draw!");
} else {
print('$player wins! 🎉');
}
}
showResult(player: 'X'); // → X wins! 🎉
showResult(isDraw: true); // → It's a draw!
showResult(player: 'O', isDraw: false); // → O wins! 🎉
// ── required named parameters: must be provided ───────────────────────────
String buildResultMessage({
required String currentPlayer, // ← must be passed, no default
bool isDraw = false,
}) {
if (isDraw) return "It's a draw! 🤝";
return '$currentPlayer wins! 🎉';
}
print(buildResultMessage(currentPlayer: 'X')); // → X wins! 🎉
print(buildResultMessage(currentPlayer: 'O', isDraw: true)); // → It's a draw! 🤝
// buildResultMessage(); // ❌ compile error: currentPlayer is required
// ── This is EXACTLY how Flutter widget constructors work ─────────────────
// Text(
// 'Hello', ← positional (required)
// style: TextStyle(...), ← named (optional)
// textAlign: TextAlign.center, ← named (optional)
// )
//
// ElevatedButton(
// onPressed: () {}, ← named (required in practice)
// child: Text('Tap me'), ← named (required in practice)
// )
8. Arrow Functions and Callbacks
Arrow syntax (=>) is a shorthand for single-expression functions. You will see it everywhere in Flutter, especially in onTap, onPressed, and list operations:
// ── Regular function vs arrow function ────────────────────────────────────
// These two are identical:
String getWinner(String player) {
return '$player wins!';
}
String getWinnerArrow(String player) => '$player wins!';
// ── Arrow functions in Flutter widgets ───────────────────────────────────
// You have seen these throughout the Tic-Tac-Toe series:
void _switchPlayer() => currentPlayer = currentPlayer == 'X' ? 'O' : 'X';
void _resetGame() => setState(() => _initializeGame());
// ── Anonymous functions (lambdas) ─────────────────────────────────────────
// A function without a name, assigned to a variable or passed directly:
var multiply = (int x, int y) => x * y;
print(multiply(3, 4)); // → 12
// ── Passing functions as callbacks ────────────────────────────────────────
// In Flutter, onTap and onPressed take a VoidCallback (a function that
// takes no arguments and returns nothing):
// Defining a callback type:
// typedef VoidCallback = void Function();
void runCallback(void Function() callback) {
print('Before callback');
callback();
print('After callback');
}
runCallback(() => print('Inside the callback!'));
// → Before callback
// → Inside the callback!
// → After callback
// ── In Flutter this looks like ────────────────────────────────────────────
// GestureDetector(
// onTap: () => _handleTap(index), ← anonymous arrow function passed as callback
// child: ...,
// )
// FilledButton(
// onPressed: _resetGame, ← named function reference passed as callback
// child: Text('Play Again'),
// )
9. Classes, Constructors, and Objects
Flutter apps are built entirely from classes — every widget, every state object, every data model is a class. Understanding how Dart classes work unlocks the whole framework.
// ── A simple data class ────────────────────────────────────────────────────
class Player {
// Fields (properties)
final String symbol; // 'X' or 'O'
final String name;
int wins; // mutable: can change
int losses;
// Constructor using initializer list syntax
Player({
required this.symbol,
required this.name,
this.wins = 0, // default value
this.losses = 0,
});
// A method (function on the class)
void recordWin() {
wins++;
}
void recordLoss() {
losses++;
}
// Getter: read-only computed property
String get record => '$wins wins, $losses losses';
// toString: called when you print or interpolate the object
@override
String toString() => 'Player($symbol: $name, $record)';
}
// Creating objects (instances of the class)
final playerX = Player(symbol: 'X', name: 'Alice');
final playerO = Player(symbol: 'O', name: 'Bob');
playerX.recordWin();
playerO.recordLoss();
print(playerX.record); // → 1 wins, 0 losses
print(playerO); // → Player(O: Bob, 0 wins, 1 losses)
Flutter widgets are classes that extend either StatelessWidget or StatefulWidget. The pattern you already know from the series is just Dart class inheritance:
// ── How widgets relate to Dart classes ────────────────────────────────────
// A StatelessWidget is a class that extends StatelessWidget
// (StatelessWidget is itself a class defined in the Flutter SDK)
class WelcomeMessage extends StatelessWidget {
const WelcomeMessage({super.key, required this.playerName});
final String playerName; // final field, set in constructor
@override // override means: replace the parent class's version of this method
Widget build(BuildContext context) {
return Text('Welcome, $playerName!');
}
}
// A data model class (no widget, pure Dart)
class GameResult {
final String winner; // 'X', 'O', or 'draw'
final int movesPlayed;
final DateTime playedAt;
const GameResult({
required this.winner,
required this.movesPlayed,
required this.playedAt,
});
bool get isXWin => winner == 'X';
bool get isOWin => winner == 'O';
bool get isDraw => winner == 'draw';
}
// Usage
final result = GameResult(
winner: 'X',
movesPlayed: 7,
playedAt: DateTime.now(),
);
print(result.isXWin); // → true
print(result.isDraw); // → false
10. Lists: Your Game Board and More
A List is an ordered collection of items — Dart’s version of an array. The Tic-Tac-Toe board is a List<String> of length 9. Almost every Flutter app uses lists to drive ListView.builder, GridView.builder, or state variables.
// ── Creating lists ────────────────────────────────────────────────────────
// Empty list of strings (growable)
List<String> moves = [];
List<String> moves2 = <String>[]; // same thing, explicit type
// List with initial values
List<String> players = ['Alice', 'Bob', 'Carol'];
// Fixed-length list filled with a default value
// This is exactly how the Tic-Tac-Toe board is created:
List<String> board = List.filled(9, '');
// → ['', '', '', '', '', '', '', '', '']
// ── Reading and writing by index ──────────────────────────────────────────
print(board[0]); // → '' (first cell, top-left)
print(board[4]); // → '' (center cell)
print(board.length); // → 9
board[4] = 'X'; // mark center cell
print(board[4]); // → 'X'
// ── Common list operations ────────────────────────────────────────────────
List<String> fruits = ['Apple', 'Banana', 'Cherry'];
fruits.add('Date'); // add to the end
print(fruits.length); // → 4
fruits.remove('Banana'); // remove by value
print(fruits); // → [Apple, Cherry, Date]
fruits.removeAt(0); // remove by index
print(fruits); // → [Cherry, Date]
fruits.insert(0, 'Avocado'); // insert at index
print(fruits); // → [Avocado, Cherry, Date]
print(fruits.contains('Cherry')); // → true
print(fruits.indexOf('Date')); // → 2
print(fruits.isEmpty); // → false
print(fruits.isNotEmpty); // → true
fruits.clear(); // remove all items
print(fruits.isEmpty); // → true
// ── Iterating over a list ─────────────────────────────────────────────────
List<String> board = ['X', '', 'O', '', 'X', '', '', '', 'O'];
// for-in loop: the most readable for simple iteration
for (final cell in board) {
print(cell);
}
// for loop with index: when you need the index too
for (int i = 0; i < board.length; i++) {
if (board[i].isEmpty) {
print('Cell $i is empty');
}
}
// ── Functional methods: where, map, any, every ────────────────────────────
// .where: filter — returns only items matching the condition
final emptyCells = board.where((cell) => cell.isEmpty).toList();
print(emptyCells.length); // → 5
// Collect the INDEXES of empty cells (used in Easy AI):
final emptyIndexes = [
for (int i = 0; i < board.length; i++)
if (board[i].isEmpty) i
];
print(emptyIndexes); // → [1, 3, 5, 6, 7]
// .any: returns true if at least one item matches
bool hasX = board.any((cell) => cell == 'X');
print(hasX); // → true
// .every: returns true only if ALL items match
// This is exactly how draw detection works in the game:
bool boardFull = board.every((cell) => cell.isNotEmpty);
print(boardFull); // → false (board still has empty cells)
// .map: transform each item and return a new list
List<int> lengths = ['Apple', 'Banana', 'Kiwi'].map((s) => s.length).toList();
print(lengths); // → [5, 6, 4]
// ── Creating a copy of a list ─────────────────────────────────────────────
// IMPORTANT for the minimax AI: always copy before mutating
List<String> original = List.filled(9, '');
List<String> copy = List.from(original); // independent copy
copy[0] = 'X';
print(original[0]); // → '' (unchanged)
print(copy[0]); // → 'X' (copy is modified)
11. Maps: Scoreboards and Key-Value Lookups
A Map is a collection of key-value pairs — Dart’s dictionary or hash table. Perfect for scoreboards, configuration, and any situation where you want to look something up by name rather than by index:
// ── Creating a map ────────────────────────────────────────────────────────
Map<String, int> scores = {
'X': 0,
'O': 0,
'draws': 0,
};
// ── Reading values ────────────────────────────────────────────────────────
print(scores['X']); // → 0
print(scores['O']); // → 0
print(scores['draws']); // → 0
print(scores['missing']); // → null (key doesn't exist, returns null)
// ── Writing values ────────────────────────────────────────────────────────
scores['X'] = 3;
scores['O'] = 1;
print(scores['X']); // → 3
// ── Safe reading with ?? ───────────────────────────────────────────────────
// Map reads return a nullable because the key might not exist:
int xScore = scores['X'] ?? 0; // 0 if key is missing
print(xScore); // → 3
// ── Updating scores after a game ─────────────────────────────────────────
String? winner = 'X';
void updateScores(String? winner) {
if (winner == 'X' || winner == 'O') {
scores[winner!] = (scores[winner] ?? 0) + 1;
} else {
scores['draws'] = (scores['draws'] ?? 0) + 1;
}
}
updateScores('X');
print(scores); // → {X: 4, O: 1, draws: 0}
// ── Map operations ────────────────────────────────────────────────────────
print(scores.containsKey('X')); // → true
print(scores.containsKey('Z')); // → false
print(scores.keys.toList()); // → [X, O, draws]
print(scores.values.toList()); // → [4, 1, 0]
print(scores.length); // → 3
scores.remove('draws');
print(scores); // → {X: 4, O: 1}
// ── Iterating over a map ──────────────────────────────────────────────────
Map<String, int> playerScores = {'Alice': 5, 'Bob': 3, 'Carol': 7};
for (final entry in playerScores.entries) {
print('${entry.key}: ${entry.value} points');
}
// → Alice: 5 points
// → Bob: 3 points
// → Carol: 7 points
12. Control Flow: if, for, switch, and Ternary
Control flow in Dart looks almost identical to Java, JavaScript, or C#. Here is everything you need with Flutter context:
// ── if / else if / else ───────────────────────────────────────────────────
String? winner = 'X';
int streakX = 3;
if (winner == 'X') {
if (streakX >= 3) {
print('X is on fire! 🔥 Streak: $streakX');
} else {
print('X wins! 🎉');
}
} else if (winner == 'O') {
print('O wins! 🎉');
} else {
print("It's a draw! 🤝");
}
// ── Ternary: shorthand if-else ────────────────────────────────────────────
String nextPlayer = winner == 'X' ? 'O' : 'X';
// Nested ternary (the status text logic from the game):
String statusText = winner != null
? '$winner wins! 🎉'
: (isDraw ? "It's a draw! 🤝" : "Player $currentPlayer's turn");
// Read as: if winner != null → winner message, else if isDraw → draw, else turn
// ── for loop ──────────────────────────────────────────────────────────────
// Standard C-style for loop
for (int i = 0; i < 9; i++) {
print('Cell $i: ${board[i]}');
}
// for-in: simpler when you don't need the index
for (final cell in board) {
if (cell.isEmpty) print('Empty cell found');
}
// ── while loop ────────────────────────────────────────────────────────────
int count = 0;
while (count < 3) {
print('Count: $count');
count++;
}
// ── switch statement ──────────────────────────────────────────────────────
// Used in the AI difficulty selection in Tic-Tac-Toe Part 3:
Difficulty difficulty = Difficulty.hard;
switch (difficulty) {
case Difficulty.easy:
print('Random moves');
case Difficulty.medium:
print('Defensive moves');
case Difficulty.hard:
print('Minimax — unbeatable');
}
// Note: Dart 3 switch no longer requires 'break' — falls through is opt-in
13. Enums: Named Constants in Flutter
Enums define a fixed set of named values. They are safer than using strings or integers for things like game modes and difficulty levels because the compiler catches typos and you get autocomplete:
// ── Defining enums ────────────────────────────────────────────────────────
enum GameMode {
twoPlayers,
vsAi,
}
enum Difficulty {
easy,
medium,
hard,
}
// ── Using enums ───────────────────────────────────────────────────────────
GameMode mode = GameMode.vsAi;
Difficulty level = Difficulty.hard;
// Enums work perfectly in switch:
switch (level) {
case Difficulty.easy:
print('Easy — random moves');
case Difficulty.medium:
print('Medium — defensive');
case Difficulty.hard:
print('Hard — unbeatable minimax');
}
// Enums in conditions:
if (mode == GameMode.vsAi) {
print('Playing against the AI');
}
// ── Why enums beat strings ────────────────────────────────────────────────
// String version:
String mode2 = 'vsAi';
if (mode2 == 'vsai') { // ← typo: 'vsai' not 'vsAi' — compiles fine, bug at runtime
print('This never runs');
}
// Enum version:
GameMode mode3 = GameMode.vsAi;
// if (mode3 == GameMode.vsai) // ← compile error: 'vsai' doesn't exist — caught immediately
// ── Enum properties (useful extras) ──────────────────────────────────────
print(Difficulty.hard.name); // → 'hard' (built-in .name getter)
print(Difficulty.values); // → [Difficulty.easy, Difficulty.medium, Difficulty.hard]
print(Difficulty.values.length); // → 3
14. async/await and Future: Making Sense of Delays and APIs
Flutter apps do many things that take time: waiting for a network response, reading from storage, or pausing between moves in a game. Dart represents these “eventual values” with Future<T> and lets you work with them cleanly using async and await.
// ── What is a Future? ─────────────────────────────────────────────────────
// A Future<T> is a value of type T that will be available LATER.
// Like a restaurant order ticket — you don't have the food now,
// but you will get it when it's ready.
// ── Marking a function as async ───────────────────────────────────────────
// To use await inside a function, mark it with async.
// async functions always return a Future automatically.
Future<void> waitAndPrint() async {
print('Before delay');
await Future.delayed(const Duration(seconds: 1)); // wait 1 second
print('After delay'); // runs 1 second later
}
// ── This is exactly the AI delay from Tic-Tac-Toe Part 3 ──────────────────
Future<void> _maybeTriggerAiMove() async {
setState(() => _isAiThinking = true);
await Future.delayed(const Duration(milliseconds: 400)); // AI "thinks"
_makeAiMove(); // runs after the 400ms delay
}
// ── Returning a value from an async function ──────────────────────────────
Future<int> getHighScore() async {
await Future.delayed(const Duration(milliseconds: 100)); // simulate storage read
return 42; // the Future completes with this value
}
// Calling it with await:
Future<void> main() async {
int score = await getHighScore(); // waits for the Future to complete
print('High score: $score'); // → High score: 42
}
// ── await does NOT block the UI thread ────────────────────────────────────
// This is the critical mental model:
//
// Synchronous code: ──────────────── blocks everything while running ────
// Async code: ── pause here ── Flutter renders frames ── resume ──
//
// When you await something, Flutter can still draw frames and handle
// other events during the wait. The UI stays responsive.
// ── try/catch with async ──────────────────────────────────────────────────
// Async operations can fail (network down, file not found, etc.)
// Wrap them in try/catch just like synchronous code:
Future<String> fetchPlayerName() async {
try {
await Future.delayed(const Duration(milliseconds: 200)); // simulate network
return 'Alice'; // success
} catch (e) {
print('Error fetching name: $e');
return 'Unknown Player'; // fallback
}
}
// ── Async in Flutter StatefulWidget ───────────────────────────────────────
// Call async functions from event handlers in State class:
class _MyWidgetState extends State<MyWidget> {
String _playerName = 'Loading...';
@override
void initState() {
super.initState();
_loadPlayerName(); // fire and forget — don't await here
}
Future<void> _loadPlayerName() async {
final name = await fetchPlayerName();
setState(() {
_playerName = name; // update UI when the name arrives
});
}
@override
Widget build(BuildContext context) {
return Text(_playerName); // shows 'Loading...' then 'Alice'
}
}
// ── Future.delayed ────────────────────────────────────────────────────────
// Creates a Future that completes after a specified duration.
// Used in the game for the AI thinking pause:
Future.delayed(const Duration(milliseconds: 400), () {
// This callback runs after 400ms
_makeAiMove();
});
// Or with await:
await Future.delayed(const Duration(seconds: 2));
print('2 seconds have passed');
15. Putting It All Together: A Pure Dart Game Snapshot
Here is everything from this crash course combined into a working pure Dart game engine — no Flutter widgets, just Dart. Run this in DartPad to see all the concepts working together:
// Pure Dart Tic-Tac-Toe — run this in DartPad at dartpad.dev
// Every concept from this crash course appears here.
import 'dart:math';
// ── Enum (Section 13) ─────────────────────────────────────────────────────
enum GameMode { twoPlayers, vsAi }
// ── Class with constructor and methods (Section 9) ────────────────────────
class TicTacToeGame {
// List (Section 10): flat board of 9 cells
List<String> board = List.filled(9, '');
// Variables (Section 2): basic types
String currentPlayer = 'X';
String? winner; // nullable (Section 4): null until someone wins
bool isDraw = false;
// Map (Section 11): score tracking
Map<String, int> scores = {'X': 0, 'O': 0, 'draws': 0};
// const (Section 3): compile-time constant list
static const List<List<int>> winningCombos = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
// ── Function with named parameters (Section 7) ────────────────────────
bool makeMove({required int index}) {
// Control flow (Section 12): guard clause
if (board[index].isNotEmpty || winner != null || isDraw) {
return false; // invalid move
}
// Update the board
board[index] = currentPlayer;
// Check results in the correct order
if (_checkWinner()) {
// Map update (Section 11) with ?? null safety (Section 4)
scores[winner!] = (scores[winner] ?? 0) + 1;
return true;
}
// List.every (Section 10): draw detection
if (board.every((cell) => cell.isNotEmpty)) {
isDraw = true;
scores['draws'] = (scores['draws'] ?? 0) + 1;
return true;
}
// Switch player using ternary (Section 6)
currentPlayer = currentPlayer == 'X' ? 'O' : 'X';
return true;
}
// ── Private helper method ─────────────────────────────────────────────
bool _checkWinner() {
for (final combo in winningCombos) {
final a = combo[0], b = combo[1], c = combo[2];
if (board[a].isNotEmpty && board[a] == board[b] && board[a] == board[c]) {
winner = board[a]; // String? assigned a String value
return true;
}
}
return false;
}
// ── Getter (Section 9) ────────────────────────────────────────────────
// String interpolation (Section 5)
String get statusText {
if (winner != null) return '$winner wins! 🎉';
if (isDraw) return "It's a draw! 🤝";
return "Player $currentPlayer's turn";
}
// ── Arrow function (Section 8) ────────────────────────────────────────
void reset() => board = List.filled(9, '');
void printBoard() {
for (int row = 0; row < 3; row++) {
final start = row * 3;
final r = board.sublist(start, start + 3)
.map((c) => c.isEmpty ? '·' : c)
.join(' │ ');
print(' $r');
if (row < 2) print('───┼───┼───');
}
print('');
}
}
// ── async/await (Section 14): simulate AI thinking delay ─────────────────
Future<int> getAiMove(List<String> board) async {
await Future.delayed(const Duration(milliseconds: 50)); // "thinking"
final rng = Random();
final available = [
for (int i = 0; i < board.length; i++)
if (board[i].isEmpty) i
];
return available.isEmpty ? -1 : available[rng.nextInt(available.length)];
}
// ── main with async ───────────────────────────────────────────────────────
void main() async {
final game = TicTacToeGame();
print('=== Tic-Tac-Toe Demo ===n');
// Play 5 moves manually
final moves = [4, 0, 8, 2, 6]; // X plays: 4,8,6 — a diagonal win!
for (final move in moves) {
final success = game.makeMove(index: move);
if (success) {
print('${game.currentPlayer == 'X' ? 'O' : 'X'} played cell $move:');
game.printBoard();
print(game.statusText);
print('---');
}
if (game.winner != null || game.isDraw) break;
}
// Final scores
print('n📊 Final Scores:');
for (final entry in game.scores.entries) {
print(' ${entry.key}: ${entry.value}');
}
}
16. Common Dart Mistakes Flutter Beginners Make
Mistake 1: Forgetting to add ? to nullable variables
// ❌ Wrong — winner can be null but String doesn't allow null
String winner; // compile error: must be initialized or nullable
// ✅ Correct — add ? to make it nullable
String? winner; // can be null until someone wins
// ✅ Also correct — initialize with a non-null value
String winner2 = 'none'; // non-nullable with a default
Mistake 2: Using = instead of == in conditions
// ❌ Wrong — = is assignment, not comparison (Dart catches this as a compile error)
if (currentPlayer = 'X') { ... } // compile error
// ✅ Correct — == is the equality check
if (currentPlayer == 'X') { ... }
Mistake 3: Mutating a list you didn’t intend to change
// ❌ Wrong — both variables point to the SAME list in memory
List<String> board = List.filled(9, '');
List<String> temp = board; // NOT a copy — same reference
temp[0] = 'X';
print(board[0]); // → 'X' — original board was mutated!
// ✅ Correct — List.from() creates an independent copy
List<String> temp2 = List.from(board);
temp2[0] = 'O';
print(board[0]); // → 'X' — original unchanged
print(temp2[0]); // → 'O' — copy modified
Mistake 4: Forgetting async/await and getting a Future instead of a value
Future<String> getPlayerName() async => 'Alice';
// ❌ Wrong — name is a Future<String>, not a String
// This won't even compile if you try to use it as a String:
var name = getPlayerName();
print(name); // → Instance of 'Future<String>'
// ✅ Correct — await unwraps the Future to get the String
// (function must be marked async to use await)
Future<void> main() async {
var name = await getPlayerName();
print(name); // → Alice
}
Mistake 5: Using var when final is more appropriate
// ❌ Suboptimal — var implies "I might reassign this" even when you won't
class GameCell {
GameCell({required this.value});
var value; // unclear intent
}
// ✅ Better — final makes your intent clear and prevents accidental reassignment
class GameCell2 {
GameCell2({required this.value});
final String value; // clearly: set once, never changes
}
// General rule: default to final, use var only when you need to reassign
17. What’s Next
You now have every piece of Dart needed to read and write the Flutter Tic-Tac-Toe series. The concepts here — variables, null safety, functions, classes, lists, maps, and async/await — appear in virtually every Flutter app you will ever build.
| Where to go next | What you’ll apply from this post |
|---|---|
| Flutter Widgets: Stateless vs Stateful | Classes, final fields, constructors — every widget is a Dart class |
| Flutter Tutorial for Beginners | All of the above — this is the first real Flutter project |
| Flutter Tic-Tac-Toe Part 1 | Lists (game board), enums, functions, control flow, null safety |
| Flutter Tic-Tac-Toe Part 3 (AI) | async/await (AI delay), List.from() (board copy), enums (difficulty), recursion |
| TextField and Forms | Classes, callbacks, named parameters, string operations |
Generics, mixins, extensions, abstract classes, streams, Dart 3.x records and patterns, isolates, and compute() are all intentionally excluded. These are important but belong in a dedicated Dart language series. This post covers the minimum needed to write real Flutter apps — everything else is extra.
