Skip to content

11 Geometry Wars

The finished Geometry Wars game

Make a twin-stick shooting game, like Geometry Wars: move with one hand, aim with the other, and survive the waves of enemies in the arena.

Geometry Wars: Retro Evolved
The original: Geometry Wars: Retro Evolved (Bizarre Creations, 2005). Screenshot © Bizarre Creations / Microsoft.

Geometry Wars puts thousands of entities on screen, from the player and the enemies to bullets, particles and a warping grid. They are built from components, as in Plants vs. Zombies. At this size a new question comes up. Which behaviour belongs in a component, and which belongs in a system that runs over many entities? We also look at:

  • Dependency injection, compared with the Service Locator, and testing with fakes
  • Object Pool
  • Flyweight
  • Particles as a system

Source code: gar-games/11-geometry-wars

Take about 10 minutes with the finished game, GeometryWars6:

  • Clone, build and play the game. How high a score can you get?
  • How is the codebase split between the core library and the Geometry Wars project?
flowchart LR
    Game1 --> PlayState --> PlaySession
    PlaySession --> EntityWorld
    PlaySession --> EntityFactory
    EntityFactory -- composes --> Components
    EntityWorld -- updates --> Components
    EntityWorld --> Systems
  • Game1 owns the application shell.
  • PlayState owns one active gameplay state.
  • PlaySession owns one run of the game.
  • EntityWorld updates entities and collisions.
  • EntityFactory builds entities by composing components.
  • Components implement the actual behaviour.
  • Systems handle concerns that cut across components.

Step GeometryWars0 onwards

Every entity is a bag of components, composed in one place, EntityFactory. There is no class per enemy type. An enemy is an entity with the components it needs. Each component represents one clear capability, such as Health, FaceVelocity, ApplyMovementInput or TakeDamageOnBulletCollision.

A capability can be split further when its parts change for different reasons. Firing the player’s weapon is four components: FireWeaponOnInput reads the trigger, Weapon keeps the rate of fire, SpawnTwinBulletsOnFired makes the bullet pattern, and PlaySoundOnWeaponFired plays the sound. A different gun swaps one of them. Split a component when it has several unrelated reasons to change, but not so finely that nobody can follow the pieces.

The lifecycle. Every component can hook into the same phases, which the entity runs in a fixed order every frame: OnAdded and OnStart once, then PreUpdate, Update, Simulate, PostUpdate, OnCollision and Draw, and OnRemoved when the entity leaves the world. OnRemoved is housekeeping (unsubscribing, for example); it is not the same as a gameplay event such as an enemy being destroyed.

Every component can hook into the same phases, which the entity runs in a fixed order.

Local events. The components of one entity talk through small events of their own, without a global event bus. Health announces Damaged and Depleted; other components on the same entity react: PlayHitParticlesOnDamage shows sparks, DestroyWhenHealthDepleted removes the entity. Health doesn’t know either of them exists. This is Observer inside one entity. The core flow (update, collide, draw) stays as direct calls, so it’s still easy to follow.

In the codebase: GARCore/ECS/Entity.cs, GARCore/ECS/Components/Component.cs, Systems/EntityFactory.cs.

Steps GeometryWars0 → GeometryWars4

Not all behaviour fits in a component. A component is about its owner, its own state and what happens to it. But some rules span many entities or the whole run, such as which pairs of entities collide, when the next enemies spawn, and what happens to the arena when the player dies. Putting those in a component means one entity reaching into all the others. They belong in systems.

A component is about its own entity; a system handles what spans many entities.

A rule of thumb from the codebase:

Use a component when the logic… Use a system when the logic…
is mostly about its owner and its own state touches many entities
can be reused on other entities owns game or session rules
needs a central order, or processes entities in bulk
Health, Weapon, FaceVelocity, SeekTarget CollisionSystem, EnemyDirector, BulletSpawner, PlaySession

For example, BeginRespawnOnLethalCollision is a component. It only decides when its player has taken a lethal hit. PlaySession handles the consequences for the whole arena (clearing enemies, resetting spawning), because those are rules of the whole run.

