Search Unity

  1. Megacity Metro Demo now available. Download now.
    Dismiss Notice
  2. Unity support for visionOS is now available. Learn more in our blog post.
    Dismiss Notice

Question Sanity checking this "deterministic" client simulation

Discussion in 'NetCode for ECS' started by HeyZoos, Jul 30, 2021.

  1. HeyZoos

    HeyZoos

    Joined:
    Jul 31, 2016
    Posts:
    50
    And when I say "sanity checking" I really mean please tear it apart. I'm not at all confident in what I'm doing.

    For context, I'm trying to build an RTS with a networking model where the server only transmits input. I transmit the inputs through a ghost that has a component representing where the player right-clicked. Here's my very naive take on keeping the client simulations in sync. I'm not sure if this qualifies as "lock stepping"? For starters, I'm just trying to keep some cube movement in sync.

    Here's the "deterministic" movement system:

    Code (CSharp):
    1. [UpdateInGroup(typeof(ClientSimulationSystemGroup))]
    2. [UpdateAfter(typeof(TransformRecordSystem))]
    3. public class ApplyTranslationTargetSystem : SystemBase
    4. {
    5.     protected override void OnUpdate()
    6.     {
    7.         Entities
    8.             .WithChangeFilter<ServerTickCopy>()
    9.             .ForEach((
    10.                 ref Translation translation,
    11.                 ref DynamicBuffer<TranslationRecordComponent> translationRecordComponents,
    12.                 in ServerTickCopy serverTickCopy,
    13.                 in TranslationTargetComponent translationTargetComponent
    14.             ) =>
    15.             {
    16.                 var targetFloat3 = new float3(
    17.                     translationTargetComponent.Value.x,
    18.                     0,
    19.                     translationTargetComponent.Value.y
    20.                 );
    21.  
    22.                 if (translation.Value.Equals(targetFloat3)) return;
    23.  
    24.                 var startRecordIndex = -1;
    25.  
    26.                 for (var i = translationRecordComponents.Length - 1; i >= 0; i--)
    27.                 {
    28.                     if (translationRecordComponents[i].Value.Tick == translationTargetComponent.Tick)
    29.                     {
    30.                         startRecordIndex = i;
    31.                         break;
    32.                     }
    33.                 }
    34.  
    35.                 if (startRecordIndex == -1) return;
    36.  
    37.                 var start = translationRecordComponents[startRecordIndex].Value.Position;
    38.                 var speed = 0.01f;
    39.  
    40.                 var howManyTicksToReachTarget = math.ceil(math.length(targetFloat3 - start) / speed);
    41.  
    42.                 var progress = math.clamp(
    43.                     (serverTickCopy.Value - translationTargetComponent.Tick) / howManyTicksToReachTarget,
    44.                     0,
    45.                     1
    46.                 );
    47.  
    48.                 translation.Value = math.lerp(start, targetFloat3, progress);
    49.  
    50.                 if (translation.Value.Equals(targetFloat3))
    51.                 {
    52.                     Debug.LogError($"Should have finished at tick #{translationTargetComponent.Tick + howManyTicksToReachTarget}");
    53.                     Debug.LogError($"Destination arrived at tick #{serverTickCopy.Value}");
    54.                 }
    55.             })
    56.             .WithoutBurst()
    57.             .Run();
    58.     }
    59. }
    The translation "history" is recorded using this system:

    Code (CSharp):
    1. [UpdateInGroup(typeof(ClientSimulationSystemGroup))]
    2. public class TransformRecordSystem : SystemBase
    3. {
    4.     protected override void OnUpdate()
    5.     {
    6.         Entities
    7.             .WithChangeFilter<ServerTickCopy>()
    8.             .ForEach((
    9.                 DynamicBuffer<TranslationRecordComponent> translationRecordComponents,
    10.                 in Translation translation,
    11.                 in ServerTickCopy serverTickCopy
    12.             ) =>
    13.             {
    14.                 translationRecordComponents.Add(new TranslationRecordComponent
    15.                 {
    16.                     Value = new TranslationRecordValue
    17.                     {
    18.                         Tick = serverTickCopy.Value,
    19.                         Position = translation.Value
    20.                     }
    21.                 });
    22.             })
    23.             .Schedule();
    24.     }
    25. }
    And the systems are "ticked" by copying the server tick to the components being worked on.

    Code (CSharp):
    1. [UpdateInGroup(typeof(ClientSimulationSystemGroup))]
    2. public class CopyServerTickSystem : SystemBase
    3. {
    4.     private uint _lastTick;
    5.  
    6.     protected override void OnUpdate()
    7.     {
    8.         var tick = World.GetExistingSystem<ClientSimulationSystemGroup>().ServerTick;
    9.         if (tick == _lastTick) return;
    10.         _lastTick = tick;
    11.         Entities
    12.             .ForEach((ref ServerTickCopy serverTickCopy) => { serverTickCopy.Value = tick; })
    13.             .Schedule();
    14.     }
    15. }
    It seems to work? But it feels really janky and extremely space inefficient. For example, the history buffer just grows forever. And my input sometimes just gets entirely swallowed. Not sure why, but it's not really my main focus currently.

    upload_2021-7-30_0-0-31.png

    Here's how the input is sampled:

    Code (CSharp):
    1. [UpdateInGroup(typeof(ClientSimulationSystemGroup))]
    2. public class SampleLocalInputCommand : ComponentSystem
    3. {
    4.     private EndSimulationEntityCommandBufferSystem _endSimulationEcbSystem;
    5.  
    6.     protected override void OnCreate()
    7.     {
    8.         RequireSingletonForUpdate<NetworkIdComponent>();
    9.         _endSimulationEcbSystem = World.GetOrCreateSystem<EndSimulationEntityCommandBufferSystem>();
    10.     }
    11.  
    12.     protected override void OnUpdate()
    13.     {
    14.         var localInput = GetSingleton<CommandTargetComponent>().targetEntity;
    15.         var ecb = _endSimulationEcbSystem.CreateCommandBuffer();
    16.  
    17.         if (localInput == Entity.Null)
    18.         {
    19.             var localPlayerId = GetSingleton<NetworkIdComponent>().Value;
    20.  
    21.             Entities
    22.                 .WithAll<PlayerInputComponent>()
    23.                 .WithNone<LocalInputCommand>()
    24.                 .ForEach((Entity entity, ref GhostOwnerComponent ghostOwner) =>
    25.                 {
    26.                     if (ghostOwner.NetworkId == localPlayerId)
    27.                     {
    28.                         Debug.Log($"Add command buffer to {entity}");
    29.                         ecb.AddBuffer<LocalInputCommand>(entity);
    30.                         ecb.SetComponent(
    31.                             GetSingletonEntity<CommandTargetComponent>(),
    32.                             new CommandTargetComponent {targetEntity = entity}
    33.                         );
    34.                     }
    35.                 });
    36.         }
    37.  
    38.         else
    39.         {
    40.             var input = default(LocalInputCommand);
    41.  
    42.             input.Tick = World.GetExistingSystem<ClientSimulationSystemGroup>().ServerTick;
    43.  
    44.             // Do ray cast sample here
    45.             if (Input.GetMouseButtonDown(1))
    46.             {
    47.                 var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
    48.              
    49.                 var raycastInput = new RaycastInput
    50.                 {
    51.                     Start = ray.origin,
    52.                     End = ray.GetPoint(1000),
    53.                     Filter = CollisionFilter.Default
    54.                 };
    55.  
    56.                 var collisionWorld = World.GetExistingSystem<BuildPhysicsWorld>().PhysicsWorld.CollisionWorld;
    57.  
    58.                 collisionWorld.CastRay(raycastInput, out var closestHit);
    59.  
    60.                 input.Position = new float2(
    61.                     closestHit.Position.x,
    62.                     closestHit.Position.z
    63.                 );
    64.             }
    65.  
    66.             var inputBuffer = EntityManager.GetBuffer<LocalInputCommand>(localInput);
    67.             inputBuffer.AddCommandData(input);
    68.         }
    69.     }
    70. }
    And here's how the input is broadcasted:

    Code (CSharp):
    1. [UpdateInGroup(typeof(GhostSimulationSystemGroup))]
    2. public class SyncInputsSystem : ComponentSystem
    3. {
    4.     private struct InputHistory : IComponentData
    5.     {
    6.     }
    7.  
    8.     protected override void OnCreate()
    9.     {
    10.         var history = EntityManager.CreateEntity();
    11.         EntityManager.AddBuffer<LocalInputCommand>(history);
    12.         EntityManager.AddComponent<InputHistory>(history);
    13.     }
    14.  
    15.     protected override void OnUpdate()
    16.     {
    17.         var group = World.GetExistingSystem<GhostPredictionSystemGroup>();
    18.         var tick = group.PredictingTick;
    19.         var deltaTime = Time.DeltaTime;
    20.      
    21.         Entities
    22.             .ForEach((
    23.                 DynamicBuffer<LocalInputCommand> inputBuffer,
    24.                 ref Translation trans,
    25.                 ref PredictedGhostComponent prediction,
    26.                 ref PlayerInputComponent playerInput
    27.             ) =>
    28.             {
    29.                 if (!GhostPredictionSystemGroup.ShouldPredict(tick, prediction)) return;
    30.              
    31.                 inputBuffer.GetDataAtTick(tick, out var input);
    32.  
    33.                 playerInput.Position = input.Position;
    34.                 playerInput.Tick = tick;
    35.             });
    36.     }
    37. }