As I’ve been working with Entity Component Systems in SpriteKit, I’ve come to realise that Apple’s implementation of the pattern is subtly different to what I understand is the ‘pure’ form of this pattern.
The idea of ECS (as far as I understand) is that an entity is nothing more than a unique ID. You could represent an entity as a raw integer.
Components are a collection of properties (or state) that give that ID some meaning in the world. You should be able to accurately represent both entities and components in a relational database.
Finally, Systems are what change the properties on the components to create the game. A physics system might change some position properties or an animation system might change a texture property.
The important thing is that the components do not change their properties, the systems do.
SpriteKit’s Implementation
Reading through the docs and sample code, there isn’t this strict separation in SpriteKit.
The framework does have a class called GKComponentSystem
which does define the order in which components are updated. This is an important aspect of systems as the order in which systems are applied matters: a movement system will need to be run before a render system to make sure that the sprite’s position properties have been updated before the sprite is redrawn.
However, GKComponentSystem
is only designed to work on a single component type. When initialising a GKComponentSystem
, you pass it a GKComponent
type as an argument and that system then ensures that all of the components of that type are updated in the order you specify.
let moveSystem = GKComponentSystem(componentClass: MoveComponent.self)
The docs state that the actual logic of the component systems should then reside in the components themselves:
The component system then forwards to the update(deltaTime:) method of all the GKComponent subclass instances it manages, allowing those objects to perform per-frame update logic.
Their demo apps have also implemented things this way.
Refactoring Components
In order to help my understanding of ECS, I have started using Swift extensions on my components. The main class declarations contain all the properties then an extension is declared that contains the methods if I want to give that component an attached system.
My rule of thumb is that, if I’m overriding the update(deltaTime:)
method of a component, then that’s a good sign that I’m actually creating a system and it should go in the extension.
I still use the GKComponentSystem
classes but strictly as a way of dictating the order in which my systems are updated.
For example, I’ve created a PositionComponent
with a single property:
class PositionComponent { var position : CGPoint = .zero }
This component simply gives the entity a position in the world.
Then if I add a SpriteComponent that looks like this:
class SpriteComponent: GKComponent { let sprite : SKSpriteNode }
This component gives an entity a visual presence in the world.
To combine the two and apply the position to the sprite I should have some sort of render system.
In order to make the fact that this is a system explicit, I create an extension that looks like this:
extension SpriteComponent { override func update(deltaTime:seconds) { if let hasPosition = self.component(ofType:PositionComponent.self).position { self.sprite.position = hasPosition } } }
I store this extension in a separate file called RenderSystem to make the distinction between the component and the system even clearer.
Adding Additional Systems
Taking the MoveComponent
from my previous post, I break it up into MoveComponent.swift
(which just contains the properties):
class MoveComponent { // The `currentPosition` is set to the entity's position when the MoveComponent is first added to the entity. var currentPosition : CGPoint = .zero var target : CGPoint? var walkableArea : CGRect }
And MovementSystem.swift
:
extension MoveComponent { func calculateScale(for target: CGPoint) -> CGFloat { let difference = maxScale - minScale guard difference > 0 else { return maxScale } let normalisedPosition = (walkableArea.height - (target.y - walkableArea.origin.y)) / walkableArea.height let newScale = minScale + ( difference * normalisedPosition) if newScale < minScale { print("Error: Scale out of range \(newScale).") } else if newScale > maxScale { print("Error: Scale out of range \(newScale).") } return minScale + ( difference * factor) } func move(to target : CGPoint ) { self.target = target // Additional pathfinding logic } override func update(deltaTime seconds: TimeInterval) { guard var hasTarget = target else { return } let offset = CGPoint(x: hasTarget.x - sprite.position.x, y: hasTarget.y - spriteComp.sprite.position.y) let length = sqrt(Double(offset.x * offset.x + offset.y * offset.y) ) if length <= 0.05 { entity?.component(ofType: PositionComponent.self).currentPosition = hasTarget hasTarget = nil return } let normalisedVector = CGPoint(x: offset.x / CGFloat(length), y: offset.y / CGFloat(length)) let scaledMovementSpeed = moveSpeedInPointsPerSecond * calculateScale(for: currentPosition) let velocity = CGPoint(x: normalisedVector.x * scaledMovementSpeed * CGFloat(seconds), y: normalisedVector.y * scaledMovementSpeed * CGFloat(seconds)) let velocityLength = sqrt(Double(velocity.x * velocity.x + velocity.y * velocity.y) ) if length < velLength { entity?.component(ofType: PositionComponent.self).currentPosition = hasTarget hasTarget = nil return } let nextPos = CGPoint(x: sprite.position.x + velocity.x, y: sprite.position.y + velocity.y) currentPosition = nextPos entity?.component(ofType: PositionComponent.self).currentPosition = nextPos } }
With this, we begin to see the real power of components. Now, movements are no longer restricted to just sprites as they were before. Any entity that has a PositionComponent
can now be made to move by adding a MoveComponent
to the same entity.
The movement system updates the position component’s properties and that’s all it does. How that property is then applied to change something on screen is left up to an entirely different system (in this case, the render system).
Finally, putting it all together, in the main scene file I create an array of systems that dictate the order in which things will be updated:
lazy var componentSystems: [GKComponentSystem] = { let moveSystem = GKComponentSystem(componentClass: MoveComponent.self) let renderSystem = GKComponentSystem(componentClass: SpriteComponent.self) return [moveSystem, renderSystem] }()
In the update(_:)
method, I loop through the systems and forward on the delta time:
override func update(_ currentTime: TimeInterval) { dt = currentTime - lastUpdateTime lastUpdateTime = currentTime for componentSystem in componentSystems { componentSystem.update(deltaTime: dt) } }
This ensures that the move system is called before the render system.
Separating out the systems from the components like this while still using the classes that GameplayKit provides has given me a deeper understanding of how ECS works and the advantages of it. AdventureKit is already reaping the rewards—features that I thought would have to wait for future versions have become trivially easy to implement just by combining different components.