A Simple Architecture for SceneKit-Based Games (Part 1)

Nicolás Miari
Mac O’Clock
Published in
7 min readMay 8, 2020

--

I recently set to develop a simple, casual (yet 3D) game leveraging Apple's frameworks (and their multiple platform support), and learned a few lessons along the way.

In this first part, I will focus on an efficient way I came up with to integrate SpriteKit into a SceneKit app, to power its GUI.

A “Mostly-SpriteKit” App

As an app, my game is mostly 2D. It consists of a “splash” screen shown on launch, a “main title” or “home” screen, an various options screens (credits, preferences, etc.), all of which can be perfectly realized with SpriteKit alone. I only needed very basic 3D graphics for the actual game play screen.

It is quite easy to integrate such basic 3D content into a SpriteKit scene by using the stock SK3DNode class. However, I soon ran into issues, the documentation is very terse, and the code samples (whether Apple’s or third-party) are few and far in between. In addition, with SK3DNode you don't get the full functionality of SceneKit (for example, the “bloom” filter and other special effects that require multiple render passes aren't supported).

So I decided to bite the bullet and go full-SceneKit. Apple’s high-level, cross-platform framework for 3D apps lets you integrate with SpriteKit by having the SceneKit view display a whole SKScene instance as an ‘overlay’ on top of your 3D content, for use as e.g. GUI elements such as in-game HUD. The price you pay is that the whole scene-transitioning machinery of SpriteKit stops working all of a sudden, because it is built on the assumption that the scenes are being presented by a SpriteKit-capable view (SKView or subclass thereof), not by SceneKit.

Some work needs to be done…

SKScene Transitions in a SceneKit App — Design Requirements

Ideally, we want our scene transitions to be initiated by the code within our SpriteKit scenes (this is because scene transitions typically result from the user interacting with e.g. a button control, and those are by definition 2D elements implemented as sprites, child nodes of the SpriteKit scene).

Because we want this functionality to be available to all our SKScene subclasses, we should implement it in a class extension. Ultimately, the transition-initiating methods we define in our extension will have to tap into some object which has actual control over which SKScene is overlaid on top of the 3D content. This, of course, is the single, persistent SceneKit view of our app.

This presents us a dilemma: The SceneKit view already knows about the overlaid 2D scene (it keeps a reference to it in its overlaySKScene property); having these two “know about each other” is way more coupling than we want; it will potentially make our code fragile and hinder reusability. The solution, of course, is to have the various pieces of our design depend on abstractions, not on concrete classes.

Protocols to The Rescue

We start by defining a protocol named OverlayScene, to be adopted by our SKScene subclasses. This abstracts our SpriteKit scenes and reduces them to “just an object that is responsible for the overlaid 2D content, I don’t care about its details”.

