Creating a Generic Node Component in SpriteKit

After the development of my simple puzzle game, one of the things I wanted to do differently was using a generic Node Component. This would be a component that would do all the scaling, rotating, and positioning in such a way that it could apply to any node type (label, sprite, shape, etc) or even things like particle emitters.

In this post, I’ll create a simple SpriteKit scene with a basic render system and a few simple components to see how this might work.

As I write this, I’m walking the Dale’s Way with nothing but my iPad Pro so this will all be done using the Swift Playgrounds app.

I’ll start by creating a new blank project:

Screenshot of the playground creation screen in the Swift Playgrounds app. The Blank playground type is highlighted.

Then I’ll name it Node Component Example and open it:

The playground rename screen in Swift Playgrounds. The playground is being renamed to Node Component Example

To start with I’ll import a few libraries and set up the scene:

 
import SpriteKit
import GameplayKit
import PlaygroundSupport
// 1.
class GameScene : SKScene {

}
// 2.
let view = SKView(frame: CGRect(x: 0, y: 0, width: 400, height: 500))
let scene = GameScene(size: view.frame.size)
// 3.
scene.scaleMode = .aspectFit
view.presentScene(scene)
// 4.
PlaygroundPage.current.liveView = view

  1. A stub of the subclass of SKScene I’ll be using to handle the interaction and manage the entities and systems.
  2. To get something appearing in playgrounds, the liveView property of the playground needs to be set to a subclass of UIView. Initialise a standard SKView with a hardcoded width and height that happens to work well on my iPad. Use the same size to initialise the scene.
  3. Set the scaleMode to aspectFit so that the scene will be squeezed down to fit in the space available in order to maintain its aspect ratio.
  4. Set the liveView property of the current playground page in order to have the SpriteKit scene appear in the playground.
A screenshot of Swift Playgrounds. There is some basic code (above) on the left and on the right is a blank, grey field.

The scene will keep track of a couple of things: the previous time (in order to calculate the delta time between frame updates) and the entities that are created:

 
class GameScene : SKScene {
    var entities : [GKEntity] = []
    var previousTime : TimeInterval = 0
}

I’ll then override the update(_:) method to calculate the delta time. At the bottom of the GameScene class, before the closing brace, I’ll add:

 
override func update(_ currentTime: TimeInterval) {
        if self.previousTime == 0 {
            self.previousTime = currentTime
        }
        let delta = currentTime - self.previousTime
}

Now I can add the node component, initially a very simple class with just an SKNode instance as it’s only property:

 
class NodeComponent : GKComponent {
    let node = SKNode()
}

Following my preferred way of managing ECS in SpriteKit, an extension on the node component is going to function as my render system. In order to make sure that this gets called every frame, I need to add a GKComponentSystem to the main game scene and then pass the delta time to this system.

At the top of the GameScene declaration, under the previous properties, I’ll add:

 
lazy var systems : [GKComponentSystem] = {
	let render = GKComponentSystem(componentClass: NodeComponent.self)
	return [render]
}()

Then, at the bottom of the update(:) method, I’ll add:

 
for system in systems {
	system.update(deltaTime: delta)
}

This ensures that every component that is registered with a system receives the update.

Adding a Shape Component

I’ll define a protocol with a single method that returns the more specific SKNode subclass (e.g. SKLabelNode, SKSpriteNode) as a generic SKNode.

Any components that want to be included in the Node Component’s render system will have to conform to this protocol.

 
protocol ChildNode {
    func asNode() -> SKNode
}

This will make it easier in future for the Node Component to accept these more specific components without knowing anything about how these SKNode subclasses are stored within the component.

Below is a ShapeComponent that has a property called shape which is an instance of the SKSpriteNode. The Node Component doesn’t need to know about this property, it will only have to check that this sprite component conforms to the ChildNode protocol and then use its method to add the Shape Component’s node as a child:

 
// 1.
enum Shape {
    case circle, square
}
class ShapeComponent : GKComponent, ChildNode {
    let shape : SKShapeNode
    // 2.
    init( shape : Shape) {
        switch shape {
            case .circle:
            self.shape = SKShapeNode(circleOfRadius: 20)
            case .square:
            self.shape = SKShapeNode(rect: CGRect(x: 0, y: 0, width: 20, height: 20))
        }
	// 3.
        self.shape.fillColor = SKColor.purple
        super.init()
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("Not implemented")
    }
    // 4.
    func asNode() -> SKNode {
        return self.shape
    }
}
  1. This enum will define some preset shapes that the component can create in order to simplify creating shapes for this example.
  2. The initialiser will accept an enum member and then create a SKShapeNode of that enum member, assigning it to the component’s property.
  3. Set all the shapes to the same colour as a visual check that everything is working as it should.
  4. This protocol conformance method returns the SKShapeNode as it’s generic superclass as a convenience for the parent node component.

Adding a Position Component

