Codenames is a word game where players try to collect all of their team’s cards from a board, by having their Spymaster give one-word hints which describe one or more cards on the table. Which cards belong to which team is determined by a random draw of pre-printed game maps, showing which positions belong to Red, which to Blue, and which is the Assassin card (the black card – revealing this ends the game for both teams).

Lately, I’ve been playing this with friends over Zoom, and they use a web-based board generator to allow the hint-givers to be in different locations.

For fun, I decided to write my own board generator. And because I couldn’t resist, this also supports arbitrary board sizes, and even up to 5 teams. Though a 5-team, 10x10 board, needing up to 19 cards win – would make for a VERY long game. (Really, although it may be interesting to try a 6x6 or 7x7 game, or a lightning-fast 4x4 game, I think having more than two teams would probably be too slow… I’m sure there’s a reason the official rules specify 25 cards and 2 teams.)

But how does it work?

You give it a key word – any random word will do. That word is used as the input text for a SHA-2 hash function, which generates 512 bits of (essentially) random numbers. It’s not really random (because the same input will always generate the same hash), but the distribution of numbers across the output is very random.

Originally, I’d thought of finding some way to just read out the board from the hash directly – the first 2 bits for the first square (00 for blue, 01 for red, 10 for tan, etc.) but I quickly realized that wouldn’t work out. It would be too difficult to ensure an evenly random distribution across the entire board. And even if I could get red/blue/tan even, how do I place the Assassin card? If it’s based on some random bit pattern, then there would be a chance it was always near the top of the board.

So instead, I convert the hash into a very large number (like: 2869589101898455553259634799602548676193789643432618152569307897648577027168951058944744836765438200336744138272160720510802825311234408202083527366619517). Then THAT number is converted into a different counting base, where the base is the size of the board.

That is, if I have a 25-card board (like the default 5x5 arrangement), then I convert the base-10 number 28695891……9517 into a base-25 number like: 12 2 5 1 21 23 15 12 17 16 4 3 7 3 0 2 2 24 8 2 5 2 7 12 14 4 10 4 22 22 5 6 20 2 17 8 24 1 10 15 23 11 6 9 21 18 8 6 14 17 19 10 20 24 22 19 2 17 14 16 12 11 24 6 13 20 13 21 24 12 0 5 15 12 17 21 2 17 11 24 3 24 7 5 4 1 21 13 12 5 11 15 16 11 13 5 0 18 0 24 1 24 15 3 2 8 13 16 5 17.

Now, I just use that number to determine where on the board to place cards. I use the final “digit” of the number (in this case, 17), to determine which team goes first. The first digit tells me the location of the Assassin card. Then, every other position is marked as a “Tan” card (for Innocent Bystanders).

How many cards do we need to win?

Each team gets a different number of cards they have to collect – which helps to even the odds for the teams which start later in the game. The official rules require the first team to collect 9 cards, and the second team, 8 cards, leaving 7 Tan cards and 1 Assassin. I extended that logic for larger boards and additional teams, trying to keep the same basic approach. For example, for an 8x5, 3 team game:

   B  T  G  B  B  T  R  B
   T  R  T  G  T  G  B  R
   T  G  K  R  G  B  R  T
   R  R  G  G  G  R  G  G
   B  T  B  B  G  R  T  B

   Total cards and Team Playing Order:
     Green: 11
     Blue: 10
     Red: 9
     Tan: 9

I can’t always get “Tan” to be exactly “last team - 1” in size – sometimes it’s equal or even 1 or 2 more than the last team. But it’s generally pretty close. The math is even a little fun:

x = number of cards for the 1st team
tot = total cards on the table

2 teams: 
    9 + 8 + 7 + 1 = 25
    or x + (x-1) + (x-2) + 1 = tot
    x = (tot + 3 - 1)/3

3 teams: 
    x + (x-1) + (x-2) + (x-3) + 1 = tot 
    x = (tot + 6 - 1) / 4

etc.. Eventually we can generalize this as: 

