This document is about: QUANTUM 3
SWITCH TO

Simple Brawler Tutorial Part 4: Charged Dash Attack and Prop Spawning

This page accompanies the Charged Dash Attack & Prop Spawning - Photon Quantum 3.1 Simple Brawler Tutorial (Part 4) video and explains how to spawn physics props onto the map at a fixed interval and how to build a charge-and-release dash attack that knocks props and other players off the platform.

Use this page as a reference for the fourth entry in the Simple Brawler series. It continues from the Part 3 project, so complete Parts 1 to 3 (or use the equivalent project) first. You can download the sample project for this part.

Charged Dash Attack & Prop Spawning - Photon Quantum 3.1 Simple Brawler Tutorial (Part 4)

Overview

Part 3 added ground detection, a fall and respawn loop, and spawn points. In Part 4 the sample becomes an actual brawler: props spawn onto the map, and players get a charged dash attack that pushes props and other players off the platform.

All physics interactions stay in sync across clients because every client runs the same deterministic Quantum simulation from the same inputs. No per-object synchronization code is written. The trade-off is that everything in the simulation has to stay deterministic, and this part points out where that matters: random numbers, system fields, and collections.

The video covers:

  • singleton components and how to access them with GetPointerSingleton;
  • using a FrameTimer and HasStoppedThisFrame for time-based logic such as spawn cadence;
  • spawning a random entity prototype with frame.RNG, Quantum's deterministic RNG session;
  • why system fields must be const or readonly, and why FP fields cannot be const;
  • randomizing the RNG seed per session through RuntimeConfig.Seed;
  • designing a charge-and-release attack: hold time, normalized strength, activation delay, duration, and cooldown;
  • storing hit entities in a hash_set with AllocateOnComponentAdded and FreeOnComponentRemoved;
  • adding a new input button and mapping it in QuantumDebugInput;
  • keeping attack tuning values in the PlayerConfig Data Asset;
  • Shape3DConfig for configurable box, sphere, and capsule shapes in casts and overlaps;
  • hit detection with Physics3D.OverlapShape and knockback with AddLinearImpulse.

Prerequisites: Parts 1 to 3 of this series (or an equivalent project), Unity 6.3 LTS with URP, and Photon Quantum 3.1.

Video Timeline

Time Section
00:00 Intro
01:15 Creating Props & Prop Spawner
02:10 Creating the Prop Spawner System
03:31 Small Refactoring
04:11 Setting Things Up in the Unity Editor
04:38 Randomizing the RNG Session
05:18 Defining the AttackData Component
05:55 Adding Attributes to the Collection in AttackData
06:30 Creating the Attack System
07:06 Reading Dash Input
07:35 Resuming the Attack System Implementation
07:49 Adding More Fields to PlayerConfig
09:25 Preparing the Attack in the Attack System
10:55 Implementing PerformImpulseMovement
11:14 Implementing PerformShapeOverlap
12:45 Connecting Things in Unity & Testing

The Attack Design

The attack works as follows:

  1. The player charges the attack by holding the attack button.
  2. Two values define the charge: the attack strength, and the holding time needed to reach maximum strength.
  3. When the player releases the button, the character performs a linear impulse (the dash).
  4. After a short delay, the system starts checking for entities in front of the player.
  5. Every entity hit is pushed away from the point of contact with a knockback force proportional to the strength built up while holding the button.

Before building the attack, the video adds props to the map so there is more to push around and the physics sync is easy to observe.

Creating Props & Prop Spawner

  1. In Simulation/QTN, create a file called Props.qtn.
  2. Define a component called Prop to tag spawned props.
  3. Define a singleton component called PropSpawner with a list of prop prototypes to spawn and a timer that sets the spawn cadence.

component Prop
{
}

singleton component PropSpawner
{
    list<asset_ref<EntityPrototype>> Props;
    FrameTimer SpawnTimer;
}

A singleton component works like a singleton in Unity: there is always exactly one instance of it in the simulation.

Creating the Prop Spawner System

