Codable conformance for ECS system revisisted
In the post I demonstrated a way of adding
Codable
conformance to the GameplayKit ECS system. For another game I'm working on (HexEngine, source here: https://github.com/maartene/HexEngine) I experimented with a different solution, that I now feel is more elegant.The problem
First,
Codable
is amazing! Second, more often than not, getting Codable
conformance is just a matter of declaring the protocol in your type definition. Example:Struct MyStruct: Codable {
…
}
The automatic synthesis of
encode(to:)
and decode(from:)
methods, fails when inheritance (classes) and protocols are involved, which is typically the case when you have an Entity Component System. Consider a
Component
protocol:protocol Component: Codable {
var ownerID: UUID { get }
// other properties
// ...
}
Also, assume that all structures and classes that implement this protocol conform to
Codable
. And a
Unit
struct that retains (among others) a list of components:struct Unit: Codable {
var array: [Component]
// other properties
// ...
}
This will give the following error:
Why this behaviour? After some experimenting with instances of structs conforming to the
Component
protocol and the protocol itself, I finally found this error message:The problem is when decoding. Swift can't decode to a protocol, because you can't make an instance of a protocol.
The array in
Unit
does not know about any concrete types, and the Swift compiler is smart enough to understand that instantiating of a protocol is not possible and thus decoding to a protocol is impossible. But I still want an array of
Components
. I don't want optionals for all possible components. I.e.:struct Unit: Codable {
var buildComponent: BuildComponent?
var attackComponent: AttackComponent?
var healthComponent: HealthComponent?
...
}
HexEngine currently has 6 components. Not a lot, but this will grow over time, so this is not a very scalable solution. Especially if every component has something like an
update(in:)
method. func update(in world: World) {
buildComponent?.update(in: world)
attackComponent?.update(in: world)
healthComponent?.update(in: world)
...
}
There has to be a better way…
My solution: a ComponentWrapper enum
The most elegant solution I found so far is using an
Enum
as a wrapper for the concrete types that conform the protocol. The Enum
wraps the components using associated values. In my HexEngine for instance, I have a ComponentWrapper
enum with the following cases:enum ComponentWrapper: Codable {
case attackComponent(value: AttackComponent)
case buildComponent(value: BuildComponent)
case healthComponent(value: HealthComponent)
case movementComponent(value: MovementComponent)
case settlerComponent(value: SettlerComponent)
case growthComponent(value: GrowthComponent)
}
This wrapper contains the members to conform to
Codable
:So, we need custom coding keys:
enum CodingKeys: CodingKey {
case type
case value
}
An
encode(to:)
function:func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .attackComponent(let value):
try container.encode("attackComponent", forKey: .type)
try container.encode(value, forKey: .value)
case .buildComponent(let value):
try container.encode("buildComponent", forKey: .type)
try container.encode(value, forKey: .value)
// other cases
...
}
}
And a decode initializer:
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let type = try values.decode(String.self, forKey: .type)
switch type {
case "attackComponent":
let value = try values.decode(AttackComponent.self, forKey: .value)
self = .attackComponent(value: value)
case "buildComponent":
let value = try values.decode(BuildComponent.self, forKey: .value)
self = .buildComponent(value: value)
// other cases
...
default:
throw ComponentWrapperErrors.cannotConvertComponentError
}
}
Note that although the
ComponentWrapper
is now conformant to Codable
, there is no way to convert between the wrapper and the actual components. To do so, we need two more functions:A function to wrap a component:
static func wrapperFor(_ component: Component) throws -> ComponentWrapper {
if let c = component as? AttackComponent {
return .attackComponent(value: c)
} else if let c = component as? BuildComponent {
return .buildComponent(value: c)
} else if let c = component as? ... other components {
...
} else {
throw ComponentWrapperErrors.cannotConvertComponentError
}
}
And a function to get the component back from the wrapper:
func component() throws -> Component {
switch self {
case .attackComponent(let component):
return component
case .buildComponent(let component):
return component
// other cases
...
}
}
Note again that this requires that all the types you want to wrap also conform to
Codable
. If the component only contains properties that all automatic synthesizing of Codable
conformance, this is a trivial activity. Otherwise, it requires the regular dance of setting CodingKeys
and manually implementing encode(to:)
and decode(from:)
.Using the ComponentWrapper
Let's go back to our Unit struct:
struct Unit: Codable {
var components: [Component]
// other properties
// ...
}
Creating the
ComponentWrapper
enum alone does not automatically make Unit
conform to Codable
. Unit still requires a custom Codable
implementation, because we need to bridge from Component
to ComponentWrapper
:extension Unit: Codable { // Simplified, actual struct contains more properties
// First, we need a custom CodingKeys enum
enum CodingKeys: CodingKey {
case id
case components
}
// Custom encode function:
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
// convert the [Component] array to a [WrappedComponent] array.
let wrappedComponents = try components.map { component in try ComponentWrapper.wrapperFor(component) }
// encode the [WrappedComponent] array.
try container.encode(wrappedComponents, forKey: .components)
}
// Custom decode initializer
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(UUID.self, forKey: .id)
// decode the [WrappedComponent] array
let wrappedComponents = try values.decode([ComponentWrapper].self, forKey: .components)
// convert the [WrappedComponent] array to a [Component] array
components = try wrappedComponents.map { wrappedComponent in try wrappedComponent.component() }
}
}
There's a lot more to say about the ECS system I use in HexEngine. There is a lot more to say about "Codable" wrappers: I also use a
CommandWrapper
to be able to load/save commands. And I also wrote a simple "AnyWrapper" you can use (https://gist.github.com/maartene/94ccc4981ceae1daf51762f1e4d635b3), for instance to encode/decode a [String: Any]
dictionary.