HealthComponent.cs
using Sandbox;
using System;

public sealed class HealthComponent : Component
{
	[RequireComponent] private Player Player { get; set; }
	[Property] public float MaxHealth { get; set; } = 100f;
	/// <summary>
	/// Amount of health regenerated per second
	/// </summary>
	[Property] public float RegenPerSec { get; set; } = 20f;
	/// <summary>
	/// Seconds without taking damage for health regen to start
	/// </summary>
	[Property] public float RegenTime { get; set; } = 5f;

	// ---- THE FIX: Use a Change Hook for network synchronization ----
	[Sync, Change( nameof( OnHealthChanged ) )]
	public float CurrentHealth { get; private set; }

	public bool IsDead => CurrentHealth <= 0f;

	public Action OnDeath { get; set; }

	public bool IsRegening { get; set; }

	private float _respawnTime = 10f;
	private bool _deathHandled;
	private bool _corpseDestroyPending;
	private float _corpseDestroyAt;
	private Guid _lastAttackerId;
	private float _lastDamagedTime;
	private Vector3 _lastDamageImpulse;
	private string _lastWeaponIconPath = "";

	protected override void OnStart()
	{
		CurrentHealth = MaxHealth;
	}

	/// <summary>
	/// Applies damage and remembers the impact impulse so a lethal hit can transfer
	/// its momentum to the spawned ragdoll.
	/// </summary>
	[Rpc.Broadcast]
	public void TakeDamage( float amount, Vector3 impulse, bool headshot, string weaponIconPath = "" )
	{
		if ( IsDead || DeathmatchGame.Instance.State != MatchState.IN_PROGRESS ) return;

		_lastAttackerId = Rpc.Caller.Id;
		_lastDamagedTime = Time.Now;
		_lastDamageImpulse = impulse;
		_lastWeaponIconPath = weaponIconPath ?? "";

		// The player owner is the single authority for their health. Having both
		// owner and host process this can produce duplicate/out-of-order deaths.
		if ( !Network.IsOwner )
			return;

		var attackerId = Rpc.Caller.Id;
		var newHealth = MathF.Max( 0f, CurrentHealth - amount );
		CurrentHealth = newHealth;

		if ( newHealth <= 0f )
			ConfirmDeath( attackerId, impulse, headshot, _lastWeaponIconPath );
	}

	/// <summary>
	/// Health replication updates UI/state; death effects are driven by ConfirmDeath
	/// so the lethal impulse and attacker identity arrive atomically.
	/// </summary>
	private void OnHealthChanged( float oldVal, float newVal )
	{
		// Intentionally empty. See ConfirmDeath.
	}

	[Rpc.Broadcast]
	private void ConfirmDeath( Guid attackerId, Vector3 impulse, bool headshot, string weaponIconPath = "" )
	{
		_lastAttackerId = attackerId;
		_lastDamageImpulse = impulse;
		_lastWeaponIconPath = weaponIconPath ?? "";
		HandleDeathShared( headshot );
	}

	private void HandleDeathShared(bool headshot)
	{
		if ( _deathHandled )
			return;

		_deathHandled = true;
		OnDeath?.Invoke();

		Tags.Add( "dead" );

		var isSuicide = _lastAttackerId == Network.OwnerId;
		KillFeed.Add( _lastAttackerId, Network.OwnerId, headshot, isSuicide, _lastWeaponIconPath );

		if ( Network.IsOwner )
		{
			Player.Inventory?.DropAllItems();
			PlayerSession.CurrentSession.LastKiller = _lastAttackerId;
			DeathmatchGame.Instance.HandleDeath( isSuicide );

			// Create one authoritative ragdoll on the dead player's owning client.
			// Its ModelPhysics transforms are then replicated to everyone instead
			// of each client running a divergent local ragdoll simulation.
			var ragdoll = Player.CreateRagdoll();
			var impulseApplier = ragdoll.AddComponent<RagdollImpulseApplier>();
			impulseApplier.Impulse = _lastDamageImpulse;

			var temporaryEffect = ragdoll.AddComponent<TemporaryEffect>();
			temporaryEffect.DestroyAfterSeconds = _respawnTime;
			temporaryEffect.WaitForChildEffects = false;

			ragdoll.NetworkSpawn();
		}

		if ( Connection.Local.Id == _lastAttackerId && IsProxy )
		{
			DeathmatchGame.Instance.HandleKill(headshot);
			if ( Network.OwnerId == PlayerSession.CurrentSession.LastKiller )
			{
				Sandbox.Services.Achievements.Unlock( $"revenge" );
			}
		}

		Player.HideBody( showShadows: false );

		if ( Networking.IsHost )
		{
			_corpseDestroyPending = true;
			_corpseDestroyAt = Time.Now + _respawnTime;
		}
	}

	protected override void OnUpdate()
	{
		HandleHealthRegen();
		HandleDestroyCorpse();
	}

	private void HandleHealthRegen()
	{
		if ( IsProxy || IsDead )
			return;

		IsRegening = false;

		if ( CurrentHealth >= MaxHealth )
			return;

		// Wait until we've gone RegenTime seconds without taking damage.
		if ( Time.Now - _lastDamagedTime < RegenTime )
			return;

		IsRegening = true;

		CurrentHealth = Math.Min(
			MaxHealth,
			CurrentHealth + RegenPerSec * Time.Delta
		);
	}

	private void HandleDestroyCorpse()
	{
		if ( !_corpseDestroyPending || Time.Now < _corpseDestroyAt )
			return;

		_corpseDestroyPending = false;

		if ( !Networking.IsHost || !GameObject.IsValid() )
			return;

		var owner = Network.Owner;
		NetworkManager.Instance.RespawnPlayer( owner );
	}
}