2D Collision Detection in MonoGame: From Basic Rectangles to Zero-Allocation Pixel-Perfect Systems
When building 2D games in visual engines like Unity or Godot, collision detection often feels like a series of inspector checkboxes: you slap on a BoxCollider2D or CircleCollider2D, attach a Rigidbody, and hope the internal physics step doesn't stutter on mobile devices.
In MonoGame and C#, however, you are in total control. There is no hidden physics overhead, no unwanted rotational inertia, and no mysterious garbage collection (GC) spikes stealing your frame budget.
At Arar Games, when we built Blocked: Pixel Panzer and Paint Trek, our arcade game loops needed to process hundreds of high-speed enemy bullets, exploding brick grids, rotating tank turrets, fighter jet flybys, and particle shields at 60 to 120 FPS across both Windows PC and Android devices. A general-purpose physics engine was out of the question—we needed a purpose-built, tiered collision architecture.
In this comprehensive, code-driven guide, we will start from the absolute basics of MonoGame collision (Rectangle.Intersects with real Bullet and Enemy sprites) and build up to advanced circle checks, mixed clamping, anti-tunneling raycasts, production-grade Pixel-Perfect collision, and Zero-Allocation Spatial Grids optimized for mobile GC survival.
1. The Foundation: A Simple MonoGame Sprite Hierarchy
Before detecting collisions, we need clean game entities. In MonoGame, an entity fundamentally possesses a position, a texture, and a bounding rectangle.
Here is the baseline entity architecture used across our games:
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
public class Sprite
{
public Vector2 Position;
public Texture2D Texture;
public Color Tint = Color.White;
public bool IsActive = true;
// The raw Axis-Aligned Bounding Box (AABB)
public virtual Rectangle Bounds => new Rectangle(
(int)Position.X,
(int)Position.Y,
Texture != null ? Texture.Width : 0,
Texture != null ? Texture.Height : 0
);
public virtual void Draw(SpriteBatch spriteBatch)
{
if (!IsActive || Texture == null) return;
spriteBatch.Draw(Texture, Position, Tint);
}
}
Now let's create concrete Player, Enemy, and Bullet classes:
public class Bullet : Sprite
{
public Vector2 Velocity;
public int Damage = 25;
public void Update(GameTime gameTime)
{
Position += Velocity * (float)gameTime.ElapsedGameTime.TotalSeconds;
}
}
public class Enemy : Sprite
{
public int Health = 100;
public void TakeDamage(int damage)
{
Health -= damage;
if (Health <= 0)
{
IsActive = false;
}
}
}
public class Player : Sprite
{
public int Health = 100;
public bool IsInvulnerable = false;
public void TakeDamage(int damage)
{
if (IsInvulnerable) return;
Health -= damage;
}
}
With these entities in place, let's explore how to detect collisions between them, starting from the simplest method.
2. Level 1: The Simplest Collision – Rectangle.Intersects (AABB)
The most fundamental 2D collision check in MonoGame is the Axis-Aligned Bounding Box (AABB) test. The term "axis-aligned" simply means that the rectangle's edges are completely parallel to the screen's \(X\) and \(Y\) axes (no rotation).
MonoGame provides a fast, built-in method: Rectangle.Intersects(Rectangle value).
How Rectangle.Intersects Works Under the Hood
Underneath the surface, MonoGame executes four integer comparisons:
public bool Intersects(Rectangle value)
{
return value.Left < this.Right &&
this.Left < value.Right &&
value.Top < this.Bottom &&
this.Top < value.Bottom;
}
If all four conditions are met, the rectangles overlap. If even one condition fails, an empty axis separates them, and no collision is possible.
Real Gameplay Code: Bullet vs. Enemy in Game1.Update
Here is how you check collisions between a list of active bullets and a list of active enemies inside your main MonoGame Update loop:
public class Game1 : Game
{
private List<Bullet> _bullets = new List<Bullet>();
private List<Enemy> _enemies = new List<Enemy>();
protected override void Update(GameTime gameTime)
{
// 1. Update bullet and enemy positions
foreach (var bullet in _bullets) bullet.Update(gameTime);
// 2. Collision Check: Bullets vs Enemies
for (int b = _bullets.Count - 1; b >= 0; b--)
{
var bullet = _bullets[b];
if (!bullet.IsActive) continue;
for (int e = _enemies.Count - 1; e >= 0; e--)
{
var enemy = _enemies[e];
if (!enemy.IsActive) continue;
// The AABB check!
if (bullet.Bounds.Intersects(enemy.Bounds))
{
// Collision occurred!
enemy.TakeDamage(bullet.Damage);
bullet.IsActive = false;
// Remove inactive bullet immediately
_bullets.RemoveAt(b);
if (!enemy.IsActive)
{
_enemies.RemoveAt(e);
}
// A bullet can only hit one enemy; break the inner loop
break;
}
}
}
base.Update(gameTime);
}
}
Performance Tip: Notice we iterate backwards (
for (int i = list.Count - 1; i >= 0; i--))! If you useforeachand try to call_bullets.Remove(bullet), C# throws anInvalidOperationException: Collection was modified. Iterating backwards allows safe element removal without memory re-indexing issues.
The Arcade Secret: "Fair Hitboxes" via Inflate
In retro games like Blocked: Pixel Panzer, sprite textures often include transparent margins or antenna spikes. If the player's tank explodes because a bullet touched an empty transparent corner of its texture, the player will feel cheated.
To make collision feel responsive and fair, games use a smaller Hitbox inside the sprite using Rectangle.Inflate:
public class EnemyTank : Enemy
{
// Shrink the bounding box by 6 pixels on all sides for fair collision
public override Rectangle Bounds
{
get
{
Rectangle raw = base.Bounds;
raw.Inflate(-6, -6); // Reduces width and height by 12px
return raw;
}
}
}
3. Level 2: Circle-to-Circle Collision (Rotational Immunity)
Rectangles work great for static blocks and grid tiles, but they fail when sprites rotate. When a non-square spaceship rotates in Paint Trek, an axis-aligned bounding box must expand to enclose the spinning corners, resulting in frustrating "phantom collisions" in empty air.
For circular asteroids, homing energy orbs, and rotating spacecraft, Bounding Circles are the ideal solution.
The Square Root Trap
Two circles collide when the distance between their centers is less than or equal to the sum of their radii:
\(\text{Distance}(C_A, C_B) \le r_A + r_B\)
In code, calculating Euclidean distance uses Math.Sqrt (or Vector2.Distance). However, calculating square roots in a loop with 200 projectiles burns hundreds of unnecessary CPU cycles!
By comparing the squared distance against the squared radius sum, we completely eliminate the square root:
\(\text{DistanceSquared} \le (r_A + r_B)^2\)
MonoGame Implementation: Circle vs. Circle
public struct Circle
{
public Vector2 Center;
public float Radius;
public Circle(Vector2 center, float radius)
{
Center = center;
Radius = radius;
}
public bool Intersects(Circle other)
{
float radiusSum = this.Radius + other.Radius;
// MonoGame built-in Vector2.DistanceSquared
return Vector2.DistanceSquared(this.Center, other.Center) <= (radiusSum * radiusSum);
}
}
Now integrate this directly into an entity:
public class PaintTrekFighter : Sprite
{
public float CollisionRadius = 18f;
public Vector2 Center => Position + new Vector2(Texture.Width * 0.5f, Texture.Height * 0.5f);
public Circle BoundingCircle => new Circle(Center, CollisionRadius);
public bool CollidesWith(PaintTrekFighter other)
{
return this.BoundingCircle.Intersects(other.BoundingCircle);
}
}
Zero square roots, immune to sprite rotation, and lightning-fast.
4. Level 3: Mixed Shapes – Circle vs. Box (MathHelper.Clamp)
What happens when a circular spaceship in Paint Trek navigates through a tight maze of rectangular defense barriers, or when a round bullet hits a square block in Blocked: Pixel Panzer?
We need Circle vs. Rectangle collision.
The Clamping Algorithm
The strategy is to find the point on the rectangle that is closest to the circle's center, and then test whether the distance from that closest point to the center is less than the circle's radius.
MonoGame's MathHelper.Clamp makes this trivial:
public static class Collision2D
{
public static bool CircleIntersectsRectangle(Circle circle, Rectangle rect)
{
// Find the closest point on the rectangle to the circle center
float closestX = MathHelper.Clamp(circle.Center.X, rect.Left, rect.Right);
float closestY = MathHelper.Clamp(circle.Center.Y, rect.Top, rect.Bottom);
// Vector from closest point to circle center
float distanceX = circle.Center.X - closestX;
float distanceY = circle.Center.Y - closestY;
// Check squared distance against squared radius
float distanceSquared = (distanceX * distanceX) + (distanceY * distanceY);
return distanceSquared <= (circle.Radius * circle.Radius);
}
}
You can now test player shields against rectangular bricks with zero allocations and high accuracy!
5. Level 4: Continuous Collision Detection (CCD) & Swept Rays
Have you ever fired a hyper-velocity sniper round or a railgun laser in your game, only to watch the bullet magically pass straight through a thin enemy ship without dealing damage?
This bug is known as tunneling.
Because discrete games update in time steps (\(\Delta t = 16.6\text{ms}\) at 60 FPS), an object moving at 1,800 pixels per second travels 30 pixels in a single frame. If the enemy hull is only 15 pixels thick, the bullet was in front of the enemy on Frame 1, and completely behind the enemy on Frame 2.
Frame 1: [ Bullet ] ---> | Enemy Wall |
Frame 2: | Enemy Wall | ---> [ Bullet ]
(NO HIT DETECTED!)
The Solution: Swept Segment vs. Box (Slab Method)
Instead of testing a single point, we test the entire line segment connecting the bullet's position on Frame 1 (previousPosition) to Frame 2 (currentPosition).
Here is the production raycasting slab-intersection method from our companion title SpiralWar:
public static class ContinuousCollision
{
public static bool IntersectsSweptRay(Vector2 rayStart, Vector2 rayEnd, Rectangle box, out Vector2 hitPoint)
{
hitPoint = Vector2.Zero;
Vector2 direction = rayEnd - rayStart;
float tMin = 0f;
float tMax = 1f;
// Clip against X slabs
if (MathF.Abs(direction.X) > 1e-6f)
{
float invX = 1f / direction.X;
float t1 = (box.Left - rayStart.X) * invX;
float t2 = (box.Right - rayStart.X) * invX;
if (t1 > t2) (t1, t2) = (t2, t1);
tMin = MathF.Max(tMin, t1);
tMax = MathF.Min(tMax, t2);
if (tMin > tMax) return false;
}
else if (rayStart.X < box.Left || rayStart.X > box.Right)
{
return false;
}
// Clip against Y slabs
if (MathF.Abs(direction.Y) > 1e-6f)
{
float invY = 1f / direction.Y;
float t1 = (box.Top - rayStart.Y) * invY;
float t2 = (box.Bottom - rayStart.Y) * invY;
if (t1 > t2) (t1, t2) = (t2, t1);
tMin = MathF.Max(tMin, t1);
tMax = MathF.Min(tMax, t2);
if (tMin > tMax) return false;
}
else if (rayStart.Y < box.Top || rayStart.Y > box.Bottom)
{
return false;
}
hitPoint = rayStart + direction * tMin;
return true;
}
}
In Blocked: Pixel Panzer, the player's continuous Laser Beam skill uses this exact raycast to slice through rows of descending blocks without missing a single collision.
6. Level 5: Production-Grade Pixel-Perfect Collision Detection
Now we reach the ultimate level of 2D accuracy: Pixel-Perfect Collision.
In a retro tank shooter or spaceship dogfight, irregular shapes (tank barrels, wings, cockpit cockpits) are surrounded by transparent pixels in the sprite texture. When an enemy missile hits that transparent space, players notice immediately.
Pixel-perfect collision inspects the actual alpha (transparency) channels of the overlapping textures. If two non-transparent pixels overlap at the same world coordinate, a true physical hit has occurred.
The Fatal Mistake: GetData inside Update()
Many tutorials instruct beginners to do this:
// DO NOT DO THIS!
Color[] dataA = new Color[textureA.Width * textureA.Height];
textureA.GetData(dataA); // STALLS GPU, CREATES MASSIVE GC LAG!
Calling Texture2D.GetData() during runtime forces the CPU to wait for the GPU pipeline to flush, pulling texture memory back over the bus. Doing this for dozens of sprites every frame will tank your game from 60 FPS to 5 FPS and trigger Android ANRs immediately!
The Battle-Tested Solution from Blocked: Pixel Panzer
In Blocked: Pixel Panzer, our Sprite.cs implements an ultra-optimized IntersectsPixel routine built around 5 crucial rules:
- Static Color Array Cache:
GetDatais called exactly once when the texture loads, and stored in aDictionary<Texture2D, Color[]>. - AABB Early Exit Guard: If
Bounds.Intersects(other.Bounds)is false, we bail out immediately. 99% of checks are eliminated before inspecting a single pixel. - Texture Atlas &
SourceRectangleSupport: Handles sprites packed into texture atlases using source rectangle offsets. - Calculated Overlap Window: We only loop over the exact intersection rectangle between both sprites (
Math.Max(a.Top, b.Top), etc.). - Alpha Short-Circuiting: If Sprite A's pixel is transparent (
A <= 20), Sprite B is completely skipped. - Property Hoisting: Accessing virtual properties (
Bounds) in nested loops creates thousands of struct copies. We store them in local stack variables before entering the loop.
Here is the complete, production-ready code:
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
public class AdvancedSprite : Sprite
{
private static readonly Dictionary<Texture2D, Color[]> _textureDataCache = new();
public Rectangle? SourceRectangle { get; set; }
public static Color[] GetCachedTextureData(Texture2D texture)
{
if (!_textureDataCache.TryGetValue(texture, out var data))
{
data = new Color[texture.Width * texture.Height];
texture.GetData(data);
_textureDataCache[texture] = data;
}
return data;
}
public bool IntersectsPixel(AdvancedSprite other)
{
// 1. Hoist bounds into local stack variables
Rectangle a = this.Bounds;
Rectangle b = other.Bounds;
// 2. Fast AABB Broad-Phase check
if (!a.Intersects(b)) return false;
// Fallback to rectangle check if textures are missing
if (this.Texture == null || other.Texture == null) return true;
// 3. Retrieve pre-cached color arrays (Zero GPU latency)
Color[] dataA = GetCachedTextureData(this.Texture);
Color[] dataB = GetCachedTextureData(other.Texture);
int aTexW = this.Texture.Width;
int bTexW = other.Texture.Width;
// Atlas / SourceRectangle offset mapping
bool aHasSrc = this.SourceRectangle.HasValue;
Rectangle aSrc = aHasSrc ? this.SourceRectangle.Value : Rectangle.Empty;
int aOriginX = aHasSrc ? aSrc.X : 0;
int aOriginY = aHasSrc ? aSrc.Y : 0;
int aSpanX = aHasSrc ? aSrc.Width : this.Texture.Width;
int aSpanY = aHasSrc ? aSrc.Height : this.Texture.Height;
bool bHasSrc = other.SourceRectangle.HasValue;
Rectangle bSrc = bHasSrc ? other.SourceRectangle.Value : Rectangle.Empty;
int bOriginX = bHasSrc ? bSrc.X : 0;
int bOriginY = bHasSrc ? bSrc.Y : 0;
int bSpanX = bHasSrc ? bSrc.Width : other.Texture.Width;
int bSpanY = bHasSrc ? bSrc.Height : other.Texture.Height;
// 4. Calculate the overlapping sub-rectangle
int top = Math.Max(a.Top, b.Top);
int bottom = Math.Min(a.Bottom, b.Bottom);
int left = Math.Max(a.Left, b.Left);
int right = Math.Min(a.Right, b.Right);
// 5. Scan only overlapping pixels
for (int y = top; y < bottom; y++)
{
// Row offsets hoisted outside the inner X loop!
int colorA_Y = aOriginY + (int)((y - a.Y) / (float)a.Height * aSpanY);
int colorB_Y = bOriginY + (int)((y - b.Y) / (float)b.Height * bSpanY);
int rowA = colorA_Y * aTexW;
int rowB = colorB_Y * bTexW;
for (int x = left; x < right; x++)
{
int colorA_X = aOriginX + (int)((x - a.X) / (float)a.Width * aSpanX);
// Early Bailout: If Pixel A is transparent, skip B entirely!
if (dataA[colorA_X + rowA].A <= 20) continue;
int colorB_X = bOriginX + (int)((x - b.X) / (float)b.Width * bSpanX);
// If Pixel B is also opaque, we have confirmed contact!
if (dataB[colorB_X + rowB].A > 20)
{
return true;
}
}
}
return false;
}
}
This routine executes in under 0.1 milliseconds on modern Android hardware and desktop systems.
7. Level 6: Broad-Phase Spatial Grid & Zero-Allocation Mobile GC
Imagine your game has 100 blocks, 60 bullets, and 20 enemies. If you test every entity against every other entity using nested for loops, you perform:
\(\frac{180 \times 179}{2} = 16,110 \text{ checks per frame!}\)
At 60 FPS, that is nearly 1,000,000 collision checks every second.
To scale your game, you must implement Broad-Phase Spatial Partitioning.
The Uniform Spatial Hash Grid
We divide the screen into a 2D grid of uniform cells (in Blocked: Pixel Panzer, each cell is \(150 \times 150\) pixels). An entity only tests collisions against other entities that reside in the same grid cells.
The Mobile Memory Problem: GC Thrashing
If your SpatialGrid creates new List<Point>() or new List<Block>() every frame, you allocate megabytes of garbage every minute on the managed heap.
On Android's Mono runtime, this triggers frequent Gen-0 Garbage Collections, causing:
mono runtime: Native lock contention (mono_class_is_subclass_)
The game stutters and Google Play flags your game with ANR warnings!
The Solution: Reusable Scratch Buffers
Here is the Zero-Allocation Spatial Hash Grid from CollisionManager.cs in Blocked: Pixel Panzer:
public class SpatialGridManager
{
private const int CELL_SIZE = 150;
private readonly Dictionary<Point, List<Sprite>> _grid = new();
// Zero-GC: Reusable scratch buffers allocated ONCE at startup
private readonly List<Point> _scratchCells = new();
private readonly HashSet<Sprite> _scratchCheckedEntities = new();
public void BuildGrid(List<Sprite> entities)
{
// Clear lists without re-allocating new List objects
foreach (var list in _grid.Values)
{
list.Clear();
}
foreach (var entity in entities)
{
if (!entity.IsActive) continue;
GetOccupiedCells(entity.Bounds, _scratchCells);
foreach (var cell in _scratchCells)
{
if (!_grid.TryGetValue(cell, out var list))
{
list = new List<Sprite>();
_grid[cell] = list;
}
list.Add(entity);
}
}
}
private void GetOccupiedCells(Rectangle bounds, List<Point> outCells)
{
outCells.Clear();
int startX = bounds.Left / CELL_SIZE;
int startY = bounds.Top / CELL_SIZE;
int endX = bounds.Right / CELL_SIZE;
int endY = bounds.Bottom / CELL_SIZE;
for (int x = startX; x <= endX; x++)
{
for (int y = startY; y <= endY; y++)
{
outCells.Add(new Point(x, y));
}
}
}
public void CheckCollisions(Player player)
{
if (player.IsInvulnerable) return;
GetOccupiedCells(player.Bounds, _scratchCells);
_scratchCheckedEntities.Clear();
foreach (var cell in _scratchCells)
{
if (_grid.TryGetValue(cell, out var entitiesInCell))
{
foreach (var other in entitiesInCell)
{
if (other == player || _scratchCheckedEntities.Contains(other)) continue;
_scratchCheckedEntities.Add(other);
// 1. Broad-phase AABB test
if (player.Bounds.Intersects(other.Bounds))
{
// 2. High-precision Pixel-Perfect test
if (player is AdvancedSprite advPlayer && other is AdvancedSprite advOther)
{
if (advPlayer.IntersectsPixel(advOther))
{
player.TakeDamage(10);
}
}
}
}
}
}
}
}
By reusing _scratchCells and _scratchCheckedEntities, the entire collision step runs with 0 bytes of heap allocation per frame.
8. Summary Comparison Table
| Technique | Mathematical Cost | Rotational Support | Tunneling Safe? | Best Use Case in MonoGame |
|---|---|---|---|---|
AABB (Rectangle.Intersects) |
Ultra-Fast (~4 integer comparisons) | No | No | Grid blocks, bullet broad-phase, UI |
Circle (DistanceSquared) |
Very Fast (3 muls, 0 sqrt) | Yes (Invariant) | No | Round ships, fireballs, energy orbs |
Circle vs Box (Clamp) |
Fast (Local MathHelper.Clamp) | Yes | No | Circular player navigating tight walls |
| Swept Ray (Slab CCD) | Moderate (Parametric Raycast) | Yes | Yes | Fast sniper bullets, railguns, lasers |
Pixel-Perfect (IntersectsPixel) |
Selective (Restricted Sub-rect) | Yes | No | Irregular sprite contours, fair hitboxes |
| Spatial Grid (Zero-GC) | Broad-phase (\(O(N^2) \to O(N)\)) | N/A | N/A | Dense waves, bullet hells, Android 60 FPS |
Real-World Production Showcases: Arar Games
These collision architectures are not theoretical experiments—they are the real engineering foundation powering our commercially released titles:
- Blocked: Pixel Panzer: Our retro tank survival arcade game on Google Play and Microsoft Store. It features our complete two-tier collision system: a zero-allocation spatial hash grid filtering falling blocks, and pixel-perfect contact checking for tanks, fighter jets, turret shells, and ColorWheel elemental ammunition.
- Paint Trek: Our fast-paced space shooter featuring rotational circle collision, continuous raycasting, and swept-volume missile defense systems.
Conclusion & Next Steps
MonoGame gives you the power to design collision detection that perfectly matches the needs of your game. By gating expensive Pixel-Perfect checks behind fast AABB tests, utilizing Squared Distances for circles, and eliminating garbage collection with Reusable Scratch Buffers, you can deliver console-smooth 60/120 FPS performance on both desktop and mobile platforms.
Check out our games on the app stores to see these collision systems in action, and start implementing these patterns in your own MonoGame projects today!
Store Links & Resources
- Blocked: Pixel Panzer on Google Play: Download for Android
- Blocked: Pixel Panzer on Microsoft Store: Download for Windows
- MonoGame Framework: monogame.net
SEO Keywords & Hashtags
Keywords: MonoGame 2D collision detection, C# game development, Rectangle.Intersects MonoGame, pixel perfect collision C#, spatial hash grid MonoGame, swept ray continuous collision, indie game performance optimization, zero allocation game loop, Android MonoGame optimization, Arar Games, Blocked Pixel Panzer, Paint Trek.
#MonoGame #CSharp #GameDev #IndieDev #GamePhysics #DotNet #2DGameDev #MobileGameDev #PerformanceOptimization #CleanCode #BlockedPixelPanzer #PaintTrek #ArarGames #GameProgramming
Hiç yorum yok:
Yorum Gönder