Chapter 7: Game Objects
One update rule, many things obeying it
Somewhere between the first sprite and the first real game, every developer writes this:
player.Update(dt);
foreach (var alien in aliens) alien.Update(dt);
foreach (var bullet in bullets) bullet.Update(dt);
foreach (var particle in particles) particle.Update(dt);
if (boss != null) boss.Update(dt);It works. It is also the beginning of a very specific kind of trouble, because every new thing in the game adds a list, a loop and a place to forget. Six months later the answer to "why did the explosion not damage the player?" is "because explosions are updated after collisions but bullets are updated before", and nobody can see that from the code.
The alternative is to have one list and one loop, and to let the differences between things be data rather than control flow. That is what this chapter builds. It is a small idea with large consequences: it makes update order explicit, it makes adding a new kind of thing a one-line change, and it gives you exactly one place to put the removal logic that would otherwise be duplicated five times and wrong in two of them.
Along the way the chapter builds the vector type the rest of the book uses, and explains why a book about MonoGame writes its own instead of using Vector2.
What you will learn in this chapter
- Why
Coredefines its ownVec2rather than referencing MonoGame forVector2. - The difference between
readonly record structandclassfor game data, and how to choose. - How to model many kinds of thing with one type and one loop, and when that stops being the right answer.
- The three common designs — switch on a kind, virtual methods, and entity-component systems — with an honest account of what each costs.
- Why removing entities during iteration is a bug, and the two safe ways to do it.
- How to keep the simulation deterministic by owning your random number generator.
- The caveats: allocation per spawn, draw order, and when to introduce pooling.
The code for this chapter
The demonstration is at github.com/nodoid/MonoGameBook/src/Chapter07. Tapping spawns entities of the selected kind into a single shared world: drifters that bounce off the walls, fallers that die when they leave the bottom of the screen, and chasers that steer towards your finger. All three go through one update loop.

