Featured image of post Next-Gen Networked Games, Part 5: Creating a Fully Destructible Networked Environment

Next-Gen Networked Games, Part 5: Creating a Fully Destructible Networked Environment

Networking a fully dynamic sandbox in Unreal Engine — deformable terrain, buildable objects, destructible environments, and a custom projectile ballistics/penetration system.

Part 5 of a six-part series adapted from my master’s thesis, “Creating a First-Person Action Game in Unreal Engine”. Previously: Part 4 — Advance Networked Physics Features. Full series and source on GitHub.

Note: this chapter of the original thesis wiki was left partially translated from Croatian — I’ve smoothed over the roughest phrasing below, but some sections stay close to the source’s literal wording.

Introduction

This article is an extension of the previous article on advance networked physics features — it should be noted that nearly all objects here depend, or will in future iterations depend, on the stable physics algorithm from Part 4. We will work on deformable terrain, destructible objects, and buildable objects.

  • Deformable terrain and destructible objects:

Terrain deformation and destruction showcase

  • Buildable objects:

Buildable objects showcase

  • Ballistics:

Ballistics showcase

Terrain deformation

Terrain deformation is a key feature for achieving a dynamic world/sandbox, giving the impression that the environment can react to anything that happens to it. In most classic networked games the terrain remains static — not even the strongest weapon can deform it.

The idea is that when we’re hit by a projectile, we simply deform the terrain around the point of impact, in proportion to the damage it would do — no complex voxel or Minecraft-esque scale needed. It should be mentioned this has been done before in networked games that budgeted for it, primarily the Battlefield and ArmA series.

Technology and algorithm

Procedural mesh technology is used to perform terrain deformation [97] — a set of vertices and edges that can be modified during execution. Furthermore, an algorithm is used to modify them, based on one defined by “CodingWithRus” [98]; this chapter deals more with the networking side of the problem.

The algorithm starts with detecting the event — receiving damage — we record the location and amount of damage and start modifying the shape with respect to these parameters. The shape modification is performed by iterating through each vertex of the terrain: if its distance from the point of impact is within the radius (defined as a terrain variable that represents its density), we apply a force over it. The force is calculated as the ratio of the deformation force (how much to multiply the damage over the terrain) and the distance from the point of impact. This force is applied along the Z axis, downwards, and is smoothed with a “Smoothing” variable for less jagged edges. As we iterate through each vertex, the changes are saved, modifying the appearance of the terrain, and replication occurs to clients (via the OnRep_ModVerts method), who apply the same event/changes only to their local terrain, resulting in synchronisation.

Figure 62: OnRep_ModVerts method performed on the client side

It should be noted that the way radial damage is received at the UE4 level has been modified. In the existing missile system, when hit, damage is inflicted at the center of the object, which is not enough for our needs, because we want the terrain to receive damage exactly where it was hit. So a system was implemented that calculates the exact hit location on the object — finding the point on the shape closest to the impact point. This was also recommended by UE4 in the comments of the actor class itself (Actor.cpp), and was implemented as follows:

Figure 63: Actor.cpp modified for terrain deformation

We generally work with terrain that consists of 100 vertices per side and covers 100 m², but it’s also possible to derive terrain from any static mesh.

First iteration — input replication / event-based replication

The first attempt consisted of simulating on both the server and the clients. If we have the same initial conditions, and we give the same inputs in the same order, we’re guaranteed the same result.

All of the above is true, but we come to an interesting problem: in order to preserve the order of operations, we must put them in an array and perform all the operations that weren’t already performed on our client. This is effectively the same as the deterministic approach, but with a potentially much larger input set.

And here we see why you often cannot rejoin a deterministic simulation while it’s in progress — the amount of accumulated input constitutes a huge packet that needs to be replayed. The problem is you can’t send just one change to a field, you have to send the whole field.

Serialisation seems like a good option here — it ensures all packets will arrive. So we effectively want to send only simulation inputs, not a whole array — sending one vector and one float is certainly less than sending a growing list of vectors, which would eventually result in ever-larger packets. But one should be careful, because while serialisation guarantees delivery, it doesn’t guarantee sequence, so it’s possible synchronisation collapses because the initial conditions (vector positions) end up different, resulting in different results [99]. Alternatively, it’s possible to track sequence on the client side via an identifier, but the list of events is potentially infinitely large.

