Projectiles Essentials

Level Intermediate

Overview

Fusion Projectiles demonstrates multiple ways to implement networked projectiles. For a simpler overview and learning experience, the example implementations have been split into two separate projects - Projectiles Essentials and Projectiles Advanced.

This project - Projectiles Essentials - serves as an entry point into the subject. It aims to explain and give simple examples of all common approaches. Every example is commented and has an independent structure that enables grabbing useful code snippets and understanding core concepts without complex relations.

projectiles advanced serves as a game sample that goes in depth by solving common use cases when building shooter games and presents solutions for different projectile types (bouncing, homing, exploding, etc.).

This documentation covers only Projectiles Essentials. Please refer to a separate project page for projectiles advanced.

This sample uses the HostMode topology.
Projectiles Overview

메인 화면으로
 

Features

메인 화면으로
 

Download

Version Release Date Download
1.1.5 Feb 21, 2023 Fusion Projectiles Essentials 1.1.5 Build 101

메인 화면으로
 

Requirements

  • Unity 2021.3
  • Fusion AppId: To run the sample, first create a Fusion AppId in the PhotonEngine Dashboard and paste it into the App Id Fusion field in Real Time Settings (reachable from the Fusion menu). Continue with instruction in Starting The Game section.

메인 화면으로
 

Choosing The Right Approach

Before diving into the project, let's discuss some of the various options available for networking projectiles in Fusion.

메인 화면으로
 

A) NetworkObject With NetworkTransform/NetworkRigidbody

Spawn projectile as a NetworkObject, use NetworkTransform or NetworkRigidbody to synchronize position.

  • ✅ Simple solution when predicted spawn is not involved
  • ✅ Interest management works out of the box
  • ✅ Can use proper physics (PhysX)
  • NetworkObject instantiation overhead
  • NetworkTransform/NetworkRigidbody means sending every transform update over the network
  • ❌ Handling of predicted spawn if needed

Takeaway: Suitable only for small numbers of projectiles in certain situations - for example when complex physics projectiles are needed (e.g. rolling ball). Otherwise usage of this approach should be avoided.

This approach is showcased in Example 1.

메인 화면으로
 

B) NetworkObject With Fire Data

Spawn projectile as a NetworkObject, use networked custom data (usually fire tick, fire position and fire direction) in a NetworkBehaviour on spawned object to calculate the full projectile trajectory on server and clients.

  • ✅ Updating position costs no bandwidth
  • NetworkObject instantiation overhead
  • ❌ Handling of predicted spawn if needed
  • ❌ Manual interest management handling

Takeaway: Suitable only for small numbers of projectiles in certain situations - for example for very long living projectiles that can outlive the shooter (e.g. nuke rocket in Unreal Tournament). Although this approach is usually better than A for non-physical projectiles, it still has significant downfalls and except for mentioned use cases should be avoided in favor of further solutions.

This approach is showcased in Example 2 and Projectiles Advanced uses this approach for long living projectiles (see standalone projectile).

메인 화면으로
 

C) Projectile Count Property

Synchronize only a fired projectile count, stored in any NetworkBehaviour. Projectile is just a visual and is spawned as a standard Unity object. On the Input Authority and the State Authority shooting will be precise, however for proxies the shooting is based on their interpolated position and rotation. When projectiles are fired with lower cadence, it is possible to save information about the last projectile (e.g. impact position) mitigating precision problems of this solution.

  • ✅ Simple solution
  • ✅ Least amount of data transferred over the network
  • ✅ Spawning of visuals can be ignored on server
  • ✅ Interest management as part of the controlling object (player, weapon)
  • ✅ No need for predictive spawning
  • ❌ Suitable only for hitscan projectiles
  • ❌ Imprecise on proxies. Proxies are in snapshot interpolated position, thus their fire position/direction can be quite different. (this point applies only when data about last projectile cannot be saved due to high fire cadence)
  • ❌ There is no data about projectile target position (hit position) so precise projectile path is unknown on proxies. This implies that some projectile visuals could travel through walls and other objects since the projectile path end is unknown. (this point applies only when data about last projectile cannot be saved due to high fire cadence)

Takeaway: Good solution for simplistic use cases where we do not care about precision and visuals on proxies much - e.g. top down shooter with simple projectiles and short projectile visuals (trails) - or when having additional networked information only for last projectile is sufficient => maximum weapon cadence is quite low and firing multiple projectiles at once (shotgun style) is not needed. Usually though a solution that uses a projectile data buffer is a better option with just slightly increased complexity - see Example 4.

