In June 2016 The Story Goes On was approved for an Xbox release. We were pleasantly surprised as it had been stuck in the approval queue for quite some time. This was really awesome news and motivation was high. There was just one problem - when we originally pitched our game to Microsoft, we had promised a local co-op feature. Whoops.
This is a write-up on how we turned a single-player game into a co-op supported one. It includes both the technical aspects and the design choices we made. While I hope everyone enjoys this post it is mostly aimed toward fellow game developers!
The Story Goes On is an adventure game featuring permadeath where you explore a book written by a struggling author. In the game you play as one of four characters. Throughout the game you will find different items that add to your abilities and power you up in different ways.
The game is originally inspired by other roguelikes but has evolved to take its own spin on the genre. It is written and made inside GameMaker: Studio.
Design Guidelines
We wanted the co-op to feel like it was planned from the start and carefully executed, not like it was slapped on in the last second (which it actually was). We settled on a few key ideas that everything would revolve around:
1. Players retain their individuality
A main selling point of the game is that each run of the game is unique. The book's author can’t settle on a story so he keeps changing things up. With such emphasis on things being different it’d be strange to have the players be exactly the same. Each player gets to tell their own story by being their own character, having their own items, power-ups and abilities.
2. Keep the stakes high
Dying in the game is very punishing and should remain so, but we don’t want to punish both players for one player’s mistake. This means one player can die. However there should be a way for the dead player to return to the game at a later point, as this keeps him interested in what’s going on in the run.
3. No player favoritism
We all hate feeling like player two. To prevent this we should never let one player be favored over the other. This means both players need to have the same priority for all actions.
Proof of Concept
The original test was very simple: spawning a new player object and setting a temporary function to switch to another controller while the new player was running their code. This made Player 1 read inputs from one controller and Player 2 from another. This totally worked! For walking around that is.
As you can see, there are quite a few problems introduced by adding a second player. For instance, all attacks are rotated in whatever direction Player 1 is facing. Additionally, a player dashing gave them both invulnerability and the hookshot moved both players. This happened because GameMaker’s syntax is very forgiving. Instead of needing to specify a specific instance of an object, GameMaker lets you just refer to an object, and it would grab any instance of that object. The attack’s code looked something like this:
image_angle = oPlayer.image_angle;
This would set the attacks rotation to the first player object it finds, and since before now there was always only one player present this was never a problem. The game was filled with this stuff! Each game component needed to be carefully readjusted to make it work. But for that we needed to set some tools into place to reference the individual players.
Working around the existing base
How do we implement a system that both works and is at the same time as easy as possible to add to the already existing code? We needed two things: a way to refer to a specific player as easy as the previously used “oPlayer” and a way to access the persistent information about each player. Before now this information was all stored inside global variables.
global.attackdamage += 3; global.attackspeed += 2; global.movespeed += 5;
Code example of picking up a stat item.
After carefully weighing the pros and cons of different approaches, I thought the best way to tackle this was using arrays. The other option would’ve been to have a persistent instance for each player that stored this information, but that would’ve required more refactoring than we’re comfortable with. We give all players a unique player number we call “num” that refers to their position in the array.
global.attackdamage[other.num] += 3; global.attackspeed[other.num] += 2; global.movespeed[other.num] += 5;
Same item as before but now supports multiple players. The keyword “other” is GameMaker specific and refers to the colliding instance.
There we go. Minimal effort with great functionality. However, it is also possible to pick up items using your sword, meaning “other” would instead reference your swing. Luckily GameMaker’s forgiving syntax came to the rescue. By giving the swing its own variable also called “num” the code could carry over and still work!
Hacks like these had to be implemented in many areas, another favorite of mine was manipulating what “other” would refer to using the “with” function. You can read more about “with” here, as it is more GameMaker-specific.
We also created some new global arrays and variables to track player count and the player instances. Together with the built in GameMaker functions to find closest and colliding instances, things were now not that difficult, just tedious!
Interfaces
As per the third design rule we must give the players equal treatment. This doesn’t only translate to in-game objects but the interfaces as well. Both players have to get the same amount of dedicated screen space and neither should look like an add-on. We started at the character select.
How could we expand this?
Well, the character descriptions were not necessary so we could cut that. That, together with the excessive horizontal screen space made us able to fit two players horizontally, giving them a half each.
We also needed to add additional functionality. The game shouldn’t start without both parties expressing consent so we needed to include a “ready” state. We show this state using adorable stickers.
The stickers work great because they let you remember and visualize your choice, they keep the book feel and did I mention they’re adorable?
Here we also started giving the players some means of identification. We associate Player 1 with blue and the left side and Player 2 with red and the right side.
We keep this color and side association when we moved onto designing the HUD.
The blue player gets the normal single player location, but the red gets the single player colors so equality is roughly the same.
But wait, this means in game hearts now looked like they belonged to red since they were all their colors!
Tweaking the game
Adding another player didn’t just mean you get a buddy, it meant you got a new source of firepower. This source needs its own upgrades, resources and things to punch at.
Dying
As mentioned, dying is a big part of the game. To keep the stakes high, we keep punishing failure. Both players have their own standard health pool with three hearts and when depleted, they’re dead. Dying removes the player object and runs their death animation. This meant we had to add additional checks inside a lot of code. If an enemy was targeting and attacking a specific player, they had to be able to deal with that player disappearing at any time.
Being dead forever is really boring though, so we implemented a way to bring dead players back.
The alive player can split his health in half and share it with his previously dead partner. As such, no new health is brought into the player's health pool - it is just redistributed. Reviving is now a gamble. Is one healthy player more likely to finish the run, or are two almost dead players your best shot?
We also chose to not give the dead player controls to the revive button. You died, be grateful if the other player is merciful enough to bring you back, you incompetent boob.
Enemies
Tweaking the enemies was surprisingly straightforward. We already had a proper targeting system in place for all enemies because certain game items would create player clones or dummies. We added both players to this target pool and it was ready to go.
More players means that disposing of enemies is easier, so we needed to adjust that. We didn’t want to change enemy speed or health because this game is all about progression and powering up your character. If the enemies had a changing health rate it would be harder to tell what your own current power-level was. So the logical thing to do was spawn more enemies. We ended up on an about 50% increase. Any more and it would turn more into a bullet hell rather than an adventure game!
Items
As previously mentioned we want to have player individuality. Our strongest way to highlight this is with the items. As each player powers up different parts of themselves the individuality really comes to life. One player can specialize to take care of ranged enemies, while the other focuses on heavy damage to kill the close by ones.
To get these differences though we need to increase the number of items. As such each floor now hosts two unique items. We do not assign an item specific for each player, they’re both up for grabs to whoever catches them first. That way you can choose items that synergizes well and if any imbalance comes up you can give your friend an extra item or two.
Another way we decided to fight the imbalance is a shared money storage. In the shop between levels you get the chance to buy items using the shared money.
One problem this gives is that the game becomes very vulnerable to infighting. One player can just snag all items before the other. However, as the game is still local, if your friend is being too much of a dick you can just punch them.
Other
We increase the size of the maps a tiny bit. Two players traversing is faster since there’s a bigger chance one of them is closer to an exit. Also we need a bit of extra space for the additional item and resurrection rooms.
All bosses were individually adjusted to switch up their targeting between the different players. The normal enemy target system would lock into a single player for the duration of the fight, which is quite boring.
Dungeons
Inside the game we have specific pre-created areas we call dungeons. Inside there are usually defined rules and a specific mechanic. For example in the snow dungeon we introduce minecarts, and in the spooky dungeon we introduce a ghost mode. These places have a different layout than the standard game. Sometimes they’re just one big room, other times they’re many smaller rooms.
First, we needed a way to move the players to each other when the rooms were switched. Here, we took inspiration from another game, Hyper Light Drifter. There, when the players get separated, Player 2 is turned into a ball-thingy and is moved to Player 1. In our game, to not have a player preference we move the other player to the one who passed a barrier. If the players are close enough we simply make them move.
This method has a huge benefit over straight up teleporting as it forces the player to traverse all the terrain it skips. As shown here the player passes the beam and is turned into a ghost like his fellow comrade. This means there’s less code to write for error checking as this is basically what happens when you walk normally. (Not that there isn’t any error checking code, there’s tons of that too)
Messing inside the dungeons also revealed some issues we did not expect.
Final Thoughts
Despite everything, developing co-op was the most fun thing I’ve ever done for this game. Working inside a framework you know very well and at the same time pushing its limits is extraordinarily satisfying. Challenges had to be solved using vastly different creative solutions that really made me think as a programmer. There’s a lot that’s omitted from this article, but it was either too boring to write about or too boring to read.
I can’t hide the fact that implementing something like this so far into development added a lot of extra unplanned work and is very much not ideal for every project.
However I think the most important thing to take away from this is to not be afraid to change your scope in the middle of development. You too often hear about developers who had to limit their scopes, so I wanted to share our story about how we successfully increased ours.