Using a C# List for Godot RPC

Godot Version

4.4-stable-mono

Question

I’m trying to convert between a C# List of type QuestionCategory (a class I created to serialise/deserialise JSON) and a Godot Array.

This doesn’t work because the type I have created isn’t a variant type, however I’m using Godot’s RPC for multiplayer features, which require variant types for serialisation across the wire, so I’d like to know what my options are for serialising this C# List of a custom class for use with RPC.

An option I’m aware of would be to send the raw JSON string to the client and serialise it there, but it feels like more work that the client could do without…

QuestionCategory class:

public class QuestionCategory
{
    public string Category { get; set; }
    
    public Godot.Collections.Dictionary<int, string> Questions { get; set; }
}

Serverside assignment of questions variable:

private void DeserialiseQuestions()
	{
		if (!LifecycleState.IsServer) return;
		
		LifecycleState.MightFailFatally(() =>
		{
			string questionsFile = File.ReadAllText("questions.json");
			_questions = JsonSerializer.Deserialize<List<QuestionCategory>>(questionsFile);
		});
	}

Clientside assignment of questions variable passed from the server:

[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = false, TransferMode = MultiplayerPeer.TransferModeEnum.Reliable)]
private void TransferQuestions(List<QuestionCategory> questions)
{
	_questions = questions;
}

This RPC call doesn’t work since the C# List isn’t a variant:

RpcId(Multiplayer.GetRemoteSenderId(), MethodName.TransferQuestions, _questions);

Something like this also unfortunately won’t work:

Godot.Collections.Array _marshalledQuestions = new Array(_questions.AsEnumerable());

Any alternative solutions would be much appreciated!

To serialize a list of custom objects via RPC in Godot, you can use a Dictionary or Array with primitive types instead of a C# List. For example, pass a Dictionary with keys and values ​​serialized as strings or numbers, or manually convert a List to a Godot Array using an intermediate step (e.g. serialize to JSON and deserialize on the client).

The structure of the QuestionCategory class must be maintained so that it matches the pattern of the JSON file containing the serialized data, so I’m not sure how I’d be able to convert the whole object to primitive types within a dictionary or array and reconstruct it…

Maybe it is just worth sending the raw JSON data to the client and having them deserialize it instead of skipping that step and sending the array of populated objects directly.

If you make your QuestionCategory thing into a Godot Resource, it becomes serializable by the engine. That should let you use a binary format over the wire, which is preferrable for performance.

public partial class QuestionCategory : Resource
{
    public string Category { get; set; }
    
    public Godot.Collections.Dictionary<int, string> Questions { get; set; }
}

public partial class Main : Control
{
    private List<QuestionCategory> _questions;

    private void TransferQuestions(Godot.Collections.Array<QuestionCategory> questions)
    {
        // convert to C# type because they work way better than Godot's
        _questions = questions.ToList();
    }
}

You might have to change string to StringName and mark them as [Export] properties so that Godot knows to serialize them.

public partial class QuestionCategory : Resource
{
    [Export]
    public StringName Category { get; set; }
    
    [Export]
    public Godot.Collections.Dictionary<int, StringName> Questions { get; set; }
}

And you might run into some problems with your JSON library because now it will have all the members from Resource. You’d have to use a lib that has explicit include and the default C# library doesn’t do that. I think Newtonsoft Json.Net has that feature, but I haven’t tested it. I decided to use Godot’s built-in serialize for the time being, which lets you save files as .tres and .res. If you mark it as [GlobalClass] then you can create them in the inspector GUI, like the built-in ones.

For the network thing, it should work once Godot can serialize it.

Here’s a technique I used to cover up Godot’s bad array implementation:

[GlobalClass]
public partial class MusicSheet : Resource
{
    // in the .tres file, you will see an array called 'notes' because it's exported.
    // when the engine sets this private property, it makes
    // the data available in a C# list that I can sort and search.
    // MusicNote is another Resource.
    [Export] private Godot.Collections.Array<MusicNote> notes {
        get => [.. Notes];
        set
        {
            Notes.Clear();
            Notes.AddRange(value);
            Notes.Sort();
        }
    }

    public List<MusicNote> Notes { get; init; } = [];
}

Thank you for your detailed response! I have made QuestionCategory extend from Resource and added the Export attribute as you stated, it now looks like this:

using System.Collections.Generic;
using Godot;
using Newtonsoft.Json;

namespace Jeopardy;

[JsonObject(MemberSerialization.OptIn)]
public partial class QuestionCategory : Resource
{
    [JsonProperty]
    [Export]
    public string Category { get; set; }
    