Setting aside the join-in-progress problem, it’s important to notice that this type of system — event-based replication — suffers from a serious flaw: it relies on repeated reliable broadcasts. These packets must be sent and received before a particular replication channel can move forward with operations. This can strain the network, since these packets can’t be bandwidth-limited and represent a fixed cost, raising the minimum requirements for a connection.

Figure 64: Event storage structure

Figure 65: Structure processing

Second implementation — output replication / effect-based replication (serialised vector field)

The solution, then, is as follows. The simulation is performed only on the server, and the result (all 100 vectors) is saved in a replicated vector field. This field is automatically serialised because vectors are a standard structure in UE4, so only the changes needed for each user are sent separately.

The key to the solution is that in this case order doesn’t matter, because eventually the result is the same — a synchronised vector field. The cost is higher, since each change affects roughly 8 vectors, giving a relatively high cost, effectively equal to tracking the movement of 3 players in a single tick. But since it guarantees simulation synchronisation, and is certainly a lower price than the first attempt, the result is satisfactory.

Figure 66: Deformed terrain in the game

The result is visible in this video [100].

To detect the receipt of damage, we use events that cover both types of damage — explosion and single-point damage.

Figure 67: Damage events

In order to process the damage, we must first determine where it occurred on the terrain — that is, convert world coordinates into terrain coordinates, which we do through the inverse transform of the location.

Figure 68: Inverse location transformation

For all replicated vertices, we find their distance from that location and check whether it’s within the allowed radius.

Figure 69: Iterating through all vertices

Then we perform the terrain deformation according to the simple formula stated earlier.

Figure 70: Terrain deformation

Finally, we update the local vertices.

Figure 71: Updating vertices

Future iteration — merging the two techniques

The core issue is the high cost of join-in-progress for the event-based technique, versus the high cost of event-based replication for the effect-based technique. We could work around this by using events while the game is in progress, and state-based replication when joining in progress. This could be done using conditional replication, specifically COND_InitialOnly, which restricts a replicated property to be replicated only with the initial packet. In this theoretical model, the limitation of repeated reliable transmission still remains, and should be tackled by combining similar inputs into one, as well as possibly limiting the amount of input acceptable per tick (biggest-impact-effects-first logic). This technique wasn’t implemented due to time constraints and the complexity of execution.

The limitation of this implementation comes to the fore in its size — if you’d like to cover the whole world with this kind of terrain, you have two options: increase the terrain size to cover the world, or place N instances of terrain in the world.

Increasing the terrain to cover the whole world isn’t practical for several reasons. The large terrain should be refreshed whenever it’s modified, and if it’s a 64 km² terrain (the standard for large worlds), that’s an unacceptably large operation — this could be mitigated with sectioning, but there’s a bigger problem: since each object replicates from the server to the client based on distance from that object (more precisely, its center — remember the spherical distance), a large field would almost never be close enough to refresh, so it wouldn’t perform its function; and if it were close enough, the amount of data sent to the client would be unacceptably large:

64 km * 1000 vertices = 64,000 floating point numbers

So the only sensible solution is to cover the world with N instances of terrain. The problem they’d then encounter is that neighbouring terrain patches need to be modified together so the marginal vertices don’t “crack” and create a hole in the world. One of the less obvious limitations of this solution is that tunnels wouldn’t be possible unless specific constraints are applied to the terrain.

It would be advantageous for designers to be able to define terrain vertices that cannot be moved, or that can only move to a certain value on the Z axis or angle relative to neighbouring vertices, to ensure some sensibility for world design during destruction. Furthermore, as UE5 moves to a deterministic approach, it could revisit the first iteration, but would need to address periodic state saving to ensure durability.

Finally, it should be mentioned that in the world of procedural meshes, specifically for UE4, many innovations and improvements have recently appeared [101], with wide possibilities for improvement that could be applied to destructible objects — similar to Rainbow Six Siege, where each bullet dynamically modifies the shape [102].

Buildable objects

Building facilities gives players a source of creativity and freedom in shaping the world. It allows players to build objects with a variety of functionalities that can help them while playing, from creating shelter to respawn points.

We want construction to take place in a separate preview mode, where the user is first shown an overview of what the object will look like when built, with the ability to place multiple copies in a row as a repeatable pattern, similar to Company of Heroes. Once placed, we want the ability to build and demolish them.

First iteration — static objects

To accomplish all of the above, we create several classes to manage various aspects of this functionality.

