v1.0.12: How to properly predictive spawn bullet for a machine gun

Predictively spawning bullets currently create multiple predicted ghosts for one bullet (these collide with each other and are subsequently destroyed).

We have the following setup:

We have an input-gathering system (inside thePredictedSimulationSystemGroup), in that system, a gun component sets its isShooting boolean ghost field to true if a button is pressed.

In a subsequent GunHandlingSystem (Also in the PredictedSimulationSystemGroup), we check for the IsShooting is set to true, and if the weapon cooldown (also ghost field) is 0, we spawn the next bullet.

Before we spawn the bullet we check the IsFirstTimeFullyPredictingTick of the NetworkTime, to prevent multiple spawns during rollback and resimulation.

But it doesn’t seem to work, the client has multiple bullets spawning for just one server bullet.

What is missing to have this work properly in netcode?

Hey Occuros!

Can you post code for this please? IsFirstTimeFullyPredictingTick should do it, but there may be a bug elsewhere.
One other idea is to replace the bool isShooting flag with a byte isShootingId, then store the id in the bullet. This also helps you write a “perfect” predicted spawn classification system.

Why the input gather is running inside the Prediction loop? This should not be the case. This should be done outside, otherwise any logic break apart (because of input data rollback and playback)

Sorry that might have not been clear, Input gathering is not from the individual clients, a better name would be an “InputApplySystem”, it’s a system that takes the gathered input from all clients and executes logic from it. (Handling positions of the hands and head, button presses etc.)

If that use-case should be supported I can see to make a minimal replication repo where I can showcase the issue.

I use the byte isShootingId approach which I can strongly recommend, especially when mutliple bullets can be spawned. This has two advantages: You can specify a bullet ID which can be used for a customized GhostSpawnClassificationSystem on the client side for example but also for other bullet identifying issues. Additionally you can control the exact amount of bullets to be spawned. There is no chance of getting a bullet more or less when properly implemented.

2 Likes

Thank you for the hint!

Could you elaborate on the isShootingId approach? Would that be a ghost field as well? Does that byte increment every frame, every time a bullet should be shot?

I couldn’t find a sample in the ECS samples repro which used that technique.

Minimal reproduction case can be found here:

https://github.com/Occuros/unity_netcode_predictive_spawning

Certainly, something obvious is missing, but up to now I could not track down, why it is spawning too many bullets on the client side.

I did not found that approach in a sample, but it seems quite appropriate when trying to figure out a solution for firing multiple bullets predictively. I can show how I implemented the approach while still other variations may be possible. Also there are some pitfalls to be handled here which mades the logic overall a bit more complicated, but I mention them all.

First the stated field has to be part of the ICommandData struct which is transferred from client to server. It should be sufficient to be a byte field except when extremly high fire rates are required like firing one or two hundred bullets per second. After reaching a value of 255, it overflows and resets to zero again, so it reuses bullet IDs. But because bullets are only short-lived, this should not be an issue but have to be kept in mind.
IssueingProjectileId

public struct UnitInput : ICommandData
{
  // ...
 
  /// <summary>
  /// Gets or sets the projectile ID which are issued up to.
  /// An issued projectile is directed to be fired imminently or may have been fired already.
  /// </summary>
  /// <remarks>
  /// Examples:
  /// When the <see cref="UnitAttackShooting.IssuedProjectileId" /> is 12 and the <see cref="IssuingProjectileId" /> is also
  /// 12 then no shots should be fired.
  /// When the <see cref="UnitAttackShooting.IssuedProjectileId" /> is 17 and the <see cref="IssuingProjectileId" /> is also
  /// 22 then 5 shots should be fired.
  /// The first first fired projectile has the ID 18 then, the second ID is 19 and so on.
  /// </remarks>
  public byte IssuingProjectileId;

  /// <summary>
  /// Gets or sets the currently used weapon firing mode.
  /// </summary>
  public WeaponFiringMode CurrentFiringMode;
}

