Message Passing in Web Workers

Once I’d implemented the Web Worker split, I had a new problem: How do I pass the messages back and forth? The main issues here is that I have to send the game state from the worker and the web page, and send requests for player interaction to the worker.

Message Object

The first thing I did was sort out the messages into a default message structure that both side use: this consisted initially of an object which contained a string to indicate the class of the message and a message body that was another object. The class name was implemented simply as messageObject::class.simpleName which is fine in javascript.

I hit my first problem quite quickly here: It appears that Kotlin can’t use the object being passed around between the two programs as the receiver doesn’t know what the object is, and I didn’t seem to be able to cast it to anything. In my first experiments I had only sent strings, and these seem fine, so presumably I should just encode the object as a string.

Encoding an object to a string should be easy, right? This particular point is javascript specific, so I can use the JSON object to encode. If not, there’s bound to be a library I can use.

JSON Messages

Unfortunately, nothing is ever simple. Firstly, I haven’t worked out how to use a library in Kotlin yet. There’s lots of instructions for java libraries, but I’m not targeting java. Also, the way I have deployed the worker means that I have to copy the kotlin library into the worker; in this case would I have to also copy in the extra library? It would be a lot simpler if I can just use the standard library.

So I went for the JSON object, using JSON.stringify and JSON.parse. This did work up to a point, but a critical issue (that may be a bug perhaps) is that when you parse to recover an object, it does not restore the object completely; in particular it is not actually of the right class – it has the correct data, but you can’t call any methods. This can be solved by using the data to create a new object of the correct class, and if the data is just a bunch of strings, this really doesn’t matter too much.

So I have a default message structure. Here we have a sub-message object that always has a decode and encode object that converts the message object into an string and back again. The convertFromJson has to be in companion object (Kotlin’s version of static) and because that function doesn’t return a real object, the extractObject has to also be static.

class WorkerMessage(messageObject: IWorkerMessage) {
    val messageType : String? = messageObject::class.simpleName
    val message : String = messageObject.encode()

    fun convertToJson(): String {
        return JSON.stringify(this)
    }

    companion object {
        fun convertFromJson(jsonString: String): WorkerMessage {
            return JSON.parse(jsonString)
        }

        fun extractObject(message: WorkerMessage): IWorkerMessage? {
            return when(message.messageType) {
                RequestUpdateMessage::class.simpleName ->
                    RequestUpdateMessage.decode(message.message)
                SetPlayerActionMessage::class.simpleName ->
                    SetPlayerActionMessage.decode(message.message)
                StateUpdateMessage::class.simpleName ->
                    StateUpdateMessage.decode(message.message)
                else -> null
            }
        }
    }
}

Encoding and Decoding the Message Object

Originally I just was going to use the JSON object to encode the message object as well, but there are some issues here:

  • I can’t encode the whole object at once, as I can’t recover it all at once.
  • If I encode everything with json, I’m going to get a lot of json encoded json, which means you end up a lot of escaped quotes. It looks ugly and isn’t particularly efficient.
  • I can’t encode the game entities (eg an actor) separately using the JSON object if I want them to be cross platform (which I do!)
  • There may be things that would be inefficient to encode as json.

That being said, we can use the same strategy as the message carrier object for the simple messages as these really are javascript specific

data class SetPlayerActionMessage(
        val type: ActionType,
        val resource: ResourceType) : IWorkerMessage {

    override fun encode(): String {
        val thisObj = this
        val proxy = object {
            val type: String = thisObj.type.toString()
            val resource: String = thisObj.resource.toString()
        }
        return JSON.stringify(proxy)
    }

    companion object {
        fun decode(jsonString: String) : SetPlayerActionMessage {
            var decoded = JSON.parse<dynamic>(jsonString)
            return SetPlayerActionMessage(ActionType.valueOf(decoded.type), ResourceType.valueOf(decoded.resource))
        }
    }
}

With this object, I can pass a request to change the player action easily enough.

Encoding the Game State

This is where things get more complex. Without any libraries or the json encoder, I need to be able to serialise and deserialise every game entity object as or from a string.

What I’ve decided to do here (and I’m not sure it’s the best decision) is to use a microformat. Essentially I’m going to declare a set of characters that are always used for encoding the string and can’t be used in strings in the game state (we can add escape characters for those if we have any player input).

I’m going to use the characters I don’t expect people to use often on my keyboard as reserved: `¬¦| and possibly £ or €. Each encoded object will start and end with a character, such as |`<encoded>`| – this is effectively brackets so if there is an encoded object that has another encoded object in it, I know where the start and end are (so if I’m splitting a string I can ignore anything in a sub-object).

For my initial experiment I encoded the resources map as |`RESOUCE¬AMOUNT¦RESOURCE¬AMOUNT`|. This is a fairly simple microformat for a map (I’ll think more about this and it’ll probably change), but a naive parsing of this is relatively simple.

class ResourceStore : Entity() {
    val resources = HashMap<ResourceType, Resource>()

    override fun encode() : String {
        val encoded = this.resources.map{kvp -> "${kvp.key}¬${kvp.value.encode()}"}.joinToString("¦")
        return "|`$encoded`|"
    }

    companion object {
        fun decode(encoded: String): ResourceStore {
            val result = ResourceStore()
            val trimmed = encoded.substring(2, encoded.length-2)
            val array = trimmed.split('¦')

            for(item in array) {
                val parts = item.split('¬')
                if(parts.count() == 2) {
                    val type = ResourceType.valueOf(parts[0])
                    result.resources[type] = Resource.decode(parts[1])
                }
            }

            return result
        }
    }
}

Note here we use Resource.decode and Resource.encode – for this experiment I made the resource decode not include my special characters yet so I can use split rather than parsing the microformat correctly.

Conclusions

This approach works – I can send a RequestState or SetPlayerAction messages to the worker, and I can send a UpdateStateMessage that contains the resources object back.

I’m happy with the message passing structure – there’s some corners to iron out, but it seems like it will be easy to get new messages into the system.

I have some concerns about doing too much work writing my own serialisation system for the game state entities, but realistically this is not a time limited commercial project and I am quite happy to write a microformat. It might be possible if I knew more about Kotlin to use a library to do this. That being said, one big advantage of this entity serialisation format is that I can now use that for the save game.

The next steps here would be to write a better library for parsing the save format – I shouldn’t have to do anything too clever to write the encode and decode functions for the entities. I also need to get the create building message through.


Leave a Reply

Your email address will not be published. Required fields are marked *