This approach is showcased in Example 3.

메인 화면으로
 

D) Projectile Data Buffer

Synchronize custom data for each projectile - stored in a networked array in the form of ring buffer in a NetworkBehaviour. The projectile is just a visual which is spawned as a standard Unity object. For optimal bandwidth usage the projectile data set should be sparse - e.g. projectile trajectory is calculated from fire position, fire direction or other data - but for some special use cases updating position constantly is also possible.

  • ✅ Good bandwidth consumption
  • ✅ Covers most projectile use cases
  • ✅ Spawning of visuals can be ignored on server
  • ✅ Interest management as part of the controlling object (player, weapon)
  • ✅ No need for predictive spawning
  • ❌ Projectiles cannot exist without the controlling object = cannot outlive it
  • ❌ Projectile physics like bouncing is only manually calculated

Takeaway: Very good solution for most situations except for very long-living or important projectiles (projectile that can outlive the owner - e.g. Redeemer projectile in Unreal Tournament that should still travel in the level even when the owner was despawned or disconnected) or very long-traveling (due to interest management).

This approach is showcased in the Example 4 and Example 5 and is also a core part of the projectiles advanced sample.

메인 화면으로
 

Project Organization

Project is structured as a set of 5 small independent examples.

/01_NetworkObject Example 1 - Network Object with NetworkRigidbody/NetworkTransform
/02_NetworkObjectFireData Example 2 - Network Object with Fire Data
/03_ProjectileCountProperty Example 3 - Projectile Count Property
/04_ProjectileDataBuffer_Hitscan Example 4 - Projectile Data Buffer - Hitscan
/05_ProjectileDataBuffer_Kinematic Example 5 - Projectile Data Buffer - Kinematic
/Common Prefabs, scripts and materials common to all examples
/ThirdParty Third party assets (models, effects)

메인 화면으로
 

Starting The Game

Each example has its own scene file which can be opened and played. Alternatively all examples can be started from the Start scene (/Common/Start).

After starting an example scene a slightly modified version of the Fusion standard NetworkDebugStart window will appear where the mode in which the game should be started in can be chosen.

Debug Start

메인 화면으로
 

Multipeer

Choosing Start Host + 1 Client or Start Host + 2 Clients will start the game in multi-peer mode. This approach is highly recommended for testing how projectiles behave on proxies.

To switch between peers, press the 0, 1, 2, and 3 keys on the numpad.

It is also possible to switch peers using the Runner Visibility Controls window (top menu Fusion/Windows/Runner Visibility Controls).

Runner Visibility Controls

To see how shooting behaves on a proxy, set only Client A as visible, and only enable Client B as an input provider. Your shooting from Client B now can be observed from Client A's perspective.

Runner Visibility Controls

메인 화면으로
 

Controls

Use W, S, A, D for movement and Mouse1 for fire.

Use the ENTER key to lock or release your cursor.

메인 화면으로
 

Examples

Projectile Essentials provides examples for every approach introduced in the Choosing The Right Approach section. All scripts are commented so feel free to dive into investigating specific code.

All examples use the same physics playground where players can shoot dummy boxes. To better showcase prediction when firing projectiles Physics Prediction is turned on (NetworkProjectConfig asset / Server Physics Mode set to ClientPrediction). Predicting physics is however performance heavy so unless your game is based around physics, this setting should be turned off.

Example 1 and Example 2 use spawning of network objects. It represents the most obvious approach for beginners however such approach is not recommended for many reasons stated above, one of which is complexity (especially due to spawn prediction). For a simple approach to projectiles jump directly to Example 3.

메인 화면으로
 

Example 1 - Network Object With NetworkRigidbody/NetworkTransform

Example 1 shows a physical projectile that can bounce and roll in the environment. Projectile is a spawned network object with its own behavior. Projectile's position and rotation (as well as other physics properties) are synchronized via NetworkRigidbody.

Projectiles Example 1

Firing such projectile can be as easy as spawning it with NetworkRunner and calling Fire on custom projectile script:

var projectile = Runner.Spawn(projectilePrefab, fireTransform.position, fireTransform.rotation, Object.InputAuthority);
projectile.Fire(fireTransform.forward * 10f);

Fire method could add impulse to the physical projectile:

