process TODO.md step4

This commit is contained in:
SepComet 2026-03-28 15:50:44 +08:00
parent 4a152e765b
commit f0064e2ae5
22 changed files with 850 additions and 62 deletions

View File

@ -0,0 +1,62 @@
using System;
using Network.Defines;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
public sealed class ClientAuthoritativePlayerState
{
public ClientAuthoritativePlayerStateSnapshot Current { get; private set; }
public bool TryAccept(PlayerState state, out ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (state == null)
{
throw new ArgumentNullException(nameof(state));
}
if (Current != null && state.Tick <= Current.Tick)
{
snapshot = Current;
return false;
}
snapshot = new ClientAuthoritativePlayerStateSnapshot(state);
Current = snapshot;
return true;
}
}
public sealed class ClientAuthoritativePlayerStateSnapshot
{
public ClientAuthoritativePlayerStateSnapshot(PlayerState state)
{
if (state == null)
{
throw new ArgumentNullException(nameof(state));
}
SourceState = state.Clone();
PlayerId = SourceState.PlayerId ?? string.Empty;
Tick = SourceState.Tick;
Position = SourceState.Position != null ? SourceState.Position.ToVector3() : Vector3.zero;
Velocity = SourceState.Velocity != null ? SourceState.Velocity.ToVector3() : Vector3.zero;
Rotation = SourceState.Rotation;
Hp = SourceState.Hp;
}
public PlayerState SourceState { get; }
public string PlayerId { get; }
public long Tick { get; }
public Vector3 Position { get; }
public Vector3 Velocity { get; }
public float Rotation { get; }
public int Hp { get; }
public Quaternion RotationQuaternion => Quaternion.Euler(0f, Rotation, 0f);
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 973b463b11350f445801c3673c585625
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -63,16 +63,14 @@ public class MovementComponent : MonoBehaviour
private Vector3 _serverPosition; private Vector3 _serverPosition;
private bool _hasServerState = false; private bool _hasServerState = false;
private PlayerState _lastServerState; private ClientAuthoritativePlayerStateSnapshot _lastAuthoritativeState;
public long Tick { get; private set; } = 0; public long Tick { get; private set; } = 0;
private long _startTickOffset = 0; private long _startTickOffset = 0;
private long _currentTickOffset = 0; private long _currentTickOffset = 0;
private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer(); private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
private Vector3 _serverPos; private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new();
private Vector3 _currentPos;
private float _lerpTime;
[SerializeField] private float _lerpRate = 0.1f; [SerializeField] private float _lerpRate = 0.1f;
private Vector3 _cachedMoveInput; private Vector3 _cachedMoveInput;
private Vector3 _lastAimDirection = Vector3.forward; private Vector3 _lastAimDirection = Vector3.forward;
@ -88,7 +86,7 @@ public class MovementComponent : MonoBehaviour
_rigid.interpolation = RigidbodyInterpolation.Interpolate; _rigid.interpolation = RigidbodyInterpolation.Interpolate;
_rigid.isKinematic = !isControlled; _rigid.isKinematic = !isControlled;
_rigid.velocity = Vector3.zero; _rigid.velocity = Vector3.zero;
if (serverTick != 0 && _isControlled) MainUI.Instance.OnStartTickOffsetChanged(serverTick); if (serverTick != 0 && _isControlled && MainUI.Instance != null) MainUI.Instance.OnStartTickOffsetChanged(serverTick);
} }
private void Update() private void Update()
@ -137,9 +135,13 @@ public class MovementComponent : MonoBehaviour
if (_isControlled) if (_isControlled)
{ {
if (_hasServerState) if (_hasServerState)
{
if (MainUI.Instance != null)
{ {
MainUI.Instance.OnServerPosChanged(_serverPosition); MainUI.Instance.OnServerPosChanged(_serverPosition);
Reconcile(_lastServerState); }
Reconcile(_lastAuthoritativeState);
_hasServerState = false; _hasServerState = false;
} }
@ -147,21 +149,27 @@ public class MovementComponent : MonoBehaviour
} }
else else
{ {
_lerpTime += Time.fixedDeltaTime / 0.05f; var sample = _remoteSnapshotInterpolator.Sample(Time.time);
_rigid.MovePosition(Vector3.Lerp(_currentPos, _serverPos, _lerpTime)); if (sample.HasValue)
{
_rigid.MovePosition(sample.Position);
_rigid.MoveRotation(sample.Rotation);
_rigid.velocity = sample.Velocity;
}
} }
} }
private void Reconcile(PlayerState state) private void Reconcile(ClientAuthoritativePlayerStateSnapshot snapshot)
{ {
if (!_predictionBuffer.TryApplyAuthoritativeState(state, out var replayInputs)) if (!_predictionBuffer.TryApplyAuthoritativeState(snapshot.SourceState, out var replayInputs))
{ {
return; return;
} }
_serverPosition = state.Position.ToVector3(); _serverPosition = snapshot.Position;
_rigid.position = Vector3.Lerp(_rigid.position, _serverPosition, _lerpRate); _rigid.position = Vector3.Lerp(_rigid.position, _serverPosition, _lerpRate);
_rigid.velocity = Vector3.zero; _rigid.rotation = Quaternion.Slerp(_rigid.rotation, snapshot.RotationQuaternion, _lerpRate);
_rigid.velocity = snapshot.Velocity;
ReplayPendingInputs(replayInputs); ReplayPendingInputs(replayInputs);
} }
@ -196,35 +204,25 @@ public class MovementComponent : MonoBehaviour
{ {
_rigid.velocity = _speed * input; _rigid.velocity = _speed * input;
if (_isControlled) if (_isControlled)
{
if (MainUI.Instance != null)
{ {
MainUI.Instance.OnClientPosChanged(_rigid.position); MainUI.Instance.OnClientPosChanged(_rigid.position);
} }
} }
}
public void OnServerState(PlayerState state) public void OnAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
{ {
if (_isControlled) if (_isControlled)
{ {
if (_predictionBuffer.LastAuthoritativeTick.HasValue && _lastAuthoritativeState = snapshot;
state.Tick <= _predictionBuffer.LastAuthoritativeTick.Value)
{
return;
}
_lastServerState = state;
_hasServerState = true; _hasServerState = true;
} }
else else
{ {
if (_lastServerState != null && state.Tick < _lastServerState.Tick) _lastAuthoritativeState = snapshot;
{ _remoteSnapshotInterpolator.TryAddSnapshot(snapshot, Time.time);
return;
}
_lastServerState = state;
_serverPos = state.Position.ToVector3();
_currentPos = _rigid.position;
_lerpTime = 0f;
} }
} }
@ -232,9 +230,12 @@ public class MovementComponent : MonoBehaviour
{ {
_currentTickOffset = serverTick - Tick - _startTickOffset; _currentTickOffset = serverTick - Tick - _startTickOffset;
if (_isControlled) if (_isControlled)
{
if (MainUI.Instance != null)
{ {
MainUI.Instance.OnServerTickChanged(serverTick); MainUI.Instance.OnServerTickChanged(serverTick);
} }
}
if (_currentTickOffset < 0) if (_currentTickOffset < 0)
{ {
@ -254,8 +255,11 @@ public class MovementComponent : MonoBehaviour
} }
if (_isControlled) if (_isControlled)
{
if (MainUI.Instance != null)
{ {
MainUI.Instance.OnClientPosChanged(_rigid.position); MainUI.Instance.OnClientPosChanged(_rigid.position);
} }
} }
}
} }

