Game Dev Behind the Scenes: Breaking Down Job Job’s Icebreakers

We’ve all been there: your grandpa starts telling a story about buying groceries and an hour later is describing a movie he saw for a nickel when he was a kid and is no closer to explaining how the pile of Werther’s Originals appeared on the kitchen table.

Similarly, I want to tell you a story about how, when you play Job Job, the game decides who gets to use those hilarious icebreaker answers of yours. This story will meander, it will drag on, but I promise at the end you’ll get a sweet, sweet butterscotch caramel of game design knowledge.

The Problem

It begins, as everything does, with Brooke Breit, who directed Job Job. Brooke’s first design doc succinctly describes the core game loop:

  • Each player fills out 3 writing prompts (questions that encourage them to write sentences with active, creative and interesting words).
  • Sentences are shuffled and sent to other players as a list of individual word tiles.
  • Each player is asked 2 job interview questions. They will use their provided list of words to answer both questions. Words can be used more than once.
  • Answers go head-to-head anonymously and players vote on their favorites.

The next 2,955 words are all about that second bullet point: “sentences are shuffled and sent to other players”. You may think to yourself, “I don’t want to read 2,955 words about that”, but don’t forget, I’m your grandpa, and I have a 1972 Ford Gran Torino in my garage that you’d really love to get in the will, so you’d best stick around for my story about butterscotch. I mean, the appearance of randomness in game design.

If you were a fly on the wall during the early design meetings for Job Job you’d see us totally failing to convey what player input we were talking about. To remedy this, we came up with secret codewords that I’ll let you in on because you’re my favorite grandkid. When answering icebreakers, you’re writing a Phrase, which is made up of Words. Between the icebreakers and interview questions, we take the Phrases you wrote and divvy them out, filling each player’s Stash. If players don’t answer the icebreakers the game provides Filler Phrases instead. When you answer the job interview questions, you use individual Words from your Stash to create a Composition. If players don’t create a Composition, the game provides a Boner to use instead. Why are they called Boners? Remember, it’s a Brooke Breit game.

How would you divvy player’s Phrases into other player’s stashes? Perhaps the most simple solution is to put all the Phrases into a big pile, shuffle them, then hand three out to each player. However, in this method, players could receive Phrases that they wrote themselves, which is a lot less fun than seeing other player’s answers to the icebreakers. I don’t write funny stuff, that’s what my friends are for!

“Players shouldn’t receive a Phrase they wrote” is the first rule the design team decided on for the divvy process. It’s worth noting that this is already a deviation from a truly random shuffle. Often in game design, when people say “random”, what they’re actually after is “variety”, but even further, curated variety. Sure, monkeys at typewriters will eventually write Hamlet, but authors at typewriters can write Hamlet before lunch and then a bunch of cooler stuff after they eat their grilled salmon steak. Monkeys aside, these are the rules we came up with for the first pass at the divvy algorithm:

  1. All players receive three phrases for all three rounds
  2. No player receives a phrase written by themselves
  3. No player receives the same phrase twice
  4. No player receives all phrases in one round from the same author
  5. No phrase is seen more than two times

I’m going to stop capitalizing our jargon words now because the internal documentation I’m copying from doesn’t and besides, you’re all grown up and have a life of your own. It makes sense that you don’t have time to call and it doesn’t bother me.

One final complication: players don’t add three new phrases into the pool every round. Early in play testing we found that answering three icebreakers every round (for a total of nine over a game) was a real drag. So players answer three icebreaker questions in the first round, two icebreakers questions in the second round, and one icebreaker question in the third round (for a total of six). This leads to a natural acceleration of the game where you get to the composition reveal faster the longer you’ve been playing.

So let’s create a divvy algorithm that fits all our above rules.

The First Pass

I’ve always found algorithmic thinking to be easier if I can represent things with physical objects. Crucially, my house was going through a Dungeon & Dragons phase, which meant I had access to a lot of dice by nature of having friends. Remember, your friends are exactly as valuable as they are useful to you for your job.

