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.
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
Physics3Draycast, 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
FallBoxtrigger volume withPhysicsCollider3Dand 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.
- Store a serialized
TMP_Textfield namedplayerNickname. - In
OnActivate, retrieve the player's data through thePlayerLinkcomponent. - Check whether the player is local.
- Set the text to the nickname from the player data.
- 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.
- Create an empty entity.
- Add
Transform3DandPhysicsCollider3D. - Set
Is Triggertotrue. - Set the shape type to
Boxand the extents to100on the X and Z axes. - Name the GameObject
FallBoxand 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:
- Check whether the entity that triggered the callback has a
FallBoxcomponent. If not, return early. - Check whether the other entity has a
PlayerLinkcomponent. If not, destroy that entity and return early. This is temporary and will be changed when more complex game flow logic is added. - 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
- In the IDE, create a QTN file called
PlayerSpawnPoint.qtncontaining a component with the same name. - In Unity, create an empty entity and assign the
PlayerSpawnPointcomponent to it. - Create an empty parent GameObject for the spawn points to keep the hierarchy clean.
- 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:
- Get the total count of
PlayerSpawnPointcomponents in the game. - 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.
- Retrieve the player entity's transform through the Unsafe API.
- A spawn point's index cannot be queried directly, so iterate all spawn points with an external counter.
- 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.
- 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
MonoBehaviourfor view-only behavior that has no dependence on Quantum lifecycles, such as pointing UI at a static camera. - Retrieve player data through
PlayerLinkand check whether the player is local before applying local-only visuals. - Use
frame.Physics3D.Raycastfor 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
IsGroundedin a component so other systems and the view layer can read it. - Set only the callback flags a collider needs, for example
OnDynamicTriggerEnterfor 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- Overview
- Video Timeline
- Making UI Face the Camera (PointToCamera)
- Displaying Player Nicknames (UIPlayerNickname)
- The Infinite-Jump and Fall-Forever Problems
- Adding IsGrounded to PlayerState
- Ground Check With a Physics3D Raycast
- Physics Query Options: HitStatics / HitDynamics
- Setting IsGrounded and Gating the Jump
- Why IsGrounded Lives in PlayerState
- Falling Off the Platform: The FallBox Trigger Volume
- Trigger Callback Flags (OnDynamicTrigger)
- Defining the FallBox Component
- Detecting the Fall With ISignalOnTriggerEnter3D
- Defining and Raising a Custom Signal: OnPlayerRespawned
- Handling Respawn in PlayerJoinedSystem
- Registering FallSystem & Testing the Respawn Loop
- Why One Respawn Point Breaks With Multiple Players
- Adding PlayerSpawnPoint Components to the Map
- Assigning Spawn Points Round-Robin (MovePlayerToSpawnPoint)
- Using it on Join and Respawn, Testing Online
- Key Concepts
- Best Practices
- Common Pitfalls
- Summary