This document is about: QUANTUM 3
SWITCH TO

Simple Brawler Tutorial Part 3: Ground Detection, Respawning and Spawn Points

This page accompanies the Ground Detection, Respawning & Spawn Points - Photon Quantum 3.1 Simple Brawler Tutorial (Part 3) video and explains how to make the Simple Brawler sample playable as a match: UI that faces the camera, ground detection, a fall and respawn loop, and spawn points so players do not stack on top of each other.

Use this page as a reference for the third entry in the Simple Brawler series. It continues from the Part 2 project; complete Part 1 and Part 2 (or use the equivalent project) first.

Ground Detection, Respawning & Spawn Points - Photon Quantum 3.1 Simple Brawler Tutorial (Part 3)

Overview

Part 2 added a camera follow, animation, and Quantum Data Assets. Part 3 adds the gameplay rules a match needs: players can only jump while grounded, players who fall off the platform respawn, and each player gets a unique spawn point.

The video covers:

  • making UI such as a nickname tag always face the camera with a plain MonoBehaviour;
  • displaying player nicknames from player data through an Entity View Component, with a different color for the local player;
  • fixing the infinite-jump bug with ground detection based on a Quantum Physics3D raycast, using query options and a ground-check distance driven by a Data Asset;
  • feeding the simulation state (IsGrounded) into the Animator from the view layer;
  • building a FallBox trigger volume with PhysicsCollider3D and a dynamic-trigger callback flag;
  • detecting the fall with ISignalOnTriggerEnter3D;
  • defining a custom Quantum signal (OnPlayerRespawned) that other systems can react to;
  • assigning players to spawn points round-robin with a component iterator.

Prerequisites: completed Parts 1 and 2 of this series (or the equivalent project), Unity 6.3 LTS with URP, and Photon Quantum 3.1.

Video Timeline

Time Section
00:00 Intro & Recap of Part 2
00:40 Making UI Face the Camera (PointToCamera)
01:14 Displaying Player Nicknames (UIPlayerNickname)
02:11 The Infinite-Jump and Fall-Forever Problems
02:25 Adding IsGrounded to PlayerState
02:35 Ground Check With a Physics3D Raycast
03:32 Physics Query Options: HitStatics / HitDynamics
03:50 Setting IsGrounded and Gating the Jump
04:10 Why IsGrounded Lives in PlayerState (and the Animator)
04:35 Testing Grounded-Only Jumping
04:42 Falling Off the Platform: The FallBox Trigger Volume
05:14 Trigger Callback Flags (OnDynamicTrigger)
05:42 Defining the FallBox Component
06:04 Detecting the Fall With ISignalOnTriggerEnter3D
07:17 Defining and Raising a Custom Signal: OnPlayerRespawned
07:58 Handling Respawn in PlayerJoinedSystem
08:19 Registering FallSystem & Testing the Respawn Loop
08:31 Why One Respawn Point Breaks With Multiple Players
08:44 Adding PlayerSpawnPoint Components to the Map
09:15 Assigning Spawn Points Round-Robin (MovePlayerToSpawnPoint)
10:41 Using It on Join and Respawn, Testing Online
10:59 Outro

Making UI Face the Camera (PointToCamera)

Create a script called PointToCamera in View/Scripts. It is a plain MonoBehaviour, not a Quantum-aware class, because it does not depend on any Quantum lifecycle.

Since the camera's rotation never changes, it can be cached once. In LateUpdate, pin the transform's rotation to the camera's rotation:

C#

using UnityEngine;

public class PointToCamera : MonoBehaviour
{
    private Quaternion _cameraRotation;

    private void Start()
    {
        _cameraRotation = Camera.main.transform.rotation;
    }

    private void LateUpdate()
    {
        transform.rotation = _cameraRotation;
    }
}

Place the script on the UI elements that sit above the character.

Displaying Player Nicknames (UIPlayerNickname)