public void Fire(Vector3 impulse)
{
    _rigidbody.AddForce(impulse, ForceMode.Impulse);
}

However this simplistic approach has a downside. Since projectile spawn is not predicted, there will be a significant pause after fire input before the projectile is actually fired when network conditions are not ideal. Normal way to mitigate this issue is to use spawn prediction.

Spawn prediction is showcased in Example 2. However for physical projectiles, such as the ones in this example, the spawn prediction is not recommended due to intricate physics interactions between predictively spawned objects (e.g. projectile dummies) and already registered networked objects in the scene. The simplest solution is to spawn some projectiles in advance, save it to the buffer and after fire input just use an already fully spawned projectile from the buffer. Such an approach is showcased as part of Example 1, check Predicted subfolder. This solution is more advanced so consider returning to it after going through the other examples.

메인 화면으로
 

Example 2 - Network Object With Fire Data

Example 2 shows a kinematic projectile that travels in time. The projectile is still a spawned network object but its position and rotation is calculated on every client based on some initial fire data instead of synchronizing position/rotation every tick over the network.

Note: Difference between hitscan and kinematic projectiles is explained in the projectiles types section of the Projectiles Advanced sample.

Firing is similar as in previous example:

var projectile = Runner.Spawn(projectilePrefab, fireTransform.position, fireTransform.rotation, Object.InputAuthority);
projectile.Fire(fireTransform.position, fireTransform.forward * 10f);

public void Fire(Vector3 position, Vector3 velocity)
{
    // Save fire data
    _fireTick = Runner.Tick;
    _firePosition = position;
    _fireVelocity = velocity;
}

Movement of the projectile is however calculated from the initial parameters:

[Networked]
private int _fireTick { get; set; }
[Networked]
private Vector3 _firePosition { get; set; }
[Networked]
private Vector3 _fireVelocity { get; set; }

// Same method can be used both for FUN and Render calls
private Vector3 GetMovePosition(float currentTick)
{
    float time = (currentTick - _fireTick) * Runner.DeltaTime;

    if (time <= 0f)
        return _firePosition;

    return _firePosition + _fireVelocity * time;
}

Usually it is enough to move the actual projectile transform in Render calls only. In FixedUpdateNetwork previous and next position can be calculated to fire raycasts and resolve potential collisions.

public override void FixedUpdateNetwork()
{
    if (IsProxy == true)
        return;

    // Previous and next position is calculated based on the initial parameters.
    // There is no point in actually moving the object in FUN.
    var previousPosition = GetMovePosition(Runner.Tick - 1);
    var nextPosition = GetMovePosition(Runner.Tick);

    var direction = nextPosition - previousPosition;

    if (Runner.LagCompensation.Raycast(previousPosition, direction, direction.magnitude, Object.InputAuthority,
             out var hit, _hitMask, HitOptions.IncludePhysX | HitOptions.IgnoreInputAuthority))
    {
        // Resolve collision
    }
}

Since spawning of network object is involved. Using spawn prediction is needed for best shooting experience on different network conditions. To enable spawn prediction, call Runner.Spawn method with prediction key:

var key = new NetworkObjectPredictionKey()
{
    Byte0 = (byte)Runner.Tick, // Low number part is enough
    Byte1 = (byte)Object.InputAuthority.RawEncoded,
};

Runner.Spawn(projectilePrefab, fireTransform.position, fireTransform.rotation, 
        Object.InputAuthority, predictionKey: key);

Predictively spawned objects need to implement IPredictedSpawnBehaviour interface that basically mimics standard NetworkBehaviour calls. This example uses the same object for predicted spawn which means that all access to networked properties has to be backed by local fields as networked properties cannot be accessed when the object does not have a networked state. This approach is slightly simpler when network properties are as part of a single networked struct:

public struct FireData : INetworkStruct
{
    public int FireTick;
    public Vector3 FirePosition;
    public Vector3 FireVelocity;
}

메인 화면으로
 

Example 3 - Projectile Count Property

Example 3 shows a very efficient approach of synchronizing just a projectile count directly on the weapon itself. This approach represents hitscan projectiles - projectile hits are immediately evaluated on fire. It means that the hit effect (e.g. damage, physics impulse) is immediately applied to the target but usually it is just fine in many cases to spawn dummy projectile visuals that still travel through the air for a short time. Dummy projectiles are used in this example (see Common/Scripts/DummyFlyingProjectile.cs).

