Behind the Scenes: Creature Navigation in Multiplayer
Hello Riftbreakers!
We hope you’re having a lot of fun with the Open Beta of The Riftbreaker 2.0 Update this summer. The full release is just around the corner, so let your friends know that a new co-op game is on the block, perfect for having fun together. If you’ve been with us for a long time, you know that reworking our single-player game into a four-player cooperative mayhem simulator has been a massive challenge. It required us not only to rework most of the codebase but also to devise solutions to problems that we had never encountered before. Our elegant, optimized features suddenly stopped working or caused massive data transfer spikes. The creature movement system was one of the affected areas. We described the details of how it works very early in the development process. You can read all about it here - part 1 and here- part 2. It worked great for single-player gameplay, however, it was problematic during network play. In today’s article, we’re going to tell you all about our new invention - the NavMeshNetworkTransformSystem.

When we began working on the multiplayer mode for The Riftbreaker, we decided that the game would operate using a client-server architecture. In this model, one PC serves as the host of the game, and all the crucial game elements are simulated on that computer. The server broadcasts the information about the gameplay state to all the connected client PCs in the form of update packages. These packages must be as small as possible to avoid transfer issues. Best optimized online games often transfer as little as 8 kilobytes of data per second. We set our goal at 25 kBps. It’s not as low as 8, but still reasonable for most internet connections out there. To achieve this, we simulate as many non-critical gameplay effects locally on client PCs as possible. However, even with this setup, creature movement would consistently add more than 200 kilobytes of data per update on top of everything else, which is unacceptable. It was clear that we needed a smart solution.

Enter the NavMeshNetworkTransferSystem. This magic system ensures enemy units move smoothly and appear in the correct location on every client. It was designed to help all the creatures you encounter in-game navigate the complex terrain of Galatea 37. The server is always “in charge” of where enemies are and which way they face, and the NavMeshNetworkTransformSystem ensures that this information is sent to players efficiently and reliably. This component is responsible for updating positions and orientations, determining when and how updates are sent, and how clients anticipate and correct enemy movements. We’ll also take a peek at developer-only clever tricks, such as height prediction, and even how movement and turning speed are fed into the animation system. It’s a deep dive today, we hope you’ll enjoy it!
[h3]Server-Side Packing: Tiny Transform Updates [/h3]
If you launch the game as a server in multiplayer mode, all creatures become equipped with an additional NavMeshNetworkTransformServerComponent. In The Riftbreaker, game logic is updated every 33 milliseconds regardless of how many image frames are rendered per second. We usually refer to this as an ‘update’ or a ‘tick’. Each tick, the NavMeshNetworkTransformServerComponent reads a unit’s (e.g., a canoptrix) current position and orientation (typically, yaw and angle are sufficient, as most of our units stand upright). We represent this data in the form of 32-bit floats - often more than one per unit. Let’s say that we need three 32-bit floats to have complete information about one unit - that’s 96 bits that we would need to transfer. Multiply that by a wave of 1000 enemies. Suddenly, you get 96 kilobits, or 12 kilobytes (8 kilobits = 1 kilobyte), in a world where we fight for each kilobyte of transfer, this is far too much

We decided to approach this by compressing the 32-bit floats into smaller, 16- or 8-bit integers, cutting the required transfer in half or by a factor of four, respectively. For position, only the X and Z coordinates are used. (Y is the vertical axis in our engine) These floating-point values are scaled using a factor of 10, clamped to a fixed range, and then converted into 16-bit unsigned integers. The result is packed into a single 16-bit integer, which also encodes the axis type in the highest bit. This provides approximately 0.1-unit precision over a range of ±1638 units.

