TANK PARTY!

Face off in Tank Party, an online multiplayer 3D top-down twin stack shooter, where teams battle in a massive arena map and blast the competition. 

SUMMARY OF RESPONSIBILITIES

  • Collaborated on core gameplay concept and design

  • Primary programmer for all game features

  • Coordinated with artists to develop the look of the game through custom shaders

  • Implemented Playfab integration for persistent data

  • UI programming and setup

  • Managed in-game economy design and balancing


DETAILED BREAKDOWN

After completing Stupid Zombies 3, GameResort was eager to launch a fresh IP. At the time, .IO style games were growing in popularity (Agar.IO was the first I can recall; Slither.IO is another popular example). For those unfamiliar, these games feature large persistent servers with dozens of players. Players wander around a large open area, collecting resources to level up characters and fight other players for resources. This game style is typically a free-for-all, with an in-game leaderboard that shows which player currently has the highest score. When a player is killed, their score is reset to zero, and they are respawned to try again.

We were inspired by this sort of “casual survival” style of gameplay, where players could hop in and out as they pleased, roaming around the level and attempting to top the leaderboards. None of us had ever made a multiplayer game before, which posed a unique challenge, but we thought it was an avenue worth exploring. 

After playing several .IO games for research, we identified key areas for improvement:

  • All of these games took place in a large empty area. We brainstormed developing more detailed maps with a variety of play spaces.

  • The combat was typically relatively simple. There is elegance in simplicity, and we didn’t necessarily think we could improve this; however, we did identify an opportunity to offer more complex combat to help our game standout. 

  • Long-term progression. Incentivizing players with long-term progression would help aid in player retention.

This allowed us to clearly define our main project goals:

  • Maintain the casual .IO style of gameplay with large, persistent, dedicated servers that players could enter and exit freely.

  • Enhance the core .IO gameplay with unique playable characters and intricate map design.

  • Support cloud-based save data to allow for unlockable content and encourage IAP.

We decided on tanks as a theme and began to prototype gameplay.


NETWORKING

The first task for Tank Party (TP) was determining how to network the game, using a server authoritative model with dedicated servers to prevent cheating and allow for a seamless player experience. To set this up, I first experimented with the UNet API (which was Unity’s networking solution at the time, though it has since been deprecated). Unfortunately, this option was somewhat buggy and featured inadequate support, so I opted for a third-party plugin. I considered using Photon, but after downloading the demo, I was unimpressed with the architecture. If I recall correctly, it created garbage when sending messages to and from the server, which triggered intermittent garbage collection. It also had low limits for total player count in a server and required a monthly fee. After additional research, I settled on a free github project called LiteNetLib, which provided a basic interface for reliable UDP. This was enough to get started, so I opted to develop the game on top of this.

For the main networking architecture, I decided to use a basic message-based system. Clients would send request messages to the server for actions (update input, spawn, select tank, etc.), and the server would send back messages to notify the client of various occurrences (updating tank positions, tank damage, spawn point, etc.). Any important messages for syncing state were sent using the reliable UDP channel, and less critical updates like player position were sent unreliably. Messages were pooled and recycled for performance.

[NetworkWorldObjectMessage(NetworkWorldObjectMessageRecipientType.RemoteAuthority_All, NetworkDeliveryType.ReliableSequenced)] public class SetTeam : NetworkWorldIdMessage<SetTeam> { public TankPlayerTeam.TankPlayerTeamReference team; protected override void WriteData( BitWriter writer, NetworkTransportConnection connection ) { TankPlayerTeam.SerializeTankPlayerTeamReference(writer, team); } protected override void ReadData( BitReader reader, NetworkTransportConnection connection, float messageDelay ) { team = TankPlayerTeam.DeserializeTankPlayerTeamReference(reader); } }

Each message was sent or received using a message handler, which was created once when the object was made. Since every object in Tank Party was pooled for performance, messages were cleared when an object was reused. 