When required to really have a short-lived unique ID of the bullet the bullet ID/projectile ID has to be combined with another ID corresponding to the shooter. In my project I combine the ID of the character/unit with it to get that short-lived unique ID.

Then an ECS component is required which will have two fields based on the stated field for counting already fired bullets and keep track of the issued ones. This component data is transferred from server to client, at least to clients which are predicting shots.
UnitAttacking and UnitPredictedAttacking

/// <summary>
/// Contains attacking relevant data.
/// </summary>
[GhostComponent]
public struct UnitAttacking : IComponentData
{
  /// <summary>
  /// Gets or sets the shooting part of the attacking.
  /// </summary>
  [GhostField(Composite = true)]
  public UnitAttackShooting Shooting;
}

/// <summary>
/// Contains predicted attacking relevant data.
/// </summary>
[GhostComponent(PrefabType = GhostPrefabType.PredictedClient, OwnerSendType = SendToOwnerType.None)]
public struct UnitPredictedAttacking : IComponentData
{
  /// <summary>
  /// Gets or sets the shooting part of the attacking.
  /// </summary>
  public UnitAttackShooting Shooting;
}

FiredProjectileId and IssuedProjectileId (including calculation methods)

/// <summary>
/// Shooting relevant data about an attack.
/// </summary>
/// <remarks>The struct is used as part of an ECS component.</remarks>
public struct UnitAttackShooting
{
  // ...

  /// <summary>
  /// Gets or sets the the projectile which has effectively has been fired last.
  /// </summary>
  [GhostField]
  public byte FiredProjectileId;

  /// <summary>
  /// Gets or sets the ID of the last issued projectile.
  /// An issued projectile is directed to be fired imminently or may have been fired already.
  /// </summary>
  [GhostField]
  public byte IssuedProjectileId;

  /// <summary>
  /// Gets or sets the tick index at which the next projectile can be shot.
  /// </summary>
  [GhostField]
  public uint NextProjectileReadyTick;

  /// <summary>
  /// Gets or sets the firing mode used for the ongoing attack.
  /// When no attack is ongoing the mode has no effect and can freely be changed.
  /// </summary>
  [GhostField]
  public WeaponFiringMode FiringMode;

  /// <summary>
  /// Gets whether a shooting is currently executed.
  /// </summary>
  public bool IsShooting
  {
    get => this.FiredProjectileId != this.IssuedProjectileId;
  }

  /// <summary>
  /// Calculates the difference between the current <see cref="FiredProjectileId"/> and the current <see cref="IssuedProjectileId"/>.
  /// </summary>
  /// <returns>A value between -127 and 128 showing whether projectiles are added or removed.</returns>
  /// <remarks>
  /// Normally the value is zero or negative, because more projectiles are issued than fired.
  /// </remarks>
  public int CalculateDifferenceFiredToIssuedProjectileId()
  {
    return this.CalculateDifferenceToIssuedProjectileId(this.FiredProjectileId);
  }
 
  /// <summary>
  /// Calculates the difference between the specified projectile ID and the current <see cref="IssuedProjectileId"/>.
  /// </summary>
  /// <param name="projectileId">The projectile ID to calculate the difference to.</param>
  /// <returns>A value between -127 and 128 showing whether projectiles are added or removed.</returns>
  public int CalculateDifferenceToIssuedProjectileId(int projectileId)
  {
    // The byte value range is split in half to determine either increase or decrement of the projectile count
    int lowerThreshold;
    int upperThreshold;
 
    unchecked
    {
      lowerThreshold = this.IssuedProjectileId - 128;
      upperThreshold = this.IssuedProjectileId + 128;
    }

    if (projectileId > upperThreshold)
    {
      return -math.abs((this.IssuedProjectileId + 256) - projectileId);
    }
    else if (projectileId <= lowerThreshold) // <= to ensure that the range is always -127 to 128 (same behavior as  > upperThreshold)
    {
      return math.abs((projectileId + 256) - this.IssuedProjectileId);
    }
    else
    {
      return projectileId - this.IssuedProjectileId;
    }
  }

