Non-host's OwnerClientIDs are inconsistent in server and client

This code checks to make sure that if an object’s OwnerClientId is not the same as the player’s OwnerClientId (meaning the object does not belong to the player), something happens to the player. However, in the host side the projectile’s id does match the owner player’s id, but in the client side the projectile’s id always has the host player’s OwnerClientId, resulting in the non host player being able to do something with his own projectiles (idValue != OwnerClientId in PlayerSkills script is true when it’s not). Below are the relevant portions of the codes for the scripts/classes Fire (MonoBehaviour) and PlayerSkills (NetworkBehaviour, within the Player object)

Fire

public ulong id;
public ulong ownerID;
private bool outside = false;

public void SetID(ulong i)
{
    id = i;
}

PlayerSkills

[ServerRpc(RequireOwnership = false)]
void BasicAttackServerRpc(Vector3 spawnPosition, Vector3 direction)
{
    int numberOfObjects = 3;

    for (int i = 0; i < numberOfObjects; i++)
    {
        GameObject skillInstance = Instantiate(skillObject, spawnPosition, Quaternion.identity);

        skillInstance.GetComponent<NetworkObject>().Spawn(true);
        skillInstance.GetComponent<Fire>().SetID(OwnerClientId);
        skillInstance.GetComponent<Fire>().SetOwnerID(OwnerClientId);
        skillInstance.GetComponent<Fire>().ChangeOrientation(direction, 30 * (1 - i));
        if (flash)
        {
            skillInstance.GetComponent<Fire>().damage *= 2;
            flash = false;
        }              
    }
}

private bool IsProjectileClose(GameObject projectile)
{
    float distance = Vector2.Distance(transform.position, projectile.transform.position);
    float thresholdDistance = 3.0f;

    MonoBehaviour[] scriptComponents = projectile.GetComponents<MonoBehaviour>();

    foreach (MonoBehaviour scriptComponent in scriptComponents)
    {
        if (scriptComponent != null)
        {
            System.Reflection.FieldInfo idField = scriptComponent.GetType().GetField("id");         
            if (idField != null)
            {
                ulong idValue = (ulong)idField.GetValue(scriptComponent);
                return distance < thresholdDistance && idValue != OwnerClientId;
            }
        }
    }

    return false;
}

Would appreciate the help. Thank you!

Just curious, why not use NetworkObject.SpawnWithOwnership in your BasicAttackServerRpc?
Otherwise, everything spawned in BasicAttackServerRpc will belong to the host/server.

Then in the IsProjectileClose method you could do something like this:

    [ServerRpc(RequireOwnership = false)]
    void BasicAttackServerRpc(Vector3 spawnPosition, Vector3 direction, ServerRpcParams serverRpcParams = default)
    {
        int numberOfObjects = 3;

        for (int i = 0; i < numberOfObjects; i++)
        {
            GameObject skillInstance = Instantiate(skillObject, spawnPosition, Quaternion.identity);

            skillInstance.GetComponent<NetworkObject>().SpawnWithOwnership(serverRpcParams.Receive.SenderClientId, true);
            // Since your skillObject already is a NetworkObject, you can check against the owner and do not need the below
            //skillInstance.GetComponent<Fire>().SetID(OwnerClientId);
            //skillInstance.GetComponent<Fire>().SetOwnerID(OwnerClientId);
            skillInstance.GetComponent<Fire>().ChangeOrientation(direction, 30 * (1 - i));
            if (flash)
            {
                skillInstance.GetComponent<Fire>().damage *= 2;
                flash = false;
            }
        }
    }

    // Pass in the NetworkObject as opposed to GameObject
    private bool IsProjectileClose(NetworkObject projectile)
    {
        // Ignore if it is owned by the local client
        if (projectile.OwnerClientId == NetworkManager.LocalClientId)
        {
            return false;
        }

        // Otherwise, if within the threshold distance then it is close
        float thresholdDistance = 3.0f;
        return Vector2.Distance(transform.position, projectile.transform.position) < thresholdDistance;
    }

