Skip to main content

Shooter Game Tutorial

This tutorial will demonstrate how to make a shooter game using advanced concepts such as scriptable objects, game state loop, and singletons.

tip

Check out the Infinite Runner tutorial for a project introducing TypeScript.

Testing Enemies

Setting Up the Scene

The starting scene will be provided from an asset package also containing the gun avatar overlay prefabs and player animations.

Download Starting Assets

Download the starting asset package: ShooterGame.unitypackage

Import the Starting Assets

Open a Unity project with the Genies SDK installed. Then right click in the Project window and select Import Package > Custom Package and select the downloaded package. Make sure all the assets are selected and click Import.

Import All

Open the Scene

Once the package is finished importing, open the scene named Shooter Game Tutorial. Press the Play button to see the starting weapon selection menu.

Open Scene

Create Scriptable Objects

The two weapons will have similar types of data like fire rate and bullet speed, but will have different values. This is a good example of how scriptable objects can be helpful to organize the weapon data.

Create a Scriptable Object Script

Right click in the Project window and select the GENIES > Scripting > Create Genies ScriptableObject Script option. Name the script WeaponScriptableObject.

Create SO

Edit the Script

Double click the WeaponScriptableObject script to open the code editor. Then add this code to create a list of properties for the weapons:

import { GameObject, ScriptableObject } from "UnityEngine";

export default class WeaponScriptableObject extends ScriptableObject {

@Header("Shooting Animation Properties")
public shootAnimationName: string;
public shootAnimationLength: number;
public shootAnimationStart: number;

@Header("Bullet Properties")
public bulletSpeed: number;
public bulletCooldown: number;

@Header("Gun Properties")
public gunPrefab: GameObject;
}

Create a M9 Instance

Save the script and then open the Project window. Right click the WeaponScriptableObject script and select the GENIES > Create GeniesScriptableObject Instance option. Name the instance M9_SO. Select the instance and open the Inspector window. Fill in the properties with these values:

M9 SO

Create an AK47 Instance

Save the script and then open the Project window. Right click the WeaponScriptableObject script and select the GENIES > Create GeniesScriptableObject Instance option. Name the instance AK47_SO. Select the instance and open the Inspector window. Fill in the properties with these values:

AK SO

Create a Game Manager

The game manager will handle the UI and track the game state which will determine the logic for other game objects.

info

A game state describes what part of the game is taking place such as choosing a weapon, playing the game, or the game is over.

Create a New Script

Right click in the Project window and select the GENIES > Scripting > Create Genies ScriptableObject Script option. Name the script GameManager. Then create an empty Game Object named Game Manager and attach the GameManager script to it.

Create GameManager

Edit the Script

Double click the GameManager script to open the code editor. Then add this code:

import { GameObject, MonoBehaviour } from "UnityEngine";
import { Button } from "UnityEngine.UI";
import { TMP_Text } from "TMPro";

import WeaponScriptableObject from "./WeaponScriptableObject";

export default class GameManager extends MonoBehaviour {

@Header("Weapon Selection UI")
@SerializeField private weaponSelectionPanel: GameObject;
@SerializeField private m9Button: Button;
@SerializeField private akButton: Button;

@Header("Replay UI")
@SerializeField private replayPanel: GameObject;
@SerializeField private replayButton: Button;

@Header("Score UI")
@SerializeField private scorePanel: GameObject;
@SerializeField private scoreText: TMP_Text;

@Header("Scriptable Object Instances")
@SerializeField private m9_so: WeaponScriptableObject;
@SerializeField private ak_so: WeaponScriptableObject;

@NonSerialized public GameState = {
INITIAL: 0,
WEAPON_SELECTION: 1,
GAME_PLAY: 2,
GAME_OVER: 3
}

@NonSerialized public OnGameStateChange: GeniesEvent<[number]> = new GeniesEvent<[number]>();

@NonSerialized public static Instance: GameManager;

private selectedWeapon: WeaponScriptableObject;
private gameState: number;
private score: number;

Awake() {
if(GameManager.Instance == null) {
GameManager.Instance = this;
}else{
GameObject.Destroy(this.gameObject);
}
}

Start() {
this.ChangeGameState(this.GameState.WEAPON_SELECTION);
this.akButton.onClick.AddListener(() => this.ChooseWeapon(this.ak_so));
this.m9Button.onClick.AddListener(() => this.ChooseWeapon(this.m9_so));
this.replayButton.onClick.AddListener(() => this.ChangeGameState(this.GameState.WEAPON_SELECTION));
}

public GetGameState() {
return this.gameState;
}

public ChangeGameState(newState: number) {
if (newState == this.gameState) {
return;
}
switch(newState) {
case this.GameState.WEAPON_SELECTION:
this.score = 0;
this.TogglePanels(true, false, false);
break;
case this.GameState.GAME_PLAY:
this.TogglePanels(false, false, true);
break;
case this.GameState.GAME_OVER:
this.TogglePanels(false, true, true);
break;
}
this.OnGameStateChange.trigger(newState);
this.gameState = newState;
}

private TogglePanels(weaponSelection: bool, replay: bool, score: bool) {
this.weaponSelectionPanel.SetActive(weaponSelection);
this.replayPanel.SetActive(replay);
this.scorePanel.SetActive(score);
}

private ChooseWeapon(weapon: WeaponScriptableObject) {
this.selectedWeapon = weapon;
this.ChangeGameState(this.GameState.GAME_PLAY);
}

public GetSelectedWeapon() {
return this.selectedWeapon;
}

public IncreaseScore() {
this.score += 1;
this.scoreText.text = this.score.toString();
}
}
note

