# Collision Relationships

> Collision Relationships in FlatRedBall2. Use when working with AddCollisionRelationship, MoveFirstOnCollision, BounceOnCollision, MoveBothOnCollision, CollisionOccurred / CollisionStarted / CollisionEnded events, collision response, collision setup, mass/elasticity, entity-vs-entity collision, screen boundaries, keeping entities in bounds, walls, floors, ceilings, static geometry, sensor shapes, awareness radius, trigger zones, zone-enter/zone-exit detection, or broad-phase partitioning (Factory<T>.PartitionAxis, DeepCollisionCount). Trigger on any collision-related question.

- Skill: `vchelaru/collision-relationships` (Agent Skill)
- Install (CLI): `npx skillmds@latest add vchelaru/collision-relationships`
- Raw SKILL.md: https://api.skillmd.com/api/skills/vchelaru/collision-relationships/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: vchelaru (https://skillmd.com/u/vchelaru)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/vchelaru/collision-relationships

---


# Collision Relationships in FlatRedBall2

`Screen.AddCollisionRelationship<A,B>` registers a pair of collidable groups. Each frame, after physics runs, every entity in group A is tested against every entity in group B. If they overlap, the configured response fires.

## Basic Setup

Call `AddCollisionRelationship` inside `Screen.CustomInitialize` after creating your factories:

```csharp
AddCollisionRelationship<Player, Wall>(_playerFactory, _wallFactory)
    .MoveFirstOnCollision();
```

## Self-Collision (Same List)

To collide entities within the same list (e.g., enemies pushing each other apart):

```csharp
AddSelfCollisionRelationship<Enemy>(_enemyFactory)
    .MoveBothOnCollision(firstMass: 1f, secondMass: 1f);
```

Iterates unique unordered pairs only — no duplicate checks, and never an entity against itself.
Set `AllowDuplicatePairs = true` if `CollisionOccurred` should also fire in the `(b, a)` ordering
(physics still runs once per pair).

## Fluent Modifiers

| Method | Effect |
|--------|--------|
| `.MoveFirstOnCollision()` | A gets pushed out, B stays fixed. Use for player vs. solid walls. |
| `.MoveSecondOnCollision()` | B gets pushed out, A stays fixed. |
| `.MoveBothOnCollision(firstMass, secondMass)` | Both objects share the separation weighted by mass. |
| `.BounceFirstOnCollision(elasticity)` | A bounces off B; B stays fixed. Use for a ball vs. immovable walls. |
| `.BounceSecondOnCollision(elasticity)` | B bounces off A; A stays fixed. Mirror of the above when the static side is A. |
| `.BounceBothOnCollision(firstMass, secondMass, elasticity)` | Both sides bounce; separation splits by mass ratio (equal masses share equally). |
| `.BounceOnCollision(firstMass, secondMass, elasticity)` | Escape hatch for the asymmetric raw-mass case. Prefer the three above. |

## Responding to Collision Events

`CollisionOccurred` fires once per overlapping pair per frame, **after** position separation and velocity adjustment have been applied.

### Entity logic

```csharp
AddCollisionRelationship<Bullet, Enemy>(_bullets, _enemies)
    .MoveFirstOnCollision()
    .CollisionOccurred += (bullet, enemy) =>
    {
        enemy.TakeDamage(bullet.Damage);
        bullet.Destroy();
    };
```

### Physics customization

Override velocity after the engine's response:

```csharp
AddCollisionRelationship(_balls, _paddles)
    .BounceFirstOnCollision()
    .CollisionOccurred += (ball, paddle) =>
    {
        ball.VelocityX = /* custom value */;
        ball.VelocityY = /* custom value */;
    };
```

### Triggers (no physics response)

Omit the fluent modifier entirely:

```csharp
AddCollisionRelationship(_balls, _deathZoneFactory)
    .CollisionOccurred += (ball, _) =>
    {
        _ballFactory.Destroy(ball);
        OnBallLost();
    };
```