  /// <summary>
  /// Returns whether a new projectile can be fired.
  /// </summary>
  /// <param name="serverTickIndex">Current server tick index.</param>
  /// <returns>True when a new projectile can be fired.</returns>
  public bool IsProjectileReady(uint serverTickIndex)
  {
    return this.FiredProjectileId == this.IssuedProjectileId
      && serverTickIndex >= this.NextProjectileReadyTick;
  }

  /// <summary>
  /// Increments the fired projectile ID.
  /// </summary>
  /// <returns>The new fired projectile ID.</returns>
  public byte IncrementFiredProjectileId()
  {
    unchecked
    {
      this.FiredProjectileId = (byte)(this.FiredProjectileId + 1);
      return this.FiredProjectileId;
    }
  }

  /// <summary>
  /// Increments the issued projectile ID.
  /// </summary>
  /// <param name="increment">Value to increment.</param>
  public void IncrementIssuedProjectileId(int increment = 1)
  {
    this.IssuedProjectileId = this.DetermineIncrementedIssuedProjectileId(increment);
  }

  /// <summary>
  /// Determines the incremented projectile ID using the specified increment value.
  /// </summary>
  /// <param name="increment">Value to increment.</param>
  /// <returns>
  /// Calculated projectile ID.
  /// </returns>
  public byte DetermineIncrementedIssuedProjectileId(int increment = 1)
  {
    unchecked
    {
      return (byte)(this.IssuedProjectileId + increment);
    }
  }
}

So, that was the data definition part. Now the data has to be read and write accordingly. Somewhere in the input system, the “IssuingProjectileId” has to be increased. There may be some ready to fire conditions before, but when shooting the issuing bullet count has to be increased. It may still overflow, but the determination/calculation methods above take care about this.
Issuing projectiles in the Input System

byte newIssuingProjectileId;
UnitAttackShooting currentUnitAttackShooting = ...;
// ...
newIssuingProjectileId = currentUnitAttackShooting.DetermineIncrementedIssuedProjectileId(effectiveAddedProjectileCount);

It is possible to add multiple bullets at ones OR add single bullet after single bullet, depending on player inputs and game logic. It is is important that the “newIssuingProjectileId” which is send as ICommandData is consistently tracked and send. This means when the ID is increased it has to send the same increased value frame after frame even when no more bullets are fired. I hold the “newIssuingProjectileId” in a separate field. But because of this it may in theory happen that the value is getting out of sync with the “IssuedProjectileId” coming from the server. Some logic for adjustment of the “newIssuingProjectileId” on the client-side may be required then.

Some important note about the “currentUnitAttackShooting” variable and where the value has to came from. Because the GhostInputSystemGroup run before the Prediction Loop, the value may have been reset on the client due client-side rollback when new snapshot from server arrived. Then the “IssuedProjectileId” is NOT the predicted one which will cause wrong incrementings to the “IssuingProjectileId”. I circumvented that problem with having two variations of the shooting component “UnitAttacking” and “UnitPredictedAttacking” like already shown above.

The field “Shooting” of the “UnitPredictedAttacking” is copied from the “UnitAttacking” in a specific ECS system on the client-side running right after the Prediction Loop, so the client can access this predicted values in the Input System even when data is rollbacked.

So now the bullet ID/projectile ID data can be processed in the Prediction Loop in a ECS system for handling the spawn of the bullet entities. This is straight forward and looks like this:
Shooting System