The static public property Instance and the Awake method is creating a singleton of the Game Manager script which means there can only be one version of the script that can be referenced by other scripts.

Add the Properties

Save the script. In the Hierarchy, select the Game Manager object and open the Inspector window. Drag in the required references for the properties:

Game Manager

Test the Project

Enter Play mode and select one of the weapons. It should transition to the game playing state and display the score UI.

Display Score

Create a Player

The player will be a loaded Avatar that has logic to equip and shoot the weapon selected.

Create a New Script

Right click in the Project window and select the GENIES > Scripting > Create Genies ScriptableObject Script option. Name the script PlayerController. Then create an empty Game Object named Player and attach the PlayerController script to it.

Add Physics Components

Add a Capsule Collider and Rigidbody component to the Player object.

Then edit the following properties:

  1. Set the Transform Position property to X: 0, Y: 1, Z: 0.
  2. Activate the Is Trigger property.
  3. Set the Center property to X: 0, Y: -0.2, Z: 0.
  4. Set the Radius property to 0.2.
  5. Set the Height property to 1.6.
  6. Deactivate the Use Gravity property.

Create Player

Edit the Script

Open the PlayerController script and add the following code:

import { MonoBehaviour, Collider, Input, Vector3, Mathf, Screen, GameObject, Time, Animator, WaitForSeconds } from 'UnityEngine';
import { AvatarOverlay, UserAvatar } from "Genies.Components.Sdk.External.Avatars";
import { GeniesSdk } from 'Genies.Components.SDK.Core';

import WeaponScriptableObject from './WeaponScriptableObject';
import GameManager from './GameManager';

export default class PlayerController extends MonoBehaviour {
private animator: Animator;
private myUserAvatar: UserAvatar;

private gameManager: GameManager;

private equippedWeapon: WeaponScriptableObject;

private bulletSpawn: GameObject;

private idleAnimationName = "Idle";
private dyingAnimationName = "actions-0179-devDying";

private lastBulletTime = 0;
private playerRotation = 0;
private maxPlayerRotation = 30;

public OnBulletShot : GeniesEvent<[Vector3, Vector3]> = new GeniesEvent<[Vector3, Vector3]>();

Start() {
await GeniesSdk.Initialize();
this.myUserAvatar = this.GetComponent<UserAvatar>();
await this.myUserAvatar.LoadAvatarAsync();
this.animator = this.GetComponentInChildren<Animator>();
this.gameManager = GameManager.Instance;
this.gameManager.OnGameStateChange.addListener(this.GameStateChange);
}

Update() {
if(Input.touchCount > 0) {
if(this.gameManager.GetGameState() == this.gameManager.GameState.GAME_PLAY) {
this.RotatePlayer();
this.CheckShoot();
}
}
}

private EquipWeapon(weapon: WeaponScriptableObject) {
this.equippedWeapon = weapon;
this.myUserAvatar.AddOverlay(weapon.gunPrefab);
this.bulletSpawn = GameObject.Find("Bullet Spawn").gameObject;
}

private RemoveWeapon() {
if(this.equippedWeapon) {
this.myUserAvatar.RemoveOverlay(this.equippedWeapon.gunPrefab);
this.equippedWeapon = null;
}
}

private ResetRotation() {
this.playerRotation = 0;
this.transform.eulerAngles = new Vector3(0, this.playerRotation, 0);
}

private RotatePlayer() {
let touch = Input.GetTouch(0);
let touchPosition = touch.position;
let screenWidth = Screen.width;
let screenRatio = Mathf.InverseLerp(0, screenWidth, touchPosition.x);
this.playerRotation = Mathf.Lerp(-this.maxPlayerRotation, this.maxPlayerRotation, screenRatio);
this.transform.eulerAngles = new Vector3(0, this.playerRotation, 0);
}

private CheckShoot() {
if(Time.time > this.lastBulletTime + this.equippedWeapon.bulletCooldown) {
this.lastBulletTime = Time.time;
let normalizedTime = this.equippedWeapon.shootAnimationStart / this.equippedWeapon.shootAnimationLength;
this.JumpToFrame(this.equippedWeapon.shootAnimationName, normalizedTime);
this.StartCoroutine(this.TriggerShoot());
}
}

private *TriggerShoot() {
yield new WaitForSeconds(0.1);
let position = this.bulletSpawn.transform.position
let velocity = this.transform.forward * this.equippedWeapon.bulletSpeed;
this.OnBulletShot.trigger(position, velocity);
}

private JumpToFrame(animationName: string, normalizedTime: number){
this.animator.Play(animationName, -1, normalizedTime);
this.animator.speed = 1;
}

OnTriggerEnter(other: Collider) {
if(other.gameObject.name == "Enemy(Clone)") {
this.gameManager.ChangeGameState(this.gameManager.GameState.GAME_OVER);
}
}

private GameStateChange(gameState: number) {
if(gameState == this.gameManager.GameState.WEAPON_SELECTION) {
this.RemoveWeapon();
this.JumpToFrame(this.idleAnimationName, 0);
this.ResetRotation();
}
if(gameState == this.gameManager.GameState.GAME_PLAY) {
this.EquipWeapon(this.gameManager.GetSelectedWeapon());
this.JumpToFrame(this.equippedWeapon.shootAnimationName, 0);
this.animator.speed = 0;

}
if(gameState == this.gameManager.GameState.GAME_OVER) {
this.JumpToFrame(this.dyingAnimationName, 0);
}
}
}

