How I stopped projectiles from dealing damage 60 times

Early in development of my RealityKit game I noticed enemies were dying suspiciously fast. A projectile that should deal 10 damage was melting health bars on contact, making it so the strongest of enemies stood no chance against even the weakest of weapons. I added some logging and found the problem immediately: a single projectile was applying its damage effect over and over during one overlap. At 60 FPS, that adds up fast.

The root cause was straightforward. The CollisionEvents.Began events were firing multiple times for what looks like a single contact between two entities. This can happen for a few reasons: entities with multiple collision shapes in their hierarchy, physics substeps within a single frame, or just the realities of continuous collision detection between fast moving objects at high frame rates. The result is the same. One projectile, one enemy, many events.

The fix

The solution I came up with was a processed collision cache. When a collision comes in, generate a unique identifier for the pair of entities involved, check if you've already seen it, and skip if you have.

private var processedCollisions: [String: TimeInterval] = [:]
private let collisionCleanupDelay: TimeInterval = 0.1

private func handleCollision(_ event: CollisionEvents.Began) {
    let collisionId = createCollisionIdentifier(
        entityA: event.entityA,
        entityB: event.entityB
    )
    guard processedCollisions[collisionId] == nil else { return }

    // ... process the collision, apply damage, etc.
    processedCollisions[collisionId] = CACurrentMediaTime()
}

The identifier is just the two entity IDs sorted and joined. Sorting matters because RealityKit doesn't guarantee which entity is entityA and which is entityB.

private func createCollisionIdentifier(entityA: Entity, entityB: Entity) -> String {
    let ids = [entityA.id, entityB.id]
        .map { String(describing: $0) }
        .sorted()
    return ids.joined(separator: "-")
}

Cleaning up

You can't just let the dictionary grow forever. Entries need to expire so the same pair of entities can collide again later (think of a piercing projectile that passes through an enemy, exits, and loops back). I run cleanup on a timer inside the system's update method:

public func update(context: SceneUpdateContext) {
    timeSinceLastCleanup += context.deltaTime
    if timeSinceLastCleanup >= cleanupInterval {
        cleanupOldCollisions(currentTime: CACurrentMediaTime())
        timeSinceLastCleanup = 0
    }
}

private func cleanupOldCollisions(currentTime: TimeInterval) {
    let cutoffTime = currentTime - collisionCleanupDelay
    processedCollisions = processedCollisions.filter { $0.value > cutoffTime }
}

The cleanup delay is 0.1 seconds. That's long enough to catch all the duplicate events from a single collision but short enough that a piercing projectile can re-hit an entity if it passes through and comes back. The cleanup itself runs every 0.5 seconds to avoid doing dictionary work every frame.

Why not use CollisionEvents.Ended?

You might think you could just track state using Began/Ended pairs. In practice this doesn't work reliably. Entity removal, physics mode changes, and projectile destruction all interfere with getting clean Ended events. The timestamp cache is simpler and more predictable.

The pattern can be generalized

This same approach works for any frame-based event system where you can receive duplicates. AoE damage ticks, trigger zones, pickup collection. Anywhere you subscribe to a physics event and need "process this exactly once," a sorted-ID cache with timed expiry does the job.

If you're building anything with RealityKit's collision system, save yourself some debugging time and add this from the start.

Next
Next

Fading out the walls between you and the camera