/// <summary>
/// System for spawning and processing shots.
/// </summary>
[BurstCompile]
[UpdateInGroup(typeof(PredictedFixedStepSimulationSystemGroup))]
[WorldSystemFilter(WorldSystemFilterFlags.ServerSimulation | WorldSystemFilterFlags.ClientSimulation)]
public unsafe partial struct ShootingProcessorSystem : ISystem
{
  // ...

  /// <inheritdoc />
  [BurstCompile]
  public void OnUpdate(ref SystemState state)
  {
    if (!NetcodeUtilities.TryGetNetworkTime(SystemAPI.GetSingleton<NetworkTime>(), out var networkTime))
    {
      return;
    }

    // ...
    this._processShotsJob.NetworkTime = networkTime;
    state.Dependency = this._processShotsJob.ScheduleByRef(this._processShotsQuery, state.Dependency);
  }

  [BurstCompile]
  private partial struct ProcessShotsJob : IJobEntity
  {
    // ...

    [ReadOnly]
    public NetworkTime NetworkTime;

    [BurstCompile]
    public void Execute(ShootingAspect shootingAspect)
    {
      // ...

      ref var weaponDefinition = ref this.WeaponDefinitionPoolReference.Reference.Value.Definitions[shootingAspect.UnitWieldedWeaponRef.ValueRO.Fixed.WeaponClassId];
      this.InitiateAndContinueAttacks(ref shootingAspect, ref weaponDefinition);
      this.ExecuteAttacks(ref shootingAspect, ref weaponDefinition);
    }

    [BurstCompile]
    private void InitiateAndContinueAttacks(
      ref ShootingAspect shootingAspect, ref WeaponDefinition weaponDefinition)
    {
      var serverTickIndex = this.NetworkTime.ServerTick.TickIndexForValidTick;

      ref UnitAttackShooting unitAttackShooting = ref shootingAspect.UnitAttackingRef.ValueRW.Shooting;
      ref var unitWieldedWeapon = ref shootingAspect.UnitWieldedWeaponRef.ValueRW;
      ref UnitInput weaponInput = ref shootingAspect.UnitWieldedWeaponRef.ValueRW.Input;

      if (unitAttackShooting.IssuedProjectileId != weaponInput.IssuingProjectileId)
      {
        if (weaponDefinition.AvailableFiringModes.Has(weaponInput.CurrentFiringMode))
        {
          // Validate data before applying
          if (unitAttackShooting.IsProjectileReady(serverTickIndex)
              && unitWieldedWeapon.IsAttackReady(serverTickIndex)
              && shootingAspect.UnitMotionRef.ValueRO.CanAttack)
          {
            var minimumBurstProjectileCount = weaponInput.CurrentFiringMode.DetermineMinimumBurstProjectileCount();
            var maximumRemainingProjectileCount = (int)unitWieldedWeapon.Dynamic.LoadedRemainingProjectileCount;
            var projectileDifferenceCount = unitAttackShooting.CalculateDifferenceToIssuedProjectileId(weaponInput.IssuingProjectileId);

            if (maximumRemainingProjectileCount != projectileDifferenceCount
                && minimumBurstProjectileCount != projectileDifferenceCount)
            {
              if (this.NetworkTime.IsFirstTimeFullyPredictingTick)
              {
                Log.To(this.JobLogger).Warning(
                  "[T={ServerTickValue}/{WorldName}] Invalid projectile count to fire weapon '{WeaponName}' ({WeaponClassId}) for unit with ID {UnitId} with mode '{FiringMode}', because {IssuedProjectileCount} projectiles are issued while {RequiredProjectileCount} projectiles are required for the mode. Projectile count adjusted and continued.",
                  serverTickIndex, this.WorldName, weaponDefinition.Name, weaponDefinition.WeaponClassId, shootingAspect.PureUnitRef.ValueRO.UnitId,
                  WeaponFiringModeBurst.Texts[(int)weaponInput.CurrentFiringMode], projectileDifferenceCount, minimumBurstProjectileCount);
              }

              projectileDifferenceCount = minimumBurstProjectileCount;
            }

            var effectiveBurstProjectileCount = (byte)math.min(projectileDifferenceCount, maximumRemainingProjectileCount);

            unitAttackShooting.FiringMode = weaponInput.CurrentFiringMode;
            unitAttackShooting.IncrementIssuedProjectileId(effectiveBurstProjectileCount);
          }
          else if (unitAttackShooting.IsShooting && unitAttackShooting.FiringMode == WeaponFiringMode.Automatic)
          {
            var projectileDifferenceCount = unitAttackShooting.CalculateDifferenceToIssuedProjectileId(weaponInput.IssuingProjectileId);

            // Only on automatic fire the issued projectile count may be increased during attack
            if (projectileDifferenceCount > 0)
            {
              unitAttackShooting.IssuedProjectileId = weaponInput.IssuingProjectileId;
            }
          }
        }
        else
        {
          if (this.NetworkTime.IsFirstTimeFullyPredictingTick)
          {
            Log.To(this.JobLogger).Warning(
              "[T={ServerTickValue}/{WorldName}] Cannot fire weapon '{WeaponName}' ({WeaponClassId}) for unit with ID {UnitId} with firing mode '{FiringMode}', because mode is not available.",
              serverTickIndex, this.WorldName, weaponDefinition.Name, weaponDefinition.WeaponClassId, shootingAspect.PureUnitRef.ValueRO.UnitId,
              WeaponFiringModeBurst.Texts[(int)weaponInput.CurrentFiringMode]);
          }
        }
      }
    }

