Technical Deep Dive: Weapons Oct 30 - Matt
Since transitioning to Unreal Engine, we've been hard at work building the Installation 01 codebase. Out of any iteration of the project, the UE5 build is by far the farthest along on the technical side of things. Today I'd like to tell you a bit about the engineering of the weapons in the game.
There are a ton of different factors to balance when programming any gameplay feature; that's what makes it engineering! For weapons, we want it to be easy for our combat designers to tweak weapon stats and behavior. We want to minimize bugs and make it easy to maintain the game code. And we'd like to avoid having the programmers manually implement the behavior of every single weapon – the functionality should be somehow driven by the designers. And, of course, it should be performant (or at least easy to optimize in the future) and it should handle the complexities of networked gameplay "under the hood" as much as possible so designers don't have to think about that.
Data Assets
How have Halo games solved this in the past? They typically define weapon behavior using "tags", which are just big data files; a list of variables (also called fields or properties) that the designer can edit to change the behavior of the weapon. Checkboxes and numerical multipliers can enable or disable certain weapon behaviors. In Unreal parlance, we'd call this a "data asset", and that is the term I'll use for the remainder of this post.
Driving weapon behavior with data assets has its benefits, but it also has downsides. By cramming all the properties for a weapon into a single list, the combat designer has to contend with a lot of extraneous variables for each weapon. For example, the assault rifle doesn't need a "charge time" property, and the plasma pistol doesn't need a "max ammo capacity" property. With data assets, both "charge time" and "max ammo capacity" will exist for every weapon!
There are ways to mitigate this – for example, a checkbox labeled "Has Ammo" could control the visibility of other ammo-related properties. However, in the weapon code, the functionality for plasma pistol and assault rifle will still be mushed together in the same place. This sort of thing tends to lead to gnarly bugs over time as the weapon code receives incremental tweaks as requested by the combat designers.
Rather than use data assets, an entirely different approach is to split up the weapon functionality (so plasma pistol and assault rifle code is completely separate) and have the functionality AND data defined in the same place (the same "asset"). Embracing this approach completely, we can allow combat designers to actually script the behavior of new weapons, rather than just editing data.
Unreal Blueprints
Unreal supports this paradigm easily. Blueprints are game assets that contain data AND contain functionality through a visual scripting system. So the charge-up functionality of the plasma pistol and the data controlling the charge-up time of the plasma pistol live in the same place: the Plasma Pistol Blueprint.
This is the fundamental design of our weapon system. A primary goal of the system's architecture is to allow many different weapon behaviors to be implemented quickly and simply in blueprints so designers can iterate on gun behavior without requiring help from programmers.
Of course, the key to success here is providing a powerful set of building blocks for the visual scripting system. Most weapon behaviors should be able to be created with just a handful of function calls, and the visual scripting should read as close to natural language as possible (e.g. "when the player pulls the trigger, fire a bullet"). This makes it easy to prototype and reduces the opportunity for bugs to be introduced by a weapon's scripting.
Components
One way to provide these building blocks is through "components". Components are standalone chunks of functionality, like peripherals plugged into a computer. Our weapons, by themselves, don't actually know how to do very much. They can't fire bullets, or store ammo, or overheat. Instead, those behaviors are added by adding the relevant component. Can the weapon overheat? Add a heat component. Does the weapon launch projectiles? Add a projectile launcher component. Or does it create a laser beam? Then add a beam component.
One special type of component is the "weapon mode", which controls how player input ("press fire button", "release fire button") gets converted into weapon action ("shoot a bullet"). We have 4 weapon modes:
- Semi-automatic
- Automatic
- Charged
- Continuous
These are largely self-explanatory. The "charged" mode is used for weapons like Plasma Pistol and Spartan Laser. The "continuous" mode is used for weapons like the Sentinel Beam. Confusingly, the magnum pistol uses the automatic weapon mode, not the semi-automatic weapon mode. Despite being a semi-automatic gun in the lore, holding down the fire input causes it to fire repeatedly – therefore, it is an automatic weapon in the game code. Meanwhile the Battle Rifle uses a semi-automatic weapon mode, because each burst requires a fresh click of the fire input. The semi-automatic mode component has built-in logic to accommodate burst fire.
One benefit of having the weapon mode as a component is that it's really easy to create a weapon with multiple fire modes. Switching between automatic, burst, and single-shot? Create 3 weapon modes: 1 automatic and 2 semi-automatic, and enable burst fire on one of the semi-automatic components.
In general, components make the underlying code easier to maintain. And since components can define their own properties (e.g. projectile launcher components have a "projectile type" property), they also help us avoid the "lots of extraneous variables" problem of data assets.
Inheritance
An additional benefit of using blueprints is that they support inheritance. This means that we can create "base" weapon blueprints from which multiple "child" weapons gain default data or default functionality. For example, the Assault Rifle and SMG are very similar. Both are automatic weapons that shoot bullets and reload using magazines. So both inherit from a base Automatic Bullet Weapon blueprint that contains the automatic bullet-shooting, magazine-reloading logic. The Assault Rifle and SMG blueprints then just override the properties that make them unique, such as the fire rate, magazine size, and bullet type.
Inheritance allows us to have commonly-shared weapon properties defined in a single place. For example, we have a base Weapon blueprint that all weapons ultimately inherit from (e.g. the SMG inherits from Automatic Bullet Weapon, but Automatic Bullet Weapon inherits from Weapon – so the SMG is a grandchild of Weapon). We can set melee damage values in the base Weapon blueprint and have it automatically update for every weapon when we change those base values. Of course, weapons with distinct melee damage values (like the Brute Shot) can override those base values and their overrides are preserved even when modifying the base.
This ability to build chains of inheritance for both data and functionality also makes it easy to manage weapon variants. For example, if we want to have both the MA5B and MA5C Assault Rifles available as weapon options, we can have a base Assault Rifle class that contains functionality for the shooting, the ammo screen display, etc. Then the MA5B and MA5C blueprints can inherit from the base Assault Rifle and just override the properties that make them distinct; the ammo capacity, weapon mesh, etc. Then any future change to Assault Rifles (be that bug fixes, animation improvements, combat design tuning, etc.) only needs to be made in one place: the base Assault Rifle blueprint.
Examples
Let's look at some examples of weapon Blueprints:
Assault Rifle
(Note: this is actually the base automatic weapon logic shared by Assault Rifle, SMG, etc.)Pretty straightforward. The "Notify Weapon Fired" function call feeds into several systems, such as detecting whether the player is in combat (for respawn functionality), disrupting active camo, appearing on motion sensors, alerting bot AI, etc.
Shotgun
Very similar to the Assault Rifle, but we spawn multiple bullets with each shot. You can see a "Num Pellets" property has been defined – this only exists on the shotgun, since it only matters for the shotgun.
Plasma Pistol
Here we have two separate projectile launcher components – one for the regular shot, and one for the charged shot. This allows properties like spread (and of course the type of projectile to shoot) to be configured independently.
Note that there is other logic (not pictured) to handle animation state changes when fully charged and overheating, as well as logic for the charge indicator on the weapon mesh itself. However, this is the core of the "fire differently when fully charged" logic.
Some of the values here (ammo consumed and heat applied) are "hardcoded" into the Select nodes, rather than exposed as variables. This is a drawback of moving functionality into bespoke, easy-to-edit scripts – it becomes tempting to violate programming best practices!
Evaluation
Obviously this system is still evolving, but so far we've been pleased with the results. It enables technical designers to prototype new weapons and it frees programmers from the task of implementing every single weapon in the game. And it means that the only properties exposed to a combat designer for tuning a weapon's behavior are the properties that actually affect the weapon's behavior.
I hope this has been an insightful peek into our development process here on Installation 01. If you are interested in helping with programming, please reach out by submitting an application.
We also need help with a lot of other things: animation, VFX, sound design, and environment art are especially in demand here! If you think you can help out, submit an application!
Matt
Join the i01 Team
Reddit Comments
View all comments