To keep a response configured but decide per collision whether it happens, set
`ArePhysicsAppliedAutomatically = false` and call `ApplyPhysics(a, b)` from the handler for the pairs
that should separate. Different from omitting the modifier: the masses and move/bounce choice still
exist, so the response can be applied later rather than never.

## Enter/Exit Events — `CollisionStarted` / `CollisionEnded`

`CollisionStarted` fires once on the first frame a pair begins overlapping. `CollisionEnded` fires once when a previously-overlapping pair stops overlapping. Use these instead of hand-rolling a per-frame `_wasOverlapping` bool.

```csharp
var rel = AddCollisionRelationship(_player, _iceZones);
rel.CollisionStarted += (_, zone) => _player.ApplyMovementProfile(zone.Profile);
rel.CollisionEnded   += (_, _)    => _player.ResetMovementProfile();
```

- **Fires on resolving relationships too**, not just triggers. `Started` is useful for footstep sounds, dust puffs, landing-animation triggers.
- **Order on entry frame:** physics response → `CollisionStarted` → `CollisionOccurred`.
- **Destroying a side mid-overlap fires `Ended` synchronously** on the same frame (e.g. inside a `CollisionOccurred` handler). The destroyed entity's shapes have already been cleared by the time the handler runs, so use the argument for identity only — don't query geometry.
- **Tunneling:** if an entity moves so fast it overlaps for zero frames, neither event fires. Same limitation as `CollisionOccurred`.
- **Zero-overhead when unused** — no tracking runs if neither event has a subscriber.

## Turning a Relationship Off — `IsEnabled`

`rel.IsEnabled = false` stops the automatic per-frame run. `rel.RunCollisions()` is public and
still works while disabled, so game code can drive collision on its own schedule.

**Landmine**: disabling fires `CollisionEnded` immediately for every pair overlapping at that
moment, and re-enabling refires `CollisionStarted` for pairs still overlapping — an enter/exit
counter sees one extra pair of events per toggle.

## Execution Order

Collision runs after physics and before `CustomActivity` — by the time game logic runs, entities are already separated from any overlapping collision partner. See `engine-overview` for the full frame loop.

## Tile Collision (TileShapes)

`TileShapes` implements `ICollidable` and works as static geometry with the `entities, staticGeometry` overload:

```csharp
var tiles = new TileShapes { GridSize = 16f };
tiles.AddTileAtCell(col, row);   // or AddTileAtWorld(x, y)
tiles.AddRectangleBorder(0, 0, cols - 1, rows - 1); // perimeter wall ring in one call

// TileShapes has a dedicated overload — no explicit type arguments needed
AddCollisionRelationship(_playerFactory, tiles)
    .MoveFirstOnCollision();
```

`SolidSides` on adjacent tiles are maintained automatically — interior shared edges are cleared so entities glide across flat surfaces without snagging on seams.

### SlopeMode (per-relationship)

When one side is a `TileShapes` containing polygon (slope) tiles, `relationship.SlopeMode` controls how overlap is resolved:

- `SlopeCollisionMode.Standard` (default) — SAT. Correct for top-down games and for non-player pairs (e.g., a ball bouncing off the same level tiles).
- `SlopeCollisionMode.PlatformerFloor` — vertical-only heightmap separation on floor slopes, with preferential landing. Use on the platformer player's relationship.

`SlopeMode` lives on the relationship — not the collection — specifically so the same level geometry can be used with different semantics per relationship (e.g., player = `PlatformerFloor`, kicked ball = `Standard`).

`PlatformerFloor` mode also **automatically contributes this collection as a ground-snap target** for any entity in the relationship that implements `IPlatformerEntity` — no separate "SnapTarget" wiring required. See the `platformer-movement` skill for how to configure `CollisionShape` / `SlopeSnapDistance` on the behavior.

```csharp
var playerVsTiles = AddCollisionRelationship(_playerFactory, solidTiles);
playerVsTiles.SlopeMode = SlopeCollisionMode.PlatformerFloor;
playerVsTiles.BounceFirstOnCollision(elasticity: 0f);

// Same solidTiles, different relationship, Standard SAT — no conflict.
AddCollisionRelationship(_ballFactory, solidTiles)
    .BounceFirstOnCollision(elasticity: 0.7f);
```