    [BurstCompile]
    private void ExecuteAttacks(
      ref ShootingAspect shootingAspect, ref WeaponDefinition weaponDefinition)
    {
      ref UnitAttackShooting unitAttackShooting = ref shootingAspect.UnitAttackingRef.ValueRW.Shooting;
      ref var wieldedWeaponDynamicBlock = ref shootingAspect.UnitWieldedWeaponRef.ValueRW.Dynamic;

      if (unitAttackShooting.IsShooting)
      {
        var serverTickIndex = this.NetworkTime.ServerTick.TickIndexForValidTick;

        // Check for out of ammunition
        if (wieldedWeaponDynamicBlock.LoadedRemainingProjectileCount == 0)
        {
          // Reset the issued projectiles to the fired once, this ends the burst
          unitAttackShooting.IssuedProjectileId = unitAttackShooting.FiredProjectileId;

          // Also activate cooldown
          wieldedWeaponDynamicBlock.NextAttackReadyTick = serverTickIndex + weaponDefinition.ImmediateGunDefinition.BurstCooldownTicks;
        }
        else if (shootingAspect.UnitMotionRef.ValueRO.CanAttack && serverTickIndex >= unitAttackShooting.NextProjectileReadyTick)
        {
          var projectileId = unitAttackShooting.IncrementFiredProjectileId();

          // Checks whether this predicted server tick is predicted for the very first time (on server always true, because every tick is processed only once)
          // Spawning attack ghost entities and calculating result is only required to be executed once per unique server tick (spawning new ghost entities!)
          if (this.NetworkTime.IsFirstTimeFullyPredictingTick)
          {
            // Spawn the shot entity
            var shotEntity = this.SpawnShot(ref shootingAspect, serverTickIndex, projectileId, out var pureImmediateShot);

            // Do additional handling of the shot like processing impact and so on
            // ...
          }

          wieldedWeaponDynamicBlock.LoadedRemainingProjectileCount--;

          unitAttackShooting.NextProjectileReadyTick = serverTickIndex + weaponDefinition.ImmediateGunDefinition.ProjectileCooldownTicks;

          // When the last shot has been fired activate cooldown
          if (!unitAttackShooting.IsShooting)
          {
            // This appoints that the attack is on cooldown (but also used for weapon switch and reload)
            wieldedWeaponDynamicBlock.NextAttackReadyTick = serverTickIndex + weaponDefinition.ImmediateGunDefinition.BurstCooldownTicks;
          }
        }
      }
    }
  }
}
3 Likes