Internally, the 16-bit value is structured so that the lower 15 bits represent the quantized position value, while the most significant bit (bit 15) is used to indicate the coordinate type (e.g., X or Z). The position value is stored as a signed integer in the range [−16,384, +16,383], which fits exactly within 15 bits. By masking the position with 0x7FFF and shifting the type flag into bit 15, both pieces of information can be compactly combined into a single value. This layout ensures that all 16 bits are efficiently used—15 for spatial data and 1 for metadata—without sacrificing numeric range or introducing ambiguity during unpacking.
We use the same treatment for rotation data. In this case, an 8-bit integer gives us rotation precision of 256 steps, or approximately 1.4 degrees. Speed is handled using the same packing approach, but with a small optimization to increase range. Before packing, the velocity magnitude is divided by 2, allowing values up to 51.0 units/second to fit into a single byte. On the client side, the unpacked speed is simply multiplied by 2.0 to restore the original scale. This technique effectively doubles the representable range while still using 8 bits, at the cost of slightly reduced precision. Without this division, an 8-bit integer could only represent values up to 25.5 with a precision of 0.1. Since the value is stored as a single byte, the number of fractional steps is fixed—only 256 possible values are available. By halving the speed before packing, we shift the balance slightly in favor of range rather than precision, which is often more valuable in fast-paced movement systems. Our code even has switches (flags or configs) to use 8-bit or 16-bit modes for testing and tuning. By packing a unit’s position coordinates and orientation into just a few bytes, we significantly reduce the amount of data sent with each update.
[h3]Timed Replication [/h3]

Okay, we reduced kilobytes into bytes in the previous step, but multiplied by the number of creatures during our attack waves, it still adds up to massive numbers. We don’t want to spam transform updates every single frame. Instead, the NavMeshNetworkTransformSystem uses a timed replication system. Throughout many playtest sessions, we determined that we can get away with sending a creature movement update every 200 milliseconds - or five times per second of gameplay. Once the timer exceeds a configured replication interval of 200 milliseconds, the NavMeshNetworkTransformServerComponent packs the current transforms and sends them to clients, then resets the timer. This ensures regular, periodic updates, keeping clients up to date. The key is to choose an interval that is short enough for smooth motion but long enough to limit bandwidth. In practice, the interval is tuned to strike a balance between smoothness and efficiency.

[h3]Velocity Capping and Movement State Detection [/h3]
Determining a unit’s position is only half the story. More often than not, the creatures you see on the screen will be moving - either wandering around, looking for food, or charging at you at full speed. We also need to take that into account. The NavMeshNetworkTransformSystem closely monitors a unit’s velocity and movement state. If it detects something weird, like an unexpected dash to another point on the map, the creature’s speed is clamped to the unit's maximum as determined by the creature’s properties in its .ent file. This prevents crazy outliers from being sent or extrapolated. It also allows us to smooth out any issues that may arise in case a unit experiences a temporary speed spike.
Next is movement state detection. The code constantly monitors whether the creature is “standing still” or “on the move”. This is typically done by checking speed against a small threshold. The value of this test determines whether the unit is idling, walking, or running. Values that do not exceed the threshold are treated as idle, because we don’t want to send clients essentially useless data in the form of minor jitters.
[h3]Client-Side Extrapolation (Dead Reckoning)[/h3]

As we mentioned before, every 200 milliseconds, the game checks for changes in creature positions. The difference between the previous and current state of a unit is called ‘delta’ (any change between the prior and current state is called a delta, to be precise. However, for the sake of this article, whenever we talk about a delta, it’s going to be the difference in the position of a unit). This 200 millisecond interval doesn’t mean that creatures only move that often on client PCs. To predict where an enemy should move next, the client relies on the direction and speed information provided by the server. We normalize this direction to a simple "forward" movement and extrapolate ahead based on an enemy’s reported speed. The faster the enemy or the longer the network update interval, the more aggressively the prediction extrapolates forward. Conversely, if the enemy is slowing down or standing still, we ease up on extrapolation to avoid overshooting their position.

When a client gets an update from the server, it first checks how far off its current prediction is from the server’s latest position. If the client’s predicted enemy position is lagging behind (or running ahead) of the server’s position, the system makes gentle corrections to catch up or slow down accordingly. Instead of abruptly snapping the enemy into position—which would look jittery—it gradually adjusts the enemy's location over several frames. These calculations may become increasingly inaccurate if network conditions are less than perfect. To counteract this, the system dynamically adjusts prediction accuracy based on the difference between the predicted and actual positions. If the discrepancy becomes too large, a gradual "correction force" pulls the enemy smoothly back toward the server’s authoritative position, ensuring enemies don’t appear to teleport or jitter awkwardly. We've also built in an experimental feature: adjusting enemy speed prediction based on how consistently the enemy stays within certain expected boundaries. If an enemy seems to be drifting too far off-track - say, due to sudden lag spikes - the system intelligently adjusts the enemy’s speed slightly. This subtle tweaking keeps enemies on believable paths without noticeable snapping or stuttering.
[h3]Orientation Smoothing: Turning Gracefully [/h3]