_setTeamMessageHandler = new NetworkWorldIdMessageHandler<SetTeam>(id, OnSetTeamMessageReceived);

Sending messages was achieved by queuing a message using the handler and setting its values. Messages were bundled and sent each frame on the client or each tick on the server.

SetTeam message = _setTeamMessageHandler.QueueMessage(); message.team = team;

Data was packed tightly per message, using a custom bit packer that I wrote to specify the exact number of bits to use for a value.

byte value = BitPacker.ReadByte(buffer, bitIndex, bits);

Since a key goal for TP was to support a high volume of players in a server while maintaining low bandwidth to run on mobile devices, network data was aggressively culled for each client. Because the game was top-down, this was relatively straightforward. The world was divided into a grid, and each client connection had a world space position used to determine the grid spaces visible to them.

List<NetworkWorldId> addedIds; HashSet<NetworkWorldId> visibleIds; Short2 connectionSpace = GetGridSpace(GetPositionForConnection(connectionInfo.connection)); Short2 min = new Short2(connectionSpace.x - _updateRange, connectionSpace.y - _updateRange); Short2 max = new Short2(connectionSpace.x + _updateRange, connectionSpace.y + _updateRange); for (short x = min.x; x <= max.x; x++) { for (short y = min.y; max.y <= max.y; y++) { AddObjectsAtSpace(connectionInfo, new Short2(x, y), addedIds, visibleIds); } }

Every client held a collection of visible network IDs, which was updated every tick before messages were sent. This list was compared to the previous frame’s list to determine which IDs were added or removed in that frame.

public class NetworkWorldConnectionVisiblityInfo { public NetworkWorldId this[int index] { get { return _visibleNetworkIdList[index]; } } public int visibleObjects { get { return _visibleNetworkIdList.Count; } } protected List<NetworkWorldId> _visibleNetworkIdList; protected HashSet<NetworkWorldId> _visibleNetworkIdHash; public NetworkWorldConnectionVisiblityInfo() { _visibleNetworkIdList = new List<NetworkWorldId>(NetworkWorld.instance.maxDynamicObjects); _visibleNetworkIdHash = new HashSet<NetworkWorldId>(new NetworkWorldId.NetworkWorldIdComparer()); } public bool Add(NetworkWorldId networkWorldId) { if(!_visibleNetworkIdHash.Add(networkWorldId)) { return false; } _visibleNetworkIdList.Add(networkWorldId); return true; } public bool Contains(NetworkWorldId networkWorldId) { return _visibleNetworkIdHash.Contains(networkWorldId); } public void RemoveObjectsNotInSet( HashSet<NetworkWorldId> visibleNetworkWorldIds, List<NetworkWorldId> removedObjects ) { int deficit = 0; for(int i = 0; i < _visibleNetworkIdList.Count; i++) { NetworkWorldId networkWorldId = _visibleNetworkIdList[i]; if(!visibleNetworkWorldIds.Contains(networkWorldId)) { deficit++; _visibleNetworkIdHash.Remove(networkWorldId); removedObjects.Add(networkWorldId); } else { _visibleNetworkIdList[i - deficit] = networkWorldId; } } _visibleNetworkIdList.Truncate(deficit); } public void Clear() { _visibleNetworkIdList.Clear(); _visibleNetworkIdHash.Clear(); } }

Network IDs that were added had their state fully replicated using the reliable channel. Subsequent updates were sent either reliably or unreliably, depending on the data.

//Example of Tank component serialization protected override void SerializeNetworkState(BitWriter writer, NetworkTransportConnection connection) { WritePosition(writer, connection, transform.position); writer.WriteRotation(transform.eulerAngles.z, moveRotationBits); writer.Write(health, healthBits); writer.Write((byte)shield); writer.Write((byte)overshield); _buffManager.SerializeNetworkState(writer); weaponCollection.SerializeNetworkState(writer); }

