Development Blog (semi)random.thoughts()

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:

Schermafbeelding 2020-03-31 om 11.33.00

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:

Schermafbeelding 2020-03-31 om 11.38.05

The problem is when decoding. Swift can't decode to a protocol, because you can't make an instance of a protocol.

Schermafbeelding 2020-03-31 om 11.40.58

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.

We value your privacy. This site collects only information required to provide you this Service. For more information, read our privacy policy.