How to make Among Us in Unity | Part 5 — Killing other Players with RPCs

Welcome to the series that will teach you how to create a game similar to Among Us, in Unity3D Engine.

Redefine Gamedev
7 min readNov 1, 2020

This is the 5th part of the series. For the first part and the full index follow this link.

Get the Game Kit and start building an Among Us-like game in minutes!

Among Us, in Unity
Among Us, in Unity

Also, you might want to check How to Make Among Us in Unity series on my channel, Redefine Gamedev. If you prefer the video version, I highly recommend you check that one as well.

In this episode we will look into:

  • How to set up the UI for the killing functionality
  • How to create the Player Body prefab
  • How to detect if other players are in range & display this visually
  • How to work with Remote Procedure Calls in Photon Network

Let’s get started!

Setting up the UI for the killing

Creating buttons in Unity is quite easy (Game Object > UI > Button). We will position it bottom right and give it a proper name. Don’t forget that in order to render UI, Unity needs the objects to be parented to the Canvas.

Image showing how to put the UI kill button in the Unity’s canvas
The kill button, in Unity’s canvas

Scripting-wise, we will create a script called UIControl. It’s main functionalities is to:

  • enable/disable the kill button
  • trigger on kill when the button was clicked

UIControl.cs

public class UIControl : MonoBehaviour {

public static UIControl Instance;
public Button _killBtn;
public bool HasTarget;
public Killable CurrentPlayer;

private void Awake() {
Instance = this;
}

private void Update() {
_killBtn.interactable = HasTarget;
}

public void OnKillButtonPressed() {
if (CurrentPlayer == null) { return; }
CurrentPlayer.Kill();
}
}

The OnKillButtonPressed() will be called by the kill button so don’t forget to assign the onClick event to it.

Imagine showing the assignment of the kill function to the kill button’s on click event
Kill button event

In Update(), happening every frame, we enable or disable the button based on the fact that the target is in the range or not.

Creating the Player Body Prefab

The body prefab will be spawned whenever a kill was successfully performed. The create a prefab, we initially will create a game object in the hierarchy, add a sprite renderer with the correct texture and drag & drop the object in the project side.

A prefab containing the player’s dead body
Prebab with the player’s body

To make this object network-enabled, we need to attach a photonView component. Notice that we also attached a script called PlayerDeadBody and we observe it in the photonView.

An image showing the components needed for the player body
Player body components

In the PlayerBody script we will perform a couple of different things:

  • assign the color for the correct player
  • propagate the color to all the other clients

The assignment of the color on the local game is simple.

public void SetColor(Color color) {
_bodyFill.color = color;
}

The multiplayer information sending is a little more complex.

Firstly, we need to implement MonoBehaviourPun and IPunObservable. These will enable our script to be PhotonNetwork enabled and to be able to send data streams over the network.

IPunObservable requires OnPhotonSerializeView(PhotonStream stream,
PhotonMessageInfo info)
where the magic happens.

Split into two parts, receiving (remote) and sending (owner) we can send the color from the local player to the remote one.

Compressing the color from Color type to 3 floats Red, Green and Blue.

if (stream.IsWriting) {
//owner.
stream.SendNext(_bodyFill.color.r);
stream.SendNext(_bodyFill.color.g);
stream.SendNext(_bodyFill.color.b);
}

Decompressing the color from R G B to Color unity type.

else { 
//remote.
float red = (float)stream.ReceiveNext();
float green = (float)stream.ReceiveNext();
float blue = (float)stream.ReceiveNext();
_bodyFill.color = new Color(red, green, blue, 1.0f);
}

PlayerBody.cs

public class PlayerDeadBody : 
Photon.Pun.MonoBehaviourPun,IPunObservable {

[SerializeField] private SpriteRenderer _bodyFill;

public void SetColor(Color color) {
_bodyFill.color = color;
}

public void OnPhotonSerializeView(PhotonStream stream,
PhotonMessageInfo info) {

if (stream.IsWriting) {
//owner.
stream.SendNext(_bodyFill.color.r);
stream.SendNext(_bodyFill.color.g);
stream.SendNext(_bodyFill.color.b);
}
else {
//remote.
float red = (float)stream.ReceiveNext();
float green = (float)stream.ReceiveNext();
float blue = (float)stream.ReceiveNext();
_bodyFill.color = new Color(red, green, blue, 1.0f);
}
}

Killing Range Detection + Visualization

To detect if the player is in range we will create a new script called Killable. It will be attached to the player prefab.

The variables that we will need for this are:

  • the range of action
  • the line renderer to display if the player is in range
  • the target (killable)
[SerializeField] private float _range = 10.0f;
private LineRenderer _lineRenderer;
private Killable _target;

And, of course, this script needs to be attached to the player. Don’t forget to add and customize a line renderer as well. I opted for a line width of 0.5f and the WireMaterial which will draw the line on top of the other UI elements.

Image showing the player components for the killable
Player components for the killable
Image showing the wire material
The WireMaterial

Since the script will be on our player as well as the enemies, there are some things we want to avoid e.g. showing the enemy kill lines between themselves. Knowing that information can tell you who is the Impostor!!!

To fix this, we need to prevent initialization of the line renderer if the component is not for the current player:

private void Awake() {
if (!photonView.IsMine) { return; }
_lineRenderer = GetComponent<LineRenderer>();
StartCoroutine(SearchForKillable());
}

Notice that we are starting a coroutine. A coroutine is a function that can have it’s action split through multiple frames, minutes or other time frames. We will use a coroutine to continuously detect the target.

Why are we not using the Update() function for this? Because update is called every frame and it’s not good for the optimization purposes to check so often. A coroutine will limit that.

private IEnumerator SearchForKillable() {
while (true) {
Killable newTarget = null;
Killable[] killList = FindObjectsOfType<Killable>();

foreach (Killable kill in killList) {
if (kill == this) { continue; }
float distance = Vector3.Distance(
transform.position, kill.transform.position);

if (distance > _range) { continue; }
// A killable new target found
newTarget= kill;
UIControl.Instance.HasTarget = _target != null;
break;
}
}

_target = newTarget;
yield return new WaitForSeconds(0.25f);
}
}

