How to (Almost) Never Lose A Game

julia
games
Author

Alec Loudenback

Published

January 20, 2024

Count Your Chickens is a cooperative game for children. I have very much enjoyed playing it with my daughter but an odd pattern appeared across many attempts: we never lost.

The game is entirely luck-based and is fairly straightforward. There are a bunch of chicks out of the chicken coop, and as you move from one space to another, you collect and return the chicks to the coop based on how many spaces you moved. You simply spin a spinner and move to the next icon that matches what you spun. There are some bonus spaces (in blue) where you get to collect an extra chick and you can also spin a fox which removes a chick from the coop.

I had a suspicion that if you were missing chicks from the game, that the game would quickly become much easier to “win” by getting all of the chicks back into the coop. Simultaneously, I had learned about SumTypes.jl and wanted to try it out. So could we simulate the game by using enumerated types? Yes, and here’s how it worked:

Setup

We’ll use four packages:

1using SumTypes
2using CairoMakie
3using ColorSchemes
4using DataFramesMeta
1
Used to model the different types of squares.
2
We’ll use this to plot outcomes of games.
3
To show the distribution of outcomes, we’ll use a custom color set for the plot.
4
Dataframe manipulation will help transform our simulated results for plotting.

Sum Types

What they are is nicely summarized as:

Sum types, sometimes called ‘tagged unions’ are the type system equivalent of the disjoint union operation (which is not a union in the traditional sense). In the Rust programming language, these are called “Enums”, and they’re more general than what Julia calls an enum.

At the end of the day, a sum type is really just a fancy word for a container that can store data of a few different, pre-declared types and is labeled by how it was instantiated.

Users of statically typed programming languages often prefer Sum types to unions because it makes type checking easier. In a dynamic language like Julia, the benefit of these objects is less obvious, but there are cases where they’re helpful, like performance sensitive branching on heterogeneous types, and enforcing the handling of cases.

We have two sets of things in this game which are similar and candidates for SumTypes:

  1. The animals on the spinner, and
  2. The different types of squares on the board.

It’s fairly simple for Animal, but Square needs a little explanation:

"Animal resprents the type of creature on the spinner and board."
@sum_type Animal begin
    Cow
    Tractor
    Sheep
    Dog
    Pig
    Fox
end

"""
Square represents the three different kinds of squares, including regular and bonus squares that contain data indicating the `Animal` in the square.
"""
@sum_type Square begin
    Empty
1    Regular(::Animal)
    Bonus(::Animal)
end
1
@sum_type will create variants that are all of the same type (Square in this case). The syntax Regular(::Animal) indicates that when, e.g, we create a Regular(Dog) we will get a Square that encloses data indicating it’s both a Regular variant of a Square in addition to holding the Dog instance of an Animal. That is, Regular(Dog) is an instance of Square type and does not create a distinct subtype of Square.

A couple of examples to show how this works:

typeof(Pig), Pig isa Animal
(Animal, true)
typeof(Bonus(Dog)), Bonus(Dog) isa Square
(Square, true)

Game Logic

I’ll first define a function that outlines how the game works and allow the number of chicks in play to vary since that’s the thesis for why it might be easier to win with missing pieces. Then I define two helper functions which give us the right behavior depending on the result of the spinner and the current state of the board. They are described in the docstrings.

"""
    playgame(board,total_chicks=40)

Simulate a game of Count Your Chickens and return how many chicks are outside of the coop at the end. The players win if there are no chicks outside of the coop. 
"""
function playgame(board, total_chicks=40)
    position = 0
    chicks_in_coop = 0
    while position < length(board)
        spin = rand((Cow, Tractor, Sheep, Dog, Pig, Fox))
        if spin == Fox
            if chicks_in_coop > 1
                chicks_in_coop -= 1
            end
        else
            result = move(board, position, spin)
            # limit the chicks in coop to available chicks remaining
            moved_chicks = min(total_chicks - chicks_in_coop, result.chicks)
            chicks_in_coop += moved_chicks
            position += result.spaces
        end
    end
    return total_chicks - chicks_in_coop

end
"""
    move(board,cur_position,spin)

Represents the result of a single turn of the game. 
Returns a named pair (tuple) of the number of spaces moved and chicks collected for that turn. 
"""
function move(board, cur_position, spin)
    next_square = findnext(space -> ismatch(space, spin), board, max(cur_position, 1))

    if isnothing(next_square)
        # nothing found that matches, so we must be at the end of the board
        l = length(board) - cur_position + 1
        (spaces=l, chicks=l)
    else
        n_spaces = next_square - cur_position
1        @cases board[next_square] begin
            Empty => (spaces=n_spaces, chicks=n_spaces)
            Bonus => (spaces=n_spaces, chicks=n_spaces + 1)
            Regular => (spaces=n_spaces, chicks=n_spaces)
        end
    end
end
1
SumTypes.jl provides a way to match the value of the board at the next square to Empty (which shouldn’t actually happen), Bonus, or Regular and the result depends on which kind of board we landed on.
"""
    ismatch(space,spin)

True or false depending on if the `spin` (an `Anmial`) matches the data within the `square` (`Animal` if not an `Empty` `Square`). 
"""
function ismatch(square, spin)
    @cases square begin
        Empty => false