Of course, you might also think about adding a trigger sphere to your projectiles that only trigger on specific layers that your player’s colliders have which would make detecting if a projectile is in range event driven as opposed to poll driven (which would be less processor intensive).
So then your code above could be reduced down to the BasicAttackServerRpc and something like this:

    private void OnTriggerEnter(Collider other)
    {
        var colliderNetworkObject = other.gameObject.GetComponent<NetworkObject>();
        if (colliderNetworkObject == null || colliderNetworkObject != null && colliderNetworkObject.OwnerClientId == NetworkManager.LocalClientId)
        {
            // Exit early
            return;
        }

        // Otherwise, perform action/event based on the projectile type
    }

You can include and exclude layers in your projectile’s collider/trigger, which determine what collider types will trigger it. Then it is just a matter of invoking whatever action you want to perform upon a projectile of one’s player getting within the threshold range of another player.

So, the projectile would have a normal collider to define the boundaries of when it hits something and a collider-trigger that defines when it is “within range”.

Hello, thanks for replying! The issue with using NetworkObject.SpawnWithOwnership is that on the non-host side, the client player’s projectiles freeze in place (in other words, skillInstance.GetComponent<Fire>().ChangeOrientation(direction, 30 * (1 - i)); does not activate), which is why I stopped using it since I encountered the same issue last time and same this time. Could there be something related to how NetworkObject.SpawnWithOwnership works that I have no idea about?

For reference, this is ChangeOrientation in the Fire script

public void ChangeOrientation(Vector3 direction, float angle)
{
    ChangeOrientationClientRpc(direction, angle);
    ChangeOrientationServerRpc(direction, angle);
}

[ServerRpc(RequireOwnership = false)]
private void ChangeOrientationServerRpc(Vector3 direction, float angle)
{
    transform.rotation = Quaternion.LookRotation(Vector3.forward, direction);
    transform.Rotate(0, 0, angle);

    Rigidbody2D rb = GetComponent<Rigidbody2D>();
    rb.velocity = transform.up * objectSpeed;
}

[ClientRpc]
private void ChangeOrientationClientRpc(Vector3 direction, float angle)
{
    transform.rotation = Quaternion.LookRotation(Vector3.forward, direction);
    transform.Rotate(0, 0, angle);

    Rigidbody2D rb = GetComponent<Rigidbody2D>();
    rb.velocity = transform.up * objectSpeed;
}

If you are using a NetworkTransform, then you would need to make it owner authoritative (or also known as a “ClientNetworkTransform”).

Could you describe in a bit more detail what you are using the ChangeOrientation RPC methods for (other than the obvious of changing the rotation)?

If it is just to synchronize a change in rotation, the recommended way to handle this is to use a NetworkTransform and change the orientation on the authority side (whether server or owner/client authoritative). This will then synchronize all other non-authority instances to the transform changes.

I’m using the methods to set the rotation and also to launch the projectiles by setting their velocity. Since I am using ClientNetworkTransform and also NetworkRigidbody2D (which doesn’t have the OnIsServerAuthoritative property so I can’t create ClientNetworkRigidbody2D), I’m surprised that the rigidbodies’ velocities can’t be synced despite trying different combinations of Server and Client RPCs (though I managed to sync their rotation at one point).

NetworkRigidBody and NetworkRigidBody2D automatically determine whether the local instance is kinematic or not based on the associated NetworkTransform. So, if you are using an owner authoritative motion model (i.e. ClientNetworkTransform) then the owner’s instance is always going to be non-kinematic.

If you don’t spawn with ownership, then the default owner will always be the server/host which if you are sending a client the update via Rpc the client’s instance would be non-kinematic (i.e. any changes applied to the Rigidbody on the client side would not be applied).