Create a new Entity View Component called UIPlayerNickname.

  1. Store a serialized TMP_Text field named playerNickname.
  2. In OnActivate, retrieve the player's data through the PlayerLink component.
  3. Check whether the player is local.
  4. Set the text to the nickname from the player data.
  5. If the player is local, set the text color to yellow so the local player is easy to spot. Other players keep the default color.

When tested, each nickname appears above its character, and the local player's nickname is yellow.

The Infinite-Jump and Fall-Forever Problems

Two issues remain after Part 2:

  • the jump button can be pressed repeatedly, allowing unlimited jumps in the air;
  • a player who falls off the platform can only get back by repeatedly jumping, with no way to recover.

Adding IsGrounded to PlayerState

In Player.qtn, add a boolean flag to the PlayerState component to indicate whether the player is grounded:

component PlayerState
{
    asset_ref<PlayerConfig> PlayerConfig;
    bool IsGrounded;
}

Ground Check With a Physics3D Raycast

As in Unity, ground detection uses a raycast. In the player movement system, call frame.Physics3D.Raycast with the following parameters:

Parameter Value
Origin The transform's position.
Direction Down.
Distance A new GroundCheckingDistance field on PlayerConfig, set to 1.
Layer mask -1, to include all layers.
Query options HitStatics | HitDynamics.

In PlayerConfig, add the public GroundCheckingDistance field and change the header from Movement to Physics.

C#

var groundHit = frame.Physics3D.Raycast(
    filter.Transform->Position,
    FPVector3.Down,
    playerConfig.GroundCheckingDistance,
    -1,
    QueryOptions.HitStatics | QueryOptions.HitDynamics);

Physics Query Options: HitStatics / HitDynamics

Query options are set as bitwise flags. Several options exist; this video uses HitStatics | HitDynamics, so the ray detects both static colliders (the platform) and dynamic bodies.

Setting IsGrounded and Gating the Jump

The raycast returns an optional Hit3D. Only the fact that something was hit matters here, not details such as hit normal or hit point, so store whether the result has a value:

C#

filter.PlayerState->IsGrounded = groundHit.HasValue;

In the if statement that checks whether jump was pressed, also check that the player is grounded:

C#

if (input->Jump.WasPressed && filter.PlayerState->IsGrounded)
{
    filter.PhysicsBody->AddLinearImpulse(new FPVector3(FP._0, playerConfig.JumpForce, FP._0));
}

Why IsGrounded Lives in PlayerState

The movement system is currently the only place that reads IsGrounded, so a local variable would work. It is stored in PlayerState because other systems may use it in the future, and because the view layer reads it now: in OnUpdateView of the player animation view component, set the Animator's isGrounded parameter from the value in PlayerState, replacing the hard-coded true from Part 2.

When tested, the character can only jump while standing on the ground.

Falling Off the Platform: The FallBox Trigger Volume

To respawn players who fall, place a large trigger box below the map, similar to regular Unity development.

  1. Create an empty entity.
  2. Add Transform3D and PhysicsCollider3D.
  3. Set Is Trigger to true.
  4. Set the shape type to Box and the extents to 100 on the X and Z axes.
  5. Name the GameObject FallBox and move it down on the Y axis, below the platform.

Trigger Callback Flags (OnDynamicTrigger)

Callback flags on a collider define which signals the entity reacts to, so the event can be processed in the simulation. See the Quantum physics callbacks documentation or the physics collisions video for details.

For FallBox, set the callback flag to OnDynamicTriggerEnter. Only the first moment a player enters the trigger is relevant.

Defining the FallBox Component

Create a new QTN file named FallBox.qtn and define a component with the same name. It is used to identify the trigger entity inside the fall system:

component FallBox
{
}

Assign the FallBox component to the FallBox entity prototype in Unity.

Detecting the Fall With ISignalOnTriggerEnter3D

In Simulation/Systems, create a new SystemSignalsOnly called FallSystem and implement ISignalOnTriggerEnter3D.

