How to structure an ARKit/SceneKit game

Your ViewController.swift is 1,000 lines long and you can no longer find the function that spawns enemies — welcome to the ARKit template trap. Apple’s stock template hands you a single view controller and lets you pile collision handling, enemy spawning, score tracking, and animations into it until the file collapses under its own weight. This post lays out a structure that holds up as the game grows, using SceneKit and GameplayKit on iOS 17 and Xcode 15.

The advice here is aimed at developers coming from a design background — people comfortable in Xcode’s Scene Editor but uncertain how to organise the surrounding Swift code. We’ll cover the three structural layers, the game loop, the entity-component system, where the Scene Editor still earns its keep, and the mistakes that cost you a weekend.

The three layers

Think of the codebase as three layers, each with one job. Anything that does not fit cleanly into one of these layers is a sign that a layer is doing too much.

1. GameViewController — the thin coordinator

The view controller owns the ARSCNView, sets up the AR session, and hands control to the game. It does not know about enemies, scores, or physics. It forwards delegate callbacks and otherwise stays out of the way.

final class GameViewController: UIViewController, ARSCNViewDelegate {
    @IBOutlet var sceneView: ARSCNView!
    private var gameManager: GameManager!

    override func viewDidLoad() {
        super.viewDidLoad()
        sceneView.delegate = self
        gameManager = GameManager(sceneView: sceneView)
        gameManager.start()
    }

    func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) {
        gameManager.update(atTime: time)
    }
}

If your view controller starts growing helper methods like spawnEnemy() or handleCollision(), stop and move them out. They belong in the next layer.

2. GameManager — the brain

GameManager owns every entity and every component system in the game. It runs the game loop, tracks game state, and is the only object that knows the full cast of characters. When a new bullet is spawned, GameManager creates the entity. When the player dies, GameManager decides what happens next.

This is also the only object outside the view controller that holds a reference to the ARSCNView or its root SCNScene.

3. Entities and components — the cast

Each game object — player, enemy, pickup, projectile — is a GKEntity. Behaviours are GKComponent subclasses attached to those entities. The pattern is composition, not inheritance. A flying enemy that can take damage is an entity with a MovementComponent, a HealthComponent, and a ChaseAIComponent. Add or remove behaviours by adding or removing components.

The game loop

SceneKit gives you exactly one place to run per-frame logic: the SCNSceneRendererDelegate method renderer(_:updateAtTime:). It fires once per frame, before SceneKit renders. Every component update, AI tick, and physics check flows from here.

final class GameManager {
    private let componentSystems: [GKComponentSystem<GKComponent>]
    private var entities: [GKEntity] = []
    private var lastUpdateTime: TimeInterval = 0

    func update(atTime time: TimeInterval) {
        let deltaTime = lastUpdateTime == 0 ? 0 : time - lastUpdateTime
        lastUpdateTime = time

        for system in componentSystems {
            system.update(deltaTime: deltaTime)
        }
    }
}

That deltaTime calculation matters — see the third common mistake below.

Entity-component system with GameplayKit

GameplayKit ships with a working ECS. You do not need to write your own. The pieces you care about:

  • GKEntity — a container for one game object. Has no behaviour of its own.
  • GKComponent — one behaviour. Override update(deltaTime:) to run logic each frame.
  • GKComponentSystem — holds all components of a given type and updates them together. Cache-friendly and easier to reason about than iterating entities.
  • GKSCNNodeComponent — the bridge between an entity and its SCNNode. Every visible entity has one.

Here is a minimal enemy entity:

final class EnemyEntity: GKEntity {
    init(node: SCNNode, target: SCNNode) {
        super.init()
        addComponent(GKSCNNodeComponent(node: node))
        addComponent(HealthComponent(maxHealth: 30))
        addComponent(MovementComponent(speed: 1.5))
        addComponent(ChaseAIComponent(target: target))
    }

    required init?(coder: NSCoder) { fatalError() }
}

GameManager creates the entity, registers each component with the matching GKComponentSystem, and adds the SCNNode to the scene graph. From that point on, the component systems drive everything.

Project file structure

A layout that matches the layers above:

MyARGame/
├── GameViewController.swift
├── Game/
│   ├── GameManager.swift
│   ├── GameState.swift
│   └── CollisionHandler.swift
├── Entities/
│   ├── PlayerEntity.swift
│   └── EnemyEntity.swift
└── Components/
    ├── HealthComponent.swift
    ├── MovementComponent.swift
    └── ChaseAIComponent.swift

When you add a new behaviour, the question “where does this go?” answers itself. New per-frame logic — that’s a component. New game-wide rule — that’s GameManager or GameState. New kind of object — that’s an entity.

When to use the Scene Editor — and when not to

The Scene Editor is genuinely useful for the things it is good at, and a liability for the things it is not.

Use the Scene Editor for:

  • Static environment layout — the level geometry, walls, props.
  • Lighting and camera setup.
  • Placing named empty SCNNodes as spawn markers, waypoints, or trigger volumes.
  • Attaching GKComponent subclasses to authored entities via the Attributes Inspector.

Use code for:

  • Dynamically spawned enemies, pickups, and projectiles.
  • Anything whose existence depends on game state.
  • Anything that needs to be pooled, recycled, or reset on level restart.

The productive middle ground: place named empty SCNNodes in the .scn file as spawn markers. Load the scene with GKScene(fileNamed:), walk the node tree to find the markers by name, and instantiate entities at those positions in code. Designers keep their authoring workflow; the runtime keeps its sanity.

Three mistakes that will cost you a weekend

1. Driving enemy logic with SCNAction

SCNAction is for scripted visual events — an idle bobbing animation, a sparkle on a pickup, a one-off door opening. It is not a behaviour engine. Actions bypass physics, ignore game state, and keep running after the entity dies. An enemy chasing the player with SCNAction.move(to:duration:) will happily walk through walls and continue chasing for a full second after you’ve shot it. Drive gameplay movement from a component’s update(deltaTime:).

2. Subclassing SCNNode for game objects

Sooner or later you write class Enemy: SCNNode, and a week later you need an enemy that is also a pickup, or a boss that shares behaviours with a regular enemy. Single inheritance does not stretch that far. Composition with GKComponent does — add a PickupComponent to the same entity and you’re done. Keep SCNNode as a dumb visual; put behaviour in components.

3. Hardcoding the frame time

0.016 looks innocent — that’s 60 fps, after all. But ProMotion devices run at up to 120 fps, older devices drop to 30 fps under load, and ARKit itself can throttle the renderer when tracking is poor. A hardcoded delta turns your “5 metres per second” enemy into a 2.5 m/s crawler on an iPhone SE. Always compute deltaTime = currentTime - lastUpdateTime in the renderer callback and pass it down to every component’s update(deltaTime:).


The structure above scales from a weekend prototype to something you can ship. Once it’s in place, adding a new enemy type is a new Entity file plus maybe one new component — not another 200 lines bolted onto the view controller.



Avoid Delays and Rejections when Submitting Your App to The Store!


Follow my FREE cheat sheets to design, develop, or even amend your app to deserve its virtual shelf space in the App Store.

* indicates required

When you subscribe you’ll also get programming tips, business advices, and career rants from the trenches about twice a month. I respect your e-mail privacy.

0 thoughts on “How to structure an ARKit/SceneKit game

Leave a Reply