Try spawning with ownership and apply these updates to your change orientation script:

        /// <summary>
        /// This and all script that follows assumes the NetworkObject is spawned with client ownership
        /// </summary>
        /// <param name="direction"></param>
        /// <param name="angle"></param>
        public void ChangeOrientation(Vector3 direction, float angle)
        {
            // If we are the owner, then apply the change directly
            if (OwnerClientId == NetworkManager.LocalClientId)
            {
                UpdateOrientation(direction, angle);
            }
            else
            {
                // If we are a server or host, send the change in orientation to the owner
                if (NetworkManager.IsServer)
                {
                    ServerSendUpdateOrientationToOwner(direction, angle);
                }
                else
                {
                    // Otherwise, send the change in orientation to the server
                    // (This would only happen if the server was a host and we were targeting the host's owner objet or
                    // this non-owner client was trying to apply the change...not sure if your game/logic allows that or not)
                    ChangeOrientationServerRpc(direction, angle);
                }
            }
        }

        private void ServerSendUpdateOrientationToOwner(Vector3 direction, float angle)
        {
            if (!NetworkManager.IsServer)
            {
                Debug.LogWarning($"Client-{NetworkManager.LocalClientId} is invoking the server only change orientation method! (ignoring)");
                return;
            }
            // Only send to the owner by setting the target client id list to only the owner's identifier
            var clientRpcParams = new ClientRpcParams
            {
                Send = new ClientRpcSendParams
                {
                    TargetClientIds = new List<ulong>() { OwnerClientId }
                }
            };
            // Send the message to the owner
            ChangeOrientationClientRpc(direction, angle, clientRpcParams);
        }


        [ServerRpc(RequireOwnership = false)]
        private void ChangeOrientationServerRpc(Vector3 direction, float angle, ServerRpcParams serverRpcParams = default)
        {
            // Just a good idea to make sure you are not receiving messages from the owner (in case any future changes introduce a bug like this)
            if (serverRpcParams.Receive.SenderClientId == OwnerClientId)
            {
                Debug.LogWarning($"Client-{NetworkManager.LocalClientId} is also the owner but it also called {nameof(ChangeOrientationServerRpc)}! (ignoring)");
                return;
            }

            // If the server is not the owner, then forward the message to the appropriate client.
            // (This would only happen if another non-owner client was trying to apply the change...not sure if your game/logic allows that or not)
            if (OwnerClientId != NetworkManager.LocalClientId)
            {
                ServerSendUpdateOrientationToOwner(direction, angle);
            }
            else
            {
                // Otherwise, we are the owner so apply the update to orientation
                UpdateOrientation(direction, angle);
            }
        }

        [ClientRpc]
        private void ChangeOrientationClientRpc(Vector3 direction, float angle, ClientRpcParams clientRpcParams)
        {
            // Always have a check to assure you are sending to the right target
            if (NetworkManager.LocalClientId != OwnerClientId)
            {
                Debug.LogWarning($"Received a change in orientation message for ownerid-{OwnerClientId} on client-{NetworkManager.LocalClientId}! (ignoring)");
                return;
            }
            // Otherwise, we are the owner so apply the update to orientation
            UpdateOrientation(direction, angle);
        }

        /// <summary>
        /// Break out the desired action/script logic to a separate method
        /// This reduces the complexity and if you need to tweak how orientation is updated you only have to change one place
        /// </summary>
        private void UpdateOrientation(Vector3 direction, float angle)
        {
            transform.rotation = Quaternion.LookRotation(Vector3.forward, direction);
            transform.Rotate(0, 0, angle);

            Rigidbody2D rb = GetComponent<Rigidbody2D>();
            rb.velocity = transform.up * objectSpeed;
        }

Let me know if this helps you resolve your issue?

Hello, the projectiles do fire when the non-host player fires them now, but in the host side, the client’s projectiles mysteriously freeze in place when they are supposed to be destroyed (when they hit a wall or the enemy player). The host player’s projectiles behave correctly as usual though and when they get displayed in the client side as well.

9586099--1357771--Screenshot 2024-01-17 100623.png

That would mean that you are possibly not destroying when despawning?
I would add debug log information where you are colliding and also make sure that only the server is what is invoking the despawn and/or destroy on the projectile.

Depending upon how you are detecting collisions (collider, trigger, etc.) you should be checking if you are the server and if not then exiting early (or vice versa you could also check if you are the owner and then send an RPC to despawn).

This is my entire code for the projectile now, particularly take a look at ExplodeServerRpc

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Unity.Netcode;
using System;

public class Fire : NetworkBehaviour
{
    [SerializeField] float objectSpeed = 30f;
    [SerializeField] float explosionRadius = 3f;
    public float damage = 5f;
    private LayerMask layer = -1;
    private Camera mainCamera;
    private float cameraHalfWidth;
    private float cameraHalfHeight;
    //public ulong id;
    //public ulong ownerID;
    private bool outside = false;

