The Rules of the Game
Every game has rules. In chess, a bishop moves diagonally. A pawn cannot move backward. You cannot castle through check. These rules define what is possible---and by implication, what is impossible.
Systems, too, have rules. Not every sequence of operations makes sense. Not every input is valid. Part of building a system is defining its boundaries---the line between what's allowed and what isn't.
Validity: What Is Allowed?
A move in chess is valid if it follows the rules. But what does that mean precisely?
A move is valid when ALL of these hold:
is_valid_move(state, move):
has_piece_at(state.board, move.from) AND
belongs_to_current_player(state, move.from) AND
can_reach(state, move.from, move.to) AND
path_is_clear(state.board, move) AND
king_safe_after(state, move)Each rule is itself a small, focused question:
has_piece_at(board, square):
board[square] is not empty
belongs_to_current_player(state, square):
state.board[square].color == state.turn
king_safe_after(state, move):
not is_in_check(state with move applied, state.turn) Notice something subtle: king_safe_after needs to compute what the board would look like after the move. This means we might compute the future state twice---once to check validity, and once to actually make the move.
As you master these building blocks, you'll learn to see such inefficiencies---and find elegant ways around them. The physical world has constraints: memory, time, energy. Good design respects these realities while keeping the logic clear.
Invariants: What Must Always Be True
Some rules aren't about individual moves---they're about the state itself. These are invariants: properties that must always hold.
Chess Invariants:
- Each player has exactly one king
- No more than 16 pieces per color
- Pawns cannot be on ranks 1 or 8
- The player to move cannot already be in checkmateInvariants are like laws of physics for our system. They're not checked on each operation---they're guaranteed by the design. If our functions are correct, invariants are preserved automatically.
// This should be impossible if our system is correct:
state.white_king_count == 0 // violated invariant!
state.board[a1] == white_pawn // pawn on rank 1!Preconditions and Postconditions
Functions also have rules about how they should be used:
Preconditions: What must be true before calling the function.
apply_move(state, move):
// Precondition: move must be valid
// Precondition: game must not be over
...Postconditions: What will be true after the function returns.
apply_move(state, move):
...
// Postcondition: returned state has opposite turn
// Postcondition: the piece has moved
// Postcondition: all invariants still holdThis is a contract between the function and its callers. "If you give me valid input (preconditions), I promise to give you valid output (postconditions)."
Encoding Rules in Types
Sometimes we can encode rules directly in our types, making violations impossible:
// Instead of checking while playing:
if color == "white" or color == "black":
...
// Encode in the type:
Color: white OR black
// Now "purple" is not a valid Color at allRemember the compiler---the translator that checks our code before it runs? The more rules we encode in types, the more the compiler catches before we even start playing:
// A board that can ONLY have valid positions:
ValidBoard:
squares: List of 64 SquareContents
white_king: Square // must exist
black_king: Square // must exist
// Invariant: kings are on the boardNot all rules can be encoded in types. "The king is not in check" depends on the positions of all pieces---too complex for most type systems. But the more we encode, the fewer checks we need while the game is actually being played.
Rules Make Functions Partial
Let's step back and think about what rules mean mathematically.
We've been describing changes to our system---events, moves, transitions---as functions. A function maps inputs to outputs:
apply_move: (State, Move) -> State But this notation hides something important. Can apply_move produce a result for any state and any move? No. An illegal move has no valid result. The function is partial---it's not defined for all inputs.
Rules create holes in our function---regions of the input space where the mapping simply doesn't exist. When we call apply_move with an illegal move, we're asking "what is 5 divided by 0?"---a question with no answer.
This raises a fundamental question: what should happen when we hit a hole?
- Crash: The program stops. Dramatic, but unhelpful.
- Return garbage: Pretend nothing is wrong. Dangerous.
- Throw an exception: Signal failure through a side channel. Common, but has drawbacks.
- Return a special value: Make failure part of the output. Elegant, but requires new concepts.
For now, notice the deep connection: rules define where functions have holes. Validation is the act of checking whether an input falls into a hole before we try to compute with it.
The Boundary Between Valid and Invalid
Every system has a boundary---a frontier between the valid interior and the invalid exterior.
All Possible Inputs
+-----------------------------------+
| |
| Invalid Inputs |
| +-----------------------+ |
| | | |
| | Valid Inputs | |
| | | |
| +-----------------------+ |
| |
+-----------------------------------+At the system's edges---where input enters---we must guard the boundary:
// At the boundary: validate everything
receive_move_from_player(input_text):
move = interpret_as_move(input_text) // "e2 to e4" -> Move
if move is failure:
return error("I don't understand that move")
if not is_valid_move(current_state, move):
return error("That move is not allowed")
// Past the boundary: we trust the move
apply_move(current_state, move)Once past the boundary, we can trust. Inside the valid region, every value satisfies our invariants. We don't need to re-check constantly.
But what happens when we encounter the invalid? When a move breaks the rules? When an operation cannot complete? In the next chapter, we confront failure---and how systems respond when they hit a wall.