Development Blog (semi)random.thoughts()

Loading and saving a game: GameplayKit and Codable protocol

GameplayKit is an Apple provided SDK that contains among others an easy-to-use Entity-Component system. I'm using it in a Roguelike I'm working on in my spare time, which I use to get a better grasp on developing in Swift.

While development in Swift with the Apple provided libraries is generally smooth sailing, getting saving and loading working was a lot more work. While most other features take me a few hours to implement, this one has taken days. This was mainly due to my lack of understanding of GameplayKit and (the limits) of protocol extension in particular. Because I could not found much (not enough for me?) information on this subject online, I'm sharing my experiences and solution here.

By no means is my solution the best, but at least it works. If you know of a better solution, the please let me know.

Archiving state in Swift


In general, saving and loading a game means saving and loading the state of a bunch of objects in your game. Saving and loading state of objects sounds a lot like serialization and archiving and fortunately Swift has some ready made solutions to do so.

In particular, the Codable protocol provides a standardized way of archiving and de-archiving objects. If you carefully design your data model, this can be as simple as adding the "Codable" protocol in the data type definition:

struct Map: Codable {

let width, height: Int
var cells: [Tile]

That's right: the compiler will create the required functions for you.

As all the relevant game state of my game is contained within an instance of the World class, my initial approach was to sprinkle Codable declarations around until the compiler stopped complying about missing Codable compliance. This approach actually got me more than half way! After about an hour or so, I had a pretty good looking JSON file describing about 70% of the world. So how much more work could it be to also get it to include the final 30% of relevant state?

Missing in the archived representation


The missing 30% of the relevant state can be divided into two different problems:

  1. The Entity Component system in GameplayKit (GKEntity and GKComponent) does not conform the the Codable protocol;
  2. The Codable protocol has no built in solution for polymorphism (not that I'm aware of anyway).

Let's tackle both problems one at a time (although they are related as we will see).

Getting GameplayKit to conform to Codable


For me, this meant two things. First, I was using a subclass of GKEntity (RLEntity) in my game to represent all entities in the game (currently: player, monsters and items). Getting the subclass to conform to Codable was very easy: I just declared the class as conforming to Codable. This archived most of the relevant state out of the box. However, the components that are associated with the entity were not archived.

As you can access the components that are associated with an instance of GKEntity in the components: [GKComponent] array, my initial approach was to also make GKComponent conform to Codable. I preferred not to subclass GKComponent, just to make it conform to Codable, as there was not anything special I wanted from GKComponent (logic is in further subclasses like HealthComponent, FighterComponent and AIComponent) other than Codable conformance. And also, Swift has a better way of adding protocol conformance to an existing type: Extensions.

I already used an extension to GKComponent to be able to quickly get the owning RLEntity, so my first instinct was to use the same pattern to add Codable conformance:

extension GKComponent: Codable {

}

However, this would have been too easy:


No worries, to conform to Codable, you only need to implement two functions: func encode(to encoder: Encoder)and init(from decoder: Decoder). Implementing encode(to: ) is pretty straight forward, but implementing init(from: ) isn't:



  • As there is no way I can access the original source file of GKComponent, there is no way I can use an extension to declare the required initializer. I've tried several ways to fix this error:
  • creating a convenience initializer (as per the "Fix" button). This silences the issues, but does not provide protocol conformance;
  • adding the initialized in all subclasses go GKComponent (and making those conform to Codable). Now I have all this duplicate code and still can't rely on encoding the components array in GKEntity;
  • migrate to using NSCoder instead of Codable. This however only works for classes, but not structs (which I also have);
  • I even started working on my own entity-component system that supported Codable from the get go. I quickly abandoned it. Not because it was difficult, but felt like the worst solution: IMHO it's only a matter of time before GameplayKit support Codable out of the box. Sticking with GameplayKit would mean some working around now, but set me up better for the future.

In the end, the only usable solution I could find was to subclass GKComponent into an (abstract) class RLComponent and make the subclass conform to Codable. All concrete classes now subclass from RLComponent instead of GKComponent. Apart from not being very "Swift-like", it adds another layer of indirection because entities will have a member components: [GKComponent] and not [RLComponent].

This solution at least solves one part of the problem: it is now possible to encode all the components associated with an entity into a JSON file. Do note, this requires you to cast all components to RLComponent manually. Something like:

var rlComponents = [RLComponent]()
for component in components {
rlComponents.append(component as! RLComponent)
}
try container.encode(rlComponents, forKey: .components)

Concrete subclasses of RLComponent that contain additional data can override the encode(to: ) method that RLComponent provides. When encoding the components from an entity they show in the resulting JSON file. So far so good…

Polymorphism and Codable protocol


The encoding of the components array in GKEntity (and therefore also RLEntity) already shows working polymorphism: the concrete component subclasses can provide their own encode(to: ) function that gets correctly called when iterating over the components array. So where is the problem?

The problem is in decoding.
The resulting JSON does not contain type information. Therefore, when decoding, we don't know what type we are decoding. We have to specify the type that we want to decode.

My first attempt to decode did something like this:

required init(from decoder: Decoder) throws {

let values = try decoder.container(
keyedBy: CodingKeys.self)

// decode other properties

super.init()

let rlComponents = try values.decode([RLComponent].self,
forKey: .components)
rlComponents.forEach { self.addComponent($0) }

}

This almost works. The one issue is that you lose concrete types. I.e. you will an instance of RLComponent for every component you decode, but you will have lost whether they were HealthComponent, FighterComponent, InventoryComponent, … Because Swift has no way of knowing what subtype it should decode as.

This is immediately clear for the components, but it happens everywhere you have an array of some generic type that is filled with instances of subtypes of that generic type. For instance, it happens also in my World class that has an array of all the entities in the game:

var entities: [RLEntity]

This array contains instances of RLEntity, as well as instances of subclasses of RLEntity such as Player and Item. Although encoding preserves all the additional data we have in the subclasses, after decoding we only have instances of RLEntity. Unfortunately, I found only one approach for this issues and that is to encode the type of instance together with all the data for that instance.

To do so, I use a two step approach:
  1. First, I make a dictionary that maps from the concrete type (as a string) to the encoded objects data (as a base64 string): [String, [String]].
  2. Then, I encode the resulting dictionary as the actual save data.

In encoding, this looks like:

var codeEntities = [String: [String]]()
for entity in entities {
let typeName = String(describing: type(of: entity))
let newCoder = JSONEncoder()
let data = try newCoder.encode(entity)

if codeEntities.keys.contains(typeName) == false {
codeEntities[typeName] = [String]()
}
codeEntities[typeName]?.append(data.base64EncodedString())
}

try container.encode(codeEntities, forKey: .entities)

When decoding, this looks like:

let loadedEntities = try values.decode([String: [String]].self, 
forKey: .entities)

let newDecoder = JSONDecoder()
for type in loadedEntities.keys {
let loadedEntityArray = loadedEntities[type]!
switch type {
case "Item":
for loadedEntity in loadedEntityArray {
let item = try newDecoder.decode(Item.self,
from: Data(base64Encoded: loadedEntity)!)
entities.append(item)
}

default:
for loadedEntity in loadedEntityArray {
let rlEntity = try newDecoder.decode(RLEntity.self,
from: Data(base64Encoded: loadedEntity)!)
entities.append(rlEntity)
}
}
}

Note: in case you know that you only want to encode one of any concrete type (which is the case for components), then you can simplify the code somewhat by using a dictionary that maps from type (as string) to encoded object (as base64 string): [String: String]

As far as I can see, this approach has one big disadvantage: when you encode like this, you will get base64 in your JSON. Now, Swift don't care, but it looks like this:



You will need to use a base64 converter to see exactly what was encoded (encoded). If you know a way how you can preserve readable JSON, I'd be very happy to hear from you.

Conclusion


So, does this work? Yep it does:


I find it a reasonably elegant solution because I can still use the auto-synthesis functions from Codable where available. I also get a JSON file describing the game state I can mostly read or use a base64 converter to look into further.

I hope that Apple adds direct support for polymorphism in the future. That would be the most important improvement. I can live with rolling my own subclasses of GKComponent and GKEntity to provide Codable conformance.

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