Easy Method to Transfer Objects in RPC Calls?

Godot Version

Godot Engine v4.4.stable.arch_linux

Question

Hey all!

I’ve been looking around for an easy way to transfer object data via RPC calls, but I haven’t found a solution that feels completely satisfactory. I’m hoping someone here can suggest a better method than what I have. I came up with a hacky solution last night. It is functional, but I’m not in love with it.

Right now, my loose requirements are:

  • Does not require defining each property of an object as parameters for the function.
  • Communicates (as clearly as possible) what data is expected as input.
  • Does not introduce RCE when deserializing messages.
  • Validates values are of the expected type (i.e. cannot provide a callable when it should be an int).

My Existing Solution

How it works

The process is:

  1. Sender serializes a message to a dictionary via the serialize method →
  2. Sender sends message via RPC to receivers →
  3. Recipient create a new object (of the expected message) →
  4. Recipient uses each key/value from the message to assign properties to their appropriate values via the deserialize method. →
  5. Recipient performs some action based on data from the message.

Messages

Messages (data class) are created and implement const PROPERTIES: Array[String] defining which properties to serialize. As an example, here is my CastResquest class.

class_name CastRequest

# This approach seems reasonable for a few reasons:
#     1. How to serialize classes only needs to be defined once and is easy to see.
#     2. It ensures only the expected properties are set when deserializing.
#     3. As long as the property types are statically set,
#         we get validation when deserializing.

const PROPERTIES: Array[String] = [
        "skill",
        "caster",
        "target",
]

var skill:      String
var caster:     String
var target:     String


func _init(skill_file: String = '', caster_node: String = '', target_node: String = ''):
        self.skill      = skill_file
        self.caster     = caster_node
        self.target     = target_node

Serializing Messages

Message objects are created, serialized, and then sent via RPC. Here a player creates a CastRequest, serializes it, and tells everyone to add it to their cast queue.

func cast_skill() -> void:
        var request := CastRequest.new(skill.file, caster.name, caster.target.name)
        GameManager.queue_cast.rpc(Network.serialize(request))

RPC Calls (Deserialization)

Messages are then received, deserialized to the expected message type, and some action is performed by the recipient. This function ingests a CastRequest (shown on the first line of the function) and pushes it to the queue.

@rpc("any_peer", "call_local", "reliable")
func queue_cast(message: Dictionary) -> void:
        var request = Network.deserialize(CastRequest.new(), message)

        # TODO: !!! Check if sender has authority over this caster !!!

        Logger.info('Queued cast.', {'skill': request.skill,'target':request.target,'caster':request.caster,'sender':multiplayer.get_remote_sender_id()})

        cast_queue.push_front(request)

Note: I have a custom logging solution to help identify which peer the output came from. This is the Logger class. These are just fancy print statements. I should look at the built in Log class. I had this lying around though, so was faster to use this in :slight_smile:

Serialization Method

This method takes an object and uses the PROPERTIES Array[String] property to identify which properties should be serialized. Finally, it returns a Dictionary of property names mapped to their values.

# Used by the sender before transferring via RPC.

static func serialize(obj: Object) -> Dictionary:
        var dict = {}

        if not obj:
                Logger.error("Tried to serialize a null Object! An empty Dictionary will be returned.")
                return dict

        var properties: Array[String] = obj.get("PROPERTIES")

        if not properties:
                Logger.error("Tried to serialize an Object without a PROPERTIES property! An empty Dictionary will be returned.")
                return dict

        for property in properties:
                var value = obj.get(property)

                if not value:
                        Logger.error("Did not find property when serializing Object! Please ensure the values in your property array are up to date.", {"property":property,"all properties":properties})
                        continue

                if value is Array or value is Dictionary:
                        dict[property] = value.duplicate()
                else:
                        dict[property] = value

        return dict

Deserialization Method

Opposite to serialize, deserialze takes in a message template as obj and the received message. It then tries to map each expected property from that message type to the value defined in the message, and returns the modified Object.

# Used by the receiver when processing the RPC call.

static func deserialize(obj: Object, data: Dictionary) -> Object:
        var dict = {}

        if not obj:
                return null

        var properties: Array[String] = obj.get("PROPERTIES")

        if not properties:
                Logger.error("Did not find PROPERTIES on object when deserializing! returning null.")
                return null

        for property in properties:
                var value = data.get(property)

                if not value:
                        Logger.error("Property not found on object when peforming deserialization! Please ensure the values in your property array are up to date.", {"missing property":property, "all properties":str(properties)})
                        continue

                obj.set(property, value)

        return obj

I’m not sure you can do much better than this in the general case. Pushing generic objects over the wire like this is not a common use case in games, for a variety of reasons:

  • usually you’re dealing with a fixed set of predefined things, so you can use shorthand rather than sending fully qualified objects
  • this kind of flexibility in communications is ripe for abuse by cheaters
  • sending full-fat objects eats bandwidth and adds latency
  • if the object has an associated script, all sorts of fun remote exploits are on the table
  • &c…

I assume your use case needs full object transfer, so obviously do what you need to do, but I think you’ll find it’s a rare enough requirement that you’re charting new territory.

Good points all around!

I’m not looking to send full objects over the network though. Only specific properties need to be sent, hence the property schema system in place. Thinking on it more, this is over engineered. Even if I wanted to do something like this, implementing the serialization logic at the class level is the way to go.

So like…

class_name MessageCastRequest

var skill:  String
var caster: String
var target: String


func _init(skill_file: String, caster_node: String, target_node: String):
        self.skill  = skill_file
        self.caster = caster_node
        self.target = target_node


func serialize() -> Dictionary:
        return {
                "skill": skill,
                "caster": caster,
                "target": target,
        } 


static func deserialize(data: Dictionary) -> MessageCastRequest:
        return MessageCastRequest.new(
                data.get("skill"),
                data.get("caster"),
                data.get("target")
        )

The goal is to have tightly defined messages which are easy to understand. Just creating a new instance of some message object with the function parameters is more clear.

@rpc("any_peer", "call_local", "reliable")
func queue_cast(skill: String, caster: String, target: String) -> void:
        var request := MessageCastRequest.new(skill, caster, target)

It also doesn’t require an understanding some weird custom serialization process. I’m more confident this is the way to go.

Thanks for the response!

1 Like