+1 (415) 993-7206

Custom Components and Systems in RealityKit: Architecture That Survives the Second Feature

Written against iOS 18+/iOS 26-era RealityKit APIs. Concepts apply unchanged on visionOS.

Almost every ARKit codebase we are asked to rescue has the same shape. A RealityView, a couple of @State arrays, a Timer or a subscribe(to: SceneEvents.Update.self) closure four hundred lines long, and a growing pile of if entity.name == "reticle" string comparisons. It works for the demo. It falls over the moment somebody asks for the second feature.

RealityKit is not a scene graph with a render loop bolted on — that was SceneKit. RealityKit is an entity-component-system engine, and if you model your AR behaviour the way the engine wants it modelled, the code stays small. This is the part of the RealityKit migration that teams coming from ARSCNView most often skip. (If you have not started that migration, read Migrating an ARSCNView App to RealityKit first, then come back.)

The three nouns

  • Entity — an identity and a transform. It holds no behaviour. A placed product, a reticle, an anchor, a debug label: all just entities.
  • Component — a plain value type of data attached to an entity. ModelComponent, CollisionComponent, and PhysicsBodyComponent are Apple's; yours are structs conforming to Component.
  • System — a piece of behaviour that runs once per frame over every entity matching a query. Systems own the logic; components own the state.

The rule that does the work: if you are about to store AR state in a SwiftUI @State array, ask whether it belongs on the entity as a component instead.

A worked example: objects that settle onto a plane

Say the brief is "tapping places a product; it drops onto the detected floor, lands with a small bounce, then glows for a second so the user can see it arrived." The closure-in-a-view version of that is a mess of timers. The ECS version is two components and two systems.

1. Define the components

import RealityKit

/// Marks an entity as user-placed and records when.
struct PlacedComponent: Component {
    var placedAt: TimeInterval
    var catalogID: String
}

/// Drives the settle animation: fall to targetY, then ease out.
struct SettleComponent: Component {
    var targetY: Float
    var velocity: Float = 0
    var settled: Bool = false
}

/// A timed highlight that removes itself when it expires.
struct HighlightComponent: Component {
    var remaining: TimeInterval
    var color: SIMD3<Float> = [0.2, 0.8, 1.0]
}

Components are structs. Keep them small, keep them Codable if you plan to persist a scene, and keep behaviour out of them.

2. Register components and systems once

Registration must happen before the first entity uses the component. An App initializer or a RealityView make closure both work; do it in exactly one place.

@main
struct ARCatalogApp: App {
    init() {
        PlacedComponent.registerComponent()
        SettleComponent.registerComponent()
        HighlightComponent.registerComponent()

        SettleSystem.registerSystem()
        HighlightSystem.registerSystem()
    }
    var body: some Scene { WindowGroup { ContentView() } }
}

3. Write the systems

A System is a struct or class with an EntityQuery and an update(context:) that runs per frame. The query is the important part: it is the engine's index, not a loop you wrote, and it stays fast as the scene grows.

struct SettleSystem: System {
    static let query = EntityQuery(where: .has(SettleComponent.self))

    init(scene: RealityKit.Scene) {}

    func update(context: SceneUpdateContext) {
        let dt = Float(context.deltaTime)

        for entity in context.entities(matching: Self.query,
                                       updatingSystemWhen: .rendering) {
            guard var settle = entity.components[SettleComponent.self],
                  !settle.settled else { continue }

            settle.velocity += -9.81 * dt
            var p = entity.position
            p.y += settle.velocity * dt

            if p.y <= settle.targetY {
                p.y = settle.targetY
                if abs(settle.velocity) < 0.35 {
                    settle.settled = true
                    entity.components[HighlightComponent.self] =
                        HighlightComponent(remaining: 1.0)
                } else {
                    settle.velocity = -settle.velocity * 0.35   // bounce
                }
            }

            entity.position = p
            entity.components[SettleComponent.self] = settle
        }
    }
}

Note what the system does not do: it does not know what a product is, it does not talk to SwiftUI, and it does not care how the entity got there. Anything with a SettleComponent settles.