public class Weapon : NetworkBehaviour
{
    [SerializeField]
    private LayerMask _hitMask;
    [SerializeField]
    private Transform _fireTransform;

    [Networked]
    private int _fireCount { get; set; }

    private int _visibleFireCount;

    public void FireProjectile()
    {
        // Whole projectile path and effects are immediately processed (= hitscan projectile)
        if (Runner.LagCompensation.Raycast(_fireTransform.position, _fireTransform.forward, 
                100f, Object.InputAuthority, out var hit, _hitMask))
        {
            // Resolve collision
        }

        _fireCount++;
    }

    public override void Spawned()
    {
        _visibleFireCount = _fireCount;
    }

    public override void Render()
    {
        if (_visibleFireCount < _fireCount)
        {
            // Show fire effect
        }

        _visibleFireCount = _fireCount;
    }
}

This approach is very simple, efficient and prediction works out of the box (no need to deal with spawn prediction because there is no network object spawning involved). As mentioned in the Choosing the Right Approach section it has some disadvantages that are easily mitigated by using projectile data buffer (Example 4).

메인 화면으로
 

Example 4 - Projectile Data Buffer - Hitscan

Example 4 showcases usage of projectile data buffer. The hitscan version of the buffer is a step up from Projectile Count Property from the previous example while retaining the simplicity.

Projectiles Example 4

Projectile Data Buffer approach uses a fixed array of ProjectileData structs that acts as a circular buffer. This means that when firing, the Input/State authority is filling the buffer with data based on the current buffer head (fireCount property) and all clients are visually catching up in Render calls based on their local head (visibleFireCount). ProjectileData contains all necessary data for reconstructing projectiles on all clients.

public class Weapon : NetworkBehaviour
{
    [SerializeField]
    private LayerMask _hitMask;
    [SerializeField]
    private Transform _fireTransform;

    [Networked]
    private int _fireCount { get; set; }
    [Networked, Capacity(32)]
    private NetworkArray<ProjectileData> _projectileData { get; }

    private int _visibleFireCount;

    public void FireProjectile()
    {
        var hitPosition = Vector3.zero;

        var hitOptions = HitOptions.IncludePhysX | HitOptions.IgnoreInputAuthority;

        // Whole projectile path and effects are immediately processed (= hitscan projectile)
        if (Runner.LagCompensation.Raycast(_fireTransform.position, _fireTransform.forward, 
                100f, Object.InputAuthority, out var hit, _hitMask))
        {
            // Resolve collision

            hitPosition = hit.Point;
        }

        _projectileData.Set(_fireCount % _projectileData.Length, new ProjectileData()
        {
            HitPosition = hitPosition,
        });

        _fireCount++;
    }

    public override void Spawned()
    {
        _visibleFireCount = _fireCount;
    }

    public override void Render()
    {
        if (_visibleFireCount < _fireCount)
        {
            // Play fire effects (e.g. fire sound, muzzle particle)
        }

        for (int i = _visibleFireCount; i < _fireCount; i++)
        {
            var data = _projectileData[i % _projectileData.Length];
            
            // Show projectile visuals (e.g. spawn dummy flying projectile or trail 
            // from fireTransform to data.HitPosition or spawn impact effect on data.HitPosition)
        }

        _visibleFireCount = _fireCount;
    }

    private struct ProjectileData : INetworkStruct
    {
        public Vector3 HitPosition;
    }
}

It is recommended to keep ProjectileData struct as small as possible and use data that does not change in time (if possible) to achieve good bandwidth even when firing large numbers of projectiles.

This approach is fairly simple, efficient and flexible and is recommended for most games. In case kinematic projectiles are needed in your game, a slightly more complex version of the buffer needs to be used - proceed to Example 5.

메인 화면으로
 

Example 5 - Projectile Data Buffer - Kinematic

In many cases projectiles in games travel through the environment for some time before they hit something or expire. We call such projectiles Kinematic Projectiles or simply just Projectiles. Projectile data buffer solution needs to be adjusted to update the projectile data based on the state of the projectile on Input/State authority and handle spawning and updating of visual representation of the projectiles for all clients in Render.

private struct ProjectileData : INetworkStruct
{
    public int FireTick;
    public int FinishTick;

    public Vector3 FirePosition;
    public Vector3 FireVelocity;
    public Vector3 HitPosition;
}

