By Chris Clark, 07/23/2021, in Everything else
I play Candy Land occasionally with my two boys (the six year old gets it much more than the three year old). It has the strange property of involving both zero skill and zero agency. There are absolutely no decisions for the player to make at any point; the outcome of the game is determined as soon as the cards are shuffled.
This makes it reduce to a game that is about as fun as flipping a coin, but significantly more fun to hack together in Python and explore!
We'll do a rules refresher in a sec, but first, some simple classes representing the board, a player, and a "move" object, which doesn't affect gameplay, but will keep track of each turn so we can analyze games later.
class Board(object):
def __init__(self, shortcuts, colors, specials, licorice, length):
self.shortcuts = shortcuts
self.colors = colors
self.specials = list(specials.keys())
self.licorice = licorice
self.spaces = (self.colors * ((length//len(colors))+1))
for k, v in specials.items():
self.spaces.insert(v, k)
self.spaces = self.spaces[:length]
class Player(object):
def __init__(self, name):
self.name = name
self.position = -1
class Move(object):
def __init__(self, player, card, new_position):
self.player = player.name
self.card = card
self.new_position = new_position
Now we can create a variety of different Candy Land boards. Here's a board with full fidelity to the original game (which you can see here). For the morbidly curious, I got the right indices for the licorice, specials, etc by laying out the board in a spreadsheet.
LENGTH = 134
COLORS = list('RPYBOG')
SPECIALS = {
'Plumpy': 8,
'Mr. Mint': 17,
'Jolly': 42,
'Gramma Nut': 74,
'Princess Lolly': 94,
'Queen Frostine': 103
}
LICORICE = [47,85,120]
SHORTCUTS = {4:58, 33:46}
board = Board(SHORTCUTS, COLORS, SPECIALS, LICORICE, LENGTH)
def spc_fmt(i, s):
if i in board.licorice:
return 'Licorice: {}'.format(s)
if i in board.shortcuts.keys():
return 'Shortcut to {0}: {1}'.format(board.shortcuts[i], s)
return s
print([spc_fmt(i, s) for i, s in enumerate(board.spaces)])
Here's our board with some formatting to signify licorice and shortcuts:
['R', 'P', 'Y', 'B', 'Shortcut to 58: O', 'G', 'R', 'P', 'Plumpy', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'Mr. Mint', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'R', 'Shortcut to 46: P', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'Jolly', 'O', 'G', 'R', 'P', 'Licorice: Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'Gramma Nut', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'Licorice: B', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'Princess Lolly', 'R', 'P', 'Y', 'B', 'O', 'G', 'R', 'P', 'Queen Frostine', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'Licorice: R', 'P', 'Y', 'B', 'O', 'G', 'R', 'P', 'Y', 'B', 'O', 'G', 'R', 'P']
Good stuff.
Ok, onto the game! For those of you who have not played Candy Land recently, you can read the official rules here, but the most salient bits are:
With that in mind, here is the game code:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
|
With default settings, the 66 cards of the deck are created in the
create_cards
method.
The core game logic is in generate_move
: given a card and a player,
we calculate the new position of the player. This is somewhat
interesting and it's the algorithm that must be running inside my
six-year-old's head, and the algorithm that my three-year-old can't
quite get a handle on.
The play
game loop simply takes turns until the win condition is met
(which happens when the drawn card's position doesn't is not present
in the remainder of the board - see line 40).
Now that we have our game objects, we can play (using the board we created previously):
players = ['Chris', 'Maggie']
g = Game(players, board)
moves = g.play()
We can print out the moves and see what happened. Weirdly, formating the move well was perhaps the hardest part of this entire exercise.
def fmt_move(i, m, b):
if m.new_position is None:
out = "On turn {0}, {1} draws {2} and WINS!"
elif m.new_position in b.licorice:
out = "On turn {0}, {1} draws {2} and is stuck on {3}."
else:
out = "On turn {0}, {1} draws {2} and moves to {3}."
return out.format(
i+1,
m.player,
m.card,
m.new_position if m.new_position is None else m.new_position+1)
for i, m in enumerate(moves):
print(fmt_move(i, m, g.board))
Here's the output, eliding over a number of moves for brevity:
On turn 1, Chris draws Plumpy and moves to 9.
On turn 2, Maggie draws O and moves to 59.
...
On turn 27, Chris draws P and is stuck on 48.
On turn 28, Maggie draws GG and is stuck on 121.
On turn 29, Chris draws RR and is stuck on 48.
On turn 30, Maggie draws R and moves to 127.
On turn 31, Chris draws R and is stuck on 48.
On turn 32, Maggie draws Princess Lolly and moves to 95.
...
On turn 49, Chris draws RR and moves to 89.
On turn 50, Maggie draws R and moves to 127.
On turn 51, Chris draws Y and moves to 91.
On turn 52, Maggie draws GG and WINS!
Grr, I lost. Oh well - I'm sure there will be more games...in fact...we can build a harness to play many games, and see the effect of the various board components:
def play_games(config, num):
return [Game(**config).play() for _ in range(num)]
def analyze(name, results):
from statistics import mean, median
lengths = list(map(len, results))
print("Turn stats for {0} '{1}' games:".format(len(lengths), name))
print("Mean: {}".format(mean(lengths)))
print("Median: {}".format(median(lengths)))
print("Min/max: {0}/{1}".format(min(lengths), max(lengths)))
print("")
PLAYERS = ['Chris']
NUM_GAMES = 10000
LENGTH = 134
STANDARD = {
'players': PLAYERS,
'board': Board(SHORTCUTS, COLORS, SPECIALS, LICORICE, LENGTH)}
NO_LICORICE = {
'players': PLAYERS,
'board': Board(SHORTCUTS, COLORS, SPECIALS, [], LENGTH)}
NO_SPECIALS = {
'players': PLAYERS,
'board': Board(SHORTCUTS, COLORS, {}, LICORICE, LENGTH)}
NO_SHORTCUTS = {
'players': PLAYERS,
'board': Board({}, COLORS, SPECIALS, LICORICE, LENGTH)}
analyze('Standard', play_games(STANDARD, NUM_GAMES))
analyze('No Licorice', play_games(NO_LICORICE, NUM_GAMES))
analyze('No Specials', play_games(NO_SPECIALS, NUM_GAMES))
analyze('No Shortcuts', play_games(NO_SHORTCUTS, NUM_GAMES))
Note that these games have only one player (me!) since they are for analytical purposes (vs. HARD-CORE COMPETITIVE purposes). The results:
Turn stats for 10000 'Standard' games:
Mean: 38.6897
Median: 33.0
Min/max: 4/242
Turn stats for 10000 'No Licorice' games:
Mean: 34.5284
Median: 30.0
Min/max: 4/178
Turn stats for 10000 'No Specials' games:
Mean: 27.6359
Median: 28.0
Min/max: 10/74
Turn stats for 10000 'No Shortcuts' games:
Mean: 40.6276
Median: 35.0
Min/max: 5/210
You now know a deep secret of the universe: that the 'special' cards in Candy Land on average send players backwards more often than forwards. My GOD! I always thought they were there to help! Doing some simple math, we determine:
Impact of licorice: 4.16 turns
Impact of specials: 11.05 turns
Impact of shortcuts: -1.94 turns
Shortcuts reduce games by an average of 1.9 turns, while licorice and specials add to the average lengths of games. Cool!
I, for one, thoroughly enjoyed acquiring this utterly worthless knowledge. There was never a chance of discovering a "better" way to play Candy Land as the player has zero agency. But I suppose that's the joy of Candy Land for small children; a perfectly even chance of winning against your far more sophisticated, Python-programming parents.