How to synchronize a racket attached on the right hand proper through the unity netcode network with meta XR?

Hello, I am developing a multiplayer Tennis Game with unity Netcode for Gameobjects and Meta XR. I spawn a ball with network Object and network rigidbody. If the client wants to hit the ball with his racket (atttached his riht hand), the racket goes through the ball. The racket is synced with the hand of the XR rig. But if the client plays slowly the ball from the ground, the collision works. I am using Relay. How to synchronize the racket properly so that the collision works well?

Because the ball is simulated on the server (host). Thus any physics collision on the player has a large enough latency to make every physics interaction feel odd (delayed, jittery).

Solution for a tennis game is to hand over authority and ownership over the ball the moment the owning player hit the ball. That lets the other player directly interact with the ball without latency.

Refer to this thread for more details.

@Friedrich90123 the guidance from CodeSmile and linked post is worth a deeper dive into. I’d encourage you to take a look.

Also, because you are already using a client-host topology, I wanted to point out the new Distributed Authority topology that is available in Unity 6 and Netcode for GameObjects 2.0 . Distributed authority quickstart for Netcode for GameObjects | Unity Multiplayer

If you set your ball NetworkObject to be Transferable you can get near instantaneous ownership transfer that will allow the interacting client’s local physics to take control. This would allow each player to transfer ownership back and forth between each and simulate physics on each of their hits. Depending on whether you’re planning on allowing doubles, there would be two ways you could approach this:

1 vs 1:
Split the court up into two halves where each half is defined by a trigger. When the ball enters either player’s side, the ownership is automatically transferred.

1 vs 1 & 2 vs 2:
Place a larger trigger around the ball that, upon another player’s racket entering the trigger, transfers ownership to that player.

We have a webinar this week that you could join to see a bit more about this new topology:

Hello to everyone, thank you for the replies.
I still set up a bigger collider as trigger to the ball which transfers the ownership of the ball to the client.
I am trying to use this pattern: how-to-call-a-method-across-everyone-in-the-network
But the collision of the ball with the racket is still delayed.
Can you give me a pattern how I can execute the physic on the client and sync to all clients?
Kind regards

Hello everyone.
I am getting a good result with the following code. I transfer the ownership of the ball with a trigger (1 m radian) to the client and the client handles the physics of the ball until the trigger exits the tennis racket. It works well even though the ball accelerates more than the normal gravity when the ball geting the ownership of the client. Do you have any idea why the ball accelerates additonally?

Unity Version 2022.3.30f1
Netcode for GameObjects version 1.9.1

The ball has additonally the NetworkRigidbody and ClientNetworkTransfom Script attached.

using System;
using Unity.Netcode;
using UnityEngine;

public class BallPhysicsNetwork : NetworkBehaviour
{
    
    private float _syncInterpolationSpeed = 25f;
    private float _forceMultiplier = 0.1f;
    private float _maxSpeed = 21.2f; // in m/s
    
    private Rigidbody _ballRigidbody;
    
    public NetworkVariable<ulong> currentOwner = new NetworkVariable<ulong>(
        default,
        NetworkVariableReadPermission.Everyone,
        NetworkVariableWritePermission.Server
    );

    public NetworkVariable<Vector3> syncedPosition = new NetworkVariable<Vector3>(
        default,
        NetworkVariableReadPermission.Everyone,
        NetworkVariableWritePermission.Owner
    );

    public NetworkVariable<Quaternion> syncedRotation = new NetworkVariable<Quaternion>(
        default,
        NetworkVariableReadPermission.Everyone,
        NetworkVariableWritePermission.Owner
    );

    public NetworkVariable<Vector3> syncedVelocity = new NetworkVariable<Vector3>(
        default,
        NetworkVariableReadPermission.Everyone,
        NetworkVariableWritePermission.Owner
    );

    private bool isTransferringOwnership;
    private float transferCooldown = 1f;
    private float lastTransferTime;

    private void Awake()
    {
        _ballRigidbody = GetComponent<Rigidbody>();
        _ballRigidbody.useGravity = true;
        _ballRigidbody.isKinematic = false;
        Debug.Log("[BallPhysicsNetwork] Awake: Rigidbody initialized");
    }

    public override void OnNetworkSpawn()
    {
        base.OnNetworkSpawn();
        
        if (IsServer)
        {
            currentOwner.Value = NetworkManager.ServerClientId;
            Debug.Log($"[BallPhysicsNetwork] OnNetworkSpawn: Server owns the ball (OwnerId: {currentOwner.Value})");
        }
        
        // Initialize network variables for owner
        if (IsOwner)
        {
            syncedPosition.Value = transform.position;
            syncedRotation.Value = transform.rotation;
            syncedVelocity.Value = _ballRigidbody.velocity;
        }
    }

    private void FixedUpdate()
    {
        if (!IsSpawned) return;

        if (IsOwner && !isTransferringOwnership)
        {
            // Owner physics control
            if (_ballRigidbody.velocity.magnitude > _maxSpeed)
            {
                _ballRigidbody.velocity = _ballRigidbody.velocity.normalized * _maxSpeed;
            }
            
            // Update network variables
            syncedPosition.Value = _ballRigidbody.position;
            syncedRotation.Value = _ballRigidbody.rotation;
            syncedVelocity.Value = _ballRigidbody.velocity;
            
            Debug.Log($"[Owner] Velocity: {_ballRigidbody.velocity.magnitude:F2}, Position Y: {_ballRigidbody.position.y:F2}");
        }
        else
        {
            // Non-owner physics synchronization
            //_ballRigidbody.velocity = syncedVelocity.Value; //Setting linear velocity of a kinematic body is not supported.
            
            if (!isTransferringOwnership)
            {
                _ballRigidbody.MovePosition(Vector3.Lerp(_ballRigidbody.position, syncedPosition.Value, Time.fixedDeltaTime * _syncInterpolationSpeed));
                _ballRigidbody.MoveRotation(Quaternion.Slerp(_ballRigidbody.rotation, syncedRotation.Value, Time.fixedDeltaTime * _syncInterpolationSpeed));
            }
            
            Debug.Log($"[Client] Velocity: {_ballRigidbody.velocity.magnitude:F2}, Position Y: {_ballRigidbody.position.y:F2}");
        }
    }