    //public void SetID(ulong i)
    //{
    //    id = i;
    //    //SetIDServerRpc(i);
    //    //SetIDClientRpc(i);
    //}

    public void ChangeOrientation(Vector3 direction, float angle)
    {
        // If we are the owner, then apply the change directly
        if (OwnerClientId == NetworkManager.LocalClientId)
        {
            UpdateOrientation(direction, angle);
        }
        else
        {
            // If we are a server or host, send the change in orientation to the owner
            if (NetworkManager.IsServer)
            {
                ServerSendUpdateOrientationToOwner(direction, angle);
            }
            else
            {
                // Otherwise, send the change in orientation to the server
                // (This would only happen if the server was a host and we were targeting the host's owner objet or
                // this non-owner client was trying to apply the change...not sure if your game/logic allows that or not)
                ChangeOrientationServerRpc(direction, angle);
            }
        }
    }

    private void ServerSendUpdateOrientationToOwner(Vector3 direction, float angle)
    {
        if (!NetworkManager.IsServer)
        {
            Debug.LogWarning($"Client-{NetworkManager.LocalClientId} is invoking the server only change orientation method! (ignoring)");
            return;
        }
        // Only send to the owner by setting the target client id list to only the owner's identifier
        var clientRpcParams = new ClientRpcParams
        {
            Send = new ClientRpcSendParams
            {
                TargetClientIds = new List<ulong>() { OwnerClientId }
            }
        };
        // Send the message to the owner
        ChangeOrientationClientRpc(direction, angle, clientRpcParams);
    }


    [ServerRpc(RequireOwnership = false)]
    private void ChangeOrientationServerRpc(Vector3 direction, float angle, ServerRpcParams serverRpcParams = default)
    {
        // Just a good idea to make sure you are not receiving messages from the owner (in case any future changes introduce a bug like this)
        if (serverRpcParams.Receive.SenderClientId == OwnerClientId)
        {
            Debug.LogWarning($"Client-{NetworkManager.LocalClientId} is also the owner but it also called {nameof(ChangeOrientationServerRpc)}! (ignoring)");
            return;
        }

        // If the server is not the owner, then forward the message to the appropriate client.
        // (This would only happen if another non-owner client was trying to apply the change...not sure if your game/logic allows that or not)
        if (OwnerClientId != NetworkManager.LocalClientId)
        {
            ServerSendUpdateOrientationToOwner(direction, angle);
        }
        else
        {
            // Otherwise, we are the owner so apply the update to orientation
            UpdateOrientation(direction, angle);
        }
    }

    [ClientRpc]
    private void ChangeOrientationClientRpc(Vector3 direction, float angle, ClientRpcParams clientRpcParams)
    {
        // Always have a check to assure you are sending to the right target
        if (NetworkManager.LocalClientId != OwnerClientId)
        {
            Debug.LogWarning($"Received a change in orientation message for ownerid-{OwnerClientId} on client-{NetworkManager.LocalClientId}! (ignoring)");
            return;
        }
        // Otherwise, we are the owner so apply the update to orientation
        UpdateOrientation(direction, angle);
    }

    /// <summary>
    /// Break out the desired action/script logic to a separate method
    /// This reduces the complexity and if you need to tweak how orientation is updated you only have to change one place
    /// </summary>
    private void UpdateOrientation(Vector3 direction, float angle)
    {
        transform.rotation = Quaternion.LookRotation(Vector3.forward, direction);
        transform.Rotate(0, 0, angle);

        Rigidbody2D rb = GetComponent<Rigidbody2D>();
        rb.velocity = transform.up * objectSpeed;
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag("Player"))
        {
            if (outside && collision.GetComponent<NetworkObject>().OwnerClientId != GetComponent<NetworkObject>().OwnerClientId)
            {
                ExplodeServerRpc();
            }
        }
    }

    private void OnTriggerExit2D(Collider2D collision)
    {
        if (collision.CompareTag("Player")) outside = true;
    }

    [ServerRpc(RequireOwnership = false)]
    void ExplodeServerRpc()
    {
        Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position, explosionRadius, layer);

        foreach (Collider2D hitCollider in colliders)
        {
            if (hitCollider.CompareTag("Player"))
            {
                hitCollider.GetComponent<PlayerMovement>().DealDamage(damage);
            }
        }
        Destroy(gameObject);
    }

    // Start is called before the first frame update
    void Start()
    {
        mainCamera = Camera.main;
        cameraHalfWidth = mainCamera.orthographicSize * mainCamera.aspect;
        cameraHalfHeight = mainCamera.orthographicSize;
    }

    // Update is called once per frame
    void Update()
    {
        //delay -= Time.deltaTime;

        if (transform.position.x < -cameraHalfWidth + (transform.localScale.x / 2) || transform.position.x > cameraHalfWidth - (transform.localScale.x / 2)
            || transform.position.y < -cameraHalfHeight + (transform.localScale.y) || transform.position.y > cameraHalfHeight - (transform.localScale.y))
        ExplodeServerRpc();
    }
}