**Prefer `TileShapes` over individual wall entities for static level geometry.** Individual entities sharing edges will cause the player to snag on seams between adjacent tiles because each entity maintains its own `SolidSides` independently. `TileShapes` solves this by automatically suppressing interior shared edges. Use individual wall entities only when tiles need independent behavior (e.g., destructible blocks, moving platforms) — and for grid-shaped arrangements of those, set `Factory<T>.IsSolidGrid = true` so the factory applies the same seam suppression across adjacent cells (see `entities-and-factories` skill).

### OneWayDirection (jump-through / cloud platforms)

`relationship.OneWayDirection` restricts a relationship so separation only fires when the entity is being pushed in the configured direction. All four directions (`Up`/`Down`/`Left`/`Right`) plus `None` (default) are supported — `Up` for jump-through floors, `Down` for ceiling-only barriers, `Left`/`Right` for one-way doors.

```csharp
var cloudTiles = new TileShapes { GridSize = 16f };
cloudTiles.AddTileAtCell(3, 5);

var playerVsClouds = AddCollisionRelationship(_playerFactory, cloudTiles);
playerVsClouds.OneWayDirection = OneWayDirection.Up;
playerVsClouds.CanDropThrough = true; // opt in to Down+Jump drop-through for clouds
playerVsClouds.BounceFirstOnCollision(elasticity: 0f);
```

Semantics — three gates, all must pass:
- Separation has the correct sign on the gated axis (e.g. `sep.Y > 0` for `Up`, `sep.X < 0` for `Left`).
- Velocity on the gated axis matches the push direction or is zero (e.g. `VelocityY <= 0` for `Up` — an upward-moving entity passes through even when SAT would push it onto the top).
- `LastPosition` on the gated axis was at or beyond the post-separation position (entity was cleanly on the correct side last frame, not peaking inside from the wrong side). For `Up` on sloped tiles this is slope-aware (surface-Y delta between `LastPosition.X` and current `X` is folded in so uphill walking passes); other directions use a flat gate.

The off-axis component of the separation is zeroed before applying — an entity clipping a platform's edge is pushed in the gated direction only, never sideways. This matches FRB1 cloud behavior.

### Sloped cloud platforms (polygon jump-through tiles)

For sloped one-way tiles (polygon cells in the `TileShapes`) you must also set `SlopeMode = PlatformerFloor`. Without it the collection falls back to SAT, the slope-aware LastPosition gate doesn't run, and the player falls through while walking uphill.

```csharp
var cloudTiles = new TileShapes { GridSize = 16f };
cloudTiles.AddPolygonTileAtCell(3, 5, rightAscendingSlopePrototype);

var playerVsClouds = AddCollisionRelationship(_playerFactory, cloudTiles);
playerVsClouds.OneWayDirection = OneWayDirection.Up;
playerVsClouds.CanDropThrough = true;
playerVsClouds.SlopeMode = SlopeCollisionMode.PlatformerFloor; // required for sloped clouds
playerVsClouds.BounceFirstOnCollision(elasticity: 0f);
```

### Moving-platform velocity transfer (automatic for platformer entities)