The highlight system is the same shape, and it demonstrates the other half of the pattern — a component that removes itself when its job is done.

struct HighlightSystem: System {
    static let query = EntityQuery(where: .has(HighlightComponent.self))

    init(scene: RealityKit.Scene) {}

    func update(context: SceneUpdateContext) {
        for entity in context.entities(matching: Self.query,
                                       updatingSystemWhen: .rendering) {
            guard var hl = entity.components[HighlightComponent.self] else { continue }
            hl.remaining -= context.deltaTime

            if hl.remaining <= 0 {
                entity.components.remove(HighlightComponent.self)
                entity.components.remove(ImageBasedLightReceiverComponent.self)
                continue
            }

            let intensity = Float(max(0, hl.remaining))
            if var model = entity.components[ModelComponent.self],
               var material = model.materials.first as? PhysicallyBasedMaterial {
                material.emissiveColor = .init(color: .init(
                    red: CGFloat(hl.color.x), green: CGFloat(hl.color.y),
                    blue: CGFloat(hl.color.z), alpha: 1))
                material.emissiveIntensity = intensity * 2
                model.materials = [material]
                entity.components.set(model)
            }
            entity.components[HighlightComponent.self] = hl
        }
    }
}

4. Placement becomes three lines

The tap handler no longer owns any behaviour. It creates an entity, attaches data, and gets out of the way.

func place(_ model: ModelEntity, at transform: simd_float4x4, catalogID: String) {
    let anchor = AnchorEntity(world: transform)
    model.position.y += 0.25                      // drop from 25cm up
    model.components.set(PlacedComponent(placedAt: CACurrentMediaTime(),
                                         catalogID: catalogID))
    model.components.set(SettleComponent(targetY: 0))
    anchor.addChild(model)
    content.add(anchor)
}

Where teams get this wrong

Doing work in the query loop that does not belong there. Systems run every frame. No file I/O, no network, no USDZ loading inside update(context:). Load asynchronously, then attach a component when the asset is ready.

Ignoring updatingSystemWhen:. Passing .rendering tells RealityKit you only care about entities whose state affects the current frame, and lets it skip the rest. Use .all only when you genuinely need entities that are not being rendered.

Fighting over update order. If two systems touch the same transform, declare it: static var dependencies: [SystemDependency] { [.before(HighlightSystem.self)] }. Implicit ordering is a bug that only shows up on a slower device.

Storing the entity list in SwiftUI. Keep the scene as the source of truth and query it. Where the UI genuinely needs to react — a count of placed items, say — publish a small snapshot out of a system to an @Observable model once per second, not once per frame.

Mutating a component struct without writing it back. entity.components[T.self] hands you a copy. Forgetting the assignment at the end of the loop produces the classic "my animation does nothing and no error appears" bug.

Why this matters commercially

ECS is not architecture astronautics here — it is the difference between an AR feature that a client's in-house iOS team can maintain after we hand it over and one they cannot. Components are testable in isolation: a system's update is a pure-ish function of components and delta time, which is exactly what makes headless testing possible (see Testing ARKit Apps Without Waving a Phone Around). And because systems key off data rather than class hierarchies, adding the second, third, and tenth behaviour does not multiply the branching in your view layer.

It also ports. The same components and systems run inside a visionOS volume or immersive space with the RealityKit scene supplied by a different host — one of the practical reasons the cross-platform story from WWDC 2026 is worth structuring for now.

A migration path for an existing app

You do not need a rewrite. Do it one behaviour at a time:

  1. Find the longest branch in your SceneEvents.Update subscription.
  2. Identify the state it reads. Make that a component.
  3. Move the branch body into a System with a query for that component.
  4. Delete the branch. Repeat.

After three or four passes the update closure is usually empty and the deletion is the satisfying part.


Need a second opinion on an AR codebase before it gets bigger? RealityRogue's senior ARKit consultants do architecture reviews, RealityKit migrations, and embedded contract work with in-house iOS teams. Get in touch with a short description of your app and where it hurts.