Now seems like it’s behaving better, projectiles are no longer freezing at the last moment, but sometimes the projectiles from the non host player don’t damage the host player after being destroyed, with this error appearing

[Netcode] Deferred messages were received for a trigger of type OnSpawn with key 52, but that trigger was not received within within 1 second(s).
UnityEngine.Debug:LogWarning (object)
Unity.Netcode.NetworkLog:LogWarning (string) (at ./Library/PackageCache/com.unity.netcode.gameobjects@1.7.1/Runtime/Logging/NetworkLog.cs:28)
Unity.Netcode.DeferredMessageManager:PurgeTrigger (Unity.Netcode.IDeferredNetworkMessageManager/TriggerType,ulong,Unity.Netcode.DeferredMessageManager/TriggerInfo) (at ./Library/PackageCache/com.unity.netcode.gameobjects@1.7.1/Runtime/Messaging/DeferredMessageManager.cs:97)
Unity.Netcode.DeferredMessageManager:CleanupStaleTriggers () (at ./Library/PackageCache/com.unity.netcode.gameobjects@1.7.1/Runtime/Messaging/DeferredMessageManager.cs:82)
Unity.Netcode.NetworkManager:NetworkUpdate (Unity.Netcode.NetworkUpdateStage) (at ./Library/PackageCache/com.unity.netcode.gameobjects@1.7.1/Runtime/Core/NetworkManager.cs:69)
Unity.Netcode.NetworkUpdateLoop:RunNetworkUpdateStage (Unity.Netcode.NetworkUpdateStage) (at ./Library/PackageCache/com.unity.netcode.gameobjects@1.7.1/Runtime/Core/NetworkUpdateLoop.cs:185)
Unity.Netcode.NetworkUpdateLoop/NetworkPostLateUpdate/<>c:<CreateLoopSystem>b__0_0 () (at ./Library/PackageCache/com.unity.netcode.gameobjects@1.7.1/Runtime/Core/NetworkUpdateLoop.cs:268)

This is most likely because the OnTriggerEnter2D is being invoked more than once on the client side. Just add a bool property that you set to true within OnTriggerEnter2D right after you invoke the ExplodeServerRpc and then exit early from OnTriggerEnter2D if it is set:

    private bool m_HitTarget = false;
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (!m_HitTarget  && collision.CompareTag("Player"))
        {
            if (outside && collision.GetComponent<NetworkObject>().OwnerClientId != GetComponent<NetworkObject>().OwnerClientId)
            {
                ExplodeServerRpc();
                m_HitTarget  = true;
            }
        }
    }

See if that adjustment prevents the error message…it is possible that the server generated DestroyObjectMessage is “in flight” (i.e. has not reached the client yet) and the client sends a message to the server…but the NetworkObject no longer exists so it gets deferred…and then times out and that message is logged.

Really, I would break this up into two stages:

  • The initial impact: ground zero, player impacted takes full damage.
  • The explosion radius damage: excludes player impacted (it already took damage) but checks for any other players within the explosion radius.

With the initial impact, I would get the PlayerMovement component in OnTriggerEnter2D and then create a NetworkBehaviourReference instance that is set to the PlayerMovement component of the player hit. Then add a NetworkBehaviourReference parameter to your ExplodeServerRpc and pass in your newly created NetworkBehaviourReference to the ExplodeServerRpc… this way you don’t need to find the player again (you already found it on the client side) and can apply the full damage to that player immediately within the ExplodeServerRpc method.