This method is called by any trigger collider that has the trigger-enter callback flag enabled, so the system must filter:

  1. Check whether the entity that triggered the callback has a FallBox component. If not, return early.
  2. Check whether the other entity has a PlayerLink component. If not, destroy that entity and return early. This is temporary and will be changed when more complex game flow logic is added.
  3. Otherwise, a player hit the fall box, and another system needs to move the player. Raise a signal.

C#

public void OnTriggerEnter3D(Frame frame, TriggerInfo3D info)
{
    if (!frame.Has<FallBox>(info.Entity))
    {
        return;
    }

    if (!frame.Has<PlayerLink>(info.Other))
    {
        frame.Destroy(info.Other);
        return;
    }

    frame.Signals.OnPlayerRespawned(info.Other);
}

The fall box detection could also be implemented with a static box collider and a dedicated layer, with the dynamic-trigger callback flag set on the player instead. The entity-based approach is simpler for this series.

Defining and Raising a Custom Signal: OnPlayerRespawned

Define a signal in Player.qtn. The syntax resembles a method signature and takes the EntityRef of the player:

signal OnPlayerRespawned(EntityRef entity);

See the Quantum documentation or the Quantum Essentials video for more on signals.

Handling Respawn in PlayerJoinedSystem

In PlayerJoinedSystem, implement the generated ISignalOnPlayerRespawned interface the same way built-in signals are implemented. Since the entity is guaranteed to be a player, set its transform position to FPVector3.Zero and reset the PhysicsBody3D velocity:

C#

public void OnPlayerRespawned(Frame frame, EntityRef entity)
{
    var transform = frame.Unsafe.GetPointer<Transform3D>(entity);
    transform->Position = FPVector3.Zero;

    var body = frame.Unsafe.GetPointer<PhysicsBody3D>(entity);
    body->Velocity = FPVector3.Zero;
}

Registering FallSystem & Testing the Respawn Loop

Add FallSystem to the systems list in the default config. When tested, the player respawns whenever it hits the FallBox.

Why One Respawn Point Breaks With Multiple Players

In an online match, several players may fall off at once. With a single respawn position they would spawn at the same spot at the same time, which gives a poor player experience. Spawn points solve this.

Adding PlayerSpawnPoint Components to the Map

  1. In the IDE, create a QTN file called PlayerSpawnPoint.qtn containing a component with the same name.
  2. In Unity, create an empty entity and assign the PlayerSpawnPoint component to it.
  3. Create an empty parent GameObject for the spawn points to keep the hierarchy clean.
  4. Duplicate the child spawn point four times and place one in each corner of the platform.

component PlayerSpawnPoint
{
}

Assigning Spawn Points Round-Robin (MovePlayerToSpawnPoint)

In PlayerJoinedSystem, define MovePlayerToSpawnPoint, taking the Frame, the EntityRef, and the PlayerRef:

  1. Get the total count of PlayerSpawnPoint components in the game.
  2. The target spawn point index is the player index modulo the spawn point count, so players beyond the fourth wrap around and reuse spawn points.
  3. Retrieve the player entity's transform through the Unsafe API.
  4. A spawn point's index cannot be queried directly, so iterate all spawn points with an external counter.
  5. When the counter reaches the target index, copy the spawn point's transform position to the player, reset the physics body's velocity and angular velocity, and return.
  6. Otherwise, increment the counter.

C#

private void MovePlayerToSpawnPoint(Frame frame, EntityRef entity, PlayerRef player)
{
    int spawnPointsCount = frame.ComponentCount<PlayerSpawnPoint>();
    int targetSpawnPoint = (int)player % spawnPointsCount;

    var playerTransform = frame.Unsafe.GetPointer<Transform3D>(entity);
    var body = frame.Unsafe.GetPointer<PhysicsBody3D>(entity);

    int spawnPointIndex = 0;
    foreach (var pair in frame.GetComponentIterator<PlayerSpawnPoint>())
    {
        if (spawnPointIndex == targetSpawnPoint)
        {
            var spawnTransform = frame.Get<Transform3D>(pair.Entity);
            playerTransform->Position = spawnTransform.Position;
            body->Velocity = FPVector3.Zero;
            body->AngularVelocity = FPVector3.Zero;
            return;
        }

        spawnPointIndex++;
    }
}