The particles (GeometryWars3) are the extreme case. Thousands of short-lived sparks aren’t entities with components at all. One ParticleManager system owns all of them and updates them in bulk. The spring grid (GeometryWars4) goes the same way, with its points in flat arrays updated in tight loops, for speed. A game can mix the two styles, using components where clarity matters and flat data where there are thousands of something. Flat data is the start of data-oriented design, which Vampire Survivors uses for its whole swarm.

Try it (GeometryWars6): remove the SeekTarget component in EntityFactory.CreateSeeker. What does a seeker do now, and what still works?

The architecture above gives every kind of code a home:

  1. The application and its screens: Game1 and the game states.
  2. The rules of one run: PlaySession and the systems.
  3. How an entity is put together: EntityFactory.
  4. Behaviour of one entity: a component, named for what it does (Health, and not PlayerHealth).
  5. Behaviour across entities: a system.

Step GeometryWars0 onwards

Components need shared services, like the input, the assets, the audio and the frame time. Pokemon found them through a Service Locator. Geometry Wars passes them in instead. Game1 creates the services once, bundles them in a PlayContext, and hands it to the code that builds and runs gameplay.

PlayContext.cs
public sealed class PlayContext
{
public FrameInfo Frame { get; }
public GameController Controller { get; }
public GameAssets Assets { get; }
public AudioManager Audio { get; }
public PerformanceMonitor Performance { get; }
public PlayContext(FrameInfo frame, GameController controller, GameAssets assets,
AudioManager audio, PerformanceMonitor performance)
{
// ...
}
}
  • Explicit: a constructor shows what a class depends on.
  • Testable: a test can pass in its own services, including fakes (see below).
  • The cost: everything that needs a service must be given it, so the context is passed through several layers.

Three ways to reach a shared service, each seen in this course:

Singleton (Flappy Bird) Service Locator (Pokemon) Dependency injection (here)
How code gets it Audio.Instance Locator.Audio passed in
Depends on one concrete class an interface whatever is passed
Dependencies visible? no no yes, in the constructor
Easy to replace? no yes, at runtime yes, when constructing

Project GeometryWars.Tests

In Sokoban, Level needed nothing, so testing it was easy. Most code needs something. AwardScoreOnDestroyed needs a score tracker, and the real one saves a high-score file and belongs to a whole play session. But the component doesn’t create it or look it up. It gets an IScoreTracker in its constructor, so a test can hand it a fake, a small class that only records what it was asked to do:

FakeScoreTracker.cs
public sealed class FakeScoreTracker : IScoreTracker
{
public List<int> PointsAdded { get; } = [];
public int MultiplierIncreases { get; private set; }
public int LivesRemoved { get; private set; }
public bool IsGameOver => false;
public void AddPoints(int basePoints) => PointsAdded.Add(basePoints);
public void IncreaseMultiplier() => MultiplierIncreases++;
public void RemoveLife() => LivesRemoved++;
}
AwardScoreOnDestroyedTests.cs
// Builds an entity the way EntityFactory does: add the components, then start it.
private static (Entity Enemy, Destroyable Destroyable) CreateEnemy(FakeScoreTracker score, bool increaseMultiplier = true)
{
var enemy = new Entity();
var destroyable = enemy.AddComponent(new Destroyable());
enemy.AddComponent(new AwardScoreOnDestroyed(score, 50, increaseMultiplier));
enemy.Start();
return (enemy, destroyable);
}
[Fact]
public void Destroying_the_enemy_awards_its_points()
{
var score = new FakeScoreTracker();
var (_, destroyable) = CreateEnemy(score);
destroyable.Destroy();
Assert.Equal([50], score.PointsAdded);
Assert.Equal(1, score.MultiplierIncreases);
}

The test needs one entity and two components, and no world or graphics. Time works the same way: ScoreTracker gets the frame time through the FrameInfo passed into its constructor, so ScoreTrackerTests decides how much time passes, and checks that the multiplier expires without waiting for it.