Next (in your ExplodeServerRpc after applying the ground zero player’s damage), instantiate and spawn a network prefab explosion FX that has a CircleCollider2D component set as a trigger and the radius of the CircleCollider2D defines the explosion radius. It would look something like this:

    public class Explosion2DFxBehaviour : NetworkBehaviour
    {
        public ParticleSystem ExplosionFx;
        public List<AudioClip> ExplosionSounds;
        public AudioSource AudioSource;
        public float MaxDamage = 100;
        private CircleCollider2D Collider;
        private ulong m_GroundZeroPlayer;
        private NetworkVariable<int> AudioClipToPlay = new NetworkVariable<int>();
        private void Awake()
        {
            Collider = GetComponent<CircleCollider2D>();
            // Assure it is a trigger
            Collider.isTrigger = true;
            // Disable the trigger (it gets enabled when spawned)
            Collider.enabled = false;
        }
        public void Initialize(ulong groundZeroPlayer)
        {
            m_GroundZeroPlayer = groundZeroPlayer;
        }
        public override void OnNetworkSpawn()
        {
            if (IsServer)
            {
                // Enabling this will cause OnTriggerEnter2D to be invoked
                Collider.enabled = true;
                // Optional, a way to synchronize a range of varying explosion FX to provide "unique explosion sounds"
                AudioClipToPlay.Value = Random.Range(0, ExplosionSounds.Count - 1);
            }
            if (ExplosionFx)
            {
                ExplosionFx.Play();
            }
         
            if (AudioSource && ExplosionSounds.Count > 0)
            {
                AudioSource.clip = ExplosionSounds[AudioClipToPlay.Value];
            }
            base.OnNetworkSpawn();
        }
        public override void OnNetworkDespawn()
        {
            Collider.enabled = false;
            base.OnNetworkDespawn();
        }
        private void Update()
        {
            if (!IsSpawned || !IsServer)
            {
                return;
            }
            if (!IsDespawning && !ExplosionFx.IsAlive() && !AudioSource.isPlaying)
            {
                IsDespawning = true;
                StartCoroutine(DelayDespawn());
            }
        }
        private bool IsDespawning;
        // You could get the average RTT for clients to determine "roughly" how
        // long you need to delay the despawn to assure all instances have finished
        private System.Collections.IEnumerator DelayDespawn()
        {
            yield return new WaitForSeconds(1.0f);
            NetworkObject.Despawn();
        }
        private void OnTriggerEnter2D(Collider2D collision)
        {
            var playerMovement = collision.gameObject.GetComponent<PlayerMovement>();
            if (playerMovement == null)
            {
                // If no PlayerMovement component, then exit early
                return;
            }
            if (playerMovement.OwnerClientId == m_GroundZeroPlayer)
            {
                // Ignore ground zero player since damage is already applied
                return;
            }
            var scaleDamage = Collider.Distance(collision).distance / Collider.radius;
            // Scale damage based on other player's distance from "ground zero"
            playerMovement.DealDamage(MaxDamage * scaleDamage);
        }
    }

Using an approach like this might help break things up into a more “logical order of operations” flow…

This is my Fire/projectile script at the moment

private bool m_HitTarget = false;
private void OnTriggerEnter2D(Collider2D collision)
{
    if (!m_HitTarget && collision.CompareTag("Player"))
    {
        if (outside && collision.GetComponent<NetworkObject>().OwnerClientId != GetComponent<NetworkObject>().OwnerClientId)
        {
            ExplodeServerRpc();
            m_HitTarget = true;
        }
    }
}

[ServerRpc(RequireOwnership = false)]
void ExplodeServerRpc(ServerRpcParams serverRpcParams = default)
{
    GameObject explosive = Instantiate(explosion, transform.position, Quaternion.identity);
    explosive.GetComponent<NetworkObject>().SpawnWithOwnership(serverRpcParams.Receive.SenderClientId, true);
    explosive.GetComponent<Explosion>().damage = damage;

    Destroy(gameObject);
}

And this is my new Explosion script

