Awaitable RPCs - yes or no?

The asynchronous Services APIs are fully awaitable, Netcode RPCs aren’t.

I wondered, why not? I made a simple test …

My LocalPlayers class spawns multiple players, awaiting for their object to be returned and instantly applying changes to the returned object. This is so convenient:

public override async void OnNetworkSpawn()
{
    base.OnNetworkSpawn();

    if (!IsOwner)
        return;

    m_Players[0] = await m_Spawner.Spawn(0, 0);
    m_Players[0].transform.position = new Vector3(-3, 0, 0);

    m_Players[1] = await m_Spawner.Spawn(1, 1);
    m_Players[1].transform.position = new Vector3(-1, 0, 0);

    m_Players[2] = await m_Spawner.Spawn(2, 2);
    m_Players[2].transform.position = new Vector3(1, 0, 0);

    m_Players[3] = await m_Spawner.Spawn(3, 3);
    m_Players[3].transform.position = new Vector3(3, 0, 0);
}

Why multiple players? I can haz multiple splitscreen players on a single client.

Client Spawner creates a TaskCompletionSource and calls the Server RPC:

public Task<LocalPlayer> Spawn(Int32 localPlayerIndex, Int32 prefabIndex)
{
    m_SpawnTcs[localPlayerIndex] = new TaskCompletionSource<LocalPlayer>();
    m_ServerPlayerSpawner.SpawnPlayerServerRpc(OwnerClientId, localPlayerIndex, prefabIndex);
    return m_SpawnTcs[localPlayerIndex].Task;
}

Server spawner instantiates, spawns and sends a “did spawn” client RPC:

[Rpc(SendTo.Server, DeferLocal = true)]
internal void SpawnPlayerServerRpc(UInt64 ownerId, Int32 localPlayerIndex, Int32 avatarIndex)
{
    avatarIndex = Mathf.Clamp(avatarIndex, 0, m_AvatarPrefabs.Count - 1);

    var playerPrefab = m_AvatarPrefabs[avatarIndex];
    var playerNetObject = Instantiate(playerPrefab).GetComponent<NetworkObject>();
    playerNetObject.SpawnAsPlayerObject(ownerId);

    m_ClientPlayerSpawner.DidSpawnPlayerClientRpc(playerNetObject, localPlayerIndex);
}

Back in client spawner, if we’re the owner we set the task result and remove that task source:

[Rpc(SendTo.ClientsAndHost, DeferLocal = true)]
internal void DidSpawnPlayerClientRpc(NetworkObjectReference playerObjectRef, Int32 localPlayerIndex)
{
    if (IsOwner)
    {
        var net = NetworkManager.Singleton;
        var player = net.SpawnManager.SpawnedObjects[playerObjectRef.NetworkObjectId];
        m_SpawnTcs[localPlayerIndex].SetResult(player.GetComponent<LocalPlayer>());
        m_SpawnTcs[localPlayerIndex] = null;
    }
}

This also works on WebGL. Lower ones are host’s players, top row are web client players. The mirror effect is entirely occidental.

I mainly wonder if there’s anything speaking against making such ping-pong RPCs awaitable? Maybe garbage?

Because if there’s no big concerns I’d really like to use this pattern.

It does pose a slight complication when you need to decide between the call issuer (owner, local) and a remote event.

I had to expand the ClientRpc to either complete the Task for the owner, otherwise call a method to register a new remote player object.

[Rpc(SendTo.ClientsAndHost, DeferLocal = true)]
internal void DidSpawnPlayerClientRpc(NetworkObjectReference playerRef, Byte couchPlayerIndex)
{
    // this should not fail thus no error check
    playerRef.TryGet(out var playerObj);

    var player = playerObj.GetComponent<Player>();

    if (IsOwner)
    {
        // end awaitable task, and discard
        m_SpawnTcs[couchPlayerIndex].SetResult(player);
        m_SpawnTcs[couchPlayerIndex] = null;
    }
    else
    {
        m_Players.RegisterRemotePlayer(player, couchPlayerIndex);
    }
}

Still not that bad but it does cause a split in code paths.

Hey, thank you so much, I applied the principles you made in your code and it worked.
Before I was using :

 async Task CheckIfVariableExist()
{
    while (variable == null )
    {
       await Task.Yield();
    }
}

with an “await” to make sure that what I need is filled, but Im gonna stick with your method.
Looks the key is the “TaskCompletionSource”, I gonna need to research and learn how to use it properly.

1 Like

I think your solution works as a quick and dirty way, it’s definitely less code. But it bears the danger that either the variable will be null forever or that something else assigns to the variable, not the intended Task.

TaskCompletionSource is (from what I understand) just an object that wraps around what you did, ensuring that the Task is cancellable and that the Task is explicitly completed with a call to SetResult().