I now have a generic node to act as a parent and a more specific node type that I might want to display in a scene. I can now add some attribute components to define how that node is displayed. I’ll start with positioning:

 
class PositionComponent : GKComponent {
    // 1.
    var currentPosition : CGPoint
    // 2.
    init(pos : CGPoint){
        self.currentPosition = pos
        super.init()
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("Not implemented")
    }
}
  1. This will define the position of the entity for any given frame and is a variable as it other components and systems might need to change it.
  2. The initialiser accepts a CGPoint, which defines the initial position of the entity.

For this example playground, I’ll use the tap location received from the touchesEnded(with:) method in the GameScene to set the initial location of an entity. I’ll start by creating a helper method:

 
func addNode(at point:CGPoint){
	// 1.
	let entity = GKEntity()
	// 2.
	let nodeComponent = NodeComponent()
	let shapeComponent = ShapeComponent(shape: .circle)
	// 3.
	nodeComponent.node.addChild(shapeComponent.asNode())
	// 4.
	let positionComponent = PositionComponent(pos: point)
	// 5.
	entity.addComponent(nodeComponent)
	entity.addComponent(typeComponent)
	entity.addComponent(positionComponent)
	// 6.
	self.entities.append(entity)
	// 7.
	self.addChild(nodeComponent.node)
	// 8.
	for system in systems {
		system.addComponent(foundIn: entity)
	}
}
  1. Create a generic entity.
  2. Initialise the node component and the shape component.
  3. Add the shape component’s shape property as a child of the node component’s node property using the protocol method I defined earlier.
  4. Initialise a position property passing through the CGPoint parameter of the method.
  5. Add all of these components to the entity.
  6. Add this new entity to the array of entities.
  7. Add the node component’s node property as a child to the scene (which will also add the node’s children—in this case the shape node—to the scene)
  8. Make sure that all of the components that belong to the entity are registered with the systems so that their update(deltaTime:) methods are called.

Then I’ll override the touchesEnded(with:) method in the GameScene class:

 
override func touchesEnded(_ touches: Set, with event: UIEvent?) {
	// 1.
	guard let touch = touches.first else {
		return
	}
	// 2. 
	let loc = touch.location(in: self)
	// 3.
	self.addNode(at:loc)
}
  1. Make sure there’s at least one touch and grab a reference to the first one.
  2. Get the location of the touch in the current scene.
  3. Call the helper method to add a new shape node to the scene.

Creating the Render System

If I run this Playground now, all the shapes will be created at 0,0. The position component is storing the correct position but the position is not currently being applied.

For this, I’ll need a render system:

 
// 1.
extension NodeComponent {
	// 2.
    override func update(deltaTime seconds: TimeInterval) {
		// 3.
        if let hasPos = self.entity?.component(ofType: PositionComponent.self)?.currentPosition {
            self.node.position = hasPos
        }
    }
}
  1. The render system is an extension of the node component (see my post about ECS).
  2. As I added this component to the systems array earlier, this update method will get called every frame with the time difference between this frame and the last
  3. If the entity that this component belongs to also has a position component, apply the current position to the node.

This will now apply the position component’s currentPosition property to the node which itself has the shape node as a child.

Expanding the Node Types

This was a lot of work to just position a shape node on screen, so let’s add a couple of different node types:

 
class LabelComponent : GKComponent, ChildNode {
    let label : SKLabelNode
    init(text: String) {
        self.label = SKLabelNode(text: text)
        super.init()
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("Not implemented")
    }
    func asNode() -> SKNode {
        return self.sprite
    }
}

Like the shape node, this one has a SKLabelNode property which is a constant. This property is created with the passed string on initialisation.

