An introduction to combinatorial game theory
In this post, I will begin to formalize a small part of
combinatorial game theory using Coq. Combinatorial game theory
attempts to model sequential, deterministic games between two players,
both of which take turns causing the game state to change. It
restricts itself to perfect information games, where the current
configuration of the game is known to both players. Thus, it can be
used to study games such as chess, tic-tac-toe, and go, but not games
such as poker or blackjack.
The power of combinatorial game theory comes from abstracting away
details that are too specific to each game, such as whether it is
played moving pieces on a board, how the pieces can move, etc. It
defines a single mathematical object that can represent all games
uniformily, allowing us to study general situations that could occur
in many kinds of games. In this post, I will present this
representation and discuss why it makes sense. I will start with a
more intuitive formalization of combinatorial games, and then show how
combinatorial game theory applies to all of those.
In combinatorial games, players are traditionally called left and
right.
Defining combinatorial games
Inductive player : Type := Left | Right.
Each combinatorial game has a set of positions. In chess, for
instance, a position is a chess board with black and white pieces on
it. The rules of the game determine which moves are available to
each player at a given position. They also describe how the game ends,
and which player wins in that case. To simplify our analysis, we will
assume that games end when some player must play but has no moves
left, in which case the other player wins. This frees us from having
to model how matches end separately for each game. We will also
consider only finite games, i.e. ones that can't be played
indefinitely. Notice that these two assumptions taken together,
strictly speaking, rule out many interesting games: chess can end in a
draw, something that can't happen in our model.
Translating the above requirements into code results in the following
definition:
Inductive combinatorial_game := CombinatorialGame {
position : Type;
moves : player -> position -> list position;
valid_move next current := exists s, In next (moves s current);
finite_game : well_founded valid_move
}.
To formalize how games are played, we define a predicate Match cg
first winner m that represents a match of game cg where player
first starts and player winner wins. m is the sequence of
positions traversed during the match, from first to last.
Definition other (s : player) : player :=
match s with
| Left => Right
| Right => Left
end.
Inductive Match cg : forall (first winner : player), list (position cg) -> Prop :=
| Match_end : forall pl pos,
moves cg pl pos = [] ->
Match cg pl (other pl) [pos]
| Match_move : forall pl winner pos next m,
In next (moves cg pl pos) ->
Match cg (other pl) winner (next :: m) ->
Match cg pl winner (pos :: next :: m).
In the Match_end clause, we check that the current player has no
moves left. In Match_move, we check that the current player can make
a move and that the match can then proceed with the other positions.
We will now define a combinatorial game that is, in a precise sense to
be explained later, the most general one. This is what combinatorial
game theory uses to study combinatorial games.
The crucial observation is that in the definition of Match we only
care about the moves that can be made from a given position, but not
about what the positions themselves are. This suggests a definition
where each position is just a pair of sets, one for each player's
moves. This forms the type game of games.
A universal game
Inductive game := Game {
left_moves : list game;
right_moves : list game
}.
Each position in this game can be pictured as an
arbitrarily-branching tree with two sets of children. On each player's
turn, they choose one child in their set of moves to be the new
position, and lose if they can't choose anything.
The simplest game is zero, where both players have no moves
available. It is a game where the first player always loses.
Definition zero : game := Game [] [].
Using zero we can define star, where the first player always
wins by making a move to zero.
Definition star : game := Game [zero] [zero].
A small variation gives us one and minus_one, where Left and
Right always win, respectively.
Definition one : game := Game [zero] [].
Definition minus_one : game := Game [] [zero].
It should be possible to encapsulate game in a
combinatorial_game record. Defining moves is simple, but proving
that games are finite requires some additional work. The nested
inductive in the definition of game makes Coq generate an induction
principle that is too weak to be useful:
game_ind
: forall P : game -> Prop,
(forall left_moves right_moves : list game,
P {| left_moves := left_moves; right_moves := right_moves |}) ->
forall g : game, P g
The usual solution is to define a new one by hand that gives us
induction hypotheses to use:
game_ind
: forall P : game -> Prop,
(forall left_moves right_moves : list game,
P {| left_moves := left_moves; right_moves := right_moves |}) ->
forall g : game, P g
Lemma lift_forall :
forall T (P : T -> Prop),
(forall t, P t) ->
forall l, Forall P l.
Proof. induction l; auto. Defined.
Definition game_ind' (P : game -> Prop)
(H : forall l r, Forall P l -> Forall P r -> P (Game l r)) :
forall g : game, P g :=
fix F (g : game) : P g :=
match g with
| Game l r =>
H l r (lift_forall _ P F l) (lift_forall _ P F r)
end.
Using this principle, we can now prove that games always
terminate and define a combinatorial_game for game.
Definition game_as_cg : combinatorial_game.
refine ({| position := game;
moves s := if s then left_moves else right_moves |}).
intros p1.
induction p1 as [l r IHl IHr] using game_ind'.
(* ... *)
Defined.
Game embeddings
Definition game_embedding (cg1 cg2 : combinatorial_game)
(embedding : position cg1 -> position cg2) : Prop :=
forall first winner (m : list (position cg1)),
Match cg1 first winner m ->
Match cg2 first winner (map embedding m).
With this notion of game embedding, combinatorial games form a
category. I will now show that every combinatorial game can be
embedded in game, making game a terminal object in this category
and the most general combinatorial game. In this formulation, it is
only a weakly terminal object (i.e., embeddings are not unique), as
we are using Coq lists to represent sets.
To embed an arbitrary combinatorial game into game, we can define a
function by well-founded recursion over the proof that games are
finite. In order to do this, we need a higher-order function
map_game that allows us to perform a well-founded recursive call on
a list of next moves. map_game acts like map, but passes to its
argument function a proof that the element is a valid_move.
Fixpoint map_In {A B} (l : list A) : (forall x, In x l -> B) -> list B :=
match l with
| [] => fun _ => []
| x :: l' => fun f =>
f x (or_introl _ eq_refl)
:: map_In l' (fun x P => f x (or_intror _ P))
end.
Definition map_game {A} (cg : combinatorial_game)
(pos : position cg) (p : player)
(f : forall pos', valid_move cg pos' pos -> A) : list A :=
map_In (moves cg p pos) (fun pos' P => f pos' (ex_intro _ p P)).
Using this function and the Fix combinator in the standard
library, we write a generic embedding function embed_in_game. Like a
regular fixpoint combinator, Fix takes a function that does a
recursive call by applying its argument (here, F). The difference is
that this argument must take a proof that shows that the recursive
call is valid.
The behavior of embed_in_game is simple: it calls itself recursively
for each possible next position, and includes that position in the set
of moves of the appropriate player.
Definition embed_in_game cg (pos : position cg) : game :=
Fix (finite_game cg)
(fun _ => position game_as_cg)
(fun pos F =>
Game (map_game cg pos Left F)
(map_game cg pos Right F))
pos.
Definitions that use Fix can be hard to manipulate directly, so
we need to prove some equations that describe the reduction behavior
of the function. I've hidden some of the auxiliary lemmas and proofs
for clarity; as usual, you can find them in the original .v file.
The proof that we can unfold embed_in_game once uses the Fix_eq
lemma in the standard library.
Lemma embed_in_game_eq cg (pos : position cg) :
embed_in_game cg pos =
Game (map (embed_in_game cg) (moves cg Left pos))
(map (embed_in_game cg) (moves cg Right pos)).
Proof.
unfold embed_in_game in *.
rewrite Fix_eq.
(* ... *)
Qed.
With this lemma, we can show that moves and embed_in_game commute.
Lemma embed_in_game_moves cg (p : position cg) :
forall s, moves game_as_cg s (embed_in_game cg p) =
map (embed_in_game cg) (moves cg s p).
Proof.
intros.
rewrite embed_in_game_eq.
destruct s; reflexivity.
Qed.
We are now ready to state and prove our theorem: every
combinatorial game can be embedded in game.
Theorem embed_in_game_correct cg :
game_embedding cg game_as_cg (embed_in_game cg).
Proof.
unfold game_embedding.
intros first winner m MATCH.
induction MATCH as [winner p H|s winner p p' m IN MATCH IH];
simpl; constructor; eauto.
- rewrite embed_in_game_moves, H. reflexivity.
- rewrite embed_in_game_moves. auto using in_map.
Qed.