Compare the other two ways to reach a service. With Audio.Instance or Locator.Audio, the dependency is hidden inside the class. A test can only replace a Service Locator’s service by changing global state, and a Singleton not at all. With dependency injection, the test passes something else in.

Try it (GeometryWars.Tests): add a test that an enemy worth 100 points awards 100, and run dotnet test. How would the test look if AwardScoreOnDestroyed used Locator.Score?

Step GeometryWars1

Geometry Wars fires 20 bullets per second, and makes 30 particles per hit and 50 debris pieces per enemy. That is thousands of short-lived allocations per second. In C#, every new object will eventually be collected by the garbage collector, and GC pauses are unpredictable. A stutter every few seconds is the telltale sign of allocation in a hot loop.

An object pool keeps objects that are no longer in use, and hands them out again:

ObjectPool.cs
public sealed class ObjectPool<T> where T : class
{
private readonly Stack<T> _pool = new();
private readonly Func<T> _factory;
public ObjectPool(Func<T> factory, int initialCapacity = 0)
{
_factory = factory;
for (int i = 0; i < initialCapacity; i++)
_pool.Push(factory());
}
public T Get() => _pool.Count > 0 ? _pool.Pop() : _factory();
public void Return(T item) => _pool.Push(item);
}

A bullet that leaves the screen goes back into the pool, and the next shot takes it out again. Only when the pool is empty is a new bullet made, so after the first seconds the game stops allocating bullets.

A pool hands out bullets that are no longer in use, so the game stops allocating new ones.

Pooled objects must be fully reset when reused, or old state leaks into the new “instance”. BulletSpawner does that for every bullet it takes out:

BulletSpawner.cs
var bullet = _pool.Get();
bullet.IsExpired = false;
bullet.Transform.Position = position;
bullet.Transform.Orientation = velocity.ToAngle();
rigidbody.Velocity = velocity;

In the codebase: ObjectPool.cs, BulletSpawner.cs.

Try it (GeometryWars6): press F3 and keep firing for ten seconds. Does memory grow? What would you expect without the pool?

Step GeometryWars0 onwards

With 500 enemies on screen, all of the same kind, the sprite and the stats are the same for every one of them. A flyweight stores that shared part once:

  • Intrinsic state is shared and immutable: the texture and the stats template.
  • Extrinsic state is per instance: position, velocity, current HP.
GameplayDefinitions.cs
public sealed record EnemyShellDefinition(
string Name,
SpriteId SpriteId,
int PointValue,
float RigidbodyDamping,
int SpawnDelayFrames,
int DeathParticleCount);
public sealed record SeekerEnemyDefinition(
EnemyShellDefinition Shell,
float Acceleration);
EntityFactory.cs
private static readonly SeekerEnemyDefinition SeekerDefinition = GameplayDefinitions.Seeker;
var enemy = CreateEnemyBase(SeekerDefinition.Shell, position);
enemy.AddComponent(new SeekTarget(getTargetPosition, SeekerDefinition.Acceleration));

There is one SeekerEnemyDefinition in the whole game, and it is a record that can’t be changed. Every seeker is built from it. What each seeker owns is in its components: its position, its velocity, whether it is still alive. The texture works the same way. It is loaded once in GameAssets, and 500 seekers hold 500 references to it.

What every seeker has in common is stored once; each seeker keeps only what is its own.

This is close to the Type Object from Plants vs. Zombies, and the two often turn up together. They answer different questions. Type Object is about design: a kind of thing is described by an object, so new kinds are data. Flyweight is about memory: what many objects have in common is stored once.

In the codebase: GameAssets.cs, GameplayDefinitions.cs.

Try it (GeometryWars6): in GameplayDefinitions, give the seekers three times their Acceleration. How many seekers change, and how many objects did you edit? Where is the seeker’s texture loaded, and how many times?

Step GeometryWars5

Geometry Wars’ neon glow and warping grid are shader work. Shaders are not required for your project.