Create a system called PropSpawnerSystem directly in the IDE. The spawner is a singleton, so the system does not need a filter. It inherits from SystemMainThread and implements Update:

  1. Retrieve a pointer to the singleton with frame.Unsafe.GetPointerSingleton<PropSpawner>().
  2. Check whether the spawn timer has stopped this exact frame with HasStoppedThisFrame. It returns true on the exact frame the timer runs out, which makes it convenient for any time-based logic in Quantum.
  3. Check that fewer than 10 Prop components exist.
  4. If so, resolve the list of prop prototypes and create an entity from a random entry. frame.RNG returns Quantum's deterministic RNG session, which provides the familiar Next(min, max) method.
  5. Get a pointer to the new entity's Transform3D and give it a random position: X between -5 and 5, Y between 9 and 12, Z between -5 and 5.
  6. After the inner if block, restart the spawn timer so the interval repeats.

Small Refactoring

The first version contains several magic numbers. Extract them into fields:

  • 10 becomes a const called MaxProps.
  • FP._5 becomes SpawnRange, 9 becomes SpawnHeightMin, and 12 becomes SpawnHeightMax.

FP is not a built-in C# type, so it cannot be const. Use static readonly instead.

Never declare a mutable field in a system. Systems must be stateless: any value written to a system field lives outside the frame state, so it is not rolled back or shared, and writing to it breaks determinism. All mutable data belongs in components.

C#

public unsafe class PropSpawnerSystem : SystemMainThread
{
    private const int MaxProps = 10;
    private static readonly FP SpawnRange = FP._5;
    private static readonly FP SpawnHeightMin = 9;
    private static readonly FP SpawnHeightMax = 12;
    private static readonly FP SpawnIntervalSeconds = 3;

    public override void Update(Frame frame)
    {
        var propSpawner = frame.Unsafe.GetPointerSingleton<PropSpawner>();

        if (!propSpawner->SpawnTimer.HasStoppedThisFrame(frame))
        {
            return;
        }

        if (frame.ComponentCount<Prop>() < MaxProps)
        {
            var props = frame.ResolveList(propSpawner->Props);
            var prototype = props[frame.RNG->Next(0, props.Count)];
            var entity = frame.Create(prototype);

            var transform = frame.Unsafe.GetPointer<Transform3D>(entity);
            transform->Position = new FPVector3(
                frame.RNG->Next(-SpawnRange, SpawnRange),
                frame.RNG->Next(SpawnHeightMin, SpawnHeightMax),
                frame.RNG->Next(-SpawnRange, SpawnRange));
        }

        propSpawner->SpawnTimer = FrameTimer.FromSeconds(frame, SpawnIntervalSeconds);
    }
}

Setting Things Up in the Unity Editor

  1. Create an empty entity for the spawner and add the PropSpawner component. Remove the Entity View, since a spawner has no visual representation.
  2. Add the Prop component to the Ball and Cube prototypes under QuantumUser/Resources.
  3. Add the entity prototype assets that sit next to Ball and Cube to the PropSpawner's Props list.
  4. On Spawn Timer, change the mode from Frames to Seconds and set it to 3.
  5. Add PropSpawnerSystem to the systems list in the default config.

Randomizing the RNG Session

frame.RNG is deterministic: the same seed produces the same sequence on every client. To get a different sequence each session, randomize the seed before the simulation starts. The sample project already does this. In your own project, set the seed in the Start UI connection code where the RuntimeConfig is prepared:

C#

RuntimeConfig.Seed = UnityEngine.Random.Range(int.MinValue, int.MaxValue);

Using UnityEngine.Random here is safe because the seed is chosen once on the Unity side and then shared with all clients through the RuntimeConfig.

When tested, props spawn onto the map every three seconds until there are 10 of them.

Defining the AttackData Component

In Player.qtn, define an AttackData component with:

  • three FrameTimer fields: ActivationTimer, DurationTimer, and CooldownTimer;
  • two FP fields: AttackHoldingTime and AttackStrength;
  • a collection of entities hit by the current attack, so knockback can be applied to each of them.

The same entity should not be pushed more than once per attack, so a hash_set fits better than a list.

Adding Attributes to the Collection in AttackData

Add three attributes to the hash_set:

Attribute Purpose
HideInInspector Hides the field on the entity prototype.
AllocateOnComponentAdded Allocates the collection when the component is added. Quantum collections are not allocated by default.
FreeOnComponentRemoved Frees the collection when the component is removed, preventing memory leaks.