View File

@ -5,12 +5,14 @@ public class Player : MonoBehaviour
{ {
//[SerializeField] private float _moveSpeed = 10f; //[SerializeField] private float _moveSpeed = 10f;
public string PlayerId { get; private set; } = "1001"; public string PlayerId { get; private set; } = "1001";
public ClientAuthoritativePlayerStateSnapshot AuthoritativeState => _authoritativeState.Current;
[SerializeField] private MeshRenderer _meshRenderer; [SerializeField] private MeshRenderer _meshRenderer;
[SerializeField] private Material[] _materials; [SerializeField] private Material[] _materials;
[SerializeField] private Camera _camera; [SerializeField] private Camera _camera;
[SerializeField] private MovementComponent _movement; [SerializeField] private MovementComponent _movement;
[SerializeField] private PlayerUI _playerUI; [SerializeField] private PlayerUI _playerUI;
[SerializeField] private bool _isControlled; [SerializeField] private bool _isControlled;
private readonly ClientAuthoritativePlayerState _authoritativeState = new();
public void LocalInit(string playerId, int speed, long serverTick) public void LocalInit(string playerId, int speed, long serverTick)
{ {
@ -49,8 +51,13 @@ public class Player : MonoBehaviour
public void SyncPosition(PlayerState movement) public void SyncPosition(PlayerState movement)
{ {
if (this._movement == null) return; if (!_authoritativeState.TryAccept(movement, out var snapshot))
_movement.OnServerState(movement); {
return;
}
_playerUI?.SyncAuthoritativeState(snapshot);
_movement?.OnAuthoritativeState(snapshot);
} }
public void SyncTick(long serverTick) public void SyncTick(long serverTick)

View File

@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
public sealed class RemotePlayerSnapshotInterpolator
{
// Keep remote rendering roughly two movement send intervals behind receive time so the client
// usually has both an older and newer authoritative sample to interpolate between.
public const float DefaultInterpolationDelaySeconds = 0.1f;
public const int DefaultMaxBufferedSnapshots = 6;
private readonly List<BufferedSnapshot> _snapshots = new();
private readonly float _interpolationDelaySeconds;
private readonly int _maxBufferedSnapshots;
public RemotePlayerSnapshotInterpolator(
float interpolationDelaySeconds = DefaultInterpolationDelaySeconds,
int maxBufferedSnapshots = DefaultMaxBufferedSnapshots)
{
if (interpolationDelaySeconds < 0f)
{
throw new ArgumentOutOfRangeException(nameof(interpolationDelaySeconds));
}
if (maxBufferedSnapshots < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxBufferedSnapshots));
}
_interpolationDelaySeconds = interpolationDelaySeconds;
_maxBufferedSnapshots = maxBufferedSnapshots;
}
public int BufferedSnapshotCount => _snapshots.Count;
public long LatestBufferedTick => _snapshots.Count == 0 ? -1 : _snapshots[^1].Snapshot.Tick;
public bool TryAddSnapshot(ClientAuthoritativePlayerStateSnapshot snapshot, float receivedAtSeconds)
{
if (snapshot == null)
{
throw new ArgumentNullException(nameof(snapshot));
}
if (_snapshots.Count > 0 && snapshot.Tick <= _snapshots[^1].Snapshot.Tick)
{
return false;
}
_snapshots.Add(new BufferedSnapshot(snapshot, receivedAtSeconds));
TrimOverflow();
return true;
}
public RemotePlayerInterpolationSample Sample(float nowSeconds)
{
if (_snapshots.Count == 0)
{
return RemotePlayerInterpolationSample.None;
}
// Sample against a fixed delayed render timestamp. If the delay cannot be bracketed by two
// buffered authoritative samples, clamp to the newest accepted snapshot instead of predicting.
var renderTime = nowSeconds - _interpolationDelaySeconds;
TrimConsumedSamples(renderTime);
if (_snapshots.Count >= 2)
{
var from = _snapshots[0];
var to = _snapshots[1];
if (from.ReceivedAtSeconds <= renderTime && renderTime <= to.ReceivedAtSeconds)
{
var duration = to.ReceivedAtSeconds - from.ReceivedAtSeconds;
var t = duration <= Mathf.Epsilon ? 1f : Mathf.Clamp01((renderTime - from.ReceivedAtSeconds) / duration);
return RemotePlayerInterpolationSample.Interpolated(
Vector3.Lerp(from.Snapshot.Position, to.Snapshot.Position, t),
Quaternion.Slerp(from.Snapshot.RotationQuaternion, to.Snapshot.RotationQuaternion, t),
Vector3.Lerp(from.Snapshot.Velocity, to.Snapshot.Velocity, t),
to.Snapshot,
from.Snapshot,
t);
}
}
return RemotePlayerInterpolationSample.Latest(_snapshots[^1].Snapshot);
}
private void TrimConsumedSamples(float renderTime)
{
while (_snapshots.Count >= 2 && _snapshots[1].ReceivedAtSeconds <= renderTime)
{
_snapshots.RemoveAt(0);
}
}
private void TrimOverflow()
{
while (_snapshots.Count > _maxBufferedSnapshots)
{
_snapshots.RemoveAt(0);
}
}
private readonly struct BufferedSnapshot
{
public BufferedSnapshot(ClientAuthoritativePlayerStateSnapshot snapshot, float receivedAtSeconds)
{
Snapshot = snapshot;
ReceivedAtSeconds = receivedAtSeconds;
}
public ClientAuthoritativePlayerStateSnapshot Snapshot { get; }
public float ReceivedAtSeconds { get; }
}
}
public readonly struct RemotePlayerInterpolationSample
{
private RemotePlayerInterpolationSample(
bool hasValue,
bool usedInterpolation,
Vector3 position,
Quaternion rotation,
Vector3 velocity,
ClientAuthoritativePlayerStateSnapshot latestSnapshot,
ClientAuthoritativePlayerStateSnapshot fromSnapshot,
float alpha)
{
HasValue = hasValue;
UsedInterpolation = usedInterpolation;
Position = position;
Rotation = rotation;
Velocity = velocity;
LatestSnapshot = latestSnapshot;
FromSnapshot = fromSnapshot;
Alpha = alpha;
}
public static RemotePlayerInterpolationSample None { get; } =
new(false, false, Vector3.zero, Quaternion.identity, Vector3.zero, null, null, 0f);
public bool HasValue { get; }
public bool UsedInterpolation { get; }
public Vector3 Position { get; }
public Quaternion Rotation { get; }
public Vector3 Velocity { get; }
public ClientAuthoritativePlayerStateSnapshot LatestSnapshot { get; }
public ClientAuthoritativePlayerStateSnapshot FromSnapshot { get; }
public float Alpha { get; }
public static RemotePlayerInterpolationSample Latest(ClientAuthoritativePlayerStateSnapshot snapshot)
{
return new RemotePlayerInterpolationSample(
true,
false,
snapshot.Position,
snapshot.RotationQuaternion,
snapshot.Velocity,
snapshot,
snapshot,
1f);
}
public static RemotePlayerInterpolationSample Interpolated(
Vector3 position,
Quaternion rotation,
Vector3 velocity,
ClientAuthoritativePlayerStateSnapshot latestSnapshot,
ClientAuthoritativePlayerStateSnapshot fromSnapshot,
float alpha)
{
return new RemotePlayerInterpolationSample(
true,
true,
position,
rotation,
velocity,
latestSnapshot,
fromSnapshot,
alpha);
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3c88aa7375c5dd94882fe09f5208af9b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -14,7 +14,12 @@ public class PlayerUI : MonoBehaviour
_canvas = this.transform.GetComponent<Canvas>(); _canvas = this.transform.GetComponent<Canvas>();
_mainCamera = Camera.main; _mainCamera = Camera.main;
this._master = master; this._master = master;
this._text.text = _master.PlayerId; RefreshText();
}
public void SyncAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
{
RefreshText(snapshot);
} }
private void FixedUpdate() private void FixedUpdate()
@ -41,4 +46,20 @@ public class PlayerUI : MonoBehaviour
{ {
_isVisible = false; _isVisible = false;
} }
private void RefreshText(ClientAuthoritativePlayerStateSnapshot snapshot = null)
{
if (_text == null || _master == null)
{
return;
}
if (snapshot == null)
{
_text.text = _master.PlayerId;
return;
}
_text.text = $"{_master.PlayerId}\nHP:{snapshot.Hp} Tick:{snapshot.Tick}";
}
} }