It still applies that ProjectileData struct should be as small as possible and preferably the data should not change much. In this example the projectile data is set twice per single projectile - once when projectile is fired (FirePosition, FireVelocity and FireTick is set) and once when projectile collides with the environment (HitPosition and FinishTick is set). In special justified cases it is of course possible to update the data in a more frequent manner - see homing projectiles in Projectiles Advanced.

메인 화면으로
 

About Projectiles Timing

Note: Make sure to understand how Prediction works in fusion first.

In Fusion we recognize two main time frames. Local time frame is the time of the local player and local objects. Remote time frame is the time of remote players and remote objects. All objects on the Host/Server are always simulated and rendered in the Local time frame. Clients are rendering objects in both time frames - usually Local is used for local objects and Remote is used for remote objects. For simulation, clients are usually only simulating their local objects (= FixedUpdateNetwork is not executed for proxies) unless proxy objects (projectiles of other players) are rendered in Local time frame and data about possible collisions and other interactions is needed to avoid overshooting.

In reality this means that when two different players A and B that stand next to each other and fire a projectile at the same tick. From player A's perspective the projectile A is immediately fired, however player A does not have any information about the projectile B yet as this information still travels from player B to server, is processed and then sent to player A. When this information finally arrives, player A is already several ticks ahead (= is predicting from the last known server state into the future) and rendering of the projectile A is ahead as well. Let's say projectile A is already 3 meters from player A. Now we have two options on how to render projectile B. We can choose to render it "correctly" in the Local time frame on position where it should be - 3 meters from player B - but that would mean that projectile visuals appear out of nowhere 3 meters from player B. Since such behavior is usually undesirable we choose to render projectile B in the Remote time frame. Which means that the projectile B is fired directly from player B's weapon but is rendered 3 meters behind player A's projectile even though projectiles were fired in the same tick. This is precisely why in all relevant examples in this sample the time for rendering projectiles is calculated as follows:

float renderTime = Object.IsProxy ? Runner.InterpolationRenderTime : Runner.SimulationRenderTime;

메인 화면으로
 

Advanced Projectiles Timing Note

Please read only if curious about intricate timing topic. It is not required to understand this topic in such detail if all you want to do is shoot some projectiles.

There are technically several time frames when it comes to rendering (or rather reading values from Render calls):

  • Local
    • = values from last forward tick
    • Value in Render: Reading network property on Input/State authority
    • Time in Render: Runner.Tick * Runner.DeltaTime or Runner.SimulationTime
  • Local Interpolated
    • = values interpolated between last two forward ticks
    • Value in Render: Reading interpolator value on Input/State authority
    • Time in Render: (Runner.Tick - 1 + Runner.StateAlpha) * Runner.DeltaTime or Runner.SimulationRenderTime or Runner.InterpolationRenderTime when called on Server (State authority)
  • Local Extrapolated
    • = values extrapolated from last forward tick
    • Value in Render: Reading network property on Input/State authority + calculating render part of the value locally
    • Time in Render: (Runner.Tick + Runner.StateAlpha) * Runner.DeltaTime or Runner.SimulationRenderTime + Runner.DeltaTime
  • Remote
    • = values from latest server tick (=> last received tick)
    • Value in Render: Reading network property on proxies (if properties are not modified on proxies during resimulation)
    • Time in Render: Runner.Simulation.LatestServerState.Tick * Runner.DeltaTime or Runner.Simulation.LatestServerState.Time
  • Remote Interpolated
    • = values interpolated between InterpFrom and InterpTo of the simulation (there needs to be a safety window before latest server tick is used for interpolation, therefore InterpFrom and InterpTo are even more in the past than latest server tick)
    • Value in Render: Reading interpolator value on proxies
    • Time in Render: Runner.InterpolationRenderTime or (Runner.Simulation.InterpFrom.Tick + (Runner.Simulation.InterpTo.Tick - Runner.Simulation.InterpFrom.Tick) * Runner.Simulation.InterpAlpha) * Runner.DeltaTime

Looking at such time frame definitions, projectiles from example 5 are rendered in Local Interpolated time frame for Input/State authority and in Remote Interpolated time frame for proxies. However for this to be exactly correct fireCount and projectileData properties should be read from respective interpolators. This is omitted for simplicity - We are using Remote Interpolated time but Remote values (and Local Interpolated time but Local values). Therefore the shooting happens slightly sooner on proxies than it technically should. The difference does not justify complexity in this case. For a full interpolation example check projectiles advanced.


기술문서 TOP으로 돌아가기