component AttackData
{
    FrameTimer ActivationTimer;
    FrameTimer DurationTimer;
    FrameTimer CooldownTimer;
    FP AttackHoldingTime;
    FP AttackStrength;

    [HideInInspector, AllocateOnComponentAdded, FreeOnComponentRemoved]
    hash_set<EntityRef> HitEntities;
}

Without the allocation attributes, code can access an unallocated collection, and removed components leak their collection memory.

Creating the Attack System

  1. In Unity, under QuantumUser/Simulation/Systems, create a SystemFilter named AttackSystem.
  2. Add it to the systems list in the default config right away.
  3. In the IDE, add PlayerLink, PlayerState, and AttackData to the filter.

At the start of Update:

  • read the player's input;
  • resolve the PlayerConfig asset;
  • take the AttackData pointer from the filter.

The player is attacking while either the activation timer or the duration timer is running. The player can charge when they are not attacking and the cooldown timer has run out.

Reading Dash Input

  1. In Player.qtn, add an Attack button to the input struct:

input
{
    FPVector2 Movement;
    button Jump;
    button Attack;
}
  1. In QuantumDebugInput, add an input action reference for the attack, read it the same way as the jump button, and assign it to input.Attack.

Adding More Fields to PlayerConfig

Add an Attack header to PlayerConfig with the following fields. KnockbackForce (default 30) also lives under this header.

Field Default Purpose
ChargeDurationSeconds 2 Time needed to fully charge the attack.
AttackImpulse FP._50 Impulse applied to the player at full charge.
AttackOffset FP._1_50 Distance from the player's position to the center of the attack shape.
AttackActivationSeconds FP._0_10 Delay between releasing the charge and the dash impulse.
AttackDurationSeconds FP._0_25 How long the attack can hit other entities.
AttackCooldownSeconds FP._1 How long the player must wait before charging again.
KnockbackForce 30 Base impulse applied to entities that are hit.
AttackShape — Shape3DConfig defining the hit volume.

C#

[Header("Attack")]
public FP ChargeDurationSeconds = 2;
public FP AttackImpulse = FP._50;
public FP AttackOffset = FP._1_50;
public FP AttackActivationSeconds = FP._0_10;
public FP AttackDurationSeconds = FP._0_25;
public FP AttackCooldownSeconds = FP._1;
public FP KnockbackForce = 30;
public Shape3DConfig AttackShape;

