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:

ruby
    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:

ruby
    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 checkmate

Invariants 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.

python
    // 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.

ruby
    apply_move(state, move):
        // Precondition: move must be valid
        // Precondition: game must not be over
        ...

Postconditions: What will be true after the function returns.

ruby
    apply_move(state, move):
        ...
        // Postcondition: returned state has opposite turn
        // Postcondition: the piece has moved
        // Postcondition: all invariants still hold

This 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:

ruby
    // 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 all

Remember 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:

python
    // 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 board

Not 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:

typescript
    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.

A partial function: valid inputs map to outputs, but invalid inputs have nowhere to go.

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.

bash
    All Possible Inputs
    +-----------------------------------+
    |                                   |
    |    Invalid Inputs                 |
    |    +-----------------------+      |
    |    |                       |      |
    |    |   Valid Inputs        |      |
    |    |                       |      |
    |    +-----------------------+      |
    |                                   |
    +-----------------------------------+

At the system's edges---where input enters---we must guard the boundary:

python
    // 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.