using System;
using System.Collections;
using System.Collections.Generic;
using Unity.Netcode;
using Unity.VisualScripting;
using UnityEngine;

public class Explosion : NetworkBehaviour
{
    public ParticleSystem ExplosionFx;
    public List<AudioClip> ExplosionSounds;
    public AudioSource AudioSource;
    public float damage = 0;
    public float explosionRadius = 1f;
    private LayerMask layer = -1;
    private bool exploded = false;
    private CircleCollider2D Collider;
    private ulong m_GroundZeroPlayer;
    private bool m_Exploded = false;
    private NetworkVariable<int> AudioClipToPlay = new NetworkVariable<int>();

    private void Awake()
    {
        Collider = GetComponent<CircleCollider2D>();
        // Assure it is a trigger
        Collider.isTrigger = true;
        // Disable the trigger (it gets enabled when spawned)
        Collider.enabled = false;
    }
    public void Initialize(ulong groundZeroPlayer)
    {
        m_GroundZeroPlayer = groundZeroPlayer;
    }
    public override void OnNetworkSpawn()
    {
        if (IsServer)
        {
            // Enabling this will cause OnTriggerEnter2D to be invoked
            Collider.enabled = true;
        }
        if (ExplosionFx)
        {
            ExplosionFx.Play();
        }

        if (AudioSource && ExplosionSounds.Count > 0)
        {
            AudioSource.clip = ExplosionSounds[AudioClipToPlay.Value];
        }
        base.OnNetworkSpawn();

        if (!m_Exploded)
        {
            StartCoroutine(DelayDespawn());
            m_Exploded = true;
        }      
    }
    public override void OnNetworkDespawn()
    {
        Collider.enabled = false;
        base.OnNetworkDespawn();
    }
    private System.Collections.IEnumerator DelayDespawn()
    {
        yield return new WaitForSeconds(0.2f);
        NetworkObject.Despawn();
        Destroy(gameObject);
    }
    private void Update()
    {
        if (!IsSpawned || !IsServer)
        {
            return;
        }

        //if (exploded) return;

        //Collider2D[] colliders = Physics2D.OverlapCircleAll(transform.position, explosionRadius, layer);

        //foreach (Collider2D hitCollider in colliders)
        //{
        //    if (hitCollider.CompareTag("Player"))
        //    {
        //        hitCollider.GetComponent<PlayerMovement>().DealDamage(damage);
        //        exploded = true;
        //    }
        //}
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        var playerMovement = collision.gameObject.GetComponent<PlayerMovement>();
        if (playerMovement == null || exploded)
        {
            // If no PlayerMovement component, then exit early
            return;
        }
        //if (playerMovement.OwnerClientId == m_GroundZeroPlayer)
        //{
        //    // Ignore ground zero player since damage is already applied
        //    return;
        //}
        // Scale damage based on other player's distance from "ground zero"
        playerMovement.DealDamage(damage);
        exploded = true;
    }
}

Now it works much better as the non-host player can now damage the host player ~90%+ of the time, though it is still not 100%. It might be due to this part which I’m not too sure how to implement.

This is a quick mock up of how you can use NetworkBehaviourReference:

    private bool m_HitTarget = false;
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (!m_HitTarget && outside)
        {
            var playerMovement = collision.gameObject.GetComponent<PlayerMovement>();
            // As long as what we are hitting is a player and it is not the player who owns the projectile
            if (playerMovement != null && playerMovement.OwnerClientId != OwnerClientId)
            {
                // Invoke the RPC passing in a NetworkBehaviourReference of the player hit
                ExplodeServerRpc(new NetworkBehaviourReference(playerMovement));
                m_HitTarget = true;
            }
        }
    }

    [ServerRpc(RequireOwnership = false)]
    void ExplodeServerRpc(NetworkBehaviourReference networkBehaviourReference, ServerRpcParams serverRpcParams = default)
    {
        var targetedPlayer = (PlayerMovement)null;
        if (networkBehaviourReference.TryGet(out targetedPlayer))
        {
            // Instantiate the explosion object
            GameObject explosiveObject = Instantiate(explosion, transform.position, Quaternion.identity);
            // Get the explosive component
            var explosive = explosiveObject.GetComponent<Explosion>();
            // Initialize the explosive and then spawn the object
            if (explosive != null)
            {
                explosive.Initialize(targetedPlayer, damage);
                explosive.GetComponent<NetworkObject>().SpawnWithOwnership(serverRpcParams.Receive.SenderClientId, true);
                Destroy(gameObject);
            }
            else
            {
                Debug.LogWarning($"Could not get Explosion component!");
            }
        }
        else
        {
            Debug.LogWarning($"Failed to get PlayerMovement component from NetworkBehaviourReference!");
        }
    }