Add the User Avatar

Save the script and add a User Avatar component to the Player object. The add the PlayerAnimator asset to the Animator Controller property.

Add User Avatar

Test the Project

Enter Play mode. The player should load and once a weapon is selected, then it should be equipped to the player. Rotate the player and activate the shooting animation by clicking the mouse.

Testing Player

Create an Object Pool Script

Creating a generic object pool script is useful to reuse for enemies and bullets.

info

Object pooling is an efficient way to manage several prefab instances. Instead of repeatedly creating and destroying objects, it spawns all needed instances at the start and recycles them as active or inactive when needed.

Create a Genies Script

Right click in the Project window and select the GENIES > Scripting > Create Genies Script option. Name the script ObjectPool.

Edit the Script

Open the ObjectPool script. Add the following code and save the script:

import { GameObject, Object, Time } from "UnityEngine";

export class ObjectPool {
private prefab: GameObject;
private objects: GameObject[] = [];
private parentContainer: GameObject;
private times: number[] = [];

constructor(prefab: GameObject, initialSize: number, parent: GameObject) {
this.prefab = prefab;
this.parentContainer = parent;

for (let i = 0; i < initialSize; i++) {
this.addObject();
}
}

private addObject(setActive = false): void {
let temp = Object.Instantiate(this.prefab, this.parentContainer.transform) as GameObject;
temp.SetActive(setActive);
this.objects.push(temp);
this.times.push[0];
}

public get(): GameObject {
let result = null;

for(let i = 0; i < this.objects.length; i++) {
if(!this.objects[i].activeInHierarchy) {
result = this.objects[i];
this.times[i] = Time.time;
break;
}
}

return result;
}

public returnToPool(obj: GameObject): void {
obj.SetActive(false);
}

public returnAll(): void {
for(let i = 0; i < this.objects.length; i++) {
this.objects[i].SetActive(false);
}
}

public getActiveCount(): number {
let count = 0;

for(let i = 0; i < this.objects.length; i++) {
if(this.objects[i].activeInHierarchy) {
count = count + 1;
}
}
return count;
}

public checkExpired(lifetime: number): void {
for(let i = 0; i < this.objects.length; i++) {
if(this.objects[i].activeInHierarchy && Time.time > this.times[i] + lifetime) {
this.returnToPool(this.objects[i]);
}
}
}
}
note

This script has functionality to return objects to the pool after a certain amount of time using the checkExpired method.

Create Bullets

Create a Bullet Prefab

Create a new 3D sphere game object and name it Bullet. Add a Rigidbody component to it. Then change the following properties:

  1. Set the Scale property to X: 0.1, Y: 0.1, Z: 0.1.
  2. Activate the Is Trigger property.
  3. Deactivate the Use Gravity property.

Then drag the Bullet object into the Project window to create a prefab. Delete it from the scene.

Create Bullet

Create a New Script

Right click in the Project window and select the GENIES > Scripting > Create Genies Behaviour Script option. Name the script BulletPoolController.

Edit the Script

Open the BulletPoolController script and add the following code:

import { GameObject, MonoBehaviour, Rigidbody, Vector3 } from "UnityEngine";
import PlayerController from "./PlayerController";
import { ObjectPool } from "./ObjectPool";
import GameManager from "./GameManager";