Shape3DConfig wraps the shapes used for casts and overlaps (the equivalent of Unity's box cast, sphere cast, and similar) into one configurable field, so the hit shape can be changed on the asset without touching code.

Preparing the Attack in the Attack System

While the player can charge:

  1. If the attack button is held, add frame.DeltaTime to AttackHoldingTime, capped at ChargeDurationSeconds with FPMath.Min.
  2. When the button is released after charging:
    • store the normalized strength: AttackHoldingTime / ChargeDurationSeconds;
    • reset AttackHoldingTime;
    • start the activation and cooldown timers from AttackActivationSeconds and AttackCooldownSeconds;
    • resolve the HitEntities hash set and clear it, so the new attack starts with a clean slate.

Outside the charging block:

  1. When the activation timer stops this frame, perform the dash impulse, start the duration timer, and return.
  2. While the duration timer is running, perform the shape overlap to find and push targets.

C#

public override void Update(Frame frame, ref Filter filter)
{
    Input* input = frame.GetPlayerInput(filter.PlayerLink->Player);
    PlayerConfig playerConfig = frame.FindAsset(filter.PlayerState->PlayerConfig);
    AttackData* attackData = filter.AttackData;

    bool isAttacking = attackData->ActivationTimer.IsRunning(frame)
                    || attackData->DurationTimer.IsRunning(frame);
    bool canCharge = !isAttacking && !attackData->CooldownTimer.IsRunning(frame);

    if (canCharge)
    {
        if (input->Attack.IsDown)
        {
            attackData->AttackHoldingTime = FPMath.Min(
                attackData->AttackHoldingTime + frame.DeltaTime,
                playerConfig.ChargeDurationSeconds);
        }
        else if (input->Attack.WasReleased && attackData->AttackHoldingTime > FP._0)
        {
            attackData->AttackStrength = attackData->AttackHoldingTime / playerConfig.ChargeDurationSeconds;
            attackData->AttackHoldingTime = FP._0;

            attackData->ActivationTimer = FrameTimer.FromSeconds(frame, playerConfig.AttackActivationSeconds);
            attackData->CooldownTimer = FrameTimer.FromSeconds(frame, playerConfig.AttackCooldownSeconds);

            var hitEntities = frame.ResolveHashSet(attackData->HitEntities);
            hitEntities.Clear();
        }
    }

    if (attackData->ActivationTimer.HasStoppedThisFrame(frame))
    {
        PerformImpulseMovement(frame, filter.Entity, playerConfig, attackData);
        attackData->DurationTimer = FrameTimer.FromSeconds(frame, playerConfig.AttackDurationSeconds);
        return;
    }

    if (attackData->DurationTimer.IsRunning(frame))
    {
        PerformShapeOverlap(frame, filter.Entity, playerConfig, attackData);
    }
}

Resolving a collection before using it applies to every Quantum collection, not only hash_set. See the DSL documentation for details.

Implementing PerformImpulseMovement

Get the player's PhysicsBody3D pointer and Transform3D, then add a linear impulse along the transform's forward direction, scaled by AttackImpulse and the normalized strength. Releasing at 50% charge produces 50% of the impulse.

C#

private void PerformImpulseMovement(Frame frame, EntityRef entity, PlayerConfig playerConfig, AttackData* attackData)
{
    var body = frame.Unsafe.GetPointer<PhysicsBody3D>(entity);
    var transform = frame.Get<Transform3D>(entity);

    body->AddLinearImpulse(transform.Forward * playerConfig.AttackImpulse * attackData->AttackStrength);
}

Implementing PerformShapeOverlap

  1. Get the player's transform and forward direction.
  2. Compute the attack center: position plus forward direction multiplied by AttackOffset.
  3. Create a shape instance from the config with playerConfig.AttackShape.CreateShape(frame).
  4. Run frame.Physics3D.OverlapShape at the attack center with the player's rotation.
  5. Resolve the HitEntities hash set.
  6. Loop over the hits and skip an entity if it is the attacker, is already in the hash set, or has no PhysicsBody3D.
  7. Add the entity to the hash set.
  8. Compute a push direction from the attacker to the target on the X and Z axes, normalized.
  9. Apply a linear impulse of push direction × KnockbackForce × attack strength.

C#

private void PerformShapeOverlap(Frame frame, EntityRef entity, PlayerConfig playerConfig, AttackData* attackData)
{
    var transform = frame.Get<Transform3D>(entity);
    var forward = transform.Forward;
    var attackCenter = transform.Position + forward * playerConfig.AttackOffset;

    var attackShape = playerConfig.AttackShape.CreateShape(frame);
    var hitCollection = frame.Physics3D.OverlapShape(attackCenter, transform.Rotation, attackShape);

    var hitEntities = frame.ResolveHashSet(attackData->HitEntities);

    for (int i = 0; i < hitCollection.Count; i++)
    {
        var hitEntity = hitCollection[i].Entity;

        if (hitEntity == entity || hitEntities.Contains(hitEntity))
        {
            continue;
        }

        if (!frame.Unsafe.TryGetPointer<PhysicsBody3D>(hitEntity, out var hitBody))
        {
            continue;
        }

        hitEntities.Add(hitEntity);

        var hitPosition = frame.Get<Transform3D>(hitEntity).Position;
        var pushDirection = new FPVector3(
            hitPosition.X - transform.Position.X,
            FP._0,
            hitPosition.Z - transform.Position.Z).Normalized;

        hitBody->AddLinearImpulse(pushDirection * playerConfig.KnockbackForce * attackData->AttackStrength);
    }
}

Connecting Things in Unity & Testing

  1. In QuantumDebugInput, assign the Sprint input action as the attack input (any action works; the video uses Sprint for keyboard play).
  2. Add the AttackData component to the player prefab's entity prototype under QuantumUser/Resources.
  3. Configure AttackShape on the PlayerConfig asset.

When tested, holding and releasing the attack button makes the character dash forward and push props and other players it runs into. The props and players stay in sync across clients without any networking code in the project.

Key Concepts

Concept Description
Singleton component Component with exactly one instance in the simulation, declared with singleton component.
GetPointerSingleton<T> Returns a pointer to a singleton component.
FrameTimer Simulation timer that can be configured in frames or seconds.
HasStoppedThisFrame Returns true only on the exact frame a FrameTimer runs out.
frame.RNG Deterministic RNG session; the same seed yields the same sequence on every client.
RuntimeConfig.Seed Seed for the RNG session, set once before the simulation starts.
Stateless systems Systems hold no mutable fields; tuning values are const or static readonly.
hash_set Quantum collection of unique values, resolved with frame.ResolveHashSet.
AllocateOnComponentAdded / FreeOnComponentRemoved Attributes that manage a collection's memory with the component's lifetime.
AttackData Component holding the attack timers, charge state, strength, and hit set.
Shape3DConfig Configurable shape (box, sphere, capsule, and others) for casts and overlaps.
Physics3D.OverlapShape Returns all colliders overlapping a shape at a given position and rotation.
Normalized attack strength Hold time divided by full charge time; scales both dash and knockback.

Best Practices

Use this checklist when adding spawners and timed abilities to a Quantum project:

  • Use a singleton component for game-wide state such as a spawner, and a filterless SystemMainThread to drive it.
  • Use FrameTimer and HasStoppedThisFrame for cadences and delays instead of counting frames by hand.
  • Use frame.RNG for every random value in the simulation, never UnityEngine.Random or System.Random.
  • Randomize RuntimeConfig.Seed before the simulation starts if each session should play out differently.
  • Keep systems stateless. Use const for built-in types and static readonly for FP values.
  • Prefer hash_set when an entity should be processed once per action.
  • Add AllocateOnComponentAdded and FreeOnComponentRemoved to collections in components.
  • Resolve a Quantum collection before reading or writing it.
  • Keep attack tuning values, including the hit shape, in a Data Asset.
  • Scale dash and knockback by the same normalized strength so the charge mechanic stays consistent.
  • Skip the attacker, already-hit entities, and entities without a physics body in the overlap loop.

Common Pitfalls

Pitfall Why It Is a Problem
Writing to a mutable field in a system System fields are not part of the frame state; determinism breaks.
Declaring an FP field as const FP is not a built-in type, so the code does not compile. Use static readonly.
Using UnityEngine.Random inside the simulation Clients produce different values and desync.
Leaving RuntimeConfig.Seed unchanged Every session spawns props in the same sequence.
Not restarting the spawn timer The spawner fires once and then stops.
Using a list for hit entities The same target can be pushed several times during one attack.
Checking the hash set without adding hit entities to it Targets are pushed again on every tick of the attack window.
Omitting the allocation attributes on a collection The collection may be unallocated when accessed, and memory leaks when the component is removed.
Forgetting to clear HitEntities when a new attack starts Targets hit by a previous attack are ignored.
Leaving out the self check in the overlap loop The attacker can knock itself back.
Forgetting to add AttackData to the player prototype The AttackSystem filter never matches the player.
Forgetting to register PropSpawnerSystem or AttackSystem The system never runs.

Summary

Part 4 of the Simple Brawler series adds props and a charged dash attack. A PropSpawner singleton component holds a list of prop prototypes and a FrameTimer. PropSpawnerSystem uses HasStoppedThisFrame to spawn a random prop with frame.RNG every three seconds, up to 10 props. Tuning values are const or static readonly because systems must stay stateless, and RuntimeConfig.Seed is randomized so each session differs.

The attack is driven by an AttackData component with activation, duration, and cooldown timers, a charge value, and a hash_set of hit entities managed by AllocateOnComponentAdded and FreeOnComponentRemoved. AttackSystem accumulates hold time up to ChargeDurationSeconds, converts it into a normalized strength on release, dashes the player forward after a short delay, and then uses Physics3D.OverlapShape with a Shape3DConfig to push every newly hit body away with a knockback impulse scaled by that strength.

All of it stays in sync across clients through Quantum's deterministic simulation, with no networking code in the project. Part 5 adds a rotating obstacle and a lives and death system with UI.

Last updated on

Back to top