The visibility grid was also used to compress the world space positions of tanks and projectiles. Since positions were sent unreliably, no delta encoding was used. When a position was written for a client, it was written relative to the client’s grid space center. This allowed me to reduce bandwidth while still maintaining high fidelity for the positions.

protected override void WritePosition(BitWriter writer, NetworkTransportConnection connection, Vector2 position) { writer.Write(position - GetGridSpaceCenter(GetPositionForConnection(connection.id)), _updateRange, positionBits); }

I chose to use a message-based architecture for several reasons.

  • This was what UNet had used, and since I had started with that system, I wanted to replicate the functionality.

  • I had experimented with the Unreal Engine, and they used a similar system with RPC functions.

In retrospect, I have mixed feelings about this approach. While the idea of the client simply calling a function on the server or vice-versa sounds nice in theory, in practice, I found that it actually created some confusion. When sending messages through the reliable, ordered UDP channel, any network issues could result in a complete halt of updates until the out of order messages had been received. Though this didn’t occur often, when it did, there was no solution. In the future, I would probably opt for a prioritized snapshot system, similar to the one outlined in this talk by David Aldridge or this talk by Philip Orwig.


EDITOR TOOLS

One of the main goals with TP was to have large levels and interesting combat encounters. To help achieve this, one of the first tools that I developed was an in-engine solution for creating custom rocks and walls to block out levels. 

The tool was versatile enough to allow for the creation of a wide variety of shapes, which made up the bulk of the level blockouts. I developed a similar tool to generate meshes for water pits.

Example of water pit in-game. Pits are cut out of the ground plane using the stencil buffer.

Example of water pit in-game. Pits are cut out of the ground plane using the stencil buffer.

In addition, I created a system for placing and configuring intractable objects. Strewn throughout each level are pressure pads that when activated can open and close doors or activate hazards, like reversing the direction of conveyor belts. 

Example of basic switch door setup. Doors would stay open while switch was held and for a configurable amount of time after.

Example of basic switch door setup. Doors would stay open while switch was held and for a configurable amount of time after.

Later on in the development process, I was also responsible for adding AI to the game, which took the form of small bots that would shoot players and could be killed for resources.

There were also large bots that featured more health and heavier weapons. These bots would randomly spawn in select locations and could be killed for an even larger amount of resources.

To achieve our project goals of creating interesting gameplay and offering players collectables and upgrades, it was key we develop a wide variety of playable tanks. I designed a system for tank and projectile creation that our designer used to create every tank in the game.

Every tank in the game.

Every tank in the game.

There were three basic tank movement styles: treads, cars and walkers

Car tanks featured settings that defined how the joystick direction mapped to turn angle, overall turn radius and slippage. 

Settings for car tanks.

Settings for car tanks.

Tread tanks calculated their movement by simulating each tread turning at an independent rate, allowing them to turn in place. They also had similar settings to the car tanks.

Settings for tread tanks.

Settings for tread tanks.

Walker tanks were the simplest, accelerating in the direction the joystick was held.

These settings created movement patterns that were simple but unique to each tank type.

Each tank was assigned a weapon settings file that the designer could use to create unique weapon behavior. These settings defined when the projectile would fire (on hold or on release). Magazine and release reload settings, as well as several shot settings, defined projectile behavior. Each of these settings could be a constant value, a linear scale between two values or a curve.

Example of weapon settings. Each tank had multiple weapon settings for different upgrade levels.

Example of weapon settings. Each tank had multiple weapon settings for different upgrade levels.

Projectiles were authored using a series of components attached to each projectile prefab. 

Projectile components in Tank Party.

Projectile components in Tank Party.

Components were assigned to a projectile, creating unique behavior.

Example of a projectile. This projectile would spawn a fan of smaller projectiles after half a second.

Example of a projectile. This projectile would spawn a fan of smaller projectiles after half a second.


PERSISTENCE

