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 the series so far we created the gameplay, bloom, and particle effects. In this final part, we will create a dynamic, warping background grid.
One of the coolest effects in Geometry Wars is the warping background grid. We’ll examine how to create a similar effect in Shape Blaster. The grid will react to bullets, black holes, and the player respawning. It’s not difficult to make and it looks awesome.
We’ll make the grid using a spring simulation. At each intersection of the grid, we’ll put a small weight and attach a spring on each side. These springs will only pull and never push, much like a rubber band. To keep the grid in position, the masses at the border of the grid will be anchored in place. Below is a diagram of the layout.
We’ll create a class called Grid
to create this effect. However, before we work on the grid itself, we need to make two helper classes: Spring
and PointMass
.
The PointMass Class
The PointMass
class represents the masses to which we will attach the springs. Springs never connect directly to other springs. Instead, they apply a force to the masses they connect, which in turn may stretch other springs.
private class PointMass { public Vector3 Position; public Vector3 Velocity; public float InverseMass; private Vector3 acceleration; private float damping = 0.98f; public PointMass(Vector3 position, float invMass) { Position = position; InverseMass = invMass; } public void ApplyForce(Vector3 force) { acceleration += force * InverseMass; } public void IncreaseDamping(float factor) { damping *= factor; } public void Update() { Velocity += acceleration; Position += Velocity; acceleration = Vector3.Zero; if (Velocity.LengthSquared() < 0.001f * 0.001f) Velocity = Vector3.Zero; Velocity *= damping; damping = 0.98f; } }
There are a few interesting points about this class. First, notice that it stores the inverse of the mass, 1 / mass
. This is often a good idea in physics simulations because physics equations tend to use the inverse of the mass more often, and because it gives us an easy way to represent infinitely heavy, immovable objects by setting the inverse mass to zero.
The class also contains a damping variable. This is used roughly as friction or air resistance. It gradually slows the mass down. This helps make the grid eventually come to rest and also increases the stability of the spring simulation.
The Update()
method does the work of moving the point mass each frame. It begins by doing symplectic Euler integration, which just means we add the acceleration to the velocity and then add the updated velocity to the position. This differs from standard Euler integration in which we would update the velocity after updating the position.
Tip: Symplectic Euler is better for spring simulations because it conserves energy. If you use regular Euler integration and create springs without damping, they will tend to stretch further and further each bounce as they gain energy, eventually breaking your simulation.
After updating the velocity and position, we check if the velocity is very small, and if so we set it to zero. This can be important to performance due to the nature of denormalized floating-point numbers.
(When floating-point numbers get very small, they use a special representation called a denormal number. This has the advantage of allowing float to represent smaller numbers, but it comes at a price. Most chipsets can’t use their standard arithmetic operations on denormalized numbers and instead must emulate them using a series of steps. This can be tens to hundreds of times slower than performing operations on normalized floating-point numbers. Since we multiply our velocity by our damping factor each frame, it will eventually become very small. We don’t actually care about such tiny velocities, so we simply set it to zero.)
The IncreaseDamping()
method is used to temporarily increase the amount of damping. We will use this later for certain effects.
The Spring Class
A spring connects two point masses, and, if stretched past its natural length, applies a force pulling the masses together. Springs follow a modified version of Hooke’s Law with damping:
\[f = -kx - bv\]
- \(f\) is the force produced by the spring.
- \(k\) is the spring constant, or the stiffness of the spring.
- \(x\) is the distance the spring is stretched beyond its natural length.
- \(b\) is the damping factor.
- \(v\) is the velocity.
The code for the Spring
class is as follows.
private struct Spring { public PointMass End1; public PointMass End2; public float TargetLength; public float Stiffness; public float Damping; public Spring(PointMass end1, PointMass end2, float stiffness, float damping) { End1 = end1; End2 = end2; Stiffness = stiffness; Damping = damping; TargetLength = Vector3.Distance(end1.Position, end2.Position) * 0.95f; } public void Update() { var x = End1.Position - End2.Position; float length = x.Length(); // these springs can only pull, not push if (length <= TargetLength) return; x = (x / length) * (length - TargetLength); var dv = End2.Velocity - End1.Velocity; var force = Stiffness * x - dv * Damping; End1.ApplyForce(-force); End2.ApplyForce(force); } }
When we create a spring, we set the natural length of the spring to be just slightly less than the distance between the two end points. This keeps the grid taut even when at rest and improves the appearance somewhat.
The Update()
method first checks if the spring is stretched beyond its natural length. If it is not stretched, nothing happens. If it is, we use the modified Hooke’s Law to find the force from the spring and apply it to the two connected masses.
Creating the Grid
Now that we have the necessary nested classes, we’re ready to create the grid. We start by creating PointMass
objects at each intersection on the grid. We also create some immovable anchor PointMass
objects to hold the grid in place. We then link up the masses with springs.
Spring[] springs; PointMass[,] points; public Grid(Rectangle size, Vector2 spacing) { var springList = new List(); int numColumns = (int)(size.Width / spacing.X) + 1; int numRows = (int)(size.Height / spacing.Y) + 1; points = new PointMass[numColumns, numRows]; // these fixed points will be used to anchor the grid to fixed positions on the screen PointMass[,] fixedPoints = new PointMass[numColumns, numRows]; // create the point masses int column = 0, row = 0; for (float y = size.Top; y <= size.Bottom; y += spacing.Y) { for (float x = size.Left; x <= size.Right; x += spacing.X) { points[column, row] = new PointMass(new Vector3(x, y, 0), 1); fixedPoints[column, row] = new PointMass(new Vector3(x, y, 0), 0); column++; } row++; column = 0; } // link the point masses with springs for (int y = 0; y < numRows; y++) for (int x = 0; x < numColumns; x++) { if (x == 0 || y == 0 || x == numColumns - 1 || y == numRows - 1) // anchor the border of the grid springList.Add(new Spring(fixedPoints[x, y], points[x, y], 0.1f, 0.1f)); else if (x % 3 == 0 && y % 3 == 0) // loosely anchor 1/9th of the point masses springList.Add(new Spring(fixedPoints[x, y], points[x, y], 0.002f, 0.02f)); const float stiffness = 0.28f; const float damping = 0.06f; if (x > 0) springList.Add(new Spring(points[x - 1, y], points[x, y], stiffness, damping)); if (y > 0) springList.Add(new Spring(points[x, y - 1], points[x, y], stiffness, damping)); } springs = springList.ToArray(); }
The first for
loop creates both regular masses and immovable masses at each intersection of the grid. We won’t actually use all of the immovable masses, and the unused masses will simply be garbage collected sometime after the constructor ends. We could optimize by avoiding creating unnecessary objects, but since the grid is usually only created once, it won’t make much difference.
In addition to using anchor point masses around the border of the grid, we will also use some anchor masses inside the grid. These will be used to very gently help pull the grid back to its original position after being deformed.
Since the anchor points never move, they don’t need to be updated each frame. We can simply hook them up to the springs and forget about them. Therefore, we don’t have a member variable in the Grid
class for these masses.
There are a number of values you can tweak in the creation of the grid. The most important ones are the stiffness and damping of the springs. The stiffness and damping of the border anchors and interior anchors are set independently of the main springs. Higher stiffness values will make the springs oscillate more quickly, and higher damping values will cause the springs to slow down faster.
Manipulating the Grid
In order for the grid to move, we must update it each frame. This is very simple as we already did all the hard work in the PointMass
and Spring
classes.
public void Update() { foreach (var spring in springs) spring.Update(); foreach (var mass in points) mass.Update(); }
Now we will add some methods that manipulate the grid. You can add methods for any kind of manipulation you can think of. We will implement three types of manipulations here: pushing part of the grid in a given direction, pushing the grid outwards from some point, and pulling the grid in towards some point. All three will affect the grid within a given radius from some target point. Below are some images of these manipulations in action.
public void ApplyDirectedForce(Vector3 force, Vector3 position, float radius) { foreach (var mass in points) if (Vector3.DistanceSquared(position, mass.Position) < radius * radius) mass.ApplyForce(10 * force / (10 + Vector3.Distance(position, mass.Position))); } public void ApplyImplosiveForce(float force, Vector3 position, float radius) { foreach (var mass in points) { float dist2 = Vector3.DistanceSquared(position, mass.Position); if (dist2 < radius * radius) { mass.ApplyForce(10 * force * (position - mass.Position) / (100 + dist2)); mass.IncreaseDamping(0.6f); } } } public void ApplyExplosiveForce(float force, Vector3 position, float radius) { foreach (var mass in points) { float dist2 = Vector3.DistanceSquared(position, mass.Position); if (dist2 < radius * radius) { mass.ApplyForce(100 * force * (mass.Position - position) / (10000 + dist2)); mass.IncreaseDamping(0.6f); } } }
We will use all three of these methods in Shape Blaster for different effects.
Rendering the Grid
We will draw the grid by drawing line segments between each neighbouring pair of points. First, we’ll make an extension method on SpriteBatch
that allows us to draw line segments by taking a texture of a single pixel and stretching it into a line.
Open the Art
class and declare a texture for the pixel.
public static Texture2D Pixel { get; private set; }
You can set the pixel texture the same way we set up the other images, or you can simply add the following two lines to the Art.Load()
method.
Pixel = new Texture2D(Player.GraphicsDevice, 1, 1); Pixel.SetData(new[] { Color.White });
This simply creates a new 1x1px texture and sets the sole pixel to white. Now add the following method in the Extensions
class.
public static void DrawLine(this SpriteBatch spriteBatch, Vector2 start, Vector2 end, Color color, float thickness = 2f) { Vector2 delta = end - start; spriteBatch.Draw(Art.Pixel, start, null, color, delta.ToAngle(), new Vector2(0, 0.5f), new Vector2(delta.Length(), thickness), SpriteEffects.None, 0f); }
This method stretches, rotates, and tints the pixel texture to produce the line we desire.
Next, we need a method to project the 3D grid points onto our 2D screen. Normally this might be done using matrices, but here we’ll transform the coordinates manually instead.
Add the following to the Grid
class.
public Vector2 ToVec2(Vector3 v) { // do a perspective projection float factor = (v.Z + 2000) / 2000; return (new Vector2(v.X, v.Y) - screenSize / 2f) * factor + screenSize / 2; }
This transform will give the grid a perspective view where far away points appear closer together on the screen. Now we can draw the grid by iterating through the rows and columns and drawing lines between them.
public void Draw(SpriteBatch spriteBatch) { int width = points.GetLength(0); int height = points.GetLength(1); Color color = new Color(30, 30, 139, 85); // dark blue for (int y = 1; y < height; y++) { for (int x = 1; x < width; x++) { Vector2 left = new Vector2(), up = new Vector2(); Vector2 p = ToVec2(points[x, y].Position); if (x > 1) { left = ToVec2(points[x - 1, y].Position); float thickness = y % 3 == 1 ? 3f : 1f; spriteBatch.DrawLine(left, p, color, thickness); } if (y > 1) { up = ToVec2(points[x, y - 1].Position); float thickness = x % 3 == 1 ? 3f : 1f; spriteBatch.DrawLine(up, p, color, thickness); } } } }
In the above code, p
is our current point on the grid, left
is the point directly to its left and up
is the point directly above it. We draw every third line thicker both horizontally and vertically for visual effect.
Interpolation
We can optimize the grid by improving the visual quality for a given number of springs without significantly increasing the performance cost. We are going to do two such optimizations.
We will make the grid denser by adding line segments inside the existing grid cells. We do so by drawing lines from the midpoint of one side of the cell to the midpoint of the opposite side. The image below shows the new interpolated lines in red.
Drawing the interpolated lines is straightforward. If you have two points, a
and b
, their midpoint is (a + b) / 2
. So, to draw the interpolated lines, we add the following code inside the for
loops of our Draw()
method.
if (x > 1 && y > 1) { Vector2 upLeft = ToVec2(points[x - 1, y - 1].Position); spriteBatch.DrawLine(0.5f * (upLeft + up), 0.5f * (left + p), color, 1f); // vertical line spriteBatch.DrawLine(0.5f * (upLeft + left), 0.5f * (up + p), color, 1f); // horizontal line }
The second improvement is to perform interpolation on our straight line segments to make them into smoother curves. XNA provides the handy Vector2.CatmullRom()
method which performs Catmull-Rom interpolation. You pass the method four sequential points on a curved line, and it will return points along a smooth curve between the second and third points you provided.
The fifth argument to Vector2.CatmullRom()
is a weighting factor that determines which point on the interpolated curve it returns. A weighting factor of 0
or 1
will respectively return the second or third point you provided, and a weighting factor of 0.5
will return the point on the interpolated curve halfway between the two points. By gradually moving the weighting factor from zero to one and drawing lines between the returned points, we can produce a perfectly smooth curve. However, to keep the performance cost low, we will only take a single interpolated point into consideration, at a weighting factor of 0.5
. We then replace the original straight line in the grid with two lines that meet at the interpolated point.
The diagram below shows the effect of this interpolation.
Since the line segments in the grid are already small, using more than one interpolated point generally does not make a noticeable difference.
Often, the lines in our grid will be very straight and won’t require any smoothing. We can check for this and avoid having to draw two lines instead of one. We check if the distance between the interpolated point and the midpoint of the straight line is greater than one pixel. If it is, we assume the line is curved and we draw two line segments. The modification to our Draw()
method for adding Catmull-Rom interpolation for the horizontal lines is shown below.
left = ToVec2(points[x - 1, y].Position); float thickness = y % 3 == 1 ? 3f : 1f; // use Catmull-Rom interpolation to help smooth bends in the grid int clampedX = Math.Min(x + 1, width - 1); Vector2 mid = Vector2.CatmullRom(ToVec2(points[x - 2, y].Position), left, p, ToVec2(points[clampedX, y].Position), 0.5f); // If the grid is very straight here, draw a single straight line. Otherwise, draw lines to our // new interpolated midpoint if (Vector2.DistanceSquared(mid, (left + p) / 2) > 1) { spriteBatch.DrawLine(left, mid, color, thickness); spriteBatch.DrawLine(mid, p, color, thickness); } else spriteBatch.DrawLine(left, p, color, thickness);
The image below shows the effects of the smoothing. A green dot is drawn at each interpolated point to better illustrate where the lines are smoothed.
Using the Grid in Shape Blaster
Now it’s time to use the grid in our game. We start by declaring a public, static Grid
variable in GameRoot
and creating the grid in the GameRoot.Initialize()
method. We’ll create a grid with roughly 1600 points like so.
const int maxGridPoints = 1600; Vector2 gridSpacing = new Vector2((float)Math.Sqrt(Viewport.Width * Viewport.Height / maxGridPoints)); Grid = new Grid(Viewport.Bounds, gridSpacing);
Then we call Grid.Update()
and Grid.Draw()
from the Update()
and Draw()
methods in GameRoot
. This will allow us to see the grid when we run the game. However, we still need to make various game objects interact with the grid.
Bullets will repel the grid. We already made a method to do this called ApplyExplosiveForce()
. Add the following line to the Bullet.Update()
method.
GameRoot.Grid.ApplyExplosiveForce(0.5f * Velocity.Length(), Position, 80);
This will make bullets repel the grid proportionally to their speed. That was pretty easy.
Now let’s work on black holes. Add this line to BlackHole.Update()
.
GameRoot.Grid.ApplyImplosiveForce((float)Math.Sin(sprayAngle / 2) * 10 + 20, Position, 200);
This makes the black hole suck in the grid with a varying amount of force. I reused the sprayAngle
variable, which will cause the force on the grid to pulsate in sync with the angle it sprays particles (although at half the frequency due to the division by two). The force passed in will vary sinusoidally between 10 and 30.
Finally, we will create a shockwave in the grid when the player’s ship respawns after death. We will do so by pulling the grid along the z-axis and then allowing the force to propagate and bounce through the springs. Again, this only requires a small modification to PlayerShip.Update()
.
if (IsDead) { if (--framesUntilRespawn == 0) GameRoot.Grid.ApplyDirectedForce(new Vector3(0, 0, 5000), new Vector3(Position, 0), 50); return; }
What’s Next?
We have the basic gameplay and effects implemented. It’s up to you to turn it into a complete and polished game with your own flavour. Try adding some interesting new mechanics, some cool new effects, or a unique story. In case you aren’t sure where to start, here are a few suggestions.
- Create new enemy types such as snakes or exploding enemies.
- Create new weapon types such as seeking missiles or a lightning gun.
- Add a title screen and main menu.
- Add a high scores table.
- Add some powerups such as a shield or bombs. For bonus points, get creative with your powerups. You can make powerups that manipulate gravity, alter time, or grow like organisms. You can attach a giant, physics-based wrecking ball to the ship to smash enemies. Experiment to find powerups that are fun and help your game stand out.
- Create multiple levels. Harder levels can introduce tougher enemies and more advanced weapons and powerups.
- Allow a second player to join with a gamepad.
- Allow the arena to scroll so that it may be larger than the game window.
- Add environmental hazards such as lasers.
- Add a shop or leveling system and allow the player to earn upgrades.
Thanks for reading!