Thank you for sharing all the details of your systems and implementation, will try to replicate this and see if it can resolve the multiple bullet spawn issue on the client side.

That being said, it looks like a lot of logic is required for something very common in many multiplayer experiences.

Are there any improvements planned to predictive spawning in netcode, to make this kind of process less laborious @CMarastoni ?

Just to mention, we implement a similar mechanics for this in our HelloNetcode sample for predictive spawning here:

It still use a unique ID for spawning, but it is handled automatically via InputEvent and you just then need to match the predicted entity using that id via a custom classification (you can see that in the sample).

That being said, it is hard to provide something completely generic that fit all. This is why we preferred to let the user decided how to implement their predictive spawning logic.
But I do agree that is not straightforward and very common.

Even thought the process it itself is not complex:

  • Assign an id to the entity (always increasing counter is fine) to some component
  • Match the spawned entity with a classification system (the default one can’t do that).
    It is still quite a bit of code to write.

We can indeed provide a default logic that does that, along with components, so you don’t need to re-invent the wheel for common case scenario like that. I will take a note and see what we can do!

1 Like

That would be ideal if that use-case could be made simpler.

But the linked example also fails when the grenade launcher is switched from an only fire on mouse click, to continue fire on mouseclick.

I simply added:

  public struct CharacterControllerPlayerInput : IInputComponentData
    {
         ...
        [GhostField] public bool IsFiring;
        ...
    }

To the input system:

input.ValueRW.IsFiring = Input.GetKey(KeyCode.Mouse0);
    public struct Character : IComponentData
    {
         ...
        [GhostField]
        public float weaponCooldown;
    }

Then inside the ProcessFireCommandsSystem:

            foreach (var (character, inputBuffer, anchorPoint) in SystemAPI.Query<CharacterAspect, DynamicBuffer<HelloNetcodeSamples.Generated.CharacterControllerPlayerInputInputBufferData>, RefRO<AnchorPoint>>().WithAll<Simulate>())
            {
                character.Character.weaponCooldown = math.max( character.Character.weaponCooldown - deltaTime, 0);
                if (character.Input.SecondaryFire.IsSet || (character.Input.IsFiring && character.Character.weaponCooldown <= 0))
                {
                    character.Character.weaponCooldown = 0.3f;

When this is done, the grenades collide with each other and explode.

What would be the easiest approach to handle continuous predictive spawning when a trigger of a gun is held down?

9141874--1270099--predictive_disaster.gif

So I have alternative solution: syncing via NetworkTick deterministically.

Basically in ApplyInputSystem (predicted loop) write current NetworkTick (should be ghost field) when shooting starts.
And then calculate ticks since shooting started in shooting loop and based on cooldown value (how many ticks it takes) figure which modulo to use to determine ticks for shot.

This works really well even on very high ping.

2 Likes

Does spawn predicted ghost working by default feature will ship very soon at 1.1 exp release? I tried to implement ClassificationSystem and found that it’s not possible to implement it for my use case. I have a lot of skill entities and each skill entity attached ghost prefab entity which make it not possible to implement without having major rewrite to make it work. I hope official can prioritize this feature and ship it asap.

It works just fine by default. There could be glitches, like classification system destroying what shouldn’t be destroyed from time to time, but in general this is just fine.

Currently it’s not working without creating custom classification system. When predictive spawn a ghost, it will just spawn 2 exact same ghost entity and 1 of it will quickly destroy itself which is not the expected behavior

In 1.1 we don’t provide a classification system that work in all cases. It is up to you to provide one that work for you specific case scenario.
For spawning multiple entities in the same frame, assigning an unique id to each ghost based on some replicated field (i.e an always increasing counter or something like that) would provide a decent starting point.