TP features a chest-based content distribution system. Players acquire chests as in-game drops and can hold a maximum of four. Chests are locked when acquired and take between 4-12 hours to unlock. A player can only select one chest to unlock at a time. Each chest contains tank upgrade cards and coins. For player persistence data, we used the PlayFab backend. PlayFab enables writing and executing server side scripts, which is what we used for chest distribution and rewarding chest contents. My boss was responsible for writing the PlayFab scripts, but I designed the server-side algorithms used to distribute loot.

Example of rewards for opening a small chest.

Example of rewards for opening a small chest.

There are four types of chests in TP: a small, a medium, and two large chests. There is a list that defines the order in which chests are distributed, defined by a settings file in the project. Chests are given in the same order to each player, although the contents vary. When a player completes the chest list, it repeats. This file was editable by the designer and could be uploaded to PlayFab by clicking a button.

Partial image of Tank Party chest list.

Partial image of Tank Party chest list.

The values for each chest were determined by an “economy” asset. This asset defined overall currency drop rates based on in-game playtime and real-world elapsed time. Free-to-play games generally favor elapsed time over playtime for distributing loot, and TP followed this model. In TP, all currency drops and upgrade costs were authored in terms of hours. All actual currency amounts were reverse-engineered, based on each currency’s drop rate per hour.

Partial image of economy settings showing coins per hour from each source.

Partial image of economy settings showing coins per hour from each source.

TP features over 70 tanks and each tank has ten upgrade levels. Players upgrade their tanks by collecting cards specific to that tank and spending coins. There are four rarities for tank cards: common, uncommon, rare and VIP. Each rarity has an overall currency per hour that was defined in the economy asset. 

Tank drop rates from the economy settings.

Tank drop rates from the economy settings.

The chest list added the total hours required to open up all of the chests in the lists, and then calculated the total amount of currency of each type that would be given. It then divided this currency between all of the chests, based on chest type. Since the division usually resulted in decimal values, the whole number portion of a currency was guaranteed to be given, and the fractional part was used as a percentage chance to give one extra.

Amount of cards and coins given per chest and the same amount multiplied by number of tanks.

Amount of cards and coins given per chest and the same amount multiplied by number of tanks.

Upgrade costs were similarly defined. It cost coins and tank cards to upgrade each tank, and the required currency for these was defined in terms of elapsed hours to afford each upgrade level.

Asset defining the cards required to upgrade a common tank to level two.

Asset defining the cards required to upgrade a common tank to level two.

Using this hours-based system allowed us to quickly iterate on progression, while keeping in mind the overall time it would take players to collect each currency. By converting to currency values only when uploading to PlayFab, we were able to avoid having to recalculate drop rates manually any time a change was made.

Tanks tab in-game.

Tanks tab in-game.

All chest drops were handled by the authoritative server. When a player joined, it queried their PlayFab data, which provided information on which tanks they had unlocked, what level they had reached, etc. The data would also display how many chest slots a player had available. Chests were given at three-minute intervals, starting when a player first joined the server. When players performed in-game actions, like damaging a wall or destroying another tank, the server would verify if the player was eligible to receive a chest. If the player qualified, the server instructed PlayFab to give the player a chest and sent a message to the client to display the chest drop animation.

BackendServerAPI.GivePlayerChest(id.owner.userId, TankPartyServerVars.vars.scriptPassword, (result) => { if(result.error) { return; } chestInventory.RefreshChests(result.chestSlotsJson); ChestDropped message = _chestDroppedMessageHandler.QueueMessage(); Drops.ChestInventoryChest inventoryChest = chestInventory.GetChest(result.chestIndex); message.chest = inventoryChest.chest; message.tier = inventoryChest.tier; message.slotIndex = result.chestIndex; message.position = position; message.locked = inventoryChest.state == Drops.ChestInventoryChestState.Locked; });
Previous
Previous

Stupid Zombies: Exterminator

Next
Next

Adventure Beaks