Here we take all the object that have the Killable component Killable[] killList = FindObjectsOfType<Killable>() and iterate through them. FindObjectsOfType is a slow function so it’s important not to use it in the Update loop.

Next, we check if it’s not the current player and if it’s a different one check the distance.

To visually display the line to the target, we will make use of the line renderer in the Update loop this time.

private void Update() {
if (!photonView.IsMine) { return; }
if (_target != null) {
_lineRenderer.SetPosition(0, transform.position);
_lineRenderer.SetPosition(1, _target.transform.position);
}
else {
_lineRenderer.SetPosition(0, Vector3.zero);
_lineRenderer.SetPosition(1, Vector3.zero);
}
}

Remote Procedure Calls

But what happens if we want to kill the other player? We set up the Kill button and rigged it up to send a signal. We receive that signal in the Killable script (of the current player).

UI Script:

public void OnKillButtonPressed() {
if (CurrentPlayer == null) { return; }
CurrentPlayer.Kill();
}

Killable Script:

public void Kill() {
if (_target == null) { return; }
PhotonView pv = _target.GetComponent<PhotonView>();
pv.RPC("KillRPC", RpcTarget.All);
}

And, here, is the first time we will introduce the concept of RPC — Remote Procedure Calls — .

RPC are functions which are called on other people’s machines. They are used to transmit things that are important to arrive on the other side: like a player kill, a score change, a different game state.

To call such a function we need to do 2 things:

  • make sure we annotate the function as RPC
  • call it via the photonView

RPC annotation:

[PunRPC]
public void KillRPC() {

RPC calling:

PhotonView pv = _target.GetComponent<PhotonView>();
pv.RPC("KillRPC", RpcTarget.All);

Notice that Photon allows us to specify the target of this RPC. It can be the master client only, the other clients only, everyone or more.

When we receive it on the other end, we don’t know if it’s the correct player that received it or not.

We can check this via the if (!photonView.IsMine) { return; } call.

Lastly, if we are the correct receiver we will spawn a player body and assign it our color. Make sure that the PlayerBody prefab is located in a Resources folder otherwise Photon cannot remotely instantiate it.

[PunRPC]
public void KillRPC() {
if (!photonView.IsMine) { return; }
PlayerDeadBody playerBody =
PhotonNetwork.Instantiate(
"PlayerBody",
transform.position,
Quaternion.identity).GetComponent<PlayerDeadBody>();

playerInfo playerInfo = GetComponent<PlayerInfo>();
playerBody.SetColor(
playerInfo._allPlayerColors[playerInfo.colorIndex]);

transform.position = new Vector3(
Random.Range(-10, 10), Random.Range(-10, 10), 0);
}

And this is the killable script:

Killable.cs

public class Killable : MonoBehaviourPun {

[SerializeField] private float _range = 10.0f;
private LineRenderer _lineRenderer;
private Killable _target;
private void Awake() {
if (!photonView.IsMine) { return; }
_lineRenderer = GetComponent<LineRenderer>();
StartCoroutine(SearchForKillable());
}

private void Start() {
if (!photonView.IsMine) { return; }
UIControl.Instance.CurrentPlayer = this;
}
private void Update() {
if (!photonView.IsMine) { return; }
if (_target != null) {
_lineRenderer.SetPosition(0, transform.position);
_lineRenderer.SetPosition(1, _target.transform.position);
}
else {
_lineRenderer.SetPosition(0, Vector3.zero);
_lineRenderer.SetPosition(1, Vector3.zero);
}
}
private IEnumerator SearchForKillable() {
while (true) {
Killable newTarget = null;
Killable[] killList = FindObjectsOfType<Killable>();

foreach (Killable kill in killList) {
if (kill == this) { continue; }
float distance = Vector3.Distance(
transform.position, kill.transform.position);

if (distance > _range) { continue; }
// A killable new target found
newTarget= kill;
UIControl.Instance.HasTarget = _target != null;
break;
}
}

_target = newTarget;
yield return new WaitForSeconds(0.25f);
}
}
public void Kill() {
if (_target == null) { return; }
PhotonView pv = _target.GetComponent<PhotonView>();
pv.RPC("KillRPC", RpcTarget.All);
}

[PunRPC]
public void KillRPC() {
if (!photonView.IsMine) { return; }
PlayerDeadBody playerBody =
PhotonNetwork.Instantiate(
"PlayerBody",
transform.position,
Quaternion.identity)
.GetComponent<PlayerDeadBody>();
playerInfo playerInfo =
GetComponent<PlayerInfo>();
playerBody.SetColor(
playerInfo._allPlayerColors[playerInfo.colorIndex]);

transform.position = new Vector3(
Random.Range(-10, 10), Random.Range(-10, 10), 0);
}
}

Want More?

You are covered! Head over to Youtube at Redefine Gamedev channel and check the video tutorial.

--

--