By: Alec Loudenback
Re-posted from: https://alecloudenback.com/posts/counting-chickens/index.html
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:
- 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:
- The animals on the spinner, and
- 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 syntaxRegular(::Animal)
indicates that when, e.g, we create aRegular(Dog)
we will get aSquare
that encloses data indicating it’s both aRegular
variant of aSquare
in addition to holding theDog
instance of an Animal. That is,Regular(Dog)
is an instance ofSquare
type and does not create a distinct subtype ofSquare
.
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 toEmpty
(which shouldn’t actually happen),Bonus
, orRegular
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 theSquare
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
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
.