In the Buildable class, we define everything that makes up an object that can be built. We need a mesh that represents the construction preview, a health component for the building that receives construction and demolition data, and meshes for the various construction phases.

Figure 72: Buildable.cpp class constructor

So in the class constructor we define all components and meshes — it should be noted all meshes are hidden via SetVisibility, with all collisions disabled.

With collision, we run into an interesting problem: collision doesn’t replicate by design in UE4 [103], and we need to change it during gameplay, since it needs to be off for hidden phases and on when they occur. We solve this by replicating a boolean list — one boolean per phase — and using an OnRep function that fires on replication. In OnRep_Collision, the collision profile is set depending on the values received.

Figure 73: Setting replicated collision variables

Figure 74: Function triggered by collision replication

We can now handle the default functionality. To place the object, we use the “Place” function, which changes the object’s visibility and collision profile, and sets its initial health, defined as 10% of total health.

Figure 75: Object placement function

It should be noted that the health component’s function had to be modified, because in its initial implementation, full health was granted immediately during construction. We corrected this by adding a new startHealth variable that defines the initial health (0).

Figure 76: Health components

Now all that’s left is processing construction and receiving damage. Construction happens in the “Build” function, where health is updated and the collision profile and phase visibility are refreshed.

Figure 77: Updating collision profiles

Receiving damage is actually identical to the character case, and the implementation is very similar.

Figure 78: Damage functions

In order to use this facility, it’s necessary to create a component for the client that organises the construction preview and calls the object’s functionality. We do this using the BuildManagerComponent class — on tick, it first waits for the construction view to be toggled on with the “O” key, after which the user is shown a preview of the building object (“ToggleBuildMode” in the project settings). By pressing the middle mouse button, the user confirms the starting position of the object to build (also configurable in the project settings), which calls the RequestBuild function.

Figure 79: Project settings

Figure 80: Function for starting the construction preview

Figure 81: Construction request function

To display the preview, the user’s camera and where they’re looking are first found via a line trace. Once we’ve defined the position, we can create an object — remember, we only do this locally, on the client side, not the server.

Figure 82: Finding the user’s view

Figure 83: Overview of the constructed object

If the point the user is looking at moves while holding the middle mouse button, more objects are created between the start and current point, so there’s no empty space between them. It should be noted objects can only be placed at a limited distance from the character.

Figure 84: Function for placing multiple objects in a row

Figure 85: Placing multiple objects in a row

To avoid objects clipping through other objects, we again use a line trace, starting from the top of the building object and drawn downward along the Z axis.

Figure 86: Handling the penetration case

Figure 87: Penetration-handling function using line traces

When we’re satisfied with the placement, we release the middle mouse button, which activates the “ReleaseBuild” function — this asks the object manager to create the objects on the server side, via the Server_PlaceBuildable function.

Figure 88: Function for placing an object on the server side

This is effectively a request to create these objects so they can be shown to all other clients.

Figure 89: Implementation of the server-side placement function

This brings us to the BuildableManager class, which manages these objects and handles their creation server-side, through the “SpawnRequest” function.

Figure 90: Function for creating objects in the manager

Figure 91: Placed objects in the game

Finally, once all the facilities are placed, we need to build them — this requires a special weapon to perform that function. This is done using the MyInstantWeapon class, which inherits from the “Weapon” class for similar functionality. The difference, of course, is that this weapon doesn’t cause damage, but builds objects when it hits one, provided it’s buildable.

Figure 92: Modified weapon for building objects

Figure 93: Constructed object

Future iterations — physical objects

The disadvantage of this iteration is that we’ve actually created static objects, which don’t react to physical events in the world. This was one of the first features implemented in the project, which is part of why it wasn’t designed with physics in mind from the start.

A future iteration should create a physical object, as defined in the object physics simulation network from Part 4, optionally locked to the Z axis for shelter consistency — though it should be noted axis locking can disrupt the behaviour of other physical objects.

Finally, depending on the design of the game’s functionality, it would be necessary to create a resource consumption system for building, to limit players from creating countless objects.

Destructible objects

Destructible facilities are the flip side of buildable ones, but have almost the same effect for the end user — providing deep opportunities for tactical creativity and freedom to move around the world.

First iteration — PhysX APEX + DENT

PhysX APEX is the current system for destructible objects, fully supported in UE4 [104], and is therefore used in this project.

It takes any object and turns it into a set of debris, connected through a simple hierarchy and connectivity graph, which defines how the object can be destroyed.