When an `IPlatformerEntity` lands on top of a regular `Entity` (separation pushes the platformer
upward), the engine automatically feeds the other entity's `VelocityX` into the platformer for
that frame — the player rides the platform and inherits its horizontal momentum on jump. No
opt-in flag; works on any relationship configured with the standard player setup. Tile
collections are excluded (tiles don't have a meaningful velocity). See `platformer-movement` for
the full picture.

### `CanDropThrough` — cloud platforms vs. hard one-way barriers

`OneWayDirection` and player drop-through are intentionally separate concerns:

- `CanDropThrough = true` — this relationship honors
  `IPlatformerEntity.Platformer.IsSuppressingOneWayCollision`; when the player drop-through flag is active (Down+Jump, or airborne with Down held), the pair is skipped entirely. **Use for cloud platforms / jump-through floors.**
- `CanDropThrough = false` (default) — player drop-through input is ignored; the relationship always blocks in the configured direction. **Use for hard one-way barriers** (e.g. Yoshi's Island ratchet doors) that should never be passable the wrong way.

Both forms are driven by the same `OneWayDirection` gate — the only difference is whether drop-through can bypass them. See the `platformer-movement` skill for drop-through wiring on the behavior.

## Default vs Non-Default Collision Shapes

Every shape attached with `Add(shape)` joins the entity's **default collision** — it participates in every `CollisionRelationship` the entity is on. That's what you want for the main body, but *not* for auxiliary shapes like:

- **Awareness radius / trigger zone** — larger shape that fires events but shouldn't push the entity.
- **Weak spot** — shape hit by bullets but not by terrain.
- **Probe** (ledge-detect, wall-slide-detect) — small shape queried manually via `CollidesWith`.
- **Muzzle point / attach anchor** — positional reference only, no collision at all.

If any of those join default collision, the entity gets shoved around by its own probes or weak spots. The fix is a single flag:

```csharp
Body = new AARect { Width = 14, Height = 14 };
Add(Body); // default collision — participates in relationships

Sensor = new Circle { Radius = 64 };
Add(Sensor, isDefaultCollision: false); // attached, moves with entity, but NOT in default collision

_footProbe = new AARect { Width = 2, Height = 2, Y = -1 };
Add(_footProbe, isDefaultCollision: false); // manual-query only
```

`SetDefaultCollision(shape, bool)` flips the flag after the fact if needed.

### Using a non-default shape in its own relationship

Register the shape's selector on the relationship — default collision stays untouched, the non-default shape participates only in this one:

```csharp
AddCollisionRelationship<Enemy, Player>(_enemies, _playerFactory)
    .WithFirstShape(e => e.Sensor)
    .CollisionOccurred += (enemy, player) => enemy.Alert(player);

AddCollisionRelationship<Bullet, Enemy>(_bullets, _enemies)
    .WithSecondShape(e => e.WeakSpot)
    .CollisionOccurred += (b, e) => e.TakeCritical(b.Damage);
```

### Using a non-default shape for manual queries

Probes queried each frame don't need a relationship at all — just call `CollidesWith` directly:

```csharp
// Ledge detection
if (_patrolInput.X > 0f && !_rightFoot.CollidesWith(SolidCollision))
    _patrolInput.X = -1f;
```

**Footgun**: forgetting `isDefaultCollision: false` on a probe/sensor silently drags the entity into collision responses (e.g., a ground-check probe pushing the entity upward every frame → jitter). If an entity misbehaves near terrain, check every `Add` call for this flag first.

## Common Pitfalls

- **Both sides move when only one should** — Use `.MoveFirstOnCollision()` for solid terrain.
- **Nothing happens on collision** — Confirm both entities have visible, correctly-sized shape children.
- **Type argument mismatch on overloads** — `AddCollisionRelationship<Enemy>(_enemies, _players)` is not the 2-list overload. Use two type args for entity-vs-entity (`<Enemy, Player>`), `AddSelfCollisionRelationship<Enemy>(_enemies)` for same-list collision, and no explicit type args for `TileShapes`.
- **Player tunnels through thin walls** — Discrete collision detection; keep velocities reasonable.
- **Don't use a `DiedThisFrame` flag** — The frame order is collision → entity `CustomActivity` → screen `CustomActivity`. A flag set during collision is stale by the time the screen reads it. Instead, destroy entities directly in `CollisionOccurred` and detect cleared groups via `_factory.Instances.Count == 0`.
- **Platformer gotcha**: using the raw `BounceOnCollision(firstMass, secondMass, elasticity)` with swapped masses can make the player phase through the floor. Prefer `BounceFirstOnCollision` / `BounceSecondOnCollision` / `BounceBothOnCollision` — they name the intent and don't require decoding mass numbers at the call site.

## Bounce Family — Practical Defaults

Prefer the named methods; they expand into the mass numbers for you:

| Scenario | Call |
|----------|------|
| Moving entity bounces off static geometry (wall, floor, ceiling) | `.BounceFirstOnCollision(elasticity)` |
| Static side is A, moving side is B (mirror of above) | `.BounceSecondOnCollision(elasticity)` |
| Entity vs entity, both sides move (equal or weighted masses) | `.BounceBothOnCollision(firstMass, secondMass, elasticity)` |
| Anything weirder | `.BounceOnCollision(firstMass, secondMass, elasticity)` (escape hatch) |

**Elasticity**: `1.0f` = perfectly elastic; `0.9f` = 10% energy loss per bounce; `0f` = no bounce.

> **Why name the intent?** In the raw `BounceOnCollision(firstMass, secondMass, elasticity)`, "which side moves" is encoded as a pair of numbers — `(0, 1, e)` means "first is displaced, second is fixed", same *shape* of call as `(1, 1, e)` for "both balls move." The named methods make the intent obvious without requiring the reader to decode masses.
>
> On the raw form: lower mass → more displacement. `firstMass = 0f` means A takes all the separation and B stays fixed (the wall case). Only matters when you need asymmetric non-0/non-1 masses, e.g. a light puck (mass 0.3) vs a heavier paddle (mass 1).

## Shape Dispatch

Collision between any two entity types just works — the engine inspects the shape children of each entity at runtime and resolves the overlap automatically. What shapes the entities contain doesn't matter; you don't need to know or specify them at the call site.

Concave `Polygon` shapes are fully supported: the engine automatically decomposes them into convex parts internally. No manual decomposition is needed and reading `CollisionDispatcher.cs` is never necessary.

## Broad-Phase Partitioning (Performance)

Setting `Factory<T>.PartitionAxis` to either `Axis.X` or `Axis.Y` enables axis-based partitioning,
which reduces the deep collision count. Once the `PartitionAxis` is set, partitioning happens
automatically.

Partitioning requires a `Factory<T>` on both sides of the relationship, and both factories must use
the same axis. A relationship built from a plain `List<T>`, a single entity, or a `TileShapes`
always checks every pair, because none of those is a factory and none has a `PartitionAxis` to set.

```csharp
_bulletFactory.PartitionAxis = Axis.X;
_enemyFactory.PartitionAxis = Axis.X;
AddCollisionRelationship<Bullet, Enemy>(_bulletFactory, _enemyFactory);
```

If the two axes differ, or either one is left null, the relationship silently falls back to checking
every pair.

- A self-collision relationship uses a single factory, so only that factory needs a `PartitionAxis`.
- To confirm partitioning is working, read `relationship.DeepCollisionCount`. It drops well below
  n×m once the sweep engages. `PerformanceMonitor.GetCollisionReport()` (see the `performance`
  skill) also reports a `PartitionStatus` for every relationship, and `Unpartitioned` is the only
  value that means something you can fix.
- Choose the axis your entities spread out along most. A wide side-scrolling level partitions best
  on `Axis.X`.

### Object Size (`BroadPhaseRadius`)

Each entity's own `BroadPhaseRadius` (farthest shape edge from center, aggregated across attached
shapes) grows and shrinks automatically as shapes are added, removed, resized, moved, or
reparented — no manual invalidation needed. `ICollidable.BroadPhaseRadius` has no default body;
`Entity` supplies the aggregation itself, so every game entity gets it for free.

The sweep itself never reads an individual entity's radius — it uses one shared upper bound per
factory (`Factory<T>.PartitionMaxRadius`, internal), the largest `BroadPhaseRadius` any entity in
that factory has ever reached. A single per-factory bound keeps the sweep's edge order valid even
when entities in the same factory have very different sizes; the tradeoff is a slightly wider (but
still correct) candidate window when sizes vary a lot.

See `Factory<T>.PartitionAxis`/`SortForPartition` in `src/Factory.cs` and the broad-phase gate in
`src/Collision/CollisionRelationship.cs`.