In this photo of my actual bed, each set of dice represents a different author. So this is a three player game with the glittery dice being phrases player one wrote, the clear plastic dice being phrases player two wrote, and the metal dice being phrases player three wrote. The middle row is the players stashes after the first divvy. You can see that player one has two phrases from player two and one phrase from player three, player two has two phrases from player one and one from player three, and so on.

The basic idea for this algorithm is that, because each player only writes two new phrases in round two and one in round three, you need to pull dice (phrases) from other player’s previous stashes in order to fill future stashes. For player one’s round two stash, they’ll need to use one of the dice from player two or player three’s round one stash. Unfortunately, one of player two’s and two of player three’s dice for round one are from player one! That means we can’t simply pick randomly from previous player’s stashes, otherwise we fail rule #2.

My first solution to this conundrum was two numbers, called divvyOffset and reuseOffset. I did four things at the start of the game:

  1. Save a list of the players in the game
  2. Create a truly randomly shuffled list of players (truly random means that the original order is valid!)
  3. Set divvyOffset to 1
  4. Set reuseOffset to -2

Then, for round one, the divvy algorithm looked like this:

do the following three times (once for each phrase each player needs):
  for each player:
    take one phrase from the shuffled player at index divvyOffset and give it to player
    increment divvyOffset
    if divvyOffset is a multiple of the number of players, increment it again

This is how that looks in the game’s gluten-free source code (numToDivvy is always three for round one):

private function _divvy(phrases:PerPlayerContainer, round:String):void
{
    var numToDivvy:int = ArrayUtil.flatten(phrases.getAllData()).length / _shuffledPlayers.length;
    for (var i:int = 0; i < numToDivvy; i++)
    {
        for (var j:int = 0; j < _shuffledPlayers.length; j++)
        {
            var receivingPlayer:Player = _shuffledPlayers[j];
            var givingPlayer:Player = ArrayUtil.getElementWrap(_shuffledPlayers, j + _divvyOffset);
            receivingPlayer.addPhrase(phrases.getDataForPlayer(givingPlayer).shift(), round);
        }
        _divvyOffset++;
        if (_divvyOffset % _shuffledPlayers.length == 0)
        {
            _divvyOffset++;
        }
    }
}

A PerPlayerContainer is just a light wrapper around a map/dictionary. In this case phrases maps players to arrays of phrases they wrote.

ArrayUtil.getElementWrap simply gets the element of an array at the provided index, but wraps around the end if the index is longer than the array (so for an array of 8 elements, if you provide an index of 10, you’ll get the second element in the array).

Essentially, this algorithm gives every player a phrase written by the player one ahead of them in the (shuffled) list, then gives every player a phrase written by the player two ahead of them in the (shuffled) list, and so on. It makes sure that the offset is never zero, so no one gets a phrase written by themselves. The randomness (variety) comes from the shuffling of the player list at the start of the game. Just because you join the lobby of the game first doesn’t mean you’ll always start the game by getting player two’s first icebreaker, for example.

This works great for round one, where we live in the idyllic land of all players writing three new phrases just for that round. But down here in the muck and the grime, and the filth of round two, things aren’t so simple. Players only generate two new phrases, so we have to start reusing phrases from players’ round one stashes.

My reuse algorithm looked very similar, except the phrases:PerPlayerContainer parameter is not a map of players to phrases they authored, but instead a map of players to the phrases in their round one stash. Also, instead of incrementing divvyOffset, I decremented reuseOffset. So you pull new phrases authored by players further and further ahead of you in the shuffled list and pull refused phrases from the stashes of players further and further behind you in the shuffled player list.

private function _reuse(phrases:PerPlayerContainer, round:String):void
{
    for (var i:int = 0; i < _shuffledPlayers.length; i++)
    {
        var receivingPlayer:Player = _shuffledPlayers[i];
        var givingPlayer:Player = ArrayUtil.getElementWrap(_shuffledPlayers, i + _reuseOffset);
        var options:Array = phrases.getDataForPlayer(givingPlayer).filter(
            function(phrase:Phrase, ...args):Boolean
            {
                return phrase.author != receivingPlayer;
            }
        );
        receivingPlayer.addPhrase(ArrayUtil.getRandomElement(options), round);
    }
    _reuseOffset--;
    if (_reuseOffset % _shuffledPlayers.length == 0)
    {
        _reuseOffset--;
    }
}

