# 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.
- URL: https://dicyaninlabs.com/blog/structuring-a-visionos-app-with-immersivetesting
- Author: Hunter
- Published: 2026-07-20
- Tags: visionOS, RealityKit, Swift, Spatial Computing
- Reading time: 5 min read (982 words)

---

Almost every immersive visionOS project I have opened starts the same way: one `RealityView` closure loading entities, wiring ARKit, running game logic, and mutating state, all in the same place. That is fine for a demo. Then you add a second entity type, then a game mode, and now every change means reading the whole closure to figure out what you might break. Everything touches everything, and there is nowhere clean to make a cut.

[ImmersiveTesting](https://github.com/hunterh37/ImmersiveTesting) is a Swift package I built to give an immersive app a spine. It is a layered architecture with a small set of services underneath, meant to keep spatial 3D code from collapsing back into that one closure. This post walks through the layers and where things go.

## Three layers, one responsibility each

The app splits into three layers. The one rule worth enforcing: each concern lives in exactly one of them.

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

Most of the benefit here is boring and practical: when something breaks, you know which file to open. Locomotion is a system. A new entity is the builder. A hand-tracking call is a provider. You are not scrolling a 400-line closure hunting for the one line that matters.

## 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.

```swift
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 just a function of `config`, scene variants (difficulty, level layout, spawn counts) come from passing different configuration instead of branching inside the view. The view never learns there is more than one kind of scene.

## 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.

```swift
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
    }
}
```

Each system is one file doing one thing. You add a behavior by adding a system, not by threading another branch through code that already works. It also happens to match how RealityKit already wants per-frame work structured, so you are going with the grain instead of against it.

> 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`). Your systems and builder only ever see the interface, never ARKit or a singleton directly. So when Apple reshuffles an API, or you want to swap an implementation for a test, you touch one adapter instead of chasing the change across the whole 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.

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

This matters more for a game than it first looks. If spawns are reproducible, a bug someone reports is a bug you can actually recreate instead of chase. A daily-challenge or shared-seed mode turns into a single seed value rather than a new subsystem. Procedural layout goes from something you can only watch happen to something you can pin down and step through.

## Real physics, not hand-rolled math

Lean on RealityKit's real physics engine (gravity, contacts, collisions) instead of approximating motion by hand. Collisions go through actual `CollisionComponent` group and mask contracts, so the thing governing the scene is the same simulation that ships in the app. Every bespoke movement equation you delete is one less place for spatial math to quietly drift out of sync with what the user is looking at.

## Why this holds up

It all comes down to keeping clean seams between parts. Scene construction, behavior, and platform services each sit in one swappable place, and each leans on an interface rather than on the layer under it. That is the whole trick to getting an immersive app past the demo stage: you can add a system, swap an adapter, or reconfigure a scene without taking the rest of it apart to do it.

## 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

It is on GitHub at [ImmersiveTesting](https://github.com/hunterh37/ImmersiveTesting). You do not have to adopt all of it at once. Pull one `RealityView` closure apart into a `SceneBuilder` and a single system, then push the platform calls behind a provider. It pays off piece by piece, which is the only kind of refactor that actually gets finished.