Vec2: writing your own vector
Core has no MonoGame reference — that was Chapter 1's rule, and it is the rule that lets Chapter 21 run the game's logic as a test suite with no graphics device present. The immediate consequence is that Core cannot use Microsoft.Xna.Framework.Vector2, so it defines its own:
public readonly record struct Vec2(float X, float Y)
{
public static Vec2 Zero => new(0f, 0f);
public float Length => MathF.Sqrt(X * X + Y * Y);
public static Vec2 operator +(Vec2 a, Vec2 b) => new(a.X + b.X, a.Y + b.Y);
public static Vec2 operator -(Vec2 a, Vec2 b) => new(a.X - b.X, a.Y - b.Y);
public static Vec2 operator *(Vec2 a, float scalar) =>
new(a.X * scalar, a.Y * scalar);
public Vec2 Clamped(float minX, float maxX, float minY, float maxY) =>
new(Math.Clamp(X, minX, maxX), Math.Clamp(Y, minY, maxY));
}Twenty lines. That is the entire cost of the rule, and it buys a Core project that compiles in a second, has no NuGet restore, and can be tested anywhere.
The declaration is worth unpacking, because each keyword is doing something.
`readonly` means no member can mutate the struct. Combined with the positional properties, that makes a Vec2 a value in the mathematical sense: a + b returns a new vector and changes neither operand. Mutable vectors are a classic source of aliasing bugs, where two objects accidentally share a position and move together.
`record` gives value equality, a useful ToString, deconstruction, and — the one that gets used most in practice — the with expression:
velocity = velocity with { X = -velocity.X };That line, from the wall-bounce code later in this chapter, reads exactly as it means: the same velocity but with X reversed. Without records it is new Vec2(-velocity.X, velocity.Y), which is easy to typo and harder to read.
`struct` means no heap allocation. A position is 8 bytes and lives inside its owner. A game holding ten thousand positions holds them in ten thousand × 8 bytes of contiguous-ish memory rather than in ten thousand separate objects with headers. On mobile, where GC pauses are the main source of frame spikes, this matters.
Do not chase Vector2 parity
It is tempting to reimplement everything Vector2 has. Do not. Add operations when a chapter needs them; this book's Vec2 grows to about forty lines by Chapter 14 and never needs more. Unused API is code you still have to maintain and read past.
Entity: a class, on purpose
The entity itself goes the other way:
public sealed class Entity
{
public EntityKind Kind { get; }
public Vec2 Position { get; set; }
public Vec2 Velocity { get; set; }
public float Radius { get; }
public float Age { get; private set; }
public bool IsAlive { get; set; } = true;
internal void Advance(float seconds)
{
Age += seconds;
Position += Velocity * seconds;
}
}A class, not a struct, and the comment in the source says why: the world mutates entities in place. If Entity were a struct, then foreach (Entity entity in entities) would hand you a copy, and entity.Position += ... would update the copy and throw the result away. You would then need for (int i = 0; ...) with entities[i] = modified everywhere, or CollectionsMarshal.AsSpan and ref locals — both workable, both noisier than the problem deserves at this scale.
The choice is not free: every Spawn allocates. Whether that matters is a question about your numbers, and the honest answer for most 2D mobile games is that it does not until you are spawning hundreds per second — at which point you want pooling, which is discussed at the end of this chapter.
Two smaller decisions in that class are worth copying.
`Kind` and `Radius` have no setters. What an entity is does not change during its life. Making that immutable removes a whole class of question ("can a faller become a chaser?") from the codebase.
`Entity` carries no rendering state at all. No colour, no texture, no sprite index, no pixel size. Those are the drawing layer's business, and keeping them out is what allows EntityWorld to live in Core. The scene decides that a chaser is drawn amber and a drifter blue; the rules do not know or care.
One loop, one rule per kind
The world is a list and an update:
public void Update(float seconds, Vec2 target)
{
foreach (Entity entity in entities)
{
switch (entity.Kind)
{
case EntityKind.Drifter:
entity.Advance(seconds);
Bounce(entity);
break;
case EntityKind.Faller:
entity.Advance(seconds);
if (entity.Position.Y - entity.Radius > height)
entity.IsAlive = false;
break;
case EntityKind.Chaser:
Vec2 toTarget = target - entity.Position;
float distance = toTarget.Length;
if (distance > 1f)
entity.Velocity = toTarget * (110f / distance);
entity.Advance(seconds);
break;
}
}
Removed += entities.RemoveAll(entity => !entity.IsAlive);
}Every kind shares Advance — position integrates velocity, age accumulates — and differs only in what happens around it. The drifter bounces. The faller checks whether it has left the world. The chaser rewrites its own velocity before advancing.
The chaser's three lines are the only interesting maths in the chapter, and they are a normalise-and-scale done without a Normalize call:
Vec2 toTarget = target - entity.Position;
float distance = toTarget.Length;
if (distance > 1f)
entity.Velocity = toTarget * (110f / distance);Dividing the vector by its own length gives a unit vector; multiplying by 110 gives a velocity of 110 units per second towards the target. Doing it as one multiply by 110f / distance avoids a second pass over the components. The distance > 1f guard is not an optimisation — it prevents a division by something very close to zero when the chaser reaches the target, which would otherwise produce an enormous velocity and fling it off screen.
Removing the dead
RemoveAll looks unremarkable and is doing something important. Consider the obvious alternative:
// Wrong: mutating a list while enumerating it.
foreach (Entity entity in entities)
if (!entity.IsAlive)
entities.Remove(entity);List<T> detects this and throws InvalidOperationException: Collection was modified. The equally common index-based version does not throw, and is worse:
// Also wrong: skips an element after every removal.
for (int i = 0; i < entities.Count; i++)
if (!entities[i].IsAlive)
entities.RemoveAt(i);Removing at i shifts everything down, so the next iteration's i + 1 skips the element that just moved into position i. Two adjacent dead entities and one survives — a bug that appears only when two things die on the same frame, which is exactly the situation nobody tests.
Three correct options, in increasing order of speed and decreasing order of clarity:
`RemoveAll` with a predicate. One pass, order preserved, no allocation beyond the delegate. This is what the chapter uses.
Iterate backwards. for (int i = entities.Count - 1; i >= 0; i--) — removal only shifts elements you have already visited. Useful when the removal has side effects that need to happen inside the loop.
Swap-remove. Copy the last element over the dead one and shorten the list. O(1) per removal instead of O(n), at the cost of scrambling the order — which matters if your draw order depends on list order, and does not if you sort before drawing anyway.
Whichever you choose, do it after the update loop rather than during it. A Removed counter, as here, is worth keeping: a game where entities are spawned and never removed is the most common cause of a mobile game that is fine for two minutes and unplayable at five.
Determinism and the random number generator
EntityWorld owns its randomness:
public EntityWorld(float width, float height, int seed = 7)
{
this.width = width;
this.height = height;
random = new Random(seed);
}A fixed default seed, injectable. Two consequences follow.
The demonstration is reproducible. Spawn twenty drifters, and they get the same twenty velocities every run, so a screenshot in this book matches what you see on your device.
The rules are testable. Chapter 21 can construct a world with a known seed, run a hundred steps, and assert on exact positions. That is impossible if the world reaches for Random.Shared.
In a shipping game you would seed from the clock at the start of a run and record the seed, so a player's bug report can be reproduced exactly. Combined with the fixed timestep from Chapter 4, a seed plus an input log is a complete replay.
The three designs, honestly compared
Switching on an enum is not the only way to do this, and it is not always the best. Here are the three you will meet.
Switch on a kind
What this chapter does. One type, one list, one loop, behaviour selected by a field.
- Good: all behaviour is visible in one place, so update order is obvious. No virtual dispatch. Trivially serialisable — an entity is plain data plus an enum, which matters in Chapter 20 when we save the game.
- Bad: every entity carries every field any kind needs. A chaser does not use
Age; a faller does not need a target. With three kinds that is nothing; with thirty it is waste and confusion. - Use when: you have a small, stable set of kinds — which is most arcade games, and every chapter in this book.
Virtual methods
abstract class Entity { public abstract void Update(float dt); }, one subclass per kind.
- Good: each kind's behaviour is self-contained; adding a kind touches no existing file. Natural in C#.
- Bad: update order becomes invisible — you can no longer read one method and know what happens in what sequence. Cross-entity interactions ("chasers should ignore fallers") end up as type tests, which is the switch statement back again in a worse form. Serialisation needs type discriminators.
- Use when: kinds differ substantially and rarely interact.
Entity-component systems
Entities are ids; behaviour lives in components; systems process arrays of components.
- Good: genuinely fast at scale, because each system walks contiguous memory. Composition beats inheritance for "a thing that falls and chases".
- Bad: a large amount of machinery. Debugging is harder, because an entity is not a thing you can look at in a debugger. Very easy to over-engineer.
- Use when: you have thousands of entities with genuinely varied combinations of behaviour. A mobile 2D game with a few hundred entities does not need it, and reaching for one early is one of the most reliable ways to never finish a game.
The version in this chapter deliberately sits at the simple end. If your game grows past it, the migration path is short: switch becomes a dispatch table, then a table of components. Starting simple does not paint you into a corner.
Caveats
Allocation per spawn
new Entity(...) allocates. At ten spawns a second nobody notices; at a thousand — a particle system — you are generating megabytes a minute and inviting a gen-2 collection mid-level. The fix is pooling: keep dead entities in the list, mark them inactive, and reuse them on the next spawn. Chapter 14 does exactly that for particles, and Chapter 22 shows how to see the problem before you guess at it.
The list grows and never shrinks
List<T> doubles its backing array as it grows and never gives the memory back. That is usually what you want in a game — the array reaches its high-water mark and stops allocating — but it means a Capacity of 4,096 after one busy level stays for the whole session. entities.Capacity = expectedMax at startup removes the growth spikes altogether.
Draw order is list order
Entities are drawn in list order, so a swap-remove changes what overlaps what. If layering matters, either keep separate lists per layer or sort by an explicit Layer field before drawing. Do not rely on insertion order surviving removals.
Update order between kinds
All drifters are not updated before all chasers; they are interleaved in list order. If one kind's rule depends on another's having already run this frame, a single pass over a mixed list is the wrong shape — do two passes, in an explicit order, and write down why.
Floating point and the bounce
Bounce clamps the position back inside the bounds after reversing the velocity:
entity.Velocity = velocity;
entity.Position = position.Clamped(
entity.Radius, width - entity.Radius,
entity.Radius, height - entity.Radius);Without the clamp, an entity that overshoots the wall by a fraction can be outside when the next frame's test runs, reverse again, and vibrate against the wall for ever. Reversing velocity is never enough on its own; always put the object back where it should be.
Building and running this chapter
The solution is src/Chapter07/Chapter07.sln in the book's repository at github.com/nodoid/MonoGameBook/src/Chapter07. It builds and runs entirely on its own; nothing in it depends on any other chapter having been read or built.
Build either head from the chapter folder:
cd src/Chapter07
dotnet build Android/Chapter.Android.csproj
dotnet build iOS/Chapter.iOS.csprojTo deploy to a connected Android device or a running emulator:
dotnet build Android/Chapter.Android.csproj -t:RunTo run on the iOS Simulator without opening an IDE, build for the simulator runtime and install the bundle by hand:
dotnet build iOS/Chapter.iOS.csproj -p:RuntimeIdentifier=iossimulator-arm64
xcrun simctl install booted \
iOS/bin/Debug/net10.0-ios/iossimulator-arm64/Chapter07.iOS.app
xcrun simctl launch booted com.monogamebook.chapter07Everything the chapter demonstrates is reachable by touch on the first screen; there are no menus to navigate and nothing to load.
Try it yourself
- Spawn a hundred chasers and drag your finger. Note that they converge into a single point and then jitter — the
distance > 1fguard is doing exactly its job. - Change
RemoveAllto a backwardsforloop withRemoveAt, and confirm the behaviour is identical. Then change it to a forwards loop and watch dead fallers survive. - Add a fourth
EntityKind— an orbiter that circles the target at a fixed radius — as one newcase. Notice that no other file changes. - Remove the seed from
EntityWorldand useRandom.Shared. The game plays the same and can no longer be tested; decide which you would rather have. - Set
entities.Capacity = 1000in the constructor and spawn a thousand entities. Compare the frame graph with and against Chapter 22's profiler.
Summary
One list and one loop is a smaller idea than it looks, and it removes a category of bug rather than a specific one. Update order stops being emergent and starts being something you can read. Adding a kind of thing is a case, not a list plus a loop plus a removal pass. And there is exactly one place where dead things are removed, which is the only way that stays correct.
Underneath it, two type choices carry the design. Vec2 is a readonly record struct — immutable, allocation-free, with with expressions that make reflection and clamping read like their intent — and it exists because Core refuses a MonoGame reference. Entity is a sealed class because the world mutates it in place, and it carries no rendering state at all, which is what allows the rules to live in a project that has never seen a GraphicsDevice.
The removal rule is the one to internalise: never mutate a list while enumerating it, and never remove forwards by index. RemoveAll after the loop, a backwards loop, or a swap-remove — those are the three, and each has a reason to be chosen.
And keep your own Random, seeded. It costs one field and turns "it happened once and I cannot reproduce it" into a test.
Chapter 8 narrows the focus from many objects to one — the player's ship — and asks a harder question than "how does it move". It asks how it should feel to move, when the input device is a thumb resting on the thing it is trying to see.