1        [Regular, Bonus](a) => spin == a
    end
end
1
The [...] lets us simplify repeated cases while the (a) syntax allows us to reference the encapsulated data within the Square SumType.

Last part of the setup is declaring what the board looks like (unhide if you want to see - it’s just a long array representing each square on the board):

Code
board = [
    Empty,
    Regular(Sheep),
    Regular(Pig),
    Bonus(Tractor),
    Regular(Cow),
    Regular(Dog),
    Regular(Pig),
    Bonus(Cow),
    Regular(Dog),
    Regular(Sheep),
    Regular(Tractor),
    Empty,
    Regular(Cow),
    Regular(Pig),
    Empty,
    Empty,
    Empty,
    Regular(Tractor),
    Empty,
    Regular(Tractor),
    Regular(Dog),
    Bonus(Sheep),
    Regular(Cow),
    Regular(Dog),
    Regular(Pig),
    Regular(Tractor),
    Empty,
    Regular(Sheep),
    Regular(Cow),
    Empty,
    Empty,
    Regular(Tractor),
    Regular(Pig),
    Regular(Sheep),
    Bonus(Dog),
    Empty,
    Regular(Sheep),
    Regular(Cow),
    Bonus(Pig),]

Examples

Here are a couple examples of how the above works. First, here’s an example where we check if our spin (a Pig matches a candidate square Bonus(Pig)):

ismatch(Bonus(Pig), Pig)
true

If our first spin was a Pig, then we would move 3 spaces and collect 3 chicks:

move(board, 0, Pig)
(spaces = 3, chicks = 3)

And a simulation of a game:

playgame(board, 40)
0

Game Dynmaics

To understand the dynamics, we will simulate 1000 games for each variation of chicks from 35 (less than should come with the game) to 42 (more than should come with the game).

chick_range = 35:42
n = 1000
n_chicks = repeat(chick_range, n)
outcomes = playgame.(Ref(board), n_chicks)

df = DataFrame(; n_chicks, outcomes)


df = @chain df begin
    # create a wide table with the first column being the 
    # number of remaining chicks while the others 
    # total chicks
    unstack(:outcomes, :n_chicks, :outcomes, combine=length)
    # turn the missing values into 0 times this combination occurred
    coalesce.(_, 0)
    # # calculate proportion of outcomes within each column
    transform(Not(:outcomes) .=> x -> x / sum(x), renamecols=false)
    # restack back into a long table
    stack(Not(:outcomes))
end
# parse the column names which became strings when unstacked to column name
df.n_chicks = parse.(Int, df.variable)
df
104×4 DataFrame
79 rows omitted
Row outcomes variable value n_chicks
Int64 String Float64 Int64
1 0 35 0.986 35
2 2 35 0.004 35
3 1 35 0.01 35
4 5 35 0.0 35
5 3 35 0.0 35
6 6 35 0.0 35
7 4 35 0.0 35
8 7 35 0.0 35
9 8 35 0.0 35
10 14 35 0.0 35
11 11 35 0.0 35
12 9 35 0.0 35
13 10 35 0.0 35
93 2 42 0.22 42
94 1 42 0.186 42
95 5 42 0.07 42
96 3 42 0.188 42
97 6 42 0.028 42
98 4 42 0.113 42
99 7 42 0.011 42
100 8 42 0.01 42
101 14 42 0.001 42
102 11 42 0.001 42
103 9 42 0.002 42
104 10 42 0.0 42

Now to visualize the results, we want to create a custom color scheme where the color is green if we “win” and an increasingly intense red color the further we were from winning the game (not all chicks made it back to the coop).

colors = vcat(get(ColorSchemes.rainbow, 0.5), get.(Ref(ColorSchemes.Reds_9), 0.6:0.025:1.0))

let
    f = Figure()
    ax = Axis(f[1, 1],
        title="Count Your Chickens Win Rate",
        xticks=chick_range,
        xlabel="Number of chicks",
        ylabel="Proportion of games",
    )
    bp = barplot!(df.n_chicks, df.value,
        stack=df.outcomes,
        color=colors[df.outcomes.+1],
        label=df.outcomes,
    )

    f
end
┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions.
└ @ Makie ~/.julia/packages/Makie/fyNiH/src/scenes.jl:220

We can see that if we have 40 chicks (probably by design) we’d expect to win just over 50% of the time (which is less often that I would have guessed for the game’s family friendly approach).

However, if you were missing a few pieces like we were, your probability of winning dramatically increases and explains our family’s winning streak.

Endnotes

Environment

Julia Packages:

using Pkg;
Pkg.status();
Status `~/prog/alecloudenback.com/posts/counting-chickens/Project.toml`
  [13f3f980] CairoMakie v0.11.5
  [35d6a980] ColorSchemes v3.24.0
  [1313f7d8] DataFramesMeta v0.14.1
  [8e1ec7a9] SumTypes v0.5.5

Acknowledgements

Thanks to Mason Protter who provided some clarifications on the workings of SumTypes.jl.