Backyard Brawl
Backyard Brawl is a competitive and chaotic party game for 2-4 players both locally and online. Players are pitted against one another in several gamemodes, ranging from competitive trick or treating to laser tag soccer. Playing as one of several cute characters, players must utilize power ups and strategy to come out on top of the competition.
On this 14-week project, I served as one of two programmers in addition to being the project's producer. I spearheaded the game’s networking and taught my co-programmer the basics of networking a video game. I also contributed heavily to the game's power ups, local multiplayer capabilities, player data management, gamemode prototypes, soccer mechanics (dribbling, scoring), trick or treat mechanics (scoring, player spawning, collecting and depositing), in-lobby controls, player animations, camera VFX, and lighting.
Networking
Backyard Brawl is a continuation of a project started in the Fall of 2023. In its initial state, the game was strictly a local multiplayer experience. However, as my team and I wanted to create a commercially viable product, we believed it necessary to push ourselves to network the game.
Over the summer, my co-programmer and I researched and practiced with Unity's networking solution, Netcode for Game Objects. We began taking some of the most basic features of the original game and implementing them in a networked state. This allowed us to hit the ground running with our full team once the semester began, and gave us enough background knowledge to not waste valuable semester time learning an entirely new system.
Local Multiplayer
After networking the game, our team realized that it was now difficult to rapidly prototype new mechanics and gamemodes due to the added complexity of networking systems. To alleviate this issue, I worked to reintroduce local multiplayer into the game.
I wanted to reuse as much of the networking code as possible to avoid re-coding the entire game to support local. To sort of “fake it,” I started the local device as a host with no open ports, so that all code relying on Networking-only functionality (such as RPCs and object spawning) could still run without actually exposing the device to the Internet.
I also created a player data manager that unified how player data was maintained and read for both local and online play. This abstracted away much of the networking-and local-specific code from almost every system in the game, saving my team and I the trouble of having to completely re-write each system to support local multiplayer.
public class ClientPlayerManager : NetworkBehaviour { public static ClientPlayerManager Singleton = null; // used to maintain order of clients/players joining, // will be used in the future to handle host switching public NetworkList<ulong> clientIdJoinOrder; public NetworkVariable<int> numClients; // this is the magic sauce, // just about every system that needs // to read player data relies on this list // and the GetMetaData() function public NetworkList<PlayerMetaData> playerMetaData; [Tooltip("This is solely used for getting player colors as of now.")] [SerializeField] private PlayerSettingsSO playerSettings; [Tooltip("List of player models")] [SerializeField] private GameObjectListSO playerModels; private void Awake() { if (Singleton != null) { Destroy(gameObject); // don't know if this is legal or not. // I think it is because NetworkSpawn isn't called until after // Start since this is an in-scene placed NetworkObject. // But we will have to see return; } Singleton = this; DontDestroyOnLoad(gameObject); clientIdJoinOrder = new NetworkList<ulong>(); numClients = new NetworkVariable<int>(value: 0); playerMetaData = new NetworkList<PlayerMetaData>(); } public override void OnNetworkSpawn() { base.OnNetworkSpawn(); if (!IsServer) return; // this prevents automatic adding of // a random controller for the host during local play. // Since the host technically is a client that connects to itself, // the Conneciton Event will fire and run code that we don't want to run if (LocalDeviceManager.LocalMultiplayerEnabled) return; NetworkManager.OnConnectionEvent += NetworkManager_OnConnectionEvent; } public override void OnNetworkDespawn() { if (IsServer) { if (!LocalDeviceManager.LocalMultiplayerEnabled) { NetworkManager.OnConnectionEvent -= NetworkManager_OnConnectionEvent; } // NetworkLists have been tricky with memory leaks, // hoping Dispose() solves some of them playerMetaData.Dispose(); } base.OnNetworkDespawn(); } public int GetClientIndex(ulong clientId) { for (int i = 0; i < clientIdJoinOrder.Count; i++) { if (clientIdJoinOrder[i] == clientId) { return i + 1; // player index is 1-based, i is 0-based } } return -1; // wasn't found } public PlayerMetaData GetMetaData(int playerIndex) { for (int i = 0; i < playerMetaData.Count; i++) { PlayerMetaData metaData = playerMetaData[i]; if (metaData.PlayerIndex == playerIndex) { return metaData; } } return PlayerMetaData.Empty; // return an "empty" meta data } public PlayerMetaData GetMetaData(ulong clientId) { int playerIndex = GetClientIndex(clientId); return GetMetaData(playerIndex); } public PlayerMetaData AddClient(ulong clientId) { if (!IsServer) return PlayerMetaData.Empty; if (clientIdJoinOrder.Contains(clientId)) return GetMetaData(clientId); // should find and return the meta data for the requested client numClients.Value += 1; clientIdJoinOrder.Add(clientId); PlayerMetaData metaData; metaData.PlayerIndex = numClients.Value; int colorIndex = SetPlayerColor(metaData.PlayerIndex-1); metaData.PlayerColor = playerSettings.playerColors[colorIndex]; metaData.TeamIndex = -1; metaData.ClientId = clientId; metaData.ModelIndex = 0; playerMetaData.Add(metaData); return metaData; } public void RemoveClient(ulong clientId) { if (!IsServer) return; numClients.Value -= 1; PlayerMetaData metaData = GetMetaData(clientId); // need to remove meta data first since it relies on clientIdJoinOrder playerMetaData.Remove(metaData); clientIdJoinOrder.Remove(clientId); } private void NetworkManager_OnConnectionEvent(NetworkManager networkManager, ConnectionEventData connectionData) { switch (connectionData.EventType) { case ConnectionEvent.ClientConnected: AddClient(connectionData.ClientId); break; case ConnectionEvent.ClientDisconnected: RemoveClient(connectionData.ClientId); break; } } }
/// <summary> /// A struct used to store generally useful data /// about a player /// </summary> public struct PlayerMetaData : INetworkSerializable, IEquatable<PlayerMetaData> { // The index of this player // Ranges from 1-4 public int PlayerIndex; /// <summary> /// -1 if player is not on a team /// </summary> public int TeamIndex; // The player's current chosen color public Color PlayerColor; // The client's id. // This will be 0 for all players in local lobbies public ulong ClientId; // The index of the player's // current chosen model public int ModelIndex; /// <summary> /// The empty PlayerMetaData struct. Use the same as a null value /// </summary> public static PlayerMetaData Empty = new PlayerMetaData(0, -1, -1, Color.HSVToRGB(0, 0, 0), -1); public PlayerMetaData(ulong clientId, int playerIndex, int teamIndex, Color playerColor, int modelIndex) { PlayerIndex = playerIndex; TeamIndex = teamIndex; PlayerColor = playerColor; ClientId = clientId; ModelIndex = modelIndex; } // Required by NetworkList that PlayerMetaData // implements IEquatable public bool Equals(PlayerMetaData other) { return PlayerIndex == other.PlayerIndex && TeamIndex == other.TeamIndex && PlayerColor == other.PlayerColor && ClientId == other.ClientId && ModelIndex == other.ModelIndex; } // This serializes the struct so it can be replicated // across the network to all players public void NetworkSerialize
(BufferSerializer serializer) where T : IReaderWriter { serializer.SerializeValue(ref PlayerIndex); serializer.SerializeValue(ref TeamIndex); serializer.SerializeValue(ref PlayerColor); serializer.SerializeValue(ref ClientId); serializer.SerializeValue(ref ModelIndex); } public bool IsEmpty() { return this.Equals(PlayerMetaData.Empty); } public override string ToString() { return $"Player Data for Client {ClientId}: Index {PlayerIndex}, TeamIndex {TeamIndex}, Color {PlayerColor}, Model Index {ModelIndex}"; } }
Outcomes:
- New Mechanics and Gamemodes could be more easily tested and iterated upon
- Player data manager saved team from having to recode systems to account for local multiplayer
Online Fairness
Backyard Brawl takes a host-based networking approach, meaning that one player starts a lobby (the host), and all players who want to join that lobby connect to the host. This is a very common approach, but caused a lot of unfair situations in our game.
Our initial approach to networking had many things being done on the server (host) side, with the idea that everything would be more secure to prevent hackers from being able to exploit the game. This led to the game feeling very unfair for anyone who wasn’t the host, with clients seeing themselves get kicked by far away players, missing candy they clearly ran through, and failing to kick players they were standing right next to. To alleviate some of these problems, we modified how we handled certain functionality to give clients more authority (at the cost of security). Our focus at the time was more on game functionality than game security, so we were willing to make the sacrifice to improve playability.
The results of the change were very positive, with clients being able to land hits much more reliably and candy collection feeling snappier than ever. While simply giving clients more authority isn't the perfect solution, it fit our project's needs at the time.
Outcomes:
- Our "favor the attacker" approach made combat feel much more fair for all players
- Picking up collectibles felt much more satisfying
Going Forward
In the future, we would like to implement more security features into our game to prevent hackers from being able to do pretty much whatever they want. After doing some research on it, we believe that the networking approach used in games like Overwatch and Rocket League would fit our game well. Implementing a networking architecture like that would be a completely new realm for my team and I, but we know it would take our game to the next level.
Key Takeaways
- Finding the balance between playability and network security is very important, and a tricky one to find (but very exciting once you do!)
- Centralizing commonly used data is very useful, especially when that data could come from different places.
Game Link
Backyard Brawl (formerly Trick or Treat Fighters) is available for download on itch.io here!