And this sort of worked! And people played the game for a while, using phrases and reusing phrases in great merriment. But the good times, listen kid. The good times. They do not last. Before long, our QA Lead, Allison Flom, came to me swearing to Mary of Magdala that she had seen a phrase used three times. Disaster! Rule #5!

The Second Pass

You can see we have to filter out the phrases that the player wrote themselves (to fulfill rule #2). We pull from a given player’s round one stash twice: once for round two and again for round three. Since we simply randomly pick from the array of round one phrases that a player didn’t author, it’s entirely possible a given phrase is seen three times. So why not just track ‘em? Why not! Answer me! Speak, coward!

We haven’t been keeping a reference to the PerPlayerContainer we’ve been passing into our _reuse function. But if we just keep that map around for when we do the round three divvy, we should avoid repeats. This is what the whole file looks like now: StashManager.as.

Success! We’re now following all five of our rules! It would be a shame if someone added more rules! I hope no one adds any more rules!

Complications

We added two more rules. We were looking for ways to incentivize players to use words from multiple phrases and landed on this thing called a rainbow bonus:

You get a rainbow bonus for using a word from three different authors in your composition. Importantly, it’s not for using a word from three different phrases, what matters is there’s three different colors signifying that the words came from phrases written by different players.

This creates new rules for our divvy algorithm because it wouldn’t be fair if some player’s stashes allowed for rainbow bonuses while other player’s didn’t. Plus, since authors are getting points every time a word is used, isn’t it only fair if every author has their phrases put into stashes the same amount of times? This brings our total number of rules to seven, which is also the number of deadly sins. Total coincidence!

This is when we determined it was impossible for humans to check all these rules over the course of a game. So I did something unprecedented in the games industry: I wrote unit tests. I can hear the tech bros now: “You write code without unit tests?!” Yes, yes we do. Games are built with used toothpicks, bubble gum, and prayers to whatever god is left.

Prior to release, the games have a developer console to run commands that can perform a variety of useful things (like skip portions of the game or make timers really short, etc.), so I wrote some for testing the divvy algorithm. Here’s an excerpt from our debug command documentation:

Command Name/ActionDescription
divvyTest(numPlayers:int)Runs our stash divvying algorithm against dummy data for the number of players provided. Must be within the min and max players supported by the game. The rules it tests for are:All players receive three phrases for all three roundsNo player receives a phrase written by themselvesNo player receives the same phrase twiceNo player receives all phrases in one round from the same authorNo phrase is seen more than two timesAll players have the same ability to earn a rainbow bonus in any given roundAll authors have phrases used the same number of times over the course of the gameAny test failures are logged as errors with the test that was failed.
allDivvyTests(numTimesPerTest:int)Runs the divvyTest debug command for all player counts. Does each test the number of times provided by numTimesPerTest. So allDivvyTests(10) would run the 3 player test ten times, then the 4 player test 10 times, etc.

This will print the time it took to run the test at the end.

Turns out, we were failing rule #6 and #7 a lot for specific player counts. In four, six, seven, and eight player games, players didn’t have rainbow-bonus-eligible stashes at an equal rate. This almost definitely has to do with the magic starting numbers for divvyOffset and reuseOffset and some complicated set theory mathematics that, while I’m sure is very interesting, I’m far too old to learn about. I’m an old dog! Woof! Bark! Gimme a caramel candy!

Just when things seemed like they couldn’t get any more complicated, we decided we wanted to test out what it would be like if players wrote three phrases in round one and two and NONE in round three (skipping straight to composition). I’ll spare you the details of how testing this design change wrought havoc on the divvy algorithm, because this blog post is already 2,288 words long. But I’ll leave this comment from that version of the code here.

// TODO: Remove this, the worst code I have ever written.

While it was definitely worth testing, we determined the 3-2-1 icebreaker format helped players fall into a rhythm that produced more wacky round three words, which outweighed any potential benefits of getting to the reveal moment faster for the final round compositions.

A New Approach

What do you do with an unfair algorithm? Well, it’s time to check on our monkeys. Have they written Hamlet yet? No? Have I written a decent divvy algorithm yet? No? Shoot.

Speaking of shooting, in the midst of my despair of my algorithm’s shortcomings I remembered an anecdote I heard about the way Gearbox Software determines the spread for the shotguns in Borderlands. When you shoot a shotgun in the game each individual pellet in the shell has a random direction it travels from the tip of the barrel. But similar to our divvy algorithm, the pellet shouldn’t fire truly randomly. We want only a range of directions (otherwise the pellets could go backwards) and all of the pellets ending up in the exact same spot wouldn’t make sense either. Plus, it’s computational work for the game to generate these values on-demand when you pull a trigger, a moment where there’s most likely a lot of other work being done.

Their solution was to pre-generate a list of possible pellet spreads, and when you fire, it picks one and directs the pellets accordingly. So what if, instead of doing all this divvy algorithm work, we just figure out an optimal divvy of the phrases?

Let me explain this cursed game of sudoku to you. The black circles are the phrases players write. They are organized into columns based on author, so the first column is the five phrases the first player writes over the course of the game. The first three rows are the round one phrases, the next two rows are the round two phrases, and the final row is the phrase they write in round three. The number inside the circle is the recipient of the phrase the first time it is divvied out (which is zero-indexed, as in, a 0 means that the first player is receiving the phrase). There’s no longer any algorithm or randomness to worry about when divvying, I just had to write out one solution to each…of the player counts…in the game. How many players did we make this game support again?

Right, ten. So I took a tablet and drew circles and numbers for a day and came up with optimal divvies for each player count.

The best use of an iPad since operating your office’s new coffee maker.

After coming up with solutions, I just had to enter them into a game file called DivvyTable.as.

Fun! But all this work totally transformed what StashManager.as looks like: StashManager.as.

Look at that! We no longer need a map of authors to the phrases they wrote (it’s just a two-dimensional array now), or track which phrases have already been used, or track magic number offsets, or have a separate _divvy and _reuse function that duplicates a bunch of code. All the magic happens in line 53, where we perform a lookup into our table to give a phrase to a player. We still get variety in this system because we shuffle the player array that we use for the divvy table lookup at the start of each game.

Not only is the code much simpler and easier to understand (outside the table lookup in line 53), it runs significantly faster. Remember that allDivvyTests debug command from earlier? It prints out the amount of time the tests take and for 1000 tests of each player count (8000 total tests), here’s the comparison:

Old iterative version:
It took 18.163 seconds to run 8000 tests.
It took 18.053 seconds to run 8000 tests.
It took 17.837 seconds to run 8000 tests.
It took 18.728 seconds to run 8000 tests.
It took 18.2 seconds to run 8000 tests.
Average: 18.196

New table lookup version:
It took 14.354 seconds to run 8000 tests.
It took 15.736 seconds to run 8000 tests.
It took 14.177 seconds to run 8000 tests.
It took 14.139 seconds to run 8000 tests.
It took 14.315 seconds to run 8000 tests.
Average: 14.544

That’s a reduction of *triple checks math* exactly 20%. Weird!

Conclusion

So, what did we learn? When you find yourself describing a game mechanic as “random”, drill down and figure out what kind of variety you’re looking for the player to experience. If there’s rails that need to be in place to create the optimal experience, write them down and see what it will take to make sure those rails are respected. And if programming an algorithmic solution to your mechanic starts to hurt your head, don’t be afraid of pre-computing optimal solutions and introducing variety elsewhere in the system. Also, take this candy, I bought a bag just for you.

Like this article?

Share on facebook
Share on Facebook
Share on twitter
Share on Twitter