TRANSMISSION_STATUS: UNSTABLE
DATA_INTEGRITY: 73.2%
SIGNAL_STRENGTH: FLUCTUATING
Back to Blog
visionOSRealityKitSwiftSpatial Computing

Structuring a visionOS app or game with the ImmersiveTesting architecture

Most RealityKit code ends up tangled inside one RealityView closure. ImmersiveTesting is a layered scaffold that keeps scene construction, game logic, and platform services in separate, swappable places.

Hunter5 min read

Most immersive visionOS code starts in the same place: one RealityView closure that loads entities, wires ARKit, runs game logic, and mutates state. It works for a demo. It stops working the moment the app grows, because everything is coupled to everything else and there is no seam to change one part without touching the rest.

ImmersiveTesting is a Swift package that gives an immersive app a spine. It is a layered architecture plus a small set of services that keep spatial 3D code clean instead of tangled into a closure. This post is about that architecture and why it holds up as an app or game grows.

Three layers, one responsibility each

The framework splits an immersive app into three layers, and the rule is that each concern lives in exactly one of them.

  1. SwiftUI shell. The ImmersiveView stays thin. It wires the environment and calls the scene builder. No game logic, no entity construction.
  2. Scene layer. The SceneBuilder constructs the entity graph, and ECS systems drive behavior through static step methods.
  3. Services layer. Provider protocols hide ARKit, .shared singletons, and other platform calls behind interfaces you inject.

The payoff is that when something breaks or changes, you know which layer to open. Locomotion is a system. A new entity is the builder. A hand-tracking call is a provider. Nothing forces you back into a 400-line closure to find it.

The scene builder is a pure function

The SceneBuilder takes a configuration and a SceneEnvironment and returns a constructed entity. It does not reach for globals and it does not read hidden state. Same inputs, same scene.

struct GameImmersiveView: View {
    @StateObject private var viewModel = GameViewModel()
 
    var body: some View {
        RealityView { content in
            let env = CompositeSceneEnvironment(
                worldTracking: LiveWorldTracking(),
                sceneEffects: LiveSceneEffects()
            )
            let scene = GameSceneBuilder().makeScene(viewModel.config, env: env)
            content.add(scene.root)
            viewModel.sceneRoot = scene.root
        }
    }
}

Because construction is a function of config, you can build variants of a scene (difficulty, level layout, spawn counts) by passing different configuration, not by branching inside the view.

Game logic lives in ECS systems

Per-frame behavior goes into systems that expose static step methods. A system reads the environment and the entities it cares about, then mutates them. It does not own the world and it does not know about SwiftUI.

static func step(entities: [Entity], dt: Float, env: any SceneEnvironment) {
    let target = env.worldTracking.devicePosition()
    for npc in entities {
        guard var ai = npc.components[NPCAIComponent.self] else { continue }
        npc.position += normalize(target - npc.position) * ai.speed * dt
    }
}

This keeps behavior composable. Each system is one file doing one thing, and you add a behavior by adding a system rather than by threading another branch through existing code. It also maps cleanly onto how RealityKit already wants per-frame work structured.

Tip: static step methods make a system trivial to reason about. There is no instance state hiding between frames, so what you read is what runs.

Platform calls hide behind provider protocols

Everything platform-specific goes behind a protocol. The device pose, scene effects, hand tracking, and even randomness each have a -Providing interface, and the SceneEnvironment carries the concrete implementations.

  • WorldTrackingProviding for device pose
  • SceneEffectsProviding for scene effects
  • HandTrackingProviding for hand input
  • RandomProviding for randomization

In the app you inject the live adapters (LiveWorldTracking, LiveSceneEffects). The value is that your systems and builder depend on interfaces, not on ARKit or singletons directly. When Apple changes an API or you want to swap an implementation, the change is contained to one adapter instead of scattered across the scene.

Determinism is built in

RandomProviding is backed by SeededRandom, so procedural content is reproducible. Give the environment a seed and the scene lays out the same way every time.

let env = CompositeSceneEnvironment(random: SeededRandom(seed: 42))
let scene = GameSceneBuilder().makeScene(config, env: env)

For a game this matters more than it looks. Reproducible spawns mean a bug someone reports is a bug you can recreate exactly. A daily-challenge or shared-seed mode becomes a seed value rather than a new subsystem. Procedural layout stops being something you can only observe and becomes something you can pin down.

Real physics, not hand-rolled math

The architecture leans on RealityKit's actual physics engine (gravity, contacts, collisions) rather than approximating motion by hand. Collisions are expressed through real CollisionComponent group and mask contracts, so what governs the scene is the same simulation shipping in the app. Fewer bespoke movement equations means fewer places for spatial math to drift out of sync with what the user sees.

Why this holds up

The through-line is separation with clean seams. Scene construction, behavior, and platform services each occupy one swappable location, and each depends on interfaces rather than on the layer beneath it. That is what lets an immersive app grow past the demo stage: you can add a system, swap an adapter, or reconfigure a scene without pulling the whole thing apart.

Requirements

Swift 6, Xcode 16 or newer, targeting visionOS 2 (with macOS 15 and iOS 18 support). The runtime library is linkable from app targets directly.

Next steps

The framework is on GitHub at ImmersiveTesting. Start by moving one RealityView closure into a SceneBuilder and a single system, then push platform calls behind a provider. The layering pays off incrementally.

Dicyanin Labs

Pioneering the future of spatial computing with innovative visionOS applications. From immersive games to productivity tools, we're building the next generation of digital experiences.

© 2026 Dicyanin Labs. All rights reserved. Built with passion for spatial computing.
INNOVATING_REALITY.EXE
00110110
10111000
00010010