Figure 94: Object converted into a set of debris

Figure 95: Hierarchy and connectivity graph

Due to the simpler implementation of this part of the work, the DENT connector, available on the Unreal Marketplace [106], is used — it simply replicates information about the fracture of an individual piece of debris.

The fundamental problem with APEX is that it’s actually a separate, mostly closed API over which we have no control, and it’s difficult to configure because it’s written in a completely different language and environment. The consequence of this design is that destruction is very limited and difficult to configure for networked needs.

The crux of all the networked problems actually stems from a lack of control over the aforementioned debris. We cannot track debris as ordinary objects, and therefore cannot replicate their position from server to clients, so there’s a loss of positional synchronization. Notice the different positions of the debris of a destroyed building in the following figure.

Figure 96: Different debris positions on different clients

Next, we come to the concept of the “Large Chunk Threshold” — it defines that debris below the threshold has collision, while debris above it doesn’t. Because of these limitations, we’re forced into a very small number of possible destructible object configurations.

So the recommended configuration is:

  • Do not simulate debris after it separates from the main object, since its position becomes unknown.
  • All debris should be connected directly to the support chunk, so it’s never possible for two pieces of debris to remain connected to each other but separated from the main object.

One such destructible object is available at Content/TakeoverPrototype/DestructibleEnvironment/StaticMeshPhysics/BlockoutWallTest. The following figure shows the configuration.

Figure 97: Properly configured destructible object

DENT is further configured as shown in the following figure.

Figure 98: Destructible object configuration

To use this configuration within a larger object, it’s necessary to configure the collision using collision filtering [107], so no collisions occur between physical bodies — the configuration is available at Content/TakeoverPrototype/DestructibleEnvironment/StaticMeshPhysics/MyStaticMeshPhysics, struct DestructibleWallFinal, visible in the following figure.

Figure 99: Collision configuration

It should be noted the root these walls are attached to has the “Destructible” collision type. This root is needed because we want destructible objects to be part of a dynamic world — compatible with terrain deformation and the like. The result is visible in this video [108].

Figure 100: Synchronized destructible object

It’s possible to configure a destructible object with more debris, but then it should be destroyed completely upon receiving damage — this works better for smaller objects. It should be noted there’s a tool called PhysXLab [109] that lets you manually shape the debris so the above problems never occur, but for the purposes of this paper the default configuration is satisfactory.

Current generation — PhysX Blast

PhysX Blast is the current generation of destructible objects — it offers better performance than APEX and slightly better networking control [105].

This feature is available as an add-on for UE4 on the Nvidia GameWorks platform [110]. It’s not addressed in this paper, though, because the fundamental problems remain unsolved — it’s still a locked platform we can’t modify for our own needs, so no time was spent on implementing it.

Future iteration — Chaos Destruction

Looking to the future, the Chaos physics system also brings an embedded destructible object system. This solution is fully integrated into UE [111] — it’s open, so anyone can modify it for their needs, and it can be fully controlled within UE, without external tools.

For us, the most important fact is that this solution is suitable for networked environments, so we’re not limited to a small number of configurations.

This also means debris can be tracked even after separating from the building. Furthermore, debris integrates with other aspects of the game, such as:

  • AI navigation [112]
  • Effects system
  • Sound system
  • Generating various events the environment can react to — for example, a character reaction when debris hits the floor
  • Persistence support (monitoring system)
  • Cached simulation, where extremely complex simulations can be partially precomputed

Figure 101: Basic concepts of “Chaos”

In terms of setup, it doesn’t differ much from PhysX, and the connectivity graph is used again — its real advantage lies in the deep engine integration.

Simulating projectiles and ballistics

The projectile and ballistics simulation system is critical, not only for game realism but also for game security and simulation detail — it’s used to simulate bullets and object penetration.

Projectiles

We can implement projectiles two ways: as a physical object, or as a line trace — let’s compare.

A projectile as a physical object is an ordinary physics object given velocity when exiting a weapon, and it can interact with the environment.

Figure 102: Projectile as a physical object in play

A line trace is another way to implement projectiles — a simple line drawn from one point to another, checking which objects along that path it collides with.

Figure 103: Line trace in the game

Every real bullet experiences various influences — air friction, gravity, wind and so on — and takes time to reach its target. A physical projectile handles all of this naturally, since it’s part of a physics system. A line trace, by contrast, resolves instantly: each weapon has an almost infinite range, with no gravity influence, and no travel time, since it reaches its target immediately.

