Designing an extensible power up system
Last term I started working on a simple procedurally-generated twinstick shooter, tentatively called Proc Cave Game, with two of my friends as our final project for AI for Interactive Media and Games. Although it started as just another assignment, we continued to work on it over winter break, expanding on what we had started to make a fun and interesting game.
Background
Instead of using an existing engine or framework we decided to implement everything from scratch. The three of us were most familiar with JavaScript, and developing for the web makes a project much more accessible, so we opted for using plain ECMAScript 2015 and the HTML canvas API built into every browser.
Cole Granof did most of the work on the engine, writing a game loop that could run at a constant speed independent of your monitor's refresh rate and robust kinematic simulations based on acceleration and drag.
Matt Puentes worked on an efficient collision system, and an algorithm to connect the randomly generated cave environments using a minimum spanning tree.
I, meanwhile, focused on designing the power up system.
Guiding principles
The Binding of Isaac is another procedurally generated top-down twinstick shooter, and it heavily inspired the design of Proc Cave Game. Something I particularly like about Isaac is the way different items synergize with each other to create unique gameplay. In The Binding of Isaac, most of these synergies are hard-coded by the developer, but I wanted power ups in our game to work together automatically.
I also wanted every power up to be able to "stack," so that getting the same power up twice would make the ability gained from it stronger. This way we could have different levels of power ups, and finding the same one twice wouldn't be disappointing for players.
Finally, I wanted all power ups to be able to apply to both the player and the enemies. This would allow us to apply random power ups to enemies to make them stronger, as well as designing new enemy types around certain power ups.
Implementation
The PowerUp
super-class is pretty basic, but defines the
interface that individual power ups must implement. Here are some of the
methods it defines:
export class PowerUp extends Entity {
constructor(
position,
magnitude = 1,
powerUpClass,
description
);
/**
* called when a creature picks up this power up
*/
apply(creature);
/**
* tests whether the creature is at
* max level for this power up
* @return boolean
*/
isAtMax(creature);
/**
* what to do when this isn't applied due
* to the creature being at max level
*/
overflowAction(creature);
/**
* draw the power up in the world as a shiny
* circle with a letter in the middle
*/
draw();
}
You can see the full implementation on GitHub.
As you can see, each power up has a magnitude
, a number
from 1 to 5 that describes how powerful it is. This enforces the design
principle that all power ups have to be able to scale, and multiple
copies of the same power up stack by essentially having their total
combined magnitude.
Each power up also needs to have some way of checking whether the
Creature
(the player character or an enemy) that picked it
up is already at the max level for that power up. For example, the
power up QuickShot
, which increases bullet speed, just
checks whether the creature's existing bullet speed is above a set limit
already.
If the creature is close to the max, but not quite at it yet, the
isAtMax
method can trim the magnitude of the power up so
that it still has an affect without going over the limit. Here's how
QuickShot
does it:
isAtMax(creature) {
// bullet speed is already too high
if (creature.bulletSpeed >= MAX_BULLET_SPEED) {
return true;
}
// see if we need to trim magnitude
const availMag = Math.floor(
Math.abs(
MAX_BULLET_SPEED - creature.bulletSpeed
) / BULLET_SPEED_FACTOR
);
if (availMag < 1) return true;
this.magnitude = Math.min(
availMag, this.magnitude
);
return false;
}
MAX_BULLET_SPEED
and BULLET_SPEED_FACTOR
are
constants that determine the maximum bullet speed, and by how much
bullet speed is increased by each point of QuickShot
respectively.
Applying simple power ups like this are easy, just increase a number by
the power up's magnitude times BULLET_SPEED_FACTOR
. But
what about more complex power ups? FlameThrower
makes the
player's bullets light enemies on fire. We could give each bullet an
attribute causesBurning
, but this would be annoying to
maintain as we added more an more power ups. Instead, I used one of the
most useful features of JavaScript: first-class functions.
In JavaScript, functions are treated as first-class citizens, meaning they can be used in the same way as any other data type: passed as arguments to other functions, returned from functions, assigned to variables, and stored in data structures. This means we can create an array of functions to execute whenever a bullet hits an enemy, and simply add a function to it that causes burning.
See the source code
for FlameThrower
for an example of this. This approach
makes the synergy I mentioned above much easier.
When you get an Xplode
power up, it makes your bullets
explode into a ring of sub-bullets. By simply passing these sub-bullets
the same array of onHitEnemy
functions that the parent had,
they will inherit the same flamethrower effect.
Now you can have flaming, exploding bullets that bounce off walls, and the game logic does all the work of combining these effects automatically. It can lead to some really complex combinations and emergent gameplay.
Proc Cave Game is released as free software, so you can download the source code, inspect, modify, and redistribute it as much as you want. It's available as source code on GitHub, or you can try it out on the web here.