Nightmare with Netcode!

I’m trying to build a TPS using Starter Assets - ThirdPerson | Updates in new CharacterController package | Essentials | Unity Asset Store for now and I’m getting a lot of odd behavior and I’m tired of tweaking this, any feedback would be appreciated. I’m using latest version of netcode. Id like a player to select different types of players before spawning as or host or as a client(joining host) I can get host and client to connect but players can’t see each other, also I have 2 player prefabs and I can only select one or other on both client and host or it doesn’t work. Im using the person controller from the Starter Assets package (end of this)

This is my player selection UI and spawn logic (at least I think)
using UnityEngine;
using UnityEngine.UI;
using Unity.Netcode;
using UnityEngine.Events;
using System.Collections.Generic;

[System.Serializable]
public class PlayerSelectionPrefab
{
public GameObject prefab; // Direct reference to the prefab to spawn
public Image characterImage;
public Button selectionButton;
public UnityEvent onSelected = new UnityEvent(); // Unity Event to handle character selection
}

public class PlayerSelectionUI : NetworkBehaviour
{
public RectTransform uiPanel;
public GameObject playerSelectionPanel;
public PlayerSelectionPrefab[ ] playerSelections;
private Dictionary<ulong, int> clientPrefabSelectionIndices = new Dictionary<ulong, int>();
public GameObject spawnLocationGameObject;
public float buttonOffsetY = -100f;
public GameObject startingMenuPanel;
public Button playButton;
private int hostPrefabIndex = -1;
private int clientPrefabIndex = -1;
private int clientSelectedCharacterIndex = -1;
// New variables for host/client selection UI
public GameObject hostClientSelectionPanel;
public Button hostButton;
public Button clientButton;
private GameObject selectedPrefab;

private Transform spawnLocation;

void Update()
{
if (!IsOwner) return;

// Handle movement and other inputs here
}
void Start()
{
startingMenuPanel.SetActive(true);
playerSelectionPanel.SetActive(false);
hostClientSelectionPanel.SetActive(false); // Initialize as inactive

spawnLocation = spawnLocationGameObject.transform;
SetupPlayerSelectionUI();

playButton.onClick.AddListener(ShowCharacterSelection);
hostButton.onClick.AddListener(StartHost);
clientButton.onClick.AddListener(StartClient);
}

private void SetupPlayerSelectionUI()
{
float totalWidth = uiPanel.rect.width;
float spacing = totalWidth / (playerSelections.Length + 1);

for (int i = 0; i < playerSelections.Length; i++)
{
RectTransform imageRect = playerSelections*.characterImage.rectTransform;*
float normalizedPosition = (i + 1) / (float)(playerSelections.Length + 1);
imageRect.anchoredPosition = new Vector2(normalizedPosition * totalWidth - (totalWidth / 2), imageRect.anchoredPosition.y);
RectTransform buttonRect = playerSelections*.selectionButton.GetComponent();*
buttonRect.anchoredPosition = new Vector2(imageRect.anchoredPosition.x, imageRect.anchoredPosition.y + buttonOffsetY);
int index = i;
playerSelections*.onSelected.AddListener(() => OnCharacterSelected(index));*
}
}
[ServerRpc]
private void SelectCharacterServerRpc(int characterIndex, ServerRpcParams rpcParams = default)
{
ulong clientId = rpcParams.Receive.SenderClientId;
SpawnCharacterForClient(clientId, characterIndex);
}
public void ShowCharacterSelection()
{
startingMenuPanel.SetActive(false);
playerSelectionPanel.SetActive(true);
}
public void OnCharacterSelected(int index)
{
if (IsClient && IsOwner)
{
// Send the selected character index to the server
SelectCharacterServerRpc(index);
}
// Transition to host/client selection panel after selecting a character
playerSelectionPanel.SetActive(false);
hostClientSelectionPanel.SetActive(true);
}
public void StartHost()
{
// Only allow host to start the session
if (!NetworkManager.Singleton.IsClient && !NetworkManager.Singleton.IsServer)
{
NetworkManager.Singleton.StartHost();
hostClientSelectionPanel.SetActive(false);
// Once the host starts, spawn the selected character
if (hostPrefabIndex != -1)
{
RequestSpawnServerRpc(playerSelections[hostPrefabIndex].prefab.GetComponent().NetworkObjectId);
}
}
else
{
Debug.LogWarning(“A network session is already running or you are not allowed to start a host.”);
}
}
private void StartClient()
{
NetworkManager.Singleton.StartClient();
hostClientSelectionPanel.SetActive(false);
// Optionally, show a UI for connecting to the host or update UI elements as needed
}
private void SpawnCharacterForClient(ulong clientId, int characterIndex)
{
if (!IsServer) return;
// Ensure the characterIndex is within the bounds of the playerSelections array
if (characterIndex >= 0 && characterIndex < playerSelections.Length)
{
GameObject prefabToSpawn = playerSelections[characterIndex].prefab;
Vector3 spawnPosition = spawnLocation.position;
GameObject playerObject = Instantiate(prefabToSpawn, spawnPosition, Quaternion.identity);
NetworkObject networkObject = playerObject.GetComponent();
if (networkObject != null)
{
networkObject.SpawnAsPlayerObject(clientId);
}
else
{
Debug.LogError(“The selected prefab does not have a NetworkObject component.”);
}
}
else
{
Debug.LogError(“Invalid character index for playerSelections array.”);
}
}
[ServerRpc(RequireOwnership = false)]
private void RequestSpawnServerRpc(ulong prefabNetworkObjectId, ServerRpcParams rpcParams = default)
{
var spawnPosition = spawnLocation.position;
// Spawn the selected prefab for the player
foreach (var playerSelection in playerSelections)
{
if (playerSelection.prefab.GetComponent().NetworkObjectId == prefabNetworkObjectId)
{
var playerObject = Instantiate(playerSelection.prefab, spawnPosition, Quaternion.identity);
playerObject.GetComponent().Spawn();
break;
}
}
}
}
------------------------------------------------------------------------------
I added this but it does nothing…
using UnityEngine;
using Unity.Netcode;
public class NetworkedPrefab : NetworkBehaviour
{
// Update rate for synchronizing position and rotation
[SerializeField] private float _synchronizationInterval = 0.1f;
// Variables to store the last synchronized position and rotation
private Vector3 _lastPosition;
private Quaternion _lastRotation;
private void Start()
{
// Initialize last position and rotation
_lastPosition = transform.position;
_lastRotation = transform.rotation;
// Check if this instance is the owner (host or client)
if (IsOwner)
{
// Start sending updates to the server
InvokeRepeating(nameof(SendUpdateToServer), 0f, _synchronizationInterval);
}
}
private void SendUpdateToServer()
{
// Check if the position or rotation has changed since the last update
if (Vector3.Distance(transform.position, _lastPosition) > 0.01f || Quaternion.Angle(transform.rotation, _lastRotation) > 1f)
{
// Update last synchronized position and rotation
_lastPosition = transform.position;
_lastRotation = transform.rotation;
// Send position and rotation updates to the server
UpdatePositionAndRotationServerRpc(_lastPosition, _lastRotation);
}
}
[ServerRpc]
private void UpdatePositionAndRotationServerRpc(Vector3 position, Quaternion rotation)
{
// Update position and rotation on all clients
transform.position = position;
transform.rotation = rotation;
// Send position and rotation updates to all clients except the owner
UpdatePositionAndRotationClientRpc(position, rotation);
}
[ClientRpc]
private void UpdatePositionAndRotationClientRpc(Vector3 position, Quaternion rotation)
{
// Update position and rotation on all clients except the owner
if (!IsOwner)
{
transform.position = position;
transform.rotation = rotation;
}
}
}
------------------------------------------------------------------------------------
using UnityEngine;
using UnityEngine.UI;
using Unity.Netcode;
using UnityEngine.Events;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
/* Note: animations are called via the controller for both the character and capsule using animator null checks
*/
namespace StarterAssets
{
[RequireComponent(typeof(CharacterController))]
#if ENABLE_INPUT_SYSTEM
[RequireComponent(typeof(PlayerInput))]
#endif
public class ThirdPersonController : NetworkBehaviour
{
[Header(“Player”)]
[Tooltip(“Move speed of the character in m/s”)]
public float MoveSpeed = 2.0f;
[Tooltip(“Sprint speed of the character in m/s”)]
public float SprintSpeed = 5.335f;
[Tooltip(“How fast the character turns to face movement direction”)]
[Range(0.0f, 0.3f)]
public float RotationSmoothTime = 0.12f;
[Tooltip(“Acceleration and deceleration”)]
public float SpeedChangeRate = 10.0f;
public AudioClip LandingAudioClip;
public AudioClip[ ] FootstepAudioClips;
[Range(0, 1)] public float FootstepAudioVolume = 0.5f;
[Space(10)]
[Tooltip(“The height the player can jump”)]
public float JumpHeight = 1.2f;
[Tooltip(“The character uses its own gravity value. The engine default is -9.81f”)]
public float Gravity = -15.0f;
[Space(10)]
[Tooltip(“Time required to pass before being able to jump again. Set to 0f to instantly jump again”)]
public float JumpTimeout = 0.50f;
[Tooltip(“Time required to pass before entering the fall state. Useful for walking down stairs”)]
public float FallTimeout = 0.15f;
[Header(“Player Grounded”)]
[Tooltip(“If the character is grounded or not. Not part of the CharacterController built in grounded check”)]
public bool Grounded = true;
[Tooltip(“Useful for rough ground”)]
public float GroundedOffset = -0.14f;
[Tooltip(“The radius of the grounded check. Should match the radius of the CharacterController”)]
public float GroundedRadius = 0.28f;
[Tooltip(“What layers the character uses as ground”)]
public LayerMask GroundLayers;
[Header(“Cinemachine”)]
[Tooltip(“The follow target set in the Cinemachine Virtual Camera that the camera will follow”)]
public GameObject CinemachineCameraTarget;
[Tooltip(“How far in degrees can you move the camera up”)]
public float TopClamp = 70.0f;
[Tooltip(“How far in degrees can you move the camera down”)]
public float BottomClamp = -30.0f;
[Tooltip(“Additional degress to override the camera. Useful for fine tuning camera position when locked”)]
public float CameraAngleOverride = 0.0f;
[Tooltip(“For locking the camera position on all axis”)]
public bool LockCameraPosition = false;
// cinemachine
private float _cinemachineTargetYaw;
private float _cinemachineTargetPitch;
// player
private float _speed;
private float _animationBlend;
private float _targetRotation = 0.0f;
private float _rotationVelocity;
private float _verticalVelocity;
private float _terminalVelocity = 53.0f;
// timeout deltatime
private float _jumpTimeoutDelta;
private float _fallTimeoutDelta;
// animation IDs
private int _animIDSpeed;
private int _animIDGrounded;
private int _animIDJump;
private int _animIDFreeFall;
private int _animIDMotionSpeed;
#if ENABLE_INPUT_SYSTEM
private PlayerInput _playerInput;
#endif
private Animator _animator;
private CharacterController _controller;
private StarterAssetsInputs _input;
private GameObject _mainCamera;
private const float _threshold = 0.01f;
private bool _hasAnimator;
private bool IsCurrentDeviceMouse
{
get
{
#if ENABLE_INPUT_SYSTEM
return _playerInput.currentControlScheme == “KeyboardMouse”;
#else
return false;
#endif
}
}
private void Awake()
{
// get a reference to our main camera
if (_mainCamera == null)
{
_mainCamera = GameObject.FindGameObjectWithTag(“MainCamera”);
}
}
private void Start()
{
_cinemachineTargetYaw = CinemachineCameraTarget.transform.rotation.eulerAngles.y;

_hasAnimator = TryGetComponent(out _animator);
_controller = GetComponent();
_input = GetComponent();
#if ENABLE_INPUT_SYSTEM
_playerInput = GetComponent();
#else
Debug.LogError( “Starter Assets package is missing dependencies. Please use Tools/Starter Assets/Reinstall Dependencies to fix it”);
#endif
AssignAnimationIDs();
// reset our timeouts on start
_jumpTimeoutDelta = JumpTimeout;
_fallTimeoutDelta = FallTimeout;
}
private void Update()
{
_hasAnimator = TryGetComponent(out _animator);
JumpAndGravity();
GroundedCheck();
Move();
}
private void LateUpdate()
{
CameraRotation();
}
private void AssignAnimationIDs()
{
_animIDSpeed = Animator.StringToHash(“Speed”);
_animIDGrounded = Animator.StringToHash(“Grounded”);
_animIDJump = Animator.StringToHash(“Jump”);
_animIDFreeFall = Animator.StringToHash(“FreeFall”);
_animIDMotionSpeed = Animator.StringToHash(“MotionSpeed”);
}
private void GroundedCheck()
{
// set sphere position, with offset
Vector3 spherePosition = new Vector3(transform.position.x, transform.position.y - GroundedOffset,
transform.position.z);
Grounded = Physics.CheckSphere(spherePosition, GroundedRadius, GroundLayers,
QueryTriggerInteraction.Ignore);
// update animator if using character
if (_hasAnimator)
{
_animator.SetBool(_animIDGrounded, Grounded);
}
}
private void CameraRotation()
{
// if there is an input and camera position is not fixed
if (_input.look.sqrMagnitude >= _threshold && !LockCameraPosition)
{
//Don’t multiply mouse input by Time.deltaTime;
float deltaTimeMultiplier = IsCurrentDeviceMouse ? 1.0f : Time.deltaTime;
cinemachineTargetYaw += input.look.x * deltaTimeMultiplier;
cinemachineTargetPitch += input.look.y * deltaTimeMultiplier;
}

// clamp our rotations so our values are limited 360 degrees

_cinemachineTargetYaw = ClampAngle(_cinemachineTargetYaw, float.MinValue, float.MaxValue);
_cinemachineTargetPitch = ClampAngle(_cinemachineTargetPitch, BottomClamp, TopClamp);
// Cinemachine will follow this target
CinemachineCameraTarget.transform.rotation = Quaternion.Euler(_cinemachineTargetPitch + CameraAngleOverride,
_cinemachineTargetYaw, 0.0f);
}
private void Move()
{
// set target speed based on move speed, sprint speed and if sprint is pressed
float targetSpeed = _input.sprint ? SprintSpeed : MoveSpeed;
// a simplistic acceleration and deceleration designed to be easy to remove, replace, or iterate upon
// note: Vector2’s == operator uses approximation so is not floating point error prone, and is cheaper than magnitude
// if there is no input, set the target speed to 0
if (_input.move == Vector2.zero) targetSpeed = 0.0f;
// a reference to the players current horizontal velocity
float currentHorizontalSpeed = new Vector3(_controller.velocity.x, 0.0f, _controller.velocity.z).magnitude;
float speedOffset = 0.1f;
float inputMagnitude = _input.analogMovement ? _input.move.magnitude : 1f;
// accelerate or decelerate to target speed
if (currentHorizontalSpeed < targetSpeed - speedOffset ||
currentHorizontalSpeed > targetSpeed + speedOffset)
{
// creates curved result rather than a linear one giving a more organic speed change
// note T in Lerp is clamped, so we don’t need to clamp our speed
speed = Mathf.Lerp(currentHorizontalSpeed, targetSpeed * inputMagnitude,
Time.deltaTime * SpeedChangeRate);
// round speed to 3 decimal places
speed = Mathf.Round(speed * 1000f) / 1000f;
}

else
{
_speed = targetSpeed;
}
animationBlend = Mathf.Lerp(animationBlend, targetSpeed, Time.deltaTime * SpeedChangeRate);
if (_animationBlend < 0.01f) _animationBlend = 0f;
// normalise input direction

Vector3 inputDirection = new Vector3(_input.move.x, 0.0f, _input.move.y).normalized;
// note: Vector2’s != operator uses approximation so is not floating point error prone, and is cheaper than magnitude
// if there is a move input rotate player when the player is moving
if (_input.move != Vector2.zero)
{
targetRotation = Mathf.Atan2(inputDirection.x, inputDirection.z) * Mathf.Rad2Deg +
_mainCamera.transform.eulerAngles.y;
float rotation = Mathf.SmoothDampAngle(transform.eulerAngles.y, _targetRotation, ref _rotationVelocity,
RotationSmoothTime);

// rotate to face input direction relative to camera position
transform.rotation = Quaternion.Euler(0.0f, rotation, 0.0f);
}
Vector3 targetDirection = Quaternion.Euler(0.0f, targetRotation, 0.0f) * Vector3.forward;
// move the player
controller.Move(targetDirection.normalized * (speed * Time.deltaTime) +
new Vector3(0.0f, verticalVelocity, 0.0f) * Time.deltaTime);
// update animator if using character

if (_hasAnimator)
{

_animator.SetFloat(_animIDSpeed, _animationBlend);
_animator.SetFloat(_animIDMotionSpeed, inputMagnitude);
}
}
private void JumpAndGravity()
{
if (Grounded)
{
// reset the fall timeout timer
_fallTimeoutDelta = FallTimeout;
// update animator if using character
if (_hasAnimator)
{
_animator.SetBool(_animIDJump, false);
_animator.SetBool(_animIDFreeFall, false);
}
// stop our velocity dropping infinitely when grounded
if (_verticalVelocity < 0.0f)
{
_verticalVelocity = -2f;
}
// Jump
if (_input.jump && _jumpTimeoutDelta <= 0.0f)
{
// the square root of H * -2 * G = how much velocity needed to reach desired height
verticalVelocity = Mathf.Sqrt(JumpHeight * -2f * Gravity);
// update animator if using character
if (_hasAnimator)
{
_animator.SetBool(_animIDJump, true);
}
}
// jump timeout
if (_jumpTimeoutDelta >= 0.0f)
{
_jumpTimeoutDelta -= Time.deltaTime;
}
}
else
{
// reset the jump timeout timer
_jumpTimeoutDelta = JumpTimeout;
// fall timeout
if (_fallTimeoutDelta >= 0.0f)
{
_fallTimeoutDelta -= Time.deltaTime;
}
else
{
// update animator if using character
if (_hasAnimator)
{
_animator.SetBool(_animIDFreeFall, true);
}
}
// if we are not grounded, do not jump
_input.jump = false;
}
// apply gravity over time if under terminal (multiply by delta time twice to linearly speed up over time)
if (_verticalVelocity < _terminalVelocity)
{
verticalVelocity += Gravity * Time.deltaTime;
}

}
private static float ClampAngle(float lfAngle, float lfMin, float lfMax)
{
if (lfAngle < -360f) lfAngle += 360f;
if (lfAngle > 360f) lfAngle -= 360f;
return Mathf.Clamp(lfAngle, lfMin, lfMax);
}
private void OnDrawGizmosSelected()
{
Color transparentGreen = new Color(0.0f, 1.0f, 0.0f, 0.35f);
Color transparentRed = new Color(1.0f, 0.0f, 0.0f, 0.35f);
if (Grounded) Gizmos.color = transparentGreen;
else Gizmos.color = transparentRed;
// when selected, draw a gizmo in the position of, and matching radius of, the grounded collider
Gizmos.DrawSphere(
new Vector3(transform.position.x, transform.position.y - GroundedOffset, transform.position.z),
GroundedRadius);
}
private void OnFootstep(AnimationEvent animationEvent)
{
if (animationEvent.animatorClipInfo.weight > 0.5f)
{
if (FootstepAudioClips.Length > 0)
{
var index = Random.Range(0, FootstepAudioClips.Length);
AudioSource.PlayClipAtPoint(FootstepAudioClips[index], transform.TransformPoint(_controller.center), FootstepAudioVolume);
}
}
}
private void OnLand(AnimationEvent animationEvent)
{
if (animationEvent.animatorClipInfo.weight > 0.5f)
{
AudioSource.PlayClipAtPoint(LandingAudioClip, transform.TransformPoint(_controller.center), FootstepAudioVolume);
}
}
}
}

Networking is hard. :wink:
Way harder than most would tend to believe.
Be sure to take very simply steps one at a time. First the players should spawn for each other and be locally controllable. Until you get that working under all conditions there’s absolutely no point in trying to work on other things like player mesh selection, audio playback, character animations - just to name a few things I noticed in this wall of code (please use code tags, this is not legible code). In fact, if you try to jump ahead and do a second thing before the first one works perfectly, it’s only going to get harder to make both things work.

Expect constantly having to change things. And keep your scripts simple, ideally a networked script should only do one simple thing. For example, I have three NetworkWeapon scripts: one for switching weapons and providing the active weapon, one for shooting the weapon and one for reloading it. That makes it conceptually a lot easier to reason and debug when each network script only focuses on one simple aspect.

It has taken me 3 weeks now to enable splitscreen networked first-person multiplayer where each local player has its own first person view while everyone else sees the third person view. I have just yesterday enabled networked weapon switching but haven’t done projectile spawning nor displaying the weapon in third person (only first person). Just to give a sense of how much time it takes someone with a lot of experience to implement even the basics.

I’ll publish this work as a multiplayer action game template which will be available likely end of April. I’ll likely call it “MultiPal” unless I come up with a better name so check the store in another 6 weeks or so. :wink:

1 Like