protocol OverlayScene: AnyObject {
// (...details TBD...)

(we will fill in the details later)

Next, we want to abstract the object capable of presenting our overlays on screen; that is, SCNView. For that, we define a second protocol named OverlayScenePresenter. Its only requirement is that it must hold a reference to an object conforming to the OverlayScene protocol we just defined above:

protocol OverlayScenePresenter: AnyObject {

var overlayScene: OverlayScene? { get set }
}

Finally, we want another protocol to be adopted by whatever object is responsible for orchestrating the transitions. To keeps things compact, we could add this functionality to OverlayScenePresenter above (after all, it already has the overlayScene property at hand!). But we know this object will be an SCNView instance behind the scenes, and we’d rather not add a lot of logic to it. Our app’s single, persistent view controller class feels like a better match.

So we define our third (and last) protocol, OverlayNavigationDelegate:

protocol OverlayNavigationDelegate: AnyObject {    var overlayContainer: OverlayScenePresenter? { get }    var transitionDuration: TimeInterval { get }

func present(_ scene: OverlayScene)
func transition(
to scene: OverlayScene,
completion: @escaping (() -> Void)
)
// (offers point of customization pre-transition)
func willTransition(to scene: OverlayScene)
// (offers point of customization post-transition)
func didTransition(to scene: OverlayScene)
}

Now comes the funny part. Let’s go back to the OverlayScene protocol, and fill in the requirements that we skipped before:

protocol OverlayScene: AnyObject {

var navigationDelegate: OverlayNavigationDelegate? { get set }

var alpha: CGFloat { get set }

func fadeAlpha(
to value: CGFloat,
duration: TimeInterval,
completion: @escaping (() -> Void)
)

func transition(
to scene: OverlayScene,
completion: @escaping (() -> Void)
)
}

Right off the bat, we notice that alpha is automatically satisfied because
SKScene’s ancestor class SKNode already defines a property with the same signature (it will become evident in a moment why we need these requirements).

Because the required read-write property navigationDelegate obviously has to be stored, we can’t conform to this protocol with a simple class extension on SKScene; we will need to define a “base class”. This is the least elegant element of this solution, but let’s get it out of the way:

/** 
All overlay SKScene subclasses must subclass this.
*/
class BaseSpriteScene: SKScene, OverlayScene {

// MARK: - OverlayScene

weak var navigationDelegate: OverlayNavigationDelegate?

/*
Protocol requirement var alpha: CGFloat { get set } is
automatically satisfied by SKScene)
*/

func fadeAlpha(
to value: CGFloat,
duration: TimeInterval,
completion: @escaping (() -> Void)
) {
run(
.fadeAlpha(
to: value,
duration: duration),
completion: completion
)
}

func transition(
to scene: OverlayScene,
completion: @escaping (() -> Void) = {}
) {
navigationDelegate?.transition(
to: scene,
completion: completion)
scene.navigationDelegate = navigationDelegate
}
}

We implemented the requirement fadeAlpha(to:duration:completion:) simply by delegating to SKAction.fadeAlpha(to:duration:transition). The need for this method in the protocol becomes evident when we add default implementations for some methods of OverlayNavigationDelegate:

extension OverlayNavigationDelegate {

var transitionDuration: TimeInterval {
return 0.5 // A sensible default value
}

func present(_ scene: OverlayScene) {
scene.navigationDelegate = self
overlayContainer?.overlayScene = scene
}

func transition(
to scene: OverlayScene,
completion: @escaping (() -> Void)
) {
guard let source = overlayContainer?.overlayScene else {
/*
No source scene: transition to destination right away:
*/
willTransition(to: scene)
scene.navigationDelegate = self
scene.alpha = 0
overlayContainer?.overlayScene = scene
scene.fadeAlpha(
to: 1,
duration: self.transitionDuration
) { [weak self] in
completion()
self?.didTransition(to: scene)
}
return
}

/*
Fade source out...
*/
source.fadeAlpha(
to: 0,
duration: self.transitionDuration
) { [weak self] in
guard let this = self else {
return
}
this.willTransition(to: scene)
scene.navigationDelegate = this
scene.alpha = 0
this.overlayContainer?.overlayScene = scene
/*
...and then fade destination in:
*/
scene.fadeAlpha(
to: 1,
duration: this.transitionDuration
) { [weak self] in
completion()
self?.didTransition(to: scene)
}
}
}
}

Now, our view controller only needs to implement willTransition(to:)w and didTransition(to:) in order to conform to the protocol (we could go one step further and add empty, default implementations of these. But they come in handy anyway…). We can provide default implementations for the central methods ( present(... and transition(... ) thanks to the requirements of OverlayNavigationDelegate and OverlayScene.

To leverage this machinery, we need to:

  1. Have all our SpriteKit scenes inherit from the intermediate class BaseSpriteScene. (subclass SKScene conforming to OverlayScene).
  2. Have our view controller conform to OverlayNavigationDelegate (the critical functionality is already provided by default implementations in a protocol extension).
  3. Have our SCNView subclass conform to OverlayScenePresenter, and implement the overlayScene property requirement as:
var overlayScene: OverlayScene? {
/*
Computed read-write property maps between:
SCNView.overlaySKScene
and:
OverlayScenePresenter.overlayScene:
*/
get {
return overlaySKScene as? OverlayScene
}
set {
self.overlaySKScene = newValue as? SKScene
}
}

Why We Do This

At this point you might be thinking: What’s the point of so much abstraction? It’s not like we’re going to replace SceneKit with some third-party libraries tomorrow, and even it we do, the new frameworks will most likely not be set up in a way compatible with our current design’s assumptions (e.g., the privileged role of SCNView). And you would be right; we could have just added all this functionality as part of subclassing SCNView, SKScene, and UIViewController/NSViewController.

But that would be just adding more disparate functionality to classes that are already quite bloated (i.e., “do too much”). By splitting this functionality into protocols with meaningful names and clearly defined roles, and conforming to them via class extensions, we are partitioning our code — structuring it, if you will — into meaningful, easy-to-make-sense-of pieces. Each of our classes still ends up doing quite a lot, but at least the responsibilities are now meaningfully partitioned.

And when you copy-paste this code into your next game, it will be immediately obvious which parts of code form part of the generic scene navigation scaffolding we have defined here (and thus will see use in the next game too), and which are specific to your old game and need to be left out. We have given a name to our navigation architecture, and specified it in as much detail as we could, depending on existing types’ circumstances as little as possible.

I have put together a demo project on GitHub, based on Apple’s SceneKit-based Multi-Platform Game template, that adopts this navigation architecture (it also spoils the object of the next article, but never mind!).

In the next and last part of this series, I will discuss the architecture I adopted to bring together user input, game logic and graphics (frame) updates. Stay tuned!

--

--

Nicolás Miari
Mac O’Clock

Software Engineer. Husband. Black Tie Afficionado. Wine Enthusiast.