On the security side, physical projectiles are very safe, since they can be fired from the server side and are therefore almost impossible to exploit. A line trace depends on the client side, so if the user can’t see the target, they can’t hit it, since the client determines the start and end of the trace — this opens various avenues for cheating. It’s possible to run the line trace on the server side, but then we rely on the server also seeing everything, which isn’t always the case; and running it server-side introduces greater latency between firing and hitting, which negates the advantages of this method.

Because of all this, physical projectile simulation was chosen. The project’s existing projectile simulation was satisfactory for our needs, and only the hit behaviour, i.e. ballistics, was modified.

Ballistics

The goal of the ballistics system is to simulate the penetration and damage a projectile strike causes to an object. Its applications are wide, from piercing walls to tank armor. It generally raises the level of detail and realism of the game, and if applied broadly enough alongside a destruction system, can be excellent. This system was mostly inspired by the game “War Thunder” [113].

The vast majority of available designs rely on line traces rather than physically simulated objects, which is far friendlier for performance, but is clearly not suited to our design.

There is one publicly available implementation using physically simulated objects, the “Mipmap” implementation [114]. In short, it relies on a collision “hit” event (when a physical object bounces off another), and then decides whether the projectile pierces the target. The disadvantage of this method is that after piercing, the projectile must be destroyed and re-created on the other side of the pierced object, which is very costly for networking performance — remember, creating objects must happen server-side, requiring position, velocity and similar attribute data to be sent. This would be disastrous for detailed environments, where almost any object can be breached.

Because of this, a new method was devised, relying on the collision “overlap” event instead. When a collision occurs, we don’t stop the projectile — we’ve ruled out any physical blocking of the projectile — but we can still monitor it through the overlap event.

Figure 104: Collision component of a bullet

This way we don’t send any data about creating a new projectile — its position data is sent according to its normal network settings.

So, when a projectile overlaps with an object, an overlap event fires on the projectile, and we save the overlap data — it should be noted the rest of the code executes only on the server side.

Figure 105: Overlap event

Here we need to look at some of the points we’ll need for the various calculations.

Figure 106: Points needed for the calculations, own production

  • A is the impact point — where the projectile initially hit.
  • B is the point of maximum penetration — if it can reach it, we consider the object penetrated.
  • C is the point on the back of the object being pierced, used to calculate the exit speed.

Since we know point A immediately on overlap, we now need to find point B by calculating the maximum penetration. This is done in a separate function for code clarity. The method is based on a simplified De Marre formula for medium-velocity projectiles [115]. It accounts for the area of the bullet tip, the bullet’s speed, its weight, and the material of the object being pierced (for now, all objects are treated as steel). This could be improved with more precise formulas [116], but for the purposes of this project it’s satisfactory.

Figure 107: Calculating maximum penetration

We can now calculate the point of maximum penetration, using the bullet’s direction of travel multiplied by the calculated value, added to point A.

Figure 108: Calculating the maximum penetration point

To find point C, we use a multi-line trace, returning the set of objects it collided with between B and A. When we find our object in that set, we check whether the hit location matches the initial location, point B. This is done because collision inside the object triggers immediately, meaning we didn’t actually break through the object. It should be mentioned there could be a gap between A and B that the projectile could theoretically pierce, but by design this is treated as impossible — this can be avoided by only considering the last collision with the shape.

Figure 109: Finding a point on the back of the object

If we didn’t break through the object, it should still take damage, the client should be notified, and the projectile destroyed.

Figure 110: Case of non-penetration

When notifying the client, we create visual and sound effects. This produces much less data than the projectile re-creation method mentioned earlier.

Figure 111: Creating effects on the client side

If we did pierce the object, we reduce the projectile’s speed according to the ratio of maximum penetration to the distance between points A and C. This ratio is multiplied by the speed, and if it reaches zero, we begin destroying the projectile as before.

Figure 112: Case of object penetration

If the projectile continues past the object, we apply damage and notify the client.

Figure 113: Continuation of the projectile

When informing the client, we again create a visual effect and sound — a red tracer dot is created to help visualise the shot, though it should be mentioned it’s client-side and can be inaccurate. The system’s underlying accuracy can be read from an on-screen log.

Figure 114: Penetration effects on the client side


Continue to Part 6 — Designing a Game Mode for Interaction. The full six-part series and source project are on GitHub.

Built with Hugo
Theme Stack designed by Jimmy