Using it on Join and Respawn, Testing Online

Replace the code that retrieves the transform and sets it to FPVector3.Zero with a call to MovePlayerToSpawnPoint, both when a player joins and in OnPlayerRespawned. For respawning, the player's PlayerRef comes from the entity's PlayerLink component.

When tested in an online match, players spawn at different spawn points and no longer stand on top of each other.

Key Concepts

Concept Description
PointToCamera Plain MonoBehaviour that pins a UI transform's rotation to the camera in LateUpdate.
UIPlayerNickname Entity View Component that shows the player's nickname from player data.
IsGrounded PlayerState flag set by a ground-check raycast and read by systems and the Animator.
frame.Physics3D.Raycast Deterministic raycast in the simulation; returns an optional Hit3D.
Query options Bitwise flags that control what a physics query hits, such as HitStatics and HitDynamics.
GroundCheckingDistance PlayerConfig field controlling the length of the ground-check ray.
FallBox Trigger volume entity, identified by a component, that detects players falling out of the map.
Callback flags Collider setting that selects which collision or trigger signals an entity raises.
ISignalOnTriggerEnter3D Signal interface called when a trigger collider with the matching callback flag is entered.
OnPlayerRespawned Custom signal raised when a player must be moved back into play.
PlayerSpawnPoint Component marking an entity as a spawn location.
Component iterator Iterates over all entities that have a given component.

Best Practices

  • Use a plain MonoBehaviour for view-only behavior that has no dependence on Quantum lifecycles, such as pointing UI at a static camera.
  • Retrieve player data through PlayerLink and check whether the player is local before applying local-only visuals.
  • Use frame.Physics3D.Raycast for ground detection and drive its distance from a Data Asset.
  • Set explicit query options so the ray only hits the collider types you need.
  • Store simulation state such as IsGrounded in a component so other systems and the view layer can read it.
  • Set only the callback flags a collider needs, for example OnDynamicTriggerEnter for enter-only events.
  • Filter inside trigger signal handlers: check for the expected component on each entity and return early otherwise.
  • Raise custom signals to decouple detection logic from the systems that react to it.
  • Reset velocity and angular velocity when teleporting a physics body.
  • Use spawn points and modulo assignment so any number of players can be placed without overlap.

Common Pitfalls

Pitfall Why It Is a Problem
Gating jump on input alone Players can jump unlimited times in the air.
Forgetting the trigger callback flag on the FallBox collider ISignalOnTriggerEnter3D is never called.
Not checking for the FallBox component in the trigger handler Any trigger with the callback flag causes the fall logic to run.
Not resetting velocity when respawning The player keeps its falling speed after teleporting.
Forgetting to register FallSystem in the systems list The system never runs and players do not respawn.
Fewer spawn points than expected without a modulo Players beyond the spawn point count have no valid spawn point.

Summary

Part 3 of the Simple Brawler series turns the sample into a playable match. UI above each character faces the camera through a plain MonoBehaviour, and an Entity View Component shows each player's nickname, with the local player highlighted in yellow.

Ground detection uses a Physics3D raycast with a distance configured in PlayerConfig. The result is stored in PlayerState.IsGrounded, which gates jumping in the simulation and drives the Animator in the view layer.

A FallBox trigger volume with an OnDynamicTriggerEnter callback flag lets FallSystem detect players falling out of the map through ISignalOnTriggerEnter3D, and raise the custom OnPlayerRespawned signal handled in PlayerJoinedSystem. Finally, PlayerSpawnPoint components and a component iterator assign players to spawn points round-robin, on join and on respawn, so players do not stack on one another. Part 4 covers attacking mechanics.

Last updated on

Back to top