Search Unity

Question Client time apparently running faster than server

Discussion in 'NetCode for ECS' started by Noctiphobia, Jun 26, 2021.

  1. Noctiphobia

    Noctiphobia

    Joined:
    Jun 9, 2021
    Posts:
    8
    Hi everyone,
    I can't figure out this one. I have an AbilityInProgressSystem that basically just subtracts delta time from an AbilityInProgressComponent. The use time of an ability is split into a preparation phase (like, moving your hand backwards), use phase (the actual ability starts happening there) and cooldown phase (the period where you can't use any ability). For simplicity, we could just assume that there's a cast time, at the end of which something happens.
    I made a simple skill that just instantly moves the player, with 3 seconds of preparation time. Player character is owner predicted.
    So, what happens? On client, those 3 seconds of preparation time apparently pass within a second, then the player is briefly moved on the client, almost instantly pulled back to the server position, and 2 seconds later (i.e. actually after 3 seconds of using the skill), the player is actually moved on the server. In short, either my code is broken (probably), or time is running significantly faster on the client.
    I tried adding/removing GhostField in AbilityInProgressComponent, but this doesn't change anything.
    So, what could be causing this behavior? I'd expect with 0 latency for this to happen at the exact same moment on server and client.

    A simplified version of the code would look something like this:
    Code (CSharp):
    1. [UpdateInGroup(typeof(AbilityUsageSystemGroup))] // part of GhostPredictionSystemGroup
    2. public class AbilityInProgressSystem : SystemBase
    3. {
    4.     protected override void OnUpdate()
    5.     {
    6.         var deltaTime = Time.DeltaTime;
    7.         var group = World.GetExistingSystem<GhostPredictionSystemGroup>();
    8.         var tick = group.PredictingTick;
    9.         Entities.ForEach((Entity entity, ref AbilityInProgressComponent abilityInProgressComponent, in PredictedGhostComponent prediction) =>
    10.     {  
    11.         abilityInProgressComponent.remainingCastTime -= deltaTime;
    12.         if (abilityInProgressComponent.remainingCastTime <= 0)
    13.             actuallyUseTheAbility();
    14.     }).ScheduleParallel();
    15.     }
    16. }
    Here's the actual code (difference from above - handling the 3 use phases as mentioned, and actually getting ability data):
    Code (CSharp):
    1.  
    2. namespace Game.Player.Character.Abilities.Systems
    3. {
    4.     [UpdateInGroup(typeof(AbilityUsageSystemGroup))] // part of GhostPredictionSystemGroup
    5.     public class AbilityInProgressSystem : SystemBase
    6.     {
    7.         private AbilityUsageEndEntityCommandBufferSystem ecbSystem;
    8.        
    9.         protected override void OnCreate()
    10.         {
    11.             ecbSystem = World.GetOrCreateSystem<AbilityUsageEndEntityCommandBufferSystem>();
    12.         }
    13.    
    14.         protected override void OnUpdate()
    15.         {
    16.             var ecb = ecbSystem.CreateCommandBuffer().AsParallelWriter();
    17.             var deltaTime = Time.DeltaTime;
    18.             var group = World.GetExistingSystem<GhostPredictionSystemGroup>();
    19.             var tick = group.PredictingTick;
    20.             Entities.ForEach((Entity entity, int entityInQueryIndex,
    21.                 ref AbilityInProgressComponent abilityInProgressComponent,
    22.                 in DynamicBuffer<AbilityElement> abilities,
    23.                 in PredictedGhostComponent prediction) =>
    24.             {
    25.                 if (!GhostPredictionSystemGroup.ShouldPredict(tick, prediction))
    26.                     return;
    27.                 var buffer = deltaTime;
    28.                 if (abilityInProgressComponent.UsedAbility != AbilitySlot.None)
    29.                 {
    30.                     if (UpdateValue(ref abilityInProgressComponent.RemainingPreparationTime, ref buffer))
    31.                         StartUse(ecb, entity, entityInQueryIndex, ref abilityInProgressComponent, in abilities);
    32.                     if (UpdateValue(ref abilityInProgressComponent.RemainingUseTime, ref buffer))
    33.                         EndUse(ecb, entity, entityInQueryIndex, ref abilityInProgressComponent, in abilities);
    34.                 }
    35.                 UpdateValue(ref abilityInProgressComponent.RemainingCooldownTime, ref buffer);
    36.                 if (abilityInProgressComponent.RemainingPreparationTime == 0f && abilityInProgressComponent.RemainingUseTime == 0f)
    37.                     abilityInProgressComponent.UsedAbility = AbilitySlot.None;
    38.             }).ScheduleParallel();
    39.             ecbSystem.AddJobHandleForProducer(this.Dependency);
    40.         }
    41.  
    42.         private static bool UpdateValue(ref float value, ref float buffer)
    43.         {
    44.             if (value == 0)
    45.                 return false;
    46.             if (value > buffer)
    47.             {
    48.                 value -= buffer;
    49.                 buffer = 0f;
    50.                 return false;
    51.             }
    52.             buffer -= value;
    53.             value = 0f;
    54.             return true;
    55.         }
    56.  
    57.         private static void StartUse(EntityCommandBuffer.ParallelWriter ecb, Entity entity, int sortKey, ref AbilityInProgressComponent abilityInProgressComponent, in DynamicBuffer<AbilityElement> abilities)
    58.         {
    59.             Debug.Log("Start use"); // this appears after 1s from client, and after 3s from server
    60.             var abilityEntity = abilities[(int)abilityInProgressComponent.UsedAbility].AbilityEntity;
    61.             if (abilityEntity == Entity.Null)
    62.                 return;
    63.             ecb.AddComponent(sortKey, abilityEntity, new AbilityEntityJustUsedComponent());
    64.         }
    65.  
    66.         private static void EndUse(EntityCommandBuffer.ParallelWriter ecb, Entity entity, int sortKey, ref AbilityInProgressComponent abilityInProgressComponent, in DynamicBuffer<AbilityElement> abilities)
    67.         {
    68.             var abilityEntity = abilities[(int)abilityInProgressComponent.UsedAbility].AbilityEntity;
    69.             if (abilityEntity == Entity.Null)
    70.                 return;
    71.             ecb.AddComponent(sortKey, abilityEntity, new AbilityEntityJustEndedComponent { EndedSuccessfully = true});
    72.         }
    73.     }
    74. }
     
  2. Noctiphobia

    Noctiphobia

    Joined:
    Jun 9, 2021
    Posts:
    8
    I added a text to show some diagnostic info. I'm setting the text from the system above (the part updated obviously depends on whether it's the server or client world).
    I think it illustrates the issue perfectly.

    Values are as follows (pretty obvious, but better to make it clear, I guess):
    - DeltaTime: Time.DeltaTime - used in calculation
    - ElapsedTime: Time.ElapsedTime
    - Ability: (abilityInProgressComponent.RemainingPreparationTime, abilityInProgressComponent.RemainingUseTime, abilityInProgressComponent.RemainingCooldownTime)

    ElapsedTime looks fine, meaning that either Time.DeltaTime is somehow wrong, or for some reason this system is being called more than once per update.
     
  3. Noctiphobia

    Noctiphobia

    Joined:
    Jun 9, 2021
    Posts:
    8
    Alright, a few minutes after posting the video, I figured out the problem, or rather the solution to it.
    I moved the AbilityUsageSystemGroup to SimulationSystemGroup. This fixed the timing issue. However, this also means I have some fundamental misunderstanding of what should be in GhostPredictionSystemGroup - I assumed that basically everything dependent on player input would end up here. This issue suggests that actually just the system that gets player input should be in that system, while basically everything else should be a part of SimulationSystemGroup. Is my understanding correct?
     
  4. CMarastoni

    CMarastoni

    Unity Technologies

    Joined:
    Mar 18, 2020
    Posts:
    895
    It is correct that everything that need to run as the server (so predicted) on the client side must be inside the GhostPredictionSystemGroup.
    The GhostPredictionSystemGroup run the logic at fixed time step (for both server and client) but with some differences on the clients.
    In particular, since the client frame rate can be higher (or lower) than the simulation rate, the client may runs "partial" tick (simulation tick for witch the delta time is less than the full simulation fixed step time) to still allow the game not act as still.
    The client in practice can run the prediction logics multiple time, always restarting from the last "full" simulation tick, until time accumulated is created than the simulation fixed step time. (this is why you see that 0.01 - 0.02 delta time jumping).
    When a new snapshot is received, the simulation is rollback to that tick and re-simulated forward and from here it continues like described before.

    Because of that, all the component data you USE inside those system MUST be completely replicated (aka, the field marked with a GhostField attribute).
    Because you are using the deltaTime to update the Ability properties (like remainingCastTime), first thing I would check is that those are replicated.

    Then, in general, you cannot expect the delta time that pass in between client and server is actually the same. It usually drift in the real life (even on the same machine)