11 Geometry Wars
Today’s Goal
Section titled “Today’s Goal”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 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
Prepare
Section titled “Prepare”- Object Pool
- Flyweight
- Optional: Component, if you want to review it from Plants vs. Zombies
- Optional: Dependency injection in .NET. It describes .NET’s container; we pass dependencies in by hand, so only the idea carries over.
Explore the Codebase
Section titled “Explore the Codebase”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?
High-Level Architecture
Section titled “High-Level Architecture”flowchart LR
Game1 --> PlayState --> PlaySession
PlaySession --> EntityWorld
PlaySession --> EntityFactory
EntityFactory -- composes --> Components
EntityWorld -- updates --> Components
EntityWorld --> Systems
Game1owns the application shell.PlayStateowns one active gameplay state.PlaySessionowns one run of the game.EntityWorldupdates entities and collisions.EntityFactorybuilds entities by composing components.- Components implement the actual behaviour.
- Systems handle concerns that cut across components.
Components at Scale
Section titled “Components at Scale”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.
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.
Components vs. Systems
Section titled “Components vs. Systems”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 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?
Where does new code go?
Section titled “Where does new code go?”The architecture above gives every kind of code a home:
- The application and its screens:
Game1and the game states. - The rules of one run:
PlaySessionand the systems. - How an entity is put together:
EntityFactory. - Behaviour of one entity: a component, named for what it does (
Health, and notPlayerHealth). - Behaviour across entities: a system.
Dependency Injection
Section titled “Dependency Injection”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.
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 |
Testing with fakes
Section titled “Testing with fakes”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:
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++;}// 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?
Object Pool
Section titled “Object Pool”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:
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.
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:
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?
Flyweight
Section titled “Flyweight”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.
public sealed record EnemyShellDefinition( string Name, SpriteId SpriteId, int PointValue, float RigidbodyDamping, int SpawnDelayFrames, int DeathParticleCount);
public sealed record SeekerEnemyDefinition( EnemyShellDefinition Shell, float Acceleration);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.
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?
Shaders (Showcase)
Section titled “Shaders (Showcase)”Step GeometryWars5
Geometry Wars’ neon glow and warping grid are shader work. Shaders are not required for your project.
- Vertex shaders run once per vertex; pixel shaders run once per pixel. The GPU runs thousands of them in parallel.
- MonoGame uses HLSL
.fxfiles. 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.
The Whole Game
Section titled “The Whole Game”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.
Summary
Section titled “Summary”| 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 |
Exercises
Section titled “Exercises”Start from GeometryWars6.
- 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? - 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?
- 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? - Test with a fake:
SpawnTwinBulletsOnFiredgets anIBulletSpawnerin its constructor. Write aFakeBulletSpawnerand a test that checks that firing the weapon spawns two bullets. What makes this harder to test than the score? (Hint: the spread usesRandom.Shared.) - Measure pooling: add an on-screen counter of allocated bytes and gen-0 GCs per
second (
F3already shows the memory). Temporarily replace the bullet pool withnewand compare. What do the two numbers do while you hold the trigger, with the pool and without it?
Apply It to Your Project
Section titled “Apply It to Your Project”- 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?
Check Yourself
Section titled “Check Yourself”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.