Regarding not hitting all of the time…I made some tweaks to your explosion adjustments:

public class Explosion : NetworkBehaviour
{
    public ParticleSystem ExplosionFx;
    public List<AudioClip> ExplosionSounds;
    public AudioSource AudioSource;
    public float TimeToLive = 1.5f;
 
    public float explosionRadius = 1f;
    private float m_Damage = 0;
    private LayerMask layer = -1;
    private bool exploded = false;
    private CircleCollider2D Collider;
    private bool m_Exploded = false;
    private NetworkVariable<int> AudioClipToPlay = new NetworkVariable<int>();
    private float ExplosionLifeTime;
    private void Awake()
    {
        Collider = GetComponent<CircleCollider2D>();
        // Assure it is a trigger
        Collider.isTrigger = true;
        // Disable the trigger (it gets enabled when spawned)
        Collider.enabled = false;
    }
    public void Initialize(PlayerMovement groundZeroPlayer, float damage)
    {
        // In the event you decide to pool this, you want to reset the list of damaged players
        DamagedPlayers.Clear();
        DamagedPlayers.Add(groundZeroPlayer);
        // Go ahead and apply damage to the player hit here
        m_Damage = damage;
        groundZeroPlayer.DealDamage(m_Damage);
    }
    public override void OnNetworkSpawn()
    {
        if (IsServer)
        {
            // Enabling this will cause OnTriggerEnter2D to be invoked
            Collider.enabled = true;
            // Start the time to live offset by the current time
            ExplosionLifeTime = Time.realtimeSinceStartup + TimeToLive;
        }
        if (ExplosionFx)
        {
            ExplosionFx.Play();
        }
        if (AudioSource && ExplosionSounds.Count > 0)
        {
            AudioSource.clip = ExplosionSounds[AudioClipToPlay.Value];
        }
        base.OnNetworkSpawn();
    }
    public override void OnNetworkDespawn()
    {
        Collider.enabled = false;
        base.OnNetworkDespawn();
    }
    private System.Collections.IEnumerator DelayDespawn()
    {
        yield return new WaitForSeconds(0.2f);
        NetworkObject.Despawn();
        Destroy(gameObject);
    }
    private void Update()
    {
        if (!IsSpawned || !IsServer)
        {
            return;
        }
        // If you aren't going to key off of the FX or audio to end the explosion, then use
        // something like a time to live approach (i.e. how long it hangs around before beginning the delayed despawn)
        if (!m_Exploded && ExplosionLifeTime < Time.realtimeSinceStartup)
        {
            StartCoroutine(DelayDespawn());
            m_Exploded = true;
        }
    }
 
    // Tracks the players that have already been damaged
    private List<PlayerMovement> DamagedPlayers = new List<PlayerMovement>();
 
    /// <summary>
    /// Since this can be invoked several times in the same frame (once per collider triggering it),
    /// you don't want to keep it from stopping damage after it applies damage the first time.
    /// </summary>
    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (m_Exploded)
        {
            return;
        }
        var playerMovement = collision.gameObject.GetComponent<PlayerMovement>();
        if (playerMovement == null)
        {
            // If no PlayerMovement component, then exit early
            return;
        }
        // Ignore players already damaged by the explosion
        if (DamagedPlayers.Contains(playerMovement))
        {
            return;
        }
        // Apply the damage
        playerMovement.DealDamage(damage);
   
        // Add this player to the damaged players list so the player isn't damaged more than once
        // Optionally, when you are about to despawn, you could handle "players hit" statistics and use this list of players hit to add to those stats.
        DamagedPlayers.Add(playerMovement);
    }
}

Give the above changes a whirl and let me know if that resolves your issue?

Hello, the issue is finally solved! Thank you very much for your continuous help!