The graphics pipeline: vertices go in, the two shaders run, and pixels come out.

  • Vertex shaders run once per vertex; pixel shaders run once per pixel. The GPU runs thousands of them in parallel.
  • MonoGame uses HLSL .fx files. See 24: Shaders.
  • Bloom: render the scene to a RenderTarget2D, keep only the bright pixels, blur them (horizontal, then vertical), and add them back over the scene.

Bloom: draw the scene, keep the bright pixels, blur them, and add them back.

Step GeometryWars6

GeometryWars6 adds music and sound effects, and is the finished game. Audio is one more service in the PlayContext, and a sound is one more component (PlaySoundOnWeaponFired, PlaySoundOnDestroyed). This is the architecture from the start of the session again, with the classes of one run filled in.

classDiagram
    Core <|-- Game1
    Game1 --> PlayState : on the state stack
    Game1 ..> PlayContext : creates
    PlayState --> PlayContext
    PlayState --> PlaySession
    PlaySession --> EntityWorld
    PlaySession --> EntityFactory
    PlaySession --> EnemyDirector
    PlaySession --> BulletSpawner
    PlaySession --> ScoreTracker
    PlaySession --> ParticleManager
    PlaySession --> Grid
    EntityWorld --> Entity : many
    Entity o-- Component
    EntityFactory ..> Entity : composes
    BulletSpawner --> ObjectPool

Everything under PlaySession is made new for each run.

Concern Answer
What an entity is Components
Where behaviour across entities lives Systems
How code gets shared services Dependency injection
When entities are allocated Object Pool
What entities share Flyweight
How entities look Shaders

Start from GeometryWars6.

  1. Composition: create a new enemy type purely by combining existing components in EntityFactory. Then add one new component (e.g. a shield that absorbs one hit) and give it to an existing enemy. How many new classes did each of the two take?
  2. Component or system? Add a bomb (one per life, on a key) that destroys every enemy on screen. Which parts are components, and which belong in a system? Where does “one per life” live?
  3. Dependency injection: add a screen-shake service to PlayContext, and shake the screen when the player dies. Which classes had to change? What would change with a Service Locator instead?
  4. Test with a fake: SpawnTwinBulletsOnFired gets an IBulletSpawner in its constructor. Write a FakeBulletSpawner and a test that checks that firing the weapon spawns two bullets. What makes this harder to test than the score? (Hint: the spread uses Random.Shared.)
  5. Measure pooling: add an on-screen counter of allocated bytes and gen-0 GCs per second (F3 already shows the memory). Temporarily replace the bullet pool with new and compare. What do the two numbers do while you hold the trigger, with the pool and without it?
  • Which of your game’s rules are about one entity, and which span many? Where do they live today?
  • How do your classes get shared services (audio, assets, input)? Would passing them in make the dependencies clearer, and let you test the class with a fake?
  • Does anything in your game allocate every frame?
  • Which data is shared between many instances, and which is unique?
When should behaviour live in a system, and when in a component?

When it spans many entities, owns rules of the whole game or session, or needs a central order. A component should be about its owner; otherwise it ends up reaching into other entities.

What does dependency injection give you that a Service Locator doesn't?

The dependencies are explicit. A constructor shows what a class needs, and a test can pass in a replacement. The cost is that services must be passed through every layer that needs them.

What is a fake, and why does dependency injection make it easy to use one?

A small stand-in for a real dependency, written for a test, that records what it was asked to do. When a class gets its dependencies through its constructor, the test passes the fake in. When the class finds them itself (a Singleton or a Service Locator), the fake has to replace global state, or can’t be used at all.

What is the difference between Object Pool and Flyweight?

Object Pool reuses instances to avoid allocation over time. Flyweight shares data between many instances to avoid duplicating it.

What must happen to a pooled object when it is taken out again?

It must be fully reset. It still holds the state of its last use, so any field that isn’t set again leaks into the new bullet. BulletSpawner sets the expired flag, the position, the orientation and the velocity of every bullet it takes out.

Related exam questions: 1, 3, 9, 10.