    [JsonProperty]
    [Export]
    public Godot.Collections.Dictionary<int, string> Questions { get; set; }
}

I have also set my members to be explicitly serialised to avoid serialising any Resource members. The RPC call is now happy that QuestionCategory extends Resource and is now a variant-type object, however, on the receiving end of the RPC call, instead of returning a Godot array of QuestionCategory objects, the array is being received as an array of EncodedObjectAsID objects, which I cannot implicitly or explicitly cast to a QuestionCategory object.

Attempting to implicitly cast the objects to QuestionCategory objects by iterating through them using:

foreach (QuestionCategory category in questions)

yields this error:

ERROR: System.InvalidCastException: Unable to cast object of type 'Godot.EncodedObjectAsId' to type 'Jeopardy.QuestionCategory'.

The array is printed as:

[<EncodedObjectAsID#-9223371997075995249>]

The TransferQuestions method looks like this now, and the ToList call also yields an InvalidCast exception from GodotObject to QuestionCategory

[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = false, TransferMode = MultiplayerPeer.TransferModeEnum.Reliable)]
private void TransferQuestions(Godot.Collections.Array<QuestionCategory> questions)
{
	_questions = questions.ToList();
	GD.Print($"received questions from server: {questions}");
	// tried an explicit cast here
	// _questions.Add((QuestionCategory)InstanceFromId(questions[0].GetInstanceId()));
}

I’m not sure if this is just due to my limited knowledge with serialising resources, or resources in general for that matter, but any more help on this issue would be much appreciated, and I can upload any other code snippets which are required.

The metedata documentation for EncodedObjectAsId says:

namespace Godot
{
    //
    // Summary:
    //     Utility class which holds a reference to the internal identifier of an Godot.GodotObject
    //     instance, as given by Godot.GodotObject.GetInstanceId. This ID can then be used
    //     to retrieve the object instance with @GlobalScope.instance_from_id.
    //
    //     This class is used internally by the editor inspector and script debugger, but
    //     can also be used in plugins to pass and display objects as their IDs.
    [GodotClassName("EncodedObjectAsID")]
    public class EncodedObjectAsId : RefCounted
    {
        public EncodedObjectAsId();

        //
        // Summary:
        //     The Godot.GodotObject identifier stored in this Godot.EncodedObjectAsId instance.
        //     The object instance can be retrieved with @GlobalScope.instance_from_id.
        public ulong ObjectId { get; set; }

You can add a custom explicit cast operator if that would be most convenient:

[JsonObject(MemberSerialization.OptIn)]
public partial class QuestionCategory : Resource
{
    [JsonProperty]
    [Export]
    public string Category { get; set; }
    
    [JsonProperty]
    [Export]
    public Godot.Collections.Dictionary<int, string> Questions { get; set; }

    // explicit cast so you can use the '(type)obj' syntax
    public static explicit operator QuestionCategory(EncodedObjectAsId id) => (QuestionCategory)InstanceFromId(id.ObjectId);
}

I think the ObjectId property is the one you feed to InstanceFromId() based on the documentation. I haven’t tested it, and I haven’t used network serialization before. Let me know if it works!

For some reason the explicit cast operator wasn’t working/I wasn’t using it correctly, so I decided to just convert the RPC’s Array parameter from a typed array to an untyped array to allow free casting, which worked and I went with:

GD.Print(InstanceFromId(((EncodedObjectAsId)questions[0]).ObjectId));

to ensure that the instance existed. To which it yielded “null”, so to be sure, I removed the InstanceFromId call to just grab the ID:

GD.Print(((EncodedObjectAsId)questions[0]).ObjectId);

which yields:

9223372068295279943

which is the same ID differs from the ID in the string representation of the array:

[<EncodedObjectAsID#-9223371996505569903>]

(I also asserted that the questions array we send to the client isn’t null in the first place)

This makes me suspect that Godot is serialising the resource as an ID to reference the resource, but not actually serialising the resource itself, so when the client tries to grab the resource using this ID reference, it can’t find the resource and returns null because the resource only lives on the server…

In theory, this is fine for serialising to the disk and whatnot, but for sending it to another Godot instance it won’t work, since the resource isn’t replicated between them by default, so the client needs more than just an ID to reference it.

This is a bit stumping, but if that is the case, and there is a way to serialise the resource itself and read it on the client then that would of course be ideal, but at this point if there are no other solutions I’m fine with just sending the client the raw JSON string and just having them deserialise it into the QuestionCategory class, even if it is a bit bulkier. Luckily we aren’t using UDP so we don’t have to worry fragmenting and things like that (I think). Some people do also feel a bit iffy about serialising resources to send over the wire since they can contain executable code.