I also can’t forget the all-important sprite component:

 
class SpriteComponent : GKComponent, ChildNode {
    let sprite : SKSpriteNode
    override init() {
        sprite = SKSpriteNode(color: #colorLiteral(red: 0.854901969432831, green: 0.250980406999588, blue: 0.47843137383461, alpha: 1.0), size: CGSize(width: 100, height: 100))
        super.init()
    }
    required init?(coder aDecoder: NSCoder) {
        fatalError("Not implemented")
    }
    func asNode() -> SKNode {
        return self.sprite
    }
}

As this is being developed on an iPad, I’m going to skip using an actual sprite and just create a 100×100 pinkish square just to show it works.

Now that I have multiple node types, to demonstrate I’ll need a way of choosing between them.

I’ll define an enum that defines the available node types and has a convenience method that will return an array of all of the available types.

enum NodeType {
    case shape, label, sprite 
    static func allTypes() -> [NodeType] {
        return [.shape, .label, .sprite]
    }
}

Then, in the GameScene, I’ll add a new property that starts as an empty array of this NodeType:

 
var typeArray = [NodeType]()

Finally, in the addNode(at:) method, I’ll replace:

 
let shapeComponent = ShapeComponent(shape: .circle)
nodeComponent.node.addChild(shapeComponent.shape)

With:

 
// 1.
if self.typeArray.isEmpty {
	self.typeArray = NodeType.allTypes()
}
// 2.
let next = self.typeArray.popLast()!
// 3.
let typeComponent : GKComponent
// 4.
switch next  {
	case .shape:
// 5.
		typeComponent = ShapeComponent(shape: .circle) 
	case .label:
		typeComponent = LabelComponent(text: "Hello!")
	case .sprite:
		typeComponent = SpriteComponent()
}
// 6.
if let hasChild = typeComponent as? ChildNode {
	nodeComponent.node.addChild(hasChild.getNode())
}
  1. If typeArray is empty, repopulate it with the available types from the NodeType convenience method.
  2. At this point, I can be sure that the array is not empty so I can pop the last item and force unwrap it to get the next type in the array.
  3. Define a generic GKComponent object to hold the component I’m about to create.
  4. Switch on the type…
  5. And create a concrete subclass of the given type.
  6. Finally, I make sure that, whatever specific type was used to populate typeComponent (which was declared as a generic GKComponent), it conforms to the ChildNode protocol. If it does, the node component can add it as a child without knowing anything more about its implementation (i.e. it doesn’t care if it’s a label, a shape, or a sprite).

Specifying all cases of the enum will ensure that any new types I add will require me to initialise their related components here, but that’s all I’ll need to do. Conformance to the protocol will ensure that these new components will automatically have their specific SKNode subclass property added to NodeComponent parent node.

A screenshot of a Swift Playground showing code on the left and a grey field on the right filled with circles, squares, and text labels that say "Hello!"

Adding a Scale Component

One of the other issues I mentioned in my jigsaw puzzle post-mortem was that the way I implemented the scale component was overkill. I had basically re-implemented the SKAction manually, so this time I’m going to create a simpler ScaleComponent that uses an SKAction:

 
class ScaleComponent : GKComponent {
	// 1.
    var scaleAmountPerSecond : CGFloat = 1.5
	// 2.
    var targetScale : CGFloat = 1 {
        didSet {
		// 3.
            guard let hasNode = self.entity?.component(ofType: NodeComponent.self)?.node else {
                return
            }
		// 4.
            let distance = abs(targetScale - hasNode.xScale)
            let duration = TimeInterval(distance / scaleAmountPerSecond)
            // 5. 
            hasNode.removeAction(forKey: "scaleAction")
            let action = SKAction.scale(to: targetScale, duration: duration)
            hasNode.run(action, withKey: "scaleAction")
        }
    }
}
  1. This adjustable property defines how quickly an entity scales. In this case, it gets 1.5x bigger every second.
  2. This is the property that other systems will set to begin scaling a node.
  3. If the entity doesn’t also have a node component, then it can’t be scaled.
  4. Using the scale SKAction requires a duration. In order to calculate this, I get the absolute difference between the node’s current scale and target scale and then I divide this distance by the scale speed to get the time it should take.
  5. If the node is already running a scale action, stop it immediately. Set up a new action passing the targetScale and duration, then run it on the node component’s node (giving it a custom name so I only remove that action).

In the GameScene class, under let positionComponent = PositionComponent(pos: point), I’ll add:

 
let scaleComponent = ScaleComponent()
entity.addComponent(scaleComponent)

Then, just before self.entities.append(entity), I’ll add:

scaleComp.targetScale = 1.5

This will cause the shape to start growing bigger as soon as it’s added to the scene.

Finally, it would be nice if I could have some visual proof that the scale system is working correctly (i.e. that the scale animation stops at the moment of interruption and begins a new one, and that the speed remains consistent whatever scale it is animating to) . every time a new entity is added I want the last entity to shrink back down to a 1x scale.

At the start of touchesEnded(with:), I can add:

 
self.entities.last?.component(ofType: ScaleComponent.self)?.targetScale = 1

This will get the scale component (if there is one) of the last entity in the array (if there is one), and set its target scale to 1.

Animated gif showing the finished playground. A mouse pointer clicks, creating a shape or label which then scales up while another item on screen scales down.

The finished Playground is available on GitHub.

Why Create a Node Component Like This?

Clearly, this is a lot of work for an effect that could be achieved more directly with many fewer lines of code. While the benefits of using ECS really become apparent when dealing with much larger projects, here are some immediate advantages as I see them:

  • Any SKNode subclass can now be positioned, rotated, or scaled using the same code.
  • All of the code that defines the SKNode subclasses (e.g. the ShapeComponent, LabelComponent, etc.) as well as all the code that handles positioning, rotating, and scaling can be moved out of the main game scene and into smaller, more manageable, and easier to find files.
  • Whether an entity can be positioned, rotated, or scaled becomes dependent on whether it has the relevant component. Neither the main game scene or the systems need any knowledge about the entity as the entity itself is defining its own attributes and abilities. Using optional chaining, systems can attempt to change a property on any component on any entity and if that entity doesn’t have that component, nothing happens.

Having said that, I am still exploring the practical implications of ECS and how it will work managing large game scenes. I am especially interested if I can get it to work with things like physics engines—there are some tutorials that I’m interested in re-implementing using ECS to see if the advantages really hold up in more practical situations—and will be exploring those next.