export default class BulletPoolController extends MonoBehaviour {

@SerializeField private prefab: GameObject;
@SerializeField private amountToPool: number;
@SerializeField private bulletLifetime: number = 5;

@SerializeField private player: PlayerController;
private gameManager: GameManager;

private bulletPool: ObjectPool;

Start() {
this.bulletPool = new ObjectPool(this.prefab, this.amountToPool, this.gameObject);
this.player.OnBulletShot.addListener(this.ShootBullet);
this.gameManager = GameManager.Instance;
this.gameManager.OnGameStateChange.addListener(this.GameStateChange);
}

Update() {
this.bulletPool.checkExpired(this.bulletLifetime);
}

private ShootBullet(position: Vector3, velocity: Vector3) {
let bullet = this.bulletPool.get();
bullet.transform.position = position;
let rb = bullet.GetComponent<Rigidbody>();
rb.velocity = velocity;
bullet.SetActive(true);
}

private GameStateChange(gameState: number) {
if(gameState == this.gameManager.GameState.GAME_OVER) {
this.bulletPool.returnAll();
}
}
}

Add to Game Object

Create new empty Game Object named Bullet Pool. Add the BulletPoolController script as a component to the Bullet Pool object. Then add in the following property values:

Bullet Pool

Test the Project

Enter Play mode. Bullets should now fire from the gun as it is being shot.

Testing Bullets

Create Enemies

Create an Enemy Object

Create a new 3D cube game object and name it Enemy. Add a Rigidbody component to it. Then change the following properties:

  1. Set the Scale property to X: 1, Y: 2, Z: 1.
  2. Activate the Is Trigger property.
  3. Deactivate the Use Gravity property.

Create Enemy

Create a New Script

Right click in the Project window and select the GENIES > Scripting > Create Genies Behaviour Script option. Name the script EnemyController.

Edit the Script

Open the EnemyController script and add the following code:

import { Collider, MonoBehaviour } from "UnityEngine";
import GameManager from "./GameManager";

export default class EnemyController extends MonoBehaviour {

private gameManager: GameManager;

Start() {
this.gameManager = GameManager.Instance;
}

OnTriggerEnter(other: Collider) {
if(other.gameObject.name == "Bullet(Clone)") {
this.gameManager.IncreaseScore();
other.gameObject.SetActive(false);
this.gameObject.SetActive(false);
}
}
}

Add to Enemy

Drag the EnemyController script into the Enemy object as a component. Then drag the Bullet object into the Project window to create a prefab. Delete it from the scene.

Create a New Script

Right click in the Project window and select the GENIES > Scripting > Create Genies Behaviour Script option. Name the script EnemyPoolController.

Edit the Script

Open the EnemyPoolController script and add the following code:

import { GameObject, MonoBehaviour, Random, Rigidbody, Transform, Vector3, WaitForSeconds } from "UnityEngine";
import { ObjectPool } from "./ObjectPool";
import GameManager from "./GameManager";

export default class EnemyPoolController extends MonoBehaviour {
@SerializeField private prefab: GameObject;
@SerializeField private amountToPool: number;
@SerializeField private enemySpeed: number = 5;
@SerializeField private enemyLifetime: number = 10;
@SerializeField private enemySpawnDelay: number = 1;

@SerializeField private target: Transform;

private gameManager: GameManager;

private enemyPool: ObjectPool;

Start() {
this.enemyPool = new ObjectPool(this.prefab, this.amountToPool, this.gameObject);
this.gameManager = GameManager.Instance;
this.gameManager.OnGameStateChange.addListener(this.GameStateChange);
}

Update() {
this.enemyPool.checkExpired(this.enemyLifetime);
}

private *SpawnEnemies() {
while(true) {
let position = new Vector3(Random.Range(-5, 5), 1, 30);
let enemy = this.enemyPool.get();
enemy.transform.position = position;
let rb = enemy.GetComponent<Rigidbody>();
let diff = this.target.position - enemy.transform.position;
rb.velocity = diff.normalized * this.enemySpeed;
enemy.SetActive(true);
yield new WaitForSeconds(this.enemySpawnDelay);
}
}

private GameStateChange(gameState: number) {
if(gameState == this.gameManager.GameState.GAME_PLAY) {
this.StartCoroutine(this.SpawnEnemies())
}
if(gameState == this.gameManager.GameState.GAME_OVER) {
this.enemyPool.returnAll();
this.StopAllCoroutines();
}
}
}

Add to Game Object

Create new empty Game Object named Enemy Pool. Add the EnemyPoolController script as a component to the Enemy Pool object. Then add in the following property values:

Enemy Pool

Test the Project

Enter Play mode. The game should now be complete!

Testing Enemies

Conclusion

Congrats on making a shooter game with TypeScript! The goal of this lesson is to introduce singletons, scriptable objects, and a game state loop.