Just like in the case of positioning, an enemy’s facing direction needs careful handling to avoid jarring rotations or awkward instant turns in multiplayer gameplay. Abrupt orientation changes break immersion, so we’ve implemented an orientation-smoothing algorithm to ensure that our creatures turn around naturally and gracefully, even under challenging network conditions. When an enemy changes direction, the client doesn't instantly snap the unit to face the new direction. Instead, it calculates the shortest angle between the enemy's current facing direction and the desired facing direction. This shortest angle ensures enemies turn the minimal necessary distance, whether clockwise or counter-clockwise, avoiding exaggerated spins. We also smartly select which orientation to follow. When an enemy is moving forward at a good pace, we assume it is facing in the direction of travel. If they’re stationary or moving slowly, we rely more on the explicit angle updates provided by the server, ensuring the enemy remains facing the right way without unnecessary jitter or micro-rotations.

[h3]Height Prediction (Ground, Air, and Hybrid) [/h3]

Apart from the horizontal, we also need to consider the vertical positioning of units. Some of them, like Wingmites in the Metallic Valley biome, can fly over terrain obstacles. Others, like baxmoth drones, always fly and never even touch ground. However, syncing the vertical positioning of units in The Riftbreaker would increase the amount of data we transfer from the server to its clients. Instead, the clients maintain their local understanding of the world’s terrain height. By keeping a local copy of the map's topological data (terrain heights and obstacles), each client independently calculates how high above ground or water an enemy unit should be displayed. This clever optimization saves a third of precious bandwidth.

[h3]Animation Integration: Feeding Movement Data to Animations [/h3]

Predicting creature animations was one of the key parts of this system.. We didn’t want to rely too heavily on information from the server, as this would likely result in inconsistent movement and various errors. To achieve this, we revamped enemy animation graphs to respond directly to movement flags generated by our movement database component. Whenever the client detects changes in movement, like speed or angle, animations update instantly and independently, ensuring immediate feedback for players.

The client continuously computes:
- Movement Speed: Calculated directly from client-side position predictions, ensuring enemies smoothly accelerate and decelerate visually, perfectly matching their actual speed.
- Angular Speed (Turning): By analyzing the rotation difference between the previous and current orientation, the client determines how fast a unit is turning. This keeps turn animations realistic, reflecting subtle directional changes smoothly.
By locally computing and applying these animation parameters, we avoid potential synchronization issues or noticeable mismatches between server and client states. Animations always match a creature's predicted movement, ensuring consistent visual presentation without relying on frequent network updates.
[h3]Putting It All Together [/h3]
Every game tick, the NavMeshNetworkTransformSystem on the server gathers each creature’s latest world position and rotation, compresses them, and decides if it’s time to broadcast. When an update is sent, clients receive the packet, unpack the data, and compare it to their current prediction. If necessary, they correct the position with either a smooth lerp (linear interpolation) or a snap. Meanwhile, the client continuously updates creature positions each frame based on the last known velocity and heading, ensuring a seamless experience. Various flags enable us to tune responsiveness, and the animation system reads out movement rates, ensuring that creatures appear alive and consistent with their movement.

In summary, the NavMeshNetworkTransformSystem is our bespoke solution for enemy synchronization in Riftbreaker’s multiplayer. It carefully packs data to save bandwidth, updates on a smart schedule, predicts and corrects movement on clients, handles special cases (like flying or death), and even keeps animations in sync. The result for players is smooth multiplayer action: enemies show up where they should, move believably, and don’t spontaneously teleport (except Lesigians. And Magmoths. And Hedroners. Okay, maybe we have quite a few teleporting units.)

As a result of all the magic described in this article, we managed to reduce the amount of data transferred by the navigation systems from about 200 kilobytes to a range between 7 and 20 kilobytes, depending on the situation. Obviously, this is just one of the systems that we had to optimize heavily for network play, but now you have an idea of what kind of problems we had to face on our way here. You can already check out the results of our efforts by playing The Riftbreaker Survival Mode in our free Multiplayer Playtest app. You can also try the Campaign Mode already by checking out the coop_beta branch of the main game. If you’d rather wait for the full, official release, you won’t have to wait long. Keep your eyes peeled for more news!
EXOR Studios