In this series of tutorials, I’ll show you how to make a neon twin stick shooter, like Geometry Wars, in XNA. The goal of these tutorials is not to leave you with an exact replica of Geometry Wars, but rather to go over the necessary elements that will allow you to create your own high-quality variant.
Overview
In this part we will build upon the previous tutorial by adding enemies, collision detection and scoring.
Here’s what we’ll have by the end of it:
We will add the following new classes to handle this:
Enemy
EnemySpawner
: Responsible for creating enemies and gradually increasing the game’s difficulty.PlayerStatus
: Tracks the player’s score, high score, and lives.
You may have noticed there are two types of enemies in the video, but there’s only one Enemy
class. We could derive subclasses from Enemy
for each enemy type. However, I prefer to avoid deep class hierarchies because they have some drawbacks:
- They add more boilerplate code.
- They can increase the complexity of the code and make it harder to understand. The state and functionality of an object becomes spread out over its entire inheritance chain.
- They aren’t very flexible. You can’t share pieces of functionality between different branches of the inheritance tree if that functionality isn’t in the base class. For example, consider making two classes,
Mammal
andBird
, which both derive fromAnimal
. TheBird
class has aFly()
method. Then you decide to add aBat
class that derives fromMammal
and can also fly. To share this functionality using only inheritance you would have to move theFly()
method to theAnimal
class where it doesn’t belong. In addition, you can’t remove methods from derived classes, so if you made aPenguin
class that derived fromBird
, it would also had aFly()
method.
For this tutorial, we will favour composition over inheritance for implementing the different types of enemies. We will do this by creating various, reusable behaviours that we can add to enemies. We can then easily mix and match behaviours when we create new types of enemies. For example, if we already had a FollowPlayer
behaviour and a DodgeBullet
behaviour, we could make a new enemy that does both simply by adding both behaviours.
Enemies
Enemies will have a few additional properties over entities. In order to give the player some time to react, we’ll make enemies gradually fade in before they become active and dangerous.
Let’s code the basic structure of the Enemy
class.
class Enemy : Entity { private int timeUntilStart = 60; public bool IsActive { get { return timeUntilStart <= 0; } } public Enemy(Texture2D image, Vector2 position) { this.image = image; Position = position; Radius = image.Width / 2f; color = Color.Transparent; } public override void Update() { if (timeUntilStart <= 0) { // enemy behaviour logic goes here. } else { timeUntilStart--; color = Color.White * (1 - timeUntilStart / 60f); } Position += Velocity; Position = Vector2.Clamp(Position, Size / 2, GameRoot.ScreenSize - Size / 2); Velocity *= 0.8f; } public void WasShot() { IsExpired = true; } }
This code will make enemies fade in for 60 frames and will allow their velocity to function. Multiplying the velocity by 0.8 fakes a friction-like effect. If we make enemies accelerate at a constant rate, this friction will cause them to smoothly approach a maximum speed. I like the simplicity and smoothness of this type of friction, but you may want to use a different formula depending on the effect you want.
The WasShot()
method will be called when the enemy gets shot. We’ll add more to it later in the series.
We want different types of enemies to behave differently. We’ll accomplish this by assigning behaviours. A behaviour will use some custom function that runs each frame to control the enemy. We’ll implement the behaviour using an iterator.
Iterators (also called generators) in C# are special methods that can stop partway through and later resume where they left off. You can make an iterator by making a method with a return type of IEnumerable<>
and using the yield keyword where you want it to return and later resume. Iterators in C# require you to return something when you yield. We don’t really need to return anything, so our iterators will simply yield zero.
Our simplest behaviour will be the FollowPlayer()
behaviour shown below.
IEnumerable<int> FollowPlayer(float acceleration = 1f) { while (true) { Velocity += (PlayerShip.Instance.Position - Position).ScaleTo(acceleration); if (Velocity != Vector2.Zero) Orientation = Velocity.ToAngle(); yield return 0; } }
This simply makes the enemy accelerate towards the player at a constant rate. The friction we added earlier will ensure it eventually tops out at some max speed (5 pixels per frame when acceleration is 1 since \(0.8 \times 5 + 1 = 5\)). Each frame, this method will run until it hits the yield statement and will then resume where it left off next frame.
You may be wondering why we bothered with iterators at all, since we could have accomplished the same task more easily with a simple delegate. Using iterators pays off with more complex methods that would otherwise require us to store state in member variables in the class.
For example, below is a behaviour that makes an enemy move in a square pattern:
IEnumerable<int> MoveInASquare() { const int framesPerSide = 30; while (true) { // move right for 30 frames for (int i = 0; i < framesPerSide; i++) { Velocity = Vector2.UnitX; yield return 0; } // move down for (int i = 0; i < framesPerSide; i++) { Velocity = Vector2.UnitY; yield return 0; } // move left for (int i = 0; i < framesPerSide; i++) { Velocity = -Vector2.UnitX; yield return 0; } // move up for (int i = 0; i < framesPerSide; i++) { Velocity = -Vector2.UnitY; yield return 0; } } }
What’s nice about this is that it not only saves us some instance variables, but it also structures the code in a very logical way. You can see right away that the enemy will move right, then down, then left, then up, and then repeat. If you were to implement this method as a state machine instead, the control flow would be less obvious.
Let’s add the scaffolding needed to make behaviours work. Enemies need to store their behaviours, so we’ll add a variable to the Enemy
class.
private List<IEnumerator<int>> behaviours = new List<IEnumerator<int>>();
Note that a behaviour has the type IEnumerator<int>
, not IEnumerable<int>
. You can think of the IEnumerable
as the template for the behaviour and the IEnumerator
as the running instance. The IEnumerator
remembers where we are in the behaviour and will pick up where it left off when you call its MoveNext()
method. Each frame we’ll go through all the behaviours the enemy has and call MoveNext()
on each of them. If MoveNext()
returns false, it means the behaviour has completed so we should remove it from the list.
We’ll add the following methods to the Enemy
class:
private void AddBehaviour(IEnumerable<int> behaviour) { behaviours.Add(behaviour.GetEnumerator()); } private void ApplyBehaviours() { for (int i = 0; i < behaviours.Count; i++) { if (!behaviours[i].MoveNext()) behaviours.RemoveAt(i--); } }
And we’ll modify the Update()
method to call ApplyBehaviours()
:
if (timeUntilStart <= 0) ApplyBehaviours(); // ...
Now we can make a static method to create seeking enemies. All we have to do is pick the image we want and add the FollowPlayer()
behaviour.
public static Enemy CreateSeeker(Vector2 position) { var enemy = new Enemy(Art.Seeker, position); enemy.AddBehaviour(enemy.FollowPlayer()); return enemy; }
To make an enemy that moves randomly, we’ll have it choose a direction and then make small random adjustments to that direction. However, if we adjust the direction every frame, the movement will be jittery, so we’ll only adjust the direction periodically. If the enemy runs into the edge of the screen, we’ll have it choose a new random direction that points away from the wall.
IEnumerable<int> MoveRandomly() { float direction = rand.NextFloat(0, MathHelper.TwoPi); while (true) { direction += rand.NextFloat(-0.1f, 0.1f); direction = MathHelper.WrapAngle(direction); for (int i = 0; i < 6; i++) { Velocity += MathUtil.FromPolar(direction, 0.4f); Orientation -= 0.05f; var bounds = GameRoot.Viewport.Bounds; bounds.Inflate(-image.Width, -image.Height); // if the enemy is outside the bounds, make it move away from the edge if (!bounds.Contains(Position.ToPoint())) direction = (GameRoot.ScreenSize / 2 - Position).ToAngle() + rand.NextFloat(-MathHelper.PiOver2, MathHelper.PiOver2); yield return 0; } } }
Collision Detection
For collision detection, we’ll model the player’s ship, the enemies, and the bullets as circles. Circular collision detection is nice because it’s simple, it’s fast, and it doesn’t change when the objects rotate. If you recall, the Entity
class has a radius and a position (the position refers to the center of the entity). This is all we need for circular collision detection.
Testing each entity against all other entities that could potentially collide can be very slow if you have a large number of entities. There are many techniques you can use to speed up broad phase collision detection, like quadtrees, sweep and prune, and BSP trees. However, for now, we will only have a few dozen entities on screen at a time, so we won’t worry about these more complex techniques. We can always add them later if we need them.
In Shape Blaster, not every entity can collide with every other type of entity. Bullets and the player’s ship can collide only with enemies. Enemies can also collide with other enemies – this will prevent them from overlapping.
To deal with these different types of collisions, we will add two new lists to the EntityManager
to keep track of bullets and enemies. Whenever we add an entity to the EntityManager
, we’ll want to add it to the appropriate list, so we’ll make a private AddEntity()
method to do so. We’ll also be sure to remove any expired entities from all the lists each frame.
static List<Enemy> enemies = new List<Enemy>(); static List<Bullet> bullets = new List<Bullet>(); private static void AddEntity(Entity entity) { entities.Add(entity); if (entity is Bullet) bullets.Add(entity as Bullet); else if (entity is Enemy) enemies.Add(entity as Enemy); } // ... // in Update() bullets = bullets.Where(x => !x.IsExpired).ToList(); enemies = enemies.Where(x => !x.IsExpired).ToList();
Replace the calls to entity.Add()
in EntityManager.Add()
and EntityManager.Update()
with calls to AddEntity()
.
Now let’s add a method that will determine whether two entities are colliding:
private static bool IsColliding(Entity a, Entity b) { float radius = a.Radius + b.Radius; return !a.IsExpired && !b.IsExpired && Vector2.DistanceSquared(a.Position, b.Position) < radius * radius; }
To determine if two circles overlap, simply check if the distance between them is less than the sum of their radii. Our method optimizes this slightly by checking if the square of the distance is less than the square of the sum of the radii. Remember that it’s a bit faster to compute the distance squared than the actual distance.
Different things will happen depending on which two objects collide. If two enemies collide, we want them to push each other away. If a bullet hits an enemy, the bullet and the enemy should both be destroyed. If the player touches an enemy, the player should die and the level should reset.
We’ll add a HandleCollision()
method to the Enemy
class to handle collisions between enemies:
public void HandleCollision(Enemy other) { var d = Position - other.Position; Velocity += 10 * d / (d.LengthSquared() + 1); }
This method will push the current enemy away from the other enemy. The closer they are, the harder it will be pushed, because the magnitude of (d / d.LengthSquared())
is just one over the distance.
Respawning the Player
Next we need a method to handle the player’s ship getting killed. When this happens, the player’s ship will disappear for a short time before respawning.
We start by adding two new members to PlayerShip
.
int framesUntilRespawn = 0; public bool IsDead { get { return framesUntilRespawn > 0; } }
At the very beginning of PlayerShip.Update()
, add the following:
if (IsDead) { framesUntilRespawn--; return; }
And we override Draw()
as shown:
public override void Draw(SpriteBatch spriteBatch) { if (!IsDead) base.Draw(spriteBatch); }
Finally, we add a Kill()
method to PlayerShip
.
public void Kill() { framesUntilRespawn = 60; }
Now that all the pieces are in place, we’ll add a method to the EntityManager
that goes through all the entities and checks for collisions.
static void HandleCollisions() { // handle collisions between enemies for (int i = 0; i < enemies.Count; i++) for (int j = i + 1; j < enemies.Count; j++) { if (IsColliding(enemies[i], enemies[j])) { enemies[i].HandleCollision(enemies[j]); enemies[j].HandleCollision(enemies[i]); } } // handle collisions between bullets and enemies for (int i = 0; i < enemies.Count; i++) for (int j = 0; j < bullets.Count; j++) { if (IsColliding(enemies[i], bullets[j])) { enemies[i].WasShot(); bullets[j].IsExpired = true; } } // handle collisions between the player and enemies for (int i = 0; i < enemies.Count; i++) { if (enemies[i].IsActive && IsColliding(PlayerShip.Instance, enemies[i])) { PlayerShip.Instance.Kill(); enemies.ForEach(x => x.WasShot()); break; } } }
Call this method from Update()
immediately after setting isUpdating
to true
.
Enemy Spawner
The last thing to do is make the EnemySpawner
class, which is responsible for creating enemies. We want the game to start off easy and get harder, so the EnemySpawner
will create enemies at an increasing rate as time progresses. When the player dies, we’ll reset the EnemySpawner
to its initial difficulty.
static class EnemySpawner { static Random rand = new Random(); static float inverseSpawnChance = 60; public static void Update() { if (!PlayerShip.Instance.IsDead && EntityManager.Count < 200) { if (rand.Next((int)inverseSpawnChance) == 0) EntityManager.Add(Enemy.CreateSeeker(GetSpawnPosition())); if (rand.Next((int)inverseSpawnChance) == 0) EntityManager.Add(Enemy.CreateWanderer(GetSpawnPosition())); } // slowly increase the spawn rate as time progresses if (inverseSpawnChance > 20) inverseSpawnChance -= 0.005f; } private static Vector2 GetSpawnPosition() { Vector2 pos; do { pos = new Vector2(rand.Next((int)GameRoot.ScreenSize.X), rand.Next((int)GameRoot.ScreenSize.Y)); } while (Vector2.DistanceSquared(pos, PlayerShip.Instance.Position) < 250 * 250); return pos; } public static void Reset() { inverseSpawnChance = 60; } }
Each frame, there is a one in inverseSpawnChance
of generating each type of enemy. The chance of spawning an enemy gradually increases until it reaches a maximum of one in twenty. Enemies are always created at least 250 pixels away from the player.
Be careful about the while loop in GetSpawnPosition()
. It will work efficiently as long as the area in which enemies can spawn is bigger than the area where they can’t spawn. However, if you make the forbidden area too large, you will get an infinite loop.
Call EnemySpawner.Update()
from GameRoot.Update()
and call EnemySpawner.Reset()
when the player is killed.
Score and Lives
In Shape Blaster, you will begin with four lives, and will gain an additional life every 2000 points. You receive points for destroying enemies, with different types of enemies being worth different amounts of points. Each enemy destroyed also increases your score multiplier by one. If you don’t kill any enemies within a short amount of time, your multiplier will be reset. The total amount of points received from each enemy you destroy is the number of points the enemy is worth multiplied by your current multiplier. If you lose all your lives, the game is over and you start a new game with your score reset to zero.
To handle all this, we will make a static class called PlayerStatus
.
static class PlayerStatus { // amount of time it takes, in seconds, for a multiplier to expire. private const float multiplierExpiryTime = 0.8f; private const int maxMultiplier = 20; public static int Lives { get; private set; } public static int Score { get; private set; } public static int Multiplier { get; private set; } private static float multiplierTimeLeft; // time until the current multiplier expires private static int scoreForExtraLife; // score required to gain an extra life // Static constructor static PlayerStatus() { Reset(); } public static void Reset() { Score = 0; Multiplier = 1; Lives = 4; scoreForExtraLife = 2000; multiplierTimeLeft = 0; } public static void Update() { if (Multiplier > 1) { // update the multiplier timer if ((multiplierTimeLeft -= (float)GameRoot.GameTime.ElapsedGameTime.TotalSeconds) <= 0) { multiplierTimeLeft = multiplierExpiryTime; ResetMultiplier(); } } } public static void AddPoints(int basePoints) { if (PlayerShip.Instance.IsDead) return; Score += basePoints * Multiplier; while (Score >= scoreForExtraLife) { scoreForExtraLife += 2000; Lives++; } } public static void IncreaseMultiplier() { if (PlayerShip.Instance.IsDead) return; multiplierTimeLeft = multiplierExpiryTime; if (Multiplier < maxMultiplier) Multiplier++; } public static void ResetMultiplier() { Multiplier = 1; } public static void RemoveLife() { Lives--; } }
Call PlayerStatus.Update()
from GameRoot.Update()
when the game is not paused.
Next we want to display your score, lives, and multiplier on screen. To do this we’ll need to add a SpriteFont
in the Content
project and a corresponding variable in the Art
class, which we will name Font
. Load the font in Art.Load()
as we did with the textures.
Modify the end of GameRoot.Draw()
where the cursor is drawn as shown below.
spriteBatch.Begin(0, BlendState.Additive); spriteBatch.DrawString(Art.Font, "Lives: " + PlayerStatus.Lives, new Vector2(5), Color.White); DrawRightAlignedString("Score: " + PlayerStatus.Score, 5); DrawRightAlignedString("Multiplier: " + PlayerStatus.Multiplier, 35); // draw the custom mouse cursor spriteBatch.Draw(Art.Pointer, Input.MousePosition, Color.White); spriteBatch.End();
DrawRightAlignedString()
is a helper method for drawing text aligned on the right side of the screen. Add it to GameRoot
by adding the code below.
private void DrawRightAlignedString(string text, float y) { var textWidth = Art.Font.MeasureString(text).X; spriteBatch.DrawString(Art.Font, text, new Vector2(ScreenSize.X - textWidth - 5, y), Color.White); }
Now your lives, score, and multiplier should display on screen. However, we still need to modify these values in response to game events. Add a property called PointValue
to the Enemy
class.
public int PointValue { get; private set; }
Set the point value for different enemies to something you feel is appropriate. I made the wandering enemies worth one point, and the seeking enemies worth two points.
Next, add the following two lines to Enemy.WasShot()
to increase the player’s score and multiplier:
PlayerStatus.AddPoints(PointValue); PlayerStatus.IncreaseMultiplier();
Call PlayerStatus.RemoveLife()
in PlayerShip.Kill()
. If the player loses all their lives, call PlayerStatus.Reset()
to reset their score and lives at the start of a new game.
High Scores
Let’s add the ability for the game to track your best score. We want this score to persist across plays so we’ll save it to a file. We’ll keep it really simple and save the high score as a single plain-text number in a file in the current working directory (this will be the same directory that contains the game’s .exe
file).
Add the following methods to PlayerStatus
:
private const string highScoreFilename = "highscore.txt"; private static int LoadHighScore() { // return the saved high score if possible and return 0 otherwise int score; return File.Exists(highScoreFilename) && int.TryParse(File.ReadAllText(highScoreFilename), out score) ? score : 0; } private static void SaveHighScore(int score) { File.WriteAllText(highScoreFilename, score.ToString()); }
The LoadHighScore()
method first checks that the high score file exists, and then checks that it contains a valid integer. The second check will most likely never fail unless the user manually edits the high score file to something invalid, but it’s good to be cautious.
We want to load the high score when the game starts up, and save it when the player gets a new high score. We’ll modify the static constructor and Reset()
methods in PlayerStatus
to do so. We’ll also add a helper property, IsGameOver
which we’ll use in a moment.
public static bool IsGameOver { get { return Lives == 0; } } static PlayerStatus() { HighScore = LoadHighScore(); Reset(); } public static void Reset() { if (Score > HighScore) SaveHighScore(HighScore = Score); Score = 0; Multiplier = 1; Lives = 4; scoreForExtraLife = 2000; multiplierTimeLeft = 0; }
That takes care of tracking the high score. Now we need to display it. Add the following code to GameRoot.Draw()
in the same SpriteBatch
block where the other text is drawn:
if (PlayerStatus.IsGameOver) { string text = "Game Over\n" + "Your Score: " + PlayerStatus.Score + "\n" + "High Score: " + PlayerStatus.HighScore; Vector2 textSize = Art.Font.MeasureString(text); spriteBatch.DrawString(Art.Font, text, ScreenSize / 2 - textSize / 2, Color.White); }
This will make it display your score and high score on game over, centered in the screen.
As a final adjustment, we’ll increase the time before the ship respawns on game over to give the player time to see their score. Modify PlayerShip.Kill()
by setting the respawn time to 300 frames (five seconds) if the player is out of lives.
// in PlayerShip.Kill() PlayerStatus.RemoveLife(); framesUntilRespawn = PlayerStatus.IsGameOver ? 300 : 120;
The game is now ready to play. It may not look like much, but it has all the basic mechanics implemented. In future tutorials we will add a bloom filter and particle effects to spice it up. But right now, let’s quickly add some sound and music to make it more interesting.
Sound and Music
Playing sound and music is easy in XNA. First, we add our sound effects and music to the content pipeline. In the Properties pane, ensure the content processor is set to Song
for the music and Sound Effect
for the sounds.
Next, we make a static helper class for the sounds.
static class Sound { public static Song Music { get; private set; } private static readonly Random rand = new Random(); private static SoundEffect[] explosions; // return a random explosion sound public static SoundEffect Explosion { get { return explosions[rand.Next(explosions.Length)]; } } private static SoundEffect[] shots; public static SoundEffect Shot { get { return shots[rand.Next(shots.Length)]; } } private static SoundEffect[] spawns; public static SoundEffect Spawn { get { return spawns[rand.Next(spawns.Length)]; } } public static void Load(ContentManager content) { Music = content.Load<Song>("Sound/Music"); // These linq expressions are just a fancy way loading all sounds of each category into an array. explosions = Enumerable.Range(1, 8).Select(x => content.Load<SoundEffect>("Sound/explosion-0" + x)).ToArray(); shots = Enumerable.Range(1, 4).Select(x => content.Load<SoundEffect>("Sound/shoot-0" + x)).ToArray(); spawns = Enumerable.Range(1, 8).Select(x => content.Load<SoundEffect>("Sound/spawn-0" + x)).ToArray(); } }
Since we have multiple variations of each sound, the Explosion
, Shot
, and Spawn
properties will pick a sound at random among the variants.
Call Sound.Load()
in GameRoot.LoadContent()
. To play the music, add the following two lines at the end of GameRoot.Initialize()
.
MediaPlayer.IsRepeating = true; MediaPlayer.Play(Sound.Music);
To play sounds in XNA, you can simply call the Play()
method on a SoundEffect
. This method also provides an overload that allows you to adjust the volume, pitch and pan of the sound. A trick to make our sounds more varied is to adjust these quantities on each play.
To trigger the sound effect for shooting, add the following line in PlayerShip.Update()
, inside the if-statement where the bullets are created. Note that we randomly shift the pitch up or down, up to a fifth of an octave, to make the sounds less repetitive.
Sound.Shot.Play(0.2f, rand.NextFloat(-0.2f, 0.2f), 0);
Likewise, trigger an explosion sound effect each time an enemy is destroyed by adding the following to Enemy.WasShot()
.
Sound.Explosion.Play(0.5f, rand.NextFloat(-0.2f, 0.2f), 0);
You now have sound and music in your game. Easy, isn’t it?
Conclusion
That wraps up the basic gameplay mechanics. In the next tutorial, we’ll add a bloom filter to make the neon lights glow.