View File

@ -89,6 +89,140 @@ namespace Tests.EditMode.Network
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(10)); Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(10));
} }
[Test]
public void ClientAuthoritativePlayerState_NewerSnapshot_ReplacesOwnedStateAndPreservesFields()
{
var owner = new ClientAuthoritativePlayerState();
var accepted = owner.TryAccept(
new PlayerState
{
PlayerId = "player-1",
Tick = 14,
Position = new global::Network.Defines.Vector3 { X = 5f, Y = 0f, Z = -3f },
Velocity = new global::Network.Defines.Vector3 { X = 1.5f, Y = 0f, Z = 0.25f },
Rotation = 90f,
Hp = 73
},
out var snapshot);
Assert.That(accepted, Is.True);
Assert.That(owner.Current, Is.SameAs(snapshot));
Assert.That(snapshot.PlayerId, Is.EqualTo("player-1"));
Assert.That(snapshot.Tick, Is.EqualTo(14));
Assert.That(snapshot.Position, Is.EqualTo(new Vector3(5f, 0f, -3f)));
Assert.That(snapshot.Velocity, Is.EqualTo(new Vector3(1.5f, 0f, 0.25f)));
Assert.That(snapshot.Rotation, Is.EqualTo(90f));
Assert.That(snapshot.RotationQuaternion.eulerAngles.y, Is.EqualTo(90f).Within(0.01f));
Assert.That(snapshot.Hp, Is.EqualTo(73));
}
[Test]
public void ClientAuthoritativePlayerState_StaleSnapshot_IsRejected()
{
var owner = new ClientAuthoritativePlayerState();
owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 10, Hp = 95 }, out var current);
var accepted = owner.TryAccept(
new PlayerState { PlayerId = "player-1", Tick = 9, Hp = 10 },
out var staleResult);
Assert.That(accepted, Is.False);
Assert.That(owner.Current, Is.SameAs(current));
Assert.That(staleResult, Is.SameAs(current));
Assert.That(owner.Current.Hp, Is.EqualTo(95));
}
[Test]
public void ClientAuthoritativePlayerStateSnapshot_ClonesSourceMessage()
{
var source = new PlayerState
{
PlayerId = "player-1",
Tick = 3,
Position = new global::Network.Defines.Vector3 { X = 1f, Y = 0f, Z = 2f },
Rotation = 45f,
Hp = 88
};
var snapshot = new ClientAuthoritativePlayerStateSnapshot(source);
source.Tick = 4;
source.Hp = 10;
source.Position.X = 99f;
Assert.That(snapshot.Tick, Is.EqualTo(3));
Assert.That(snapshot.Hp, Is.EqualTo(88));
Assert.That(snapshot.Position, Is.EqualTo(new Vector3(1f, 0f, 2f)));
Assert.That(snapshot.SourceState.Tick, Is.EqualTo(3));
Assert.That(snapshot.SourceState.Hp, Is.EqualTo(88));
}
[Test]
public void RemotePlayerSnapshotInterpolator_StaleOrDuplicateSnapshots_AreRejected()
{
var interpolator = new RemotePlayerSnapshotInterpolator();
var firstAccepted = interpolator.TryAddSnapshot(CreateSnapshot(10, new Vector3(1f, 0f, 0f)), 1f);
var duplicateAccepted = interpolator.TryAddSnapshot(CreateSnapshot(10, new Vector3(2f, 0f, 0f)), 1.1f);
var staleAccepted = interpolator.TryAddSnapshot(CreateSnapshot(9, new Vector3(3f, 0f, 0f)), 1.2f);
Assert.That(firstAccepted, Is.True);
Assert.That(duplicateAccepted, Is.False);
Assert.That(staleAccepted, Is.False);
Assert.That(interpolator.BufferedSnapshotCount, Is.EqualTo(1));
Assert.That(interpolator.LatestBufferedTick, Is.EqualTo(10));
}
[Test]
public void RemotePlayerSnapshotInterpolator_BufferOverflow_TrimsOldestSnapshots()
{
var interpolator = new RemotePlayerSnapshotInterpolator(maxBufferedSnapshots: 3);
interpolator.TryAddSnapshot(CreateSnapshot(1, new Vector3(1f, 0f, 0f)), 0f);
interpolator.TryAddSnapshot(CreateSnapshot(2, new Vector3(2f, 0f, 0f)), 0.05f);
interpolator.TryAddSnapshot(CreateSnapshot(3, new Vector3(3f, 0f, 0f)), 0.1f);
interpolator.TryAddSnapshot(CreateSnapshot(4, new Vector3(4f, 0f, 0f)), 0.15f);
Assert.That(interpolator.BufferedSnapshotCount, Is.EqualTo(3));
Assert.That(interpolator.LatestBufferedTick, Is.EqualTo(4));
var sample = interpolator.Sample(0.3f);
Assert.That(interpolator.BufferedSnapshotCount, Is.EqualTo(1));
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(4));
Assert.That(sample.UsedInterpolation, Is.False);
}
[Test]
public void RemotePlayerSnapshotInterpolator_BracketedRenderTime_InterpolatesBetweenSnapshots()
{
var interpolator = new RemotePlayerSnapshotInterpolator();
interpolator.TryAddSnapshot(CreateSnapshot(10, new Vector3(0f, 0f, 0f), 0f), 0f);
interpolator.TryAddSnapshot(CreateSnapshot(11, new Vector3(10f, 0f, 0f), 90f), 0.05f);
var sample = interpolator.Sample(0.125f);
Assert.That(sample.HasValue, Is.True);
Assert.That(sample.UsedInterpolation, Is.True);
Assert.That(sample.Position.x, Is.EqualTo(5f).Within(0.001f));
Assert.That(sample.Alpha, Is.EqualTo(0.5f).Within(0.001f));
Assert.That(sample.Rotation.eulerAngles.y, Is.EqualTo(45f).Within(0.01f));
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(11));
}
[Test]
public void RemotePlayerSnapshotInterpolator_WithoutUsableBracket_ClampsToLatestSnapshot()
{
var interpolator = new RemotePlayerSnapshotInterpolator();
interpolator.TryAddSnapshot(CreateSnapshot(12, new Vector3(2f, 0f, -1f), 15f), 0.2f);
var sample = interpolator.Sample(0.35f);
Assert.That(sample.HasValue, Is.True);
Assert.That(sample.UsedInterpolation, Is.False);
Assert.That(sample.Position, Is.EqualTo(new Vector3(2f, 0f, -1f)));
Assert.That(sample.Rotation.eulerAngles.y, Is.EqualTo(15f).Within(0.01f));
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(12));
}
[Test] [Test]
public void ClockSyncState_RejectsOlderSamples() public void ClockSyncState_RejectsOlderSamples()
{ {
@ -161,6 +295,19 @@ namespace Tests.EditMode.Network
}.ToByteArray(); }.ToByteArray();
} }
private static ClientAuthoritativePlayerStateSnapshot CreateSnapshot(long tick, Vector3 position, float rotation = 0f)
{
return new ClientAuthoritativePlayerStateSnapshot(new PlayerState
{
PlayerId = "player-1",
Tick = tick,
Position = new global::Network.Defines.Vector3 { X = position.x, Y = position.y, Z = position.z },
Velocity = new global::Network.Defines.Vector3 { X = 0f, Y = 0f, Z = 0f },
Rotation = rotation,
Hp = 100
});
}
private sealed class FakeTransport : ITransport private sealed class FakeTransport : ITransport
{ {
public event System.Action<byte[], IPEndPoint> OnReceive; public event System.Action<byte[], IPEndPoint> OnReceive;

50
TODO.md
View File

@ -53,46 +53,46 @@ Acceptance:
### 2. Align Client Input Flow With MVP ### 2. Align Client Input Flow With MVP
- [ ] Update [`Assets/Scripts/MovementComponent.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/MovementComponent.cs) so movement intent can send an explicit zero-vector stop message when the player releases input - [x] Update [`Assets/Scripts/MovementComponent.cs`](D:/Learn/GameLearn/UnityProjects/NetworkFW/Assets/Scripts/MovementComponent.cs) so movement intent can send an explicit zero-vector stop message when the player releases input
- [ ] Keep local prediction immediate for the controlled player - [x] Keep local prediction immediate for the controlled player
- [ ] Add a client shooting input capture path - [x] Add a client shooting input capture path
- [ ] Add `NetworkManager.SendShootInput(...)` - [x] Add `NetworkManager.SendShootInput(...)`
- [ ] Ensure the client sends only `MoveInput` and `ShootInput` for gameplay actions - [x] Ensure the client sends only `MoveInput` and `ShootInput` for gameplay actions
- [ ] Keep local shooting presentation optional and purely cosmetic - [x] Keep local shooting presentation optional and purely cosmetic
Acceptance: Acceptance:
- [ ] Releasing movement input produces a final `MoveInput` that stops authoritative movement - [x] Releasing movement input produces a final `MoveInput` that stops authoritative movement
- [ ] Firing produces a `ShootInput` sent on the reliable lane - [x] Firing produces a `ShootInput` sent on the reliable lane
- [ ] No MVP gameplay action depends on legacy broad messages such as `PlayerAction` - [x] No MVP gameplay action depends on legacy broad messages such as `PlayerAction`
### 3. Apply Full Authoritative `PlayerState` On The Client ### 3. Apply Full Authoritative `PlayerState` On The Client
- [ ] Extend the player-side presentation model to consume authoritative `position`, `rotation`, `hp`, and optional `velocity` - [x] Extend the player-side presentation model to consume authoritative `position`, `rotation`, `hp`, and optional `velocity`
- [ ] Keep local-player reconciliation driven by authoritative `PlayerState.Tick` - [x] Keep local-player reconciliation driven by authoritative `PlayerState.Tick`
- [ ] Use authoritative HP instead of any local guesswork - [x] Use authoritative HP instead of any local guesswork
- [ ] Decide where authoritative player state lives on the client side and keep that ownership explicit - [x] Decide where authoritative player state lives on the client side and keep that ownership explicit
- [ ] Update UI or diagnostics so authoritative HP/state changes are observable during development - [x] Update UI or diagnostics so authoritative HP/state changes are observable during development
Acceptance: Acceptance:
- [ ] Local player corrects to server truth for position and rotation - [x] Local player corrects to server truth for position and rotation
- [ ] Local and remote players expose authoritative HP from `PlayerState` - [x] Local and remote players expose authoritative HP from `PlayerState`
- [ ] The client does not finalize gameplay truth outside authoritative messages - [x] The client does not finalize gameplay truth outside authoritative messages
### 4. Replace Ad-Hoc Remote Movement Smoothing With Snapshot Interpolation ### 4. Replace Ad-Hoc Remote Movement Smoothing With Snapshot Interpolation
- [ ] Add a small `PlayerState` snapshot buffer for remote players - [x] Add a small `PlayerState` snapshot buffer for remote players
- [ ] Interpolate between buffered snapshots instead of lerping directly to the latest state - [x] Interpolate between buffered snapshots instead of lerping directly to the latest state
- [ ] Discard stale snapshots by tick - [x] Discard stale snapshots by tick
- [ ] Keep remote players non-predicted - [x] Keep remote players non-predicted
- [ ] Document the interpolation delay / sample strategy in code comments or docs if it is non-obvious - [x] Document the interpolation delay / sample strategy in code comments or docs if it is non-obvious
Acceptance: Acceptance:
- [ ] Remote movement is based on buffered authoritative snapshots - [x] Remote movement is based on buffered authoritative snapshots
- [ ] Out-of-order remote `PlayerState` packets do not corrupt presentation - [x] Out-of-order remote `PlayerState` packets do not corrupt presentation
- [ ] Remote players are smoothed without becoming locally authoritative - [x] Remote players are smoothed without becoming locally authoritative
### 5. Add Client-Side `CombatEvent` Handling ### 5. Add Client-Side `CombatEvent` Handling

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View File

@ -0,0 +1,51 @@
## Context
The current client receives authoritative `PlayerState` packets through `NetworkManager`, routes them via `MasterManager`, and lets `MovementComponent` consume them. That path only applies position meaningfully today: the controlled player uses `PlayerState.Tick` for reconciliation, while remote players lerp toward the latest position. Rotation, HP, and optional velocity are present in the wire contract but do not have one explicit client-side owner, so TODO step 3 cannot be completed without clarifying where authoritative state lives and how presentation reads it.
This change sits between two already-decided networking layers. The wire contract already defines `PlayerState.position`, `rotation`, `hp`, and optional `velocity`, and the sync strategy already treats `PlayerState` as authoritative latest-wins sync traffic. The next TODO step will add buffered interpolation for remote players, so this design must avoid baking interpolation policy into the same change.
## Goals / Non-Goals
**Goals:**
- Define one explicit client-side ownership point for authoritative `PlayerState` per player.
- Ensure both local and remote players can read authoritative position, rotation, HP, and optional velocity from that owned state.
- Keep local reconciliation keyed by authoritative `PlayerState.Tick` while applying the full authoritative payload, not only position.
- Make authoritative HP/state changes visible in existing MVP diagnostics or player UI.
**Non-Goals:**
- Add remote snapshot interpolation buffers or interpolation delay tuning from TODO step 4.
- Introduce client-side authoritative combat resolution or speculative HP changes.
- Redesign the networking receive path beyond the minimum ownership and presentation boundaries needed for authoritative state application.
## Decisions
### Decision: Store authoritative runtime state in a dedicated client-side snapshot model per player
`Player` will own a small runtime model representing the latest accepted authoritative state for that player, instead of leaving `MovementComponent` as the only place that has partial knowledge of the last server packet. This keeps HP, rotation, velocity, and position under one player-scoped owner that both movement/presentation code and UI can query.
Alternative considered:
- Keep authoritative data only inside `MovementComponent`. Rejected because HP and other non-movement fields would still be awkwardly owned by a movement-focused component, and UI/diagnostic code would need to reach into movement internals for non-movement truth.
### Decision: Preserve local reconciliation in `MovementComponent`, but reconcile from the owned authoritative snapshot
The controlled player still needs prediction replay and authoritative-tick pruning, so `MovementComponent` remains responsible for local reconciliation. The difference is that it will reconcile using the latest accepted authoritative snapshot rather than treating `PlayerState` as a transient position correction packet.
Alternative considered:
- Move all reconciliation into `Player`. Rejected because it would either duplicate prediction-buffer logic or make `Player` absorb low-level Rigidbody concerns that already live in `MovementComponent`.
### Decision: Remote players apply full authoritative fields immediately without adding a buffer in this change
Remote players will consume authoritative position, rotation, HP, and velocity from the owned snapshot, but this step will not introduce snapshot buffering. If simple smoothing remains, it must still respect the latest accepted authoritative snapshot and stale-drop rules without creating a second source of truth.
Alternative considered:
- Add remote snapshot buffering now. Rejected because it overlaps directly with TODO step 4 and would blur the acceptance criteria for this change.
### Decision: Surface authoritative HP/state changes through existing lightweight diagnostics
Development visibility should come from the current MVP UI layer, such as `MainUI` or `PlayerUI`, instead of a larger debugging framework. The chosen output only needs to make authoritative HP and other key state changes observable during playtests.
Alternative considered:
- Defer all diagnostics until combat events land. Rejected because TODO step 3 explicitly requires observability of authoritative state before combat-result handling is added.
## Risks / Trade-offs
- [Player and movement responsibilities drift together again] → Mitigation: keep `Player` as the owner of authoritative snapshots and keep `MovementComponent` focused on applying movement/presentation behavior.
- [Remote players still look rough before interpolation work lands] → Mitigation: document that this step applies full authority but intentionally leaves higher-quality smoothing to TODO step 4.
- [UI begins depending on speculative state by accident] → Mitigation: wire HP/state text from the authoritative snapshot only, not from locally predicted movement or combat code.
- [Optional velocity field is absent in some packets] → Mitigation: treat missing velocity as a valid zero/unknown state and avoid making presentation correctness depend on it being present.

View File

@ -0,0 +1,24 @@
## Why
The client currently treats authoritative `PlayerState` mostly as a position-correction signal, so rotation, HP, and optional velocity do not have one explicit owner on the receiving side. TODO step 3 now depends on making server truth visible and consistently applied on both local and remote players before snapshot interpolation or combat-result handling can build on top of it.
## What Changes
- Introduce an explicit client-side authoritative player-state capability that defines where received `PlayerState` data lives and how local versus remote presentation consumes it.
- Apply authoritative `position`, `rotation`, `hp`, and optional `velocity` on the client while keeping local-player reconciliation keyed by authoritative `PlayerState.Tick`.
- Expose authoritative HP and related state changes through existing player-facing UI or diagnostics so MVP development can observe server truth during playtests.
- Keep remote authoritative-state application minimal for this step and leave buffered interpolation behavior to the next TODO step.
## Capabilities
### New Capabilities
- `client-authoritative-player-state`: Defines client-side ownership, application, and observability of authoritative `PlayerState` data for local and remote players.
### Modified Capabilities
- None.
## Impact
- Affected code: `Assets/Scripts/MovementComponent.cs`, `Assets/Scripts/Player.cs`, `Assets/Scripts/MasterManager.cs`, and client UI/diagnostic scripts that expose authoritative state.
- Affected behavior: Local reconciliation remains prediction-based but consumes full authoritative state, while remote players stop relying on ad hoc position-only updates.
- Testing: Edit-mode regressions will need coverage for authoritative-state application, stale-state rejection, and explicit ownership of HP/rotation data on the client.

View File

@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: Client keeps one owned authoritative player-state snapshot per player
The client SHALL keep one explicit owned authoritative `PlayerState` snapshot for each known player instead of spreading authoritative field ownership across unrelated presentation components. The owned snapshot MUST be the source of truth for authoritative `position`, `rotation`, `hp`, and optional `velocity` on the client.
#### Scenario: Incoming authoritative state replaces the owned snapshot
- **WHEN** the client accepts a newer `PlayerState` for a player
- **THEN** the latest accepted packet becomes that player's owned authoritative snapshot on the client
- **THEN** presentation and diagnostics read authoritative `position`, `rotation`, `hp`, and optional `velocity` from that owned snapshot
### Requirement: Local player reconciliation applies the full authoritative state by tick
The controlled client SHALL continue reconciling local prediction from authoritative `PlayerState.Tick`, and that reconciliation MUST apply the accepted authoritative `position` and `rotation` while keeping authoritative HP and optional velocity synchronized with the owned player-state snapshot.
#### Scenario: Local authoritative state corrects predicted presentation
- **WHEN** the controlled player accepts an authoritative `PlayerState` for tick `N`
- **THEN** local reconciliation prunes or replays predicted movement using tick `N` according to the sync strategy
- **THEN** the local player's visible transform is corrected toward authoritative `position` and `rotation`
- **THEN** the local player's authoritative HP on the client matches the accepted `PlayerState`
### Requirement: Remote players apply authoritative state without inventing gameplay truth
Remote player presentation SHALL consume the latest accepted authoritative player-state snapshot and MUST NOT invent HP or final gameplay state locally. Stale remote `PlayerState` packets that are older than the latest accepted authoritative tick for that player MUST NOT overwrite the owned snapshot.
#### Scenario: Remote authoritative state updates presentation and rejects stale packets
- **WHEN** a remote player receives a newer authoritative `PlayerState`
- **THEN** the client's owned snapshot for that remote player updates to the newer authoritative state
- **THEN** remote presentation uses authoritative `position`, `rotation`, and `hp` from that snapshot
- **THEN** an older later-arriving `PlayerState` for that remote player does not overwrite the newer authoritative snapshot
### Requirement: Authoritative HP and state changes are observable during MVP development
The client SHALL expose authoritative HP or comparable authoritative state information through lightweight UI or diagnostics so developers can observe server-truth changes during MVP playtests.
#### Scenario: Development UI reflects authoritative HP
- **WHEN** the client accepts a `PlayerState` whose authoritative HP differs from the previously accepted snapshot
- **THEN** the relevant UI or diagnostics update to show the new authoritative HP value
- **THEN** the displayed value comes from authoritative `PlayerState` data rather than speculative local gameplay logic

View File

@ -0,0 +1,16 @@
## 1. Authoritative State Ownership
- [x] 1.1 Add a client-side authoritative player-state snapshot model or owner on `Player` so position, rotation, HP, and optional velocity have one explicit owner per player.
- [x] 1.2 Update the `NetworkManager -> MasterManager -> Player` receive path so accepted `PlayerState` packets refresh that owned authoritative snapshot before presentation reads it.
## 2. Client Application
- [x] 2.1 Update local-player reconciliation so authoritative `PlayerState.Tick` still drives prediction replay while authoritative position and rotation are applied from the accepted snapshot.
- [x] 2.2 Update remote-player presentation to consume authoritative position, rotation, HP, and optional velocity from the owned snapshot without inventing gameplay truth.
- [x] 2.3 Expose authoritative HP or comparable authoritative-state diagnostics in the current MVP UI so server-truth changes are visible during development.
## 3. Verification
- [x] 3.1 Add or extend edit-mode tests for authoritative `PlayerState` ownership and stale-packet rejection on the client side where practical.
- [x] 3.2 Add or extend regression tests for local reconciliation and remote authoritative-state application behavior that this step changes.
- [x] 3.3 Run the relevant validation flow and confirm the client-side authoritative `PlayerState` path works in editor play/testing.

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View File

@ -0,0 +1,60 @@
## Context
The client now keeps one authoritative `PlayerState` snapshot per player, and local prediction already reconciles against authoritative ticked state. Remote players, however, still smooth presentation by lerping directly from the current transform toward the latest accepted snapshot, which couples presentation quality to packet spacing and can visibly snap when packets arrive unevenly.
This step only targets remote-player presentation. It must preserve the existing authoritative ownership model, keep stale-packet rejection, and avoid turning remote players into predicted entities. The implementation should stay on the Unity client side and must not change shared networking contracts under `Assets/Scripts/Network/`.
## Goals / Non-Goals
**Goals:**
- Add a small remote-only snapshot buffer that stores accepted authoritative `PlayerState` presentation samples in tick order.
- Render remote players by interpolating between buffered authoritative snapshots instead of lerping straight to the newest packet.
- Reject stale or duplicate remote snapshots before they affect interpolation state.
- Document the interpolation delay/sample strategy clearly enough that later tuning does not require reverse-engineering the code.
**Non-Goals:**
- Do not change local-player prediction or reconciliation behavior.
- Do not add extrapolation, lag compensation, or remote gameplay simulation.
- Do not move authoritative ownership out of `Player` or change message schemas/lane selection.
## Decisions
### Use a dedicated remote snapshot interpolation helper
Remote interpolation state will live in a focused client-side helper, separate from `ClientAuthoritativePlayerState`. `ClientAuthoritativePlayerState` remains the authoritative latest-wins owner, while the new helper manages a short ordered presentation buffer for remote players only.
Why this approach:
- It keeps authoritative state ownership and presentation buffering as distinct concerns.
- It lets local players continue reading the latest accepted snapshot directly for reconcile.
- It avoids pushing Unity presentation logic into shared networking code.
Alternative considered:
- Reusing only the latest authoritative snapshot and lerping harder. Rejected because it does not solve uneven packet spacing or properly use buffered snapshots.
### Buffer a small ordered window and interpolate at a fixed delay
The remote helper will keep a capped ordered list of accepted authoritative snapshots plus their local receive timestamps. Rendering will target a small fixed delay behind real time, initially `0.1s`, which is roughly two MVP movement send intervals (`0.05s`). Each remote `FixedUpdate` will find the two buffered samples that bracket `now - interpolationDelay` and interpolate position/rotation between them.
Why this approach:
- A fixed delay gives the client a high chance of having both an older and newer sample even under modest jitter.
- Receive-time bracketing keeps the MVP implementation simple without introducing a full server-clock interpolation timeline.
- Using two send intervals is small enough for MVP responsiveness while still providing smoothing headroom.
Alternatives considered:
- Tick-only interpolation against estimated server time. Rejected for now because it adds more clock-sync coupling than this TODO step requires.
- Extrapolating past the latest sample. Rejected because remote players must remain non-predicted in this step.
### Hold the latest snapshot when interpolation cannot bracket two samples
If the buffer has only one sample or the render time falls after the newest buffered snapshot, remote presentation will clamp to the latest accepted authoritative snapshot instead of extrapolating. Older samples that are no longer needed for the current interpolation window will be trimmed, and the total buffer size will stay small.
Why this approach:
- It preserves authoritative presentation and avoids introducing speculative remote movement.
- It keeps the buffer bounded and simple to reason about in tests.
Alternative considered:
- Continuing to lerp from the current transform to the newest snapshot as fallback. Rejected because it reintroduces the ad-hoc smoothing path this change is replacing.
## Risks / Trade-offs
- [Interpolation delay adds visible latency to remote presentation] → Keep the initial delay small (`0.1s`) and document it so future tuning is straightforward.
- [Sparse packet arrival may still produce brief holds on the newest snapshot] → Clamp to the latest authoritative snapshot rather than extrapolating incorrect movement.
- [Buffer bookkeeping can drift from authoritative ownership rules] → Keep stale rejection in the authoritative owner and cover the remote buffer behavior with edit-mode regression tests.
- [Rotation smoothing may disagree with velocity direction on sharp turns] → Interpolate authoritative rotation directly from buffered snapshots instead of deriving facing from velocity.

View File

@ -0,0 +1,24 @@
## Why
Remote players are still smoothed by lerping directly toward the latest authoritative `PlayerState`, which makes presentation sensitive to uneven packet spacing and late packets. Now that the client already owns authoritative per-player snapshots, the next MVP step is to buffer remote snapshots and interpolate across them so remote motion stays smooth without becoming locally authoritative.
## What Changes
- Add a dedicated client-side remote snapshot interpolation path for authoritative `PlayerState` updates.
- Buffer a small number of remote authoritative snapshots and interpolate between buffered samples instead of lerping directly to the newest packet.
- Reject stale remote snapshots by tick before they can affect presentation.
- Keep remote players non-predicted and presentation-only while documenting the interpolation delay/sample strategy used by the MVP client.
## Capabilities
### New Capabilities
- `client-remote-snapshot-interpolation`: Define how the client buffers and interpolates authoritative remote `PlayerState` snapshots for presentation-only smoothing.
### Modified Capabilities
- `client-authoritative-player-state`: Remote-player presentation changes from "latest snapshot apply" to "buffered authoritative snapshot interpolation" while keeping stale-state rejection and authoritative ownership requirements.
## Impact
- Affected code: `Assets/Scripts/MovementComponent.cs`, `Assets/Scripts/Player.cs`, and any new remote snapshot helper used by the client.
- Affected tests: `Assets/Tests/EditMode/Network/` regression coverage for remote snapshot buffering, stale-packet rejection, and interpolation behavior.
- Documentation/spec impact: new interpolation capability spec plus a delta update for `client-authoritative-player-state`.

View File

@ -0,0 +1,10 @@
## MODIFIED Requirements
### Requirement: Remote players apply authoritative state without inventing gameplay truth
Remote player presentation SHALL consume the accepted authoritative player-state snapshot owned by the client and MUST NOT invent HP or final gameplay state locally. Remote movement presentation MUST smooth authoritative position and rotation through a small buffered snapshot interpolation path instead of applying only the latest snapshot directly. Stale remote `PlayerState` packets that are older than the latest accepted authoritative tick for that player MUST NOT overwrite the owned snapshot or enter the interpolation buffer.
#### Scenario: Remote authoritative state updates interpolation input and rejects stale packets
- **WHEN** a remote player receives a newer authoritative `PlayerState`
- **THEN** the client's owned snapshot for that remote player updates to the newer authoritative state
- **THEN** remote presentation adds that accepted authoritative sample to the interpolation buffer for position and rotation smoothing
- **THEN** an older later-arriving `PlayerState` for that remote player does not overwrite the newer authoritative snapshot or affect interpolation

View File

@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Remote players interpolate between buffered authoritative snapshots
The client SHALL smooth remote-player presentation by buffering a small ordered set of accepted authoritative `PlayerState` snapshots and interpolating between buffered samples instead of lerping directly toward the latest snapshot.
#### Scenario: Remote presentation uses buffered samples
- **WHEN** the client has at least two buffered authoritative snapshots for a remote player
- **THEN** remote position and rotation are calculated from interpolation between buffered snapshots
- **THEN** the client does not smooth that remote player by directly lerping from the current transform to only the newest snapshot
### Requirement: Remote snapshot interpolation uses a documented fixed delay
The client SHALL render remote players at a small fixed interpolation delay behind the newest received authoritative snapshot timeline, and that delay/sample strategy MUST be documented in code comments or adjacent docs when the implementation is not otherwise obvious.
#### Scenario: Interpolation delay is explicit to maintainers
- **WHEN** a maintainer reads the remote snapshot interpolation path
- **THEN** the code or nearby documentation states the interpolation delay and how buffered samples are selected
- **THEN** the remote smoothing behavior can be tuned without reverse-engineering timing assumptions
### Requirement: Remote interpolation remains presentation-only
The client SHALL keep remote players non-predicted while using buffered snapshot interpolation. If interpolation cannot bracket two authoritative samples, the client MUST clamp to the latest accepted authoritative snapshot rather than extrapolating remote gameplay state.
#### Scenario: Missing future sample does not trigger remote prediction
- **WHEN** a remote player has fewer than two usable buffered snapshots for the current render time
- **THEN** the client presents the latest accepted authoritative snapshot for that remote player
- **THEN** the client does not extrapolate or simulate additional remote gameplay truth locally

View File

@ -0,0 +1,17 @@
## 1. Remote Snapshot Buffer
- [x] 1.1 Add a focused client-side remote snapshot interpolation helper that stores accepted authoritative `PlayerState` samples in tick order with bounded buffer size and receive-time metadata.
- [x] 1.2 Ensure stale or duplicate remote `PlayerState` samples are rejected before they can enter the interpolation buffer, while keeping `ClientAuthoritativePlayerState` as the latest authoritative owner.
- [x] 1.3 Document the chosen interpolation delay and sample-selection strategy in code comments or adjacent docs where the helper is introduced.
## 2. Client Presentation Integration
- [x] 2.1 Update remote-player presentation in `MovementComponent` to read from the interpolation helper and interpolate authoritative position and rotation between buffered snapshots instead of lerping directly to the latest snapshot.
- [x] 2.2 Keep the local-player reconcile path unchanged and ensure remote fallback behavior clamps to the latest accepted authoritative snapshot when interpolation cannot bracket two samples.
- [x] 2.3 Keep remote players presentation-only by avoiding extrapolation or any new remote prediction path while continuing to consume authoritative ownership from `Player`.
## 3. Validation
- [x] 3.1 Add or extend edit-mode tests for remote snapshot buffering, stale-packet rejection, and bounded-buffer behavior.
- [x] 3.2 Add or extend regression tests for remote interpolation output or fallback-to-latest behavior when samples are insufficient or out of order.
- [x] 3.3 Run the relevant validation flow and confirm the new remote snapshot interpolation path works in editor or CLI testing.

View File

@ -0,0 +1,39 @@
# client-authoritative-player-state Specification
## Purpose
Define how the Unity client owns, applies, and exposes authoritative `PlayerState` snapshots for local and remote players.
## Requirements
### Requirement: Client keeps one owned authoritative player-state snapshot per player
The client SHALL keep one explicit owned authoritative `PlayerState` snapshot for each known player instead of spreading authoritative field ownership across unrelated presentation components. The owned snapshot MUST be the source of truth for authoritative `position`, `rotation`, `hp`, and optional `velocity` on the client.
#### Scenario: Incoming authoritative state replaces the owned snapshot
- **WHEN** the client accepts a newer `PlayerState` for a player
- **THEN** the latest accepted packet becomes that player's owned authoritative snapshot on the client
- **THEN** presentation and diagnostics read authoritative `position`, `rotation`, `hp`, and optional `velocity` from that owned snapshot
### Requirement: Local player reconciliation applies the full authoritative state by tick
The controlled client SHALL continue reconciling local prediction from authoritative `PlayerState.Tick`, and that reconciliation MUST apply the accepted authoritative `position` and `rotation` while keeping authoritative HP and optional velocity synchronized with the owned player-state snapshot.
#### Scenario: Local authoritative state corrects predicted presentation
- **WHEN** the controlled player accepts an authoritative `PlayerState` for tick `N`
- **THEN** local reconciliation prunes or replays predicted movement using tick `N` according to the sync strategy
- **THEN** the local player's visible transform is corrected toward authoritative `position` and `rotation`
- **THEN** the local player's authoritative HP on the client matches the accepted `PlayerState`
### Requirement: Remote players apply authoritative state without inventing gameplay truth
Remote player presentation SHALL consume the accepted authoritative player-state snapshot owned by the client and MUST NOT invent HP or final gameplay state locally. Remote movement presentation MUST smooth authoritative position and rotation through a small buffered snapshot interpolation path instead of applying only the latest snapshot directly. Stale remote `PlayerState` packets that are older than the latest accepted authoritative tick for that player MUST NOT overwrite the owned snapshot or enter the interpolation buffer.
#### Scenario: Remote authoritative state updates interpolation input and rejects stale packets
- **WHEN** a remote player receives a newer authoritative `PlayerState`
- **THEN** the client's owned snapshot for that remote player updates to the newer authoritative state
- **THEN** remote presentation adds that accepted authoritative sample to the interpolation buffer for position and rotation smoothing
- **THEN** an older later-arriving `PlayerState` for that remote player does not overwrite the newer authoritative snapshot or affect interpolation
### Requirement: Authoritative HP and state changes are observable during MVP development
The client SHALL expose authoritative HP or comparable authoritative state information through lightweight UI or diagnostics so developers can observe server-truth changes during MVP playtests.
#### Scenario: Development UI reflects authoritative HP
- **WHEN** the client accepts a `PlayerState` whose authoritative HP differs from the previously accepted snapshot
- **THEN** the relevant UI or diagnostics update to show the new authoritative HP value
- **THEN** the displayed value comes from authoritative `PlayerState` data rather than speculative local gameplay logic

View File

@ -0,0 +1,29 @@
# client-remote-snapshot-interpolation Specification
## Purpose
Define how the Unity client buffers and interpolates authoritative remote `PlayerState` snapshots for presentation-only movement smoothing.
## Requirements
### Requirement: Remote players interpolate between buffered authoritative snapshots
The client SHALL smooth remote-player presentation by buffering a small ordered set of accepted authoritative `PlayerState` snapshots and interpolating between buffered samples instead of lerping directly toward the latest snapshot.
#### Scenario: Remote presentation uses buffered samples
- **WHEN** the client has at least two buffered authoritative snapshots for a remote player
- **THEN** remote position and rotation are calculated from interpolation between buffered snapshots
- **THEN** the client does not smooth that remote player by directly lerping from the current transform to only the newest snapshot
### Requirement: Remote snapshot interpolation uses a documented fixed delay
The client SHALL render remote players at a small fixed interpolation delay behind the newest received authoritative snapshot timeline, and that delay/sample strategy MUST be documented in code comments or adjacent docs when the implementation is not otherwise obvious.
#### Scenario: Interpolation delay is explicit to maintainers
- **WHEN** a maintainer reads the remote snapshot interpolation path
- **THEN** the code or nearby documentation states the interpolation delay and how buffered samples are selected
- **THEN** the remote smoothing behavior can be tuned without reverse-engineering timing assumptions
### Requirement: Remote interpolation remains presentation-only
The client SHALL keep remote players non-predicted while using buffered snapshot interpolation. If interpolation cannot bracket two authoritative samples, the client MUST clamp to the latest accepted authoritative snapshot rather than extrapolating remote gameplay state.
#### Scenario: Missing future sample does not trigger remote prediction
- **WHEN** a remote player has fewer than two usable buffered snapshots for the current render time
- **THEN** the client presents the latest accepted authoritative snapshot for that remote player
- **THEN** the client does not extrapolate or simulate additional remote gameplay truth locally