N teams: 
    x = (total_cards + sum(1..N) - 1) / (N+1)
    x = (tot + (n * (n+1))/2 - 1) / (n+1)  

   (and round the result up to the nearest whole number)

Here are example distributions for square boards with 2, 3, 4, and 5 teams: (the “,1” at the end is the assassin.)

 size    2 teams     3 teams         4 teams           5 teams

  9     4,3,1,1     4,3,2,-1,1     4,3,2,1,-2,1      4,3,2,1,0,-2,1
  16    6,5,4,1     5,4,3,3,1      5,4,3,2,1,1       5,4,3,2,1,0,1
  25    9,8,7,1     8,7,6,3,1      7,6,5,4,2,1       7,6,5,4,3,-1,1
  36    13,12,10,1  10,9,8,8,1     9,8,7,6,5,1       8,7,6,5,4,5,1
  49    17,16,15,1  14,13,12,9,1   12,11,10,9,6,1    11,10,9,8,7,3,1
  64    22,21,20,1  17,16,15,15,1  15,14,13,12,9,1   13,12,11,10,9,8,1
  81    28,27,25,1  22,21,20,17,1  18,17,16,15,14,1  16,15,14,13,12,10,1
  100   34,33,32,1  26,25,24,24,1  22,21,20,19,17,1  19,18,17,16,15,14,1

Filling out the board

Now, where was I…? Oh, yeah. We’ve converted the user-supplied keyword into a really long string of numbers. I’ll repeat the example we’re using:

12 2 5 1 21 23 15 12 17 16 4 3 7 3 0 2 2 24 8 2 5 2 7 12 14 4 10 4 22 22 5 
6 20 2 17 8 24 1 10 15 23 11 6 9 21 18 8 6 14 17 19 10 20 24 22 19 2 17 14 
16 12 11 24 6 13 20 13 21 24 12 0 5 15 12 17 21 2 17 11 24 3 24 7 5 4 1 21 
13 12 5 11 15 16 11 13 5 0 18 0 24 1 24 15 3 2 8 13 16 5 17

The first number (12) is where the assassin is hiding. In our example of a 2-team, 5x5 board, the next nine numbers belong to the first team to play (let’s say it’s Red). So I know that cards in positions 2, 5, 1, 21, 23, 15, 12, 17, and 16 all belong to Red. Oh, wait, we’ve already used 12. So we skip that one, and add the next one in the list, a 4. Then the next eight go to Blue: 3, 7, not 3 again, 0, not 2, still not 2, 24, 8, still not 2, or 5, or 2, or 7…12…14 works!…not 4…but 10. And finally, 22.

So [1, 2, 4, 5, 15, 16, 17, 21, 23] go to Red, and [0, 3, 7, 8, 10, 14, 22, 24] belong to Blue. Which looks like this (and remember, we start counting in the top-left, at ZERO, not 1):

   B  R  R  B  R
   R  T  B  B  T
   B  T  K  T  B
   R  R  R  T  T
   T  R  B  R  B

All the others are Tan, except for position 12, which is the Assassin (Black card).

Bad runs of luck

What happens if we run out of numbers, because the board is too big and the random numbers just aren’t in our favor? Then I just extend it. I take the hexadecimal version of the hash (which is just a long string of Base-16 digits), use that as a new keyword, and run the process again. But instead of restarting from scratch, I append that to the nubmers I had before (so now I have twice as many to pick from), and try again to fill out the board.

Eventually, I’ll get enough numbers without a repeat, and the board is done.

Nifty color output!

Now it’s time to print out the resulting board. I added some ANSI color sequences, because ANSI is cool, or at least it was cool in 1987 when I was writing BBS software. Then I display the board, the order of play, and the total number of cards for each team (as a check to make sure the math didn’t go wonky along the way).

Finally, here’s an example:

Codenames Script Output

The code, such as it is, is available as a gist on GitHub.

Anyway, that’s my latest silly distraction. And hopefully the beginning of a long-delayed return to blogging, even about silly things. I’ve been working a lot with IoT and home automation and stuff lately, and really need to share some of the things I’ve been building…