Search Unity

Question Netcode for GameObjects: ClientRpc Not Executing on All Clients After ServerRpc Call

Discussion in 'Multiplayer' started by SteenPetersen, Feb 26, 2024.

  1. SteenPetersen

    SteenPetersen

    Joined:
    Mar 13, 2016
    Posts:
    103
    Hello everyone,

    I'm working on a multiplayer game using Unity's Netcode for GameObjects where players can edit terrain in a procedurally generated world hosted by one of the players. The editing functionality works great on a single client, but I'm facing challenges syncing these edits across all connected clients.

    My initial approach was straightforward: when a player wants to make an edit, they send a ServerRpc to the host with the details of the edit (position and type). Subsequently, the host should broadcast this information to all clients using a ClientRpc, which would then execute the edit locally on each client, thus keeping the terrain state consistent across the game.

    Here's the process I intended to implement:

    1. Player sends a ServerRpc to the server.
    2. Server processes the request.
    3. Server sends a ClientRpc to all clients with the edit details.
    4. All clients receive the ClientRpc and perform the terrain edit locally.
    However, the issue arises after the ServerRpc is called: the ClientRpc seems to only execute on the client that initiated the ServerRpc call. Other clients ignore the ClientRpc, failing the if (!IsOwner) return; check.

    This behavior does not align with my understanding of the documentation, which suggests that a ClientRpc should execute on all clients. I'm beginning to suspect there's a fundamental concept I've misunderstood about the ownership and execution flow of ClientRpc and ServerRpc methods.

    Below is the code snippet illustrating the logic for the terrain editing and networking calls:


    Code (CSharp):
    1. public class TerrainNetworkLogic : NetworkBehaviour
    2. {
    3.     private myBrush brush;
    4.     private TerrainEditorUpdater updater;
    5.     private LayerMask terrainLayer;
    6.    
    7.     public void Setup(myBrush b, TerrainEditorUpdater u, LayerMask t)
    8.     {
    9.         brush = b;
    10.         updater = u;
    11.         terrainLayer = t;
    12.         DebugController.Log("TerrainEditor components set on TerrainNetworkLogic " + brush + " " + updater + " " + terrainLayer);
    13.     }
    14.  
    15.     private void Update()
    16.     {
    17.         if (!IsOwner) return;
    18.        
    19.         if (Input.GetKeyDown(KeyCode.T))
    20.         {
    21.             TestGroundInteraction();
    22.         }
    23.     }
    24.  
    25.     [Command] // command that can be called from console
    26.     private void TestGroundInteraction()
    27.     {
    28.         if (IsOwner)
    29.         {
    30.             DebugController.Log("TestGroundInteraction called");
    31.  
    32.             Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    33.             if (Physics.Raycast(ray, out RaycastHit hit, 1000, terrainLayer))
    34.             {
    35.                 Vector3 pos = hit.point;
    36.                 DebugController.Log("Hit terrain at " + pos);
    37.                 EditTerrainServerRpc(pos, BrushType.Raise); // call the serverrpc so that this terrain edit can be synchronised by the server
    38.             }
    39.         }
    40.     }
    41.  
    42.    
    43.     [ServerRpc]
    44.     private void EditTerrainServerRpc(Vector3 pos, BrushType brushType)
    45.     {
    46.         DebugController.Log("EditTerrainServerRpc called test " + OwnerClientId + " " + pos + " " + brushType);
    47.  
    48.         testClientRpc(pos, BrushType.Raise); // simply forward this information too all clients, so the terrain edit can happen in everyones world
    49.     }
    50.  
    51.    
    52.     [ClientRpc]
    53.     private void testClientRpc(Vector3 pos, BrushType brushType)
    54.     {
    55.         if (!IsOwner) return;
    56.        
    57.         DebugController.Log("testClientRpc called " + pos + " " + brushType + OwnerClientId);
    58.         PerformTerrainUpdate(pos, brushType);
    59.     }
    60.    
    61.    
    62.    
    63.     // a local method
    64.     private void PerformTerrainUpdate(Vector3 pos, BrushType brushType)
    65.     {
    66.      
    67.         var tmp = brush.preset; // this is never run because it's not owner
    68.        
    69.         ...
    70.    
    71.         DebugController.Log("Performed " + brushType + " at " + pos);
    72.     }
    73. }


    Has anyone encountered a similar issue, or can anyone spot what I might be doing wrong here? I'm looking for any advice or suggestions that could help me understand this problem better and, hopefully, resolve it.

    Thank you in advance for your time and help!
     
  2. CodeSmile

    CodeSmile

    Joined:
    Apr 10, 2014
    Posts:
    5,838
    What networking framework?

    Netcode uses [Rpc] attribute if you work with the latest 1.8 (or even earlier versions).
    Within the attribute you specify who receives the Rpc call.

    Anyhow, if you do (!IsOwner) in a client Rpc that all clients should process, then you've misunderstood the ownership concept: there can be only one! (cue music) ;)

    If this script is on a terrain modifier object and the server owns this terrain object, then all clients will early out in that method. Otherwise only a single client owns the object with the script on, and only that client will not early out. Depending on the network framework, the owner may refer to who sent the Rpc but the result is the same.

    With NGO you can specify who to send the Rpc to, here this should be ClientsAndHost.
     
  3. SteenPetersen

    SteenPetersen

    Joined:
    Mar 13, 2016
    Posts:
    103
    Hi and thanks for answering,

    The script is a script attached to all players, however this script only has its references set for the owner, I suspect this is related to why I get null references when I don't owner check the client rpc.

    The ClientsAndHost declaration, I'm a bit confused, because as I understand it, that is the default setting for the rpcs isn't it? Why would setting that change anything?

    How do I use that Enum? Is it part of the SendParams or? I cant find an example of where/how it is used.
     
  4. CodeSmile

    CodeSmile

    Joined:
    Apr 10, 2014
    Posts:
    5,838
    I like to specify it explicitly because I want to read it in the code, not having to know/remember what it does implicitly. Hence I don't even know what the default is to be honest. ;)

    The 1.8 release of Netcode adds the plain [Rpc] attribute with parameters.
    You may want to consider upgrading because it makes working with Rpc easier and more flexible. Specifically you can specify that an Rpc is sent only to the object's Owner. The methods also only need to end in "Rpc" now.
     
  5. SteenPetersen

    SteenPetersen

    Joined:
    Mar 13, 2016
    Posts:
    103
    Thank you very much for the insight, I upgraded to 1.8.1, I too prefer this way of working it is more explicit. I managed to fix my issue. And I would love to know if my thinking of this fix is correct or not, I don't want to run around with wrong assumptions. Let me split my thinking into 3 steps to better explain it:

    Step 1: Initial RPC Call
    • Client 1 sends an RPC to the server requesting a terrain edit.
    • Client 1 has a reference to the network object (in this case, a brush) that sends the RPC.
    Step 2: Issue with Client-Side Execution
    • The server attempts to send the RPC to all clients to perform the terrain edit.
    • Client 1 can execute the edit because it has the correct reference to the brush on Network Object 1.
    • Other clients fail to execute the edit because they don't have the correct reference to the brush. The reference they hold points to a different instance or object, which doesn't match Network Object 1.
    Step 3: The Fix
    • The server sends RPCs to all clients to perform the terrain edit.
    • All clients receive the ClientRpc on their respective network objects.
    • Instead of trying to perform the operation directly on the network object (which caused the previous issue), the operation is sent to a non-network object (perhaps a manager or controller) that is responsible for applying the terrain edit.

      Here it is in Diagram form:

      upload_2024-2-26_18-42-14.png

      So is my thinking correct? That the key takeaway is that each client calls a method on a locally available object, which then performs the necessary operation. This method doesn't rely on the network object's ownership but instead relies on a common functionality available to all clients?
     
  6. CodeSmile

    CodeSmile

    Joined:
    Apr 10, 2014
    Posts:
    5,838
    I wouldn't have the brush send the RPC. That's just the editing tool. A script on the terrain object would make more sense, since the brush will also change the terrain it should call into the terrain script to make the modification, which then does the RPC call to the server. It may also apply the change but store the command in case the server rejects the edit.

    Command pattern is important for undo/redo and the general implementation. You may even need to keep a history of all terrain change commands from all users so that the sequence of changes can be applied to the initial terrain to restore the current terrain by applying all changes in sequence, in the order the changes were made (network tick).

    The tricky part being two users editing the same area with conflicting tools. One client may raise the terrain +10, another may set it to an absolute height 0 (flatten). Locally, the clients will have their edit applied before the remote edit, so they see the terrain differently until the server tells them what order these changes happened. The server needs to instruct clients to apply the (most recent) changes in a specific order based on the network tick the clients sent their change requests.
     
  7. SteenPetersen

    SteenPetersen

    Joined:
    Mar 13, 2016
    Posts:
    103
    Thanks @CodeSmile you've been very helpful. I'm still trying to figure out the in's and outs of this system, but even though im a bit lost, I can tell it's a really nice system and quite explicit. I don't understand however why this doesnt do what I expect it to:


    Code (CSharp):
    1. [Rpc(SendTo.Server)]
    2.     private void EditTerrainServerRpc(Vector3 pos, BrushType brushType)
    3.     {
    4.         DebugController.Log("SERVERRPC called: " +
    5.                             "OwnerId " + OwnerClientId + " " +
    6.                             "at position " + pos + " " +
    7.                             "with brush type " + brushType);
    8.         testClientRpc(pos, BrushType.Raise);
    9.     }
    10.  
    11.     [Rpc(SendTo.NotMe)]
    12.     private void testClientRpc(Vector3 pos, BrushType brushType)
    13.     {
    14.         DebugController.Log("CLIENTRPC called: " +
    15.                             "OwnerId " + OwnerClientId + " " +
    16.                             "at position " + pos + " " +
    17.                             "with brush type " + brushType);
    18.         GameManager.Instance.GameManagerPerformTerrainUpdate(pos, brushType);
    19.     }
    20.  
    Here I'd expect the player to send the RPC to the server, and then the client rpc would be sent to everyone but 'not the original sender', so in this case I was trying to make the edit in terrain locally, and then send the serverrpc and consequentially clients rpc to everyone else so that everyone updates to my state, but I don't need to wait for the server to have my edits made. (I do take your point that We may want to keep track of it in case we have rules, which we will, and the server wants to reject the edit. but im not there yet).

    What happens here is, if the host sends this code, everything works fine, but if a client does it, then that client will edit the terrain twice, once locally and asecond time from the client RPC and the host wont edit it even once.
     
  8. CodeSmile

    CodeSmile

    Joined:
    Apr 10, 2014
    Posts:
    5,838
    Me too. :)

    Networking is not easy, and particularly because there's no clear separation between server and client code, or owner and non-owner code, or local vs remote code. It's all in the same script by default.

    No, the "NotMe" refers to the server/host here because of the execution context. Since testClientRpc is called by the server, the "me" is the server/host.

    SendTo.NotOwner should work as you intend if the server/host owns the object in question.
    You can use the RpcParams to filter out the target owner, or simply add the clientId as a parameter and locally check if the clientId is the same as "mine".
     
  9. SteenPetersen

    SteenPetersen

    Joined:
    Mar 13, 2016
    Posts:
    103
    Yea this seems to work well:

    Code (CSharp):
    1. [Rpc(SendTo.Server)]
    2. private void NotifyServerForTerrainEditRpc(Vector3 pos, BrushType brushType, ulong clientId)
    3. {
    4.     // will do some validation here to see if this edit should be accepted
    5.  
    6.     DebugController.Log("Server notified for terrain edit at position " + pos + " with brush type " + brushType);
    7.     UpdateOtherClientsRpc(pos, BrushType.Raise, RpcTarget.Not(clientId, RpcTargetUse.Temp));
    8. }
    thanks for the input