    private void OnTriggerEnter(Collider other)
    {
        if (Time.time - lastTransferTime < transferCooldown) return;

        if (other.CompareTag("Tag_TennisRacketRight") || other.CompareTag("Tag_TennisRacketLeft"))
        {
            NetworkObject racketNetObj = other.GetComponentInParent<NetworkObject>();
            if (racketNetObj != null && racketNetObj.OwnerClientId != currentOwner.Value)
            {
                RequestTransferOwnershipServerRpc(racketNetObj.OwnerClientId);
                lastTransferTime = Time.time;
                Debug.Log($"[BallPhysicsNetwork] OnTriggerEnter: Requesting ownership transfer to {racketNetObj.OwnerClientId}");
            }
        }
    }

    private void OnTriggerExit(Collider other)
    {
        if (Time.time - lastTransferTime < transferCooldown) return;

        if (other.CompareTag("Tag_TennisRacketRight") || other.CompareTag("Tag_TennisRacketLeft"))
        {
            NetworkObject racketNetObj = other.GetComponentInParent<NetworkObject>();
            if (racketNetObj != null && racketNetObj.OwnerClientId == currentOwner.Value)
            {
                RequestTransferOwnershipServerRpc(NetworkManager.ServerClientId);
                lastTransferTime = Time.time;
                Debug.Log($"[BallPhysicsNetwork] OnTriggerExit: Requesting ownership transfer to server");
            }
        }
    }
    
    [ServerRpc(RequireOwnership = false)]
    private void RequestTransferOwnershipServerRpc(ulong requestedOwnerId, ServerRpcParams serverRpcParams = default)
    {
        if (!IsServer) return;
        if (Time.time - lastTransferTime < transferCooldown) return;

        if (NetworkManager.Singleton.ConnectedClients.ContainsKey(requestedOwnerId))
        {
            // Capture the current state before transfer
            Vector3 transferPosition = _ballRigidbody.position;
            Vector3 transferVelocity = _ballRigidbody.velocity;
            Quaternion transferRotation = _ballRigidbody.rotation;

            // Notify all clients to prepare for ownership transfer
            PrepareForOwnershipTransferClientRpc();

            // Change ownership
            NetworkObject.ChangeOwnership(requestedOwnerId);
            currentOwner.Value = requestedOwnerId;

            // Sync state to all clients
            SyncStateAfterTransferClientRpc(transferPosition, transferRotation, transferVelocity);

            lastTransferTime = Time.time;
            Debug.Log($"[BallPhysicsNetwork] Transfer Complete - To: {requestedOwnerId}, Vel: {transferVelocity.magnitude:F2}");
        }
    }

    [ClientRpc]
    private void PrepareForOwnershipTransferClientRpc()
    {
        isTransferringOwnership = true;
    }

    [ClientRpc]
    private void SyncStateAfterTransferClientRpc(Vector3 position, Quaternion rotation, Vector3 velocity)
    {
        // Apply state to all clients
        //_ballRigidbody.velocity = velocity; //Setting linear velocity of a kinematic body is not supported.

        if (IsOwner)
        {
            // New owner updates network variables
            syncedPosition.Value = position;
            syncedRotation.Value = rotation;
            syncedVelocity.Value = velocity;
            isTransferringOwnership = false;
        }

        // For non-owners, smoothly interpolate to the new position
        _ballRigidbody.MovePosition(position);
        _ballRigidbody.MoveRotation(rotation);

        Debug.Log($"[BallPhysicsNetwork] State Synced - IsOwner: {IsOwner}, Vel: {velocity.magnitude:F2}");
    }

    private void OnCollisionEnter(Collision collision)
    {
        if (!IsOwner || isTransferringOwnership) return;
        
        if (collision.gameObject.CompareTag("Tag_TennisRacketRight") || collision.gameObject.CompareTag("Tag_TennisRacketLeft"))
        {
            HandleRacketCollision(collision);
        }
    }

    private void HandleRacketCollision(Collision collision)
    {
        Rigidbody racketRb = collision.gameObject.GetComponent<Rigidbody>();
        if (racketRb == null) return;

        // Apply force based on racket velocity
        Vector3 appliedForce = racketRb.velocity * _forceMultiplier;
        _ballRigidbody.AddForce(appliedForce, ForceMode.Force);
        
        // Clamp velocity to max speed
        _ballRigidbody.velocity = Vector3.ClampMagnitude(_ballRigidbody.velocity, _maxSpeed);
        
        // Update synced velocity immediately
        syncedVelocity.Value = _ballRigidbody.velocity;
        
        Debug.Log($"[BallPhysicsNetwork] Racket Hit - Force: {appliedForce.magnitude:F2}, Velocity: {_ballRigidbody.velocity.magnitude:F2}");
        
        NotifyBallHitClientRpc();
    }

    [ClientRpc]
    private void NotifyBallHitClientRpc()
    {
        if (!IsOwner)
        {
            PlayHitEffects();
        }
    }

    private void PlayHitEffects()
    {
        // Add visual/audio effects here
        Debug.Log("[BallPhysicsNetwork] Hit Effects Played");
    }
}