Unheard Software

The Projectile System

Welcome back to the Ripper devlogs! Today we are diving into how the projectile system works. Fair warning: this one builds heavily on the previous devlog about the world, especially the multi-floor setup and collision layers. If you have not read that one yet, we highly recommend starting there — it will make this a lot easier to follow.

Banner the projectile system

Quick Recap and What We Need

As covered in the last devlog, Ripper is a top-down, multi-floor game. All floors exist in the same physics space but are separated using collision layers — each floor gets its own dedicated layer. This setup works great for the world itself, but it introduces some interesting challenges when you start firing projectiles through it.

Here is what the projectile system needs to handle:

Let's walk through how we tackled these problems.

What Even Is a Projectile?

At its core, a projectile is just an object moving through space. Simple, right? Well, kind of. The tricky part is making it flexible enough to support all the different behaviours we want — bullets, shotgun pellets, sniper rounds, thrown grenades, whatever the modding community dreams up.

Our solution keeps projectiles abstract. Each projectile is stored as a simple piece of data:

Different projectile types are handled by factories, the same system we use for world objects. Each factory defines the behaviour for its projectile type — speed, trajectory changes, hit detection, everything. This keeps the system modular and easy to extend.

One important detail: on the server, projectiles do not actually exist as physical objects in the world. They are just data. The factory uses that data to perform raycasts and calculate movement, but no physics bodies are spawned. This keeps things lightweight and prevents unnecessary physics overhead.

Managing All the Projectiles

With individual projectiles sorted, we need a way to keep track of them all. We use a simple list that holds every active projectile in the world. Each projectile gets a unique ID made up of two parts: the current game tick (a 64-bit integer) and the projectile's index within that tick (a 16-bit integer). This guarantees uniqueness without needing to track or recycle IDs manually.

Every game tick, we loop through the list and call each projectile's factory to handle movement and updates.

There is a key difference between how the server and client handle this. The client is only responsible for visualizing projectiles and updating their positions smoothly. The server, on the other hand, does not visualize anything — it just handles collision detection and physics.

The Raycast Problem

Here is where the multi-floor system starts causing headaches. The server needs to detect collisions for projectiles, which means casting a ray from the projectile's current position to where it will be next frame. In a normal single-floor game, you would just use the engine's built-in raycast. Done.

But we cannot do that. Godot's raycast uses an OR operation across collision layers — if any of the requested bits match, it registers a hit. That means if we ask for "projectile hitboxes on floor 2," it would also pick up projectile hitboxes on floors 1, 3, 4, and so on. We need an AND operation instead — the collision layer must have all the required bits (both the floor bit and the projectile bit).

We also cannot write our own raycast from scratch, because the actual physics calculations happen in Godot's C++ core and we are working in C#. So we had to get creative.

Our workaround works like this:

  1. Cast a ray from the projectile's current position toward its next position
  2. If the ray hits something, check whether the hit object's collision layer contains all the required bits (e.g., for a projectile on floor 2, we need both bit 5 for projectiles and bit 17 for floor 2)
  3. If it does not have all the bits, add that object to an ignore list and cast the ray again
  4. Repeat until we either hit something valid or reach the end position

It is not the most elegant solution — repeatedly casting rays is not cheap — but it works, and it handles the multi-floor collision logic correctly.

Keeping Clients in Sync

Multiplayer introduces another challenge: making sure projectiles on the client side match what the server is doing, without overwhelming the network.

When a new projectile is created, the server sends a message to all clients within visibility range. This message includes where the projectile spawned, what type it is, and what direction it is moving. The client receives this, looks up the appropriate factory for that projectile type, and starts simulating the movement locally.

In theory, if the server and client are running the same movement code, the projectile will stay perfectly in sync. In practice... it is pretty close. Our testing so far has shown it works well enough, though there is always going to be some minor drift.

If something significant happens — the projectile hits a target, penetrates a wall, or gets destroyed — the server sends an update or removal message using the projectile's unique ID. This keeps the important events aligned without needing to constantly broadcast position updates for every single projectile.

One other detail: we only sync projectiles to clients that are nearby. If a projectile is on the other side of the map, there is no point sending updates about it. This cuts down on bandwidth significantly in larger matches.

In the Game Right Now

Ripper currently has four different types of projectiles:

Projectiles in game

Each of these has its own factory controlling behaviour — the sniper round penetrates more surfaces before expiring, shotgun pellets spread out, thrown items have more air drag. The system is flexible enough that adding new types (explosive rounds, ricochet bullets, whatever) is just a matter of defining a new factory.

Potential Issues

No system is perfect, and ours has a few known quirks:

We are keeping an eye on these and are ready to tweak the system based on community feedback once the game is out there.

Wrapping Up

This system meets our requirements — multi-floor support, multiplayer sync, moddability, and penetration mechanics all work. Is it perfect? Not quite. We are still learning as we go, and we are committed to iterating and improving based on feedback. Transparency is important to us, so we want to be upfront about how the system works and where its rough edges are.

As always, if you have not already, please wishlist Ripper on Steam! This started as a passion project and has turned into something we are genuinely excited about. We want to keep working on it for some time.

For the next devlog, we are not committing to a specific date — time is tight at the moment — but we can tell you what is coming: the item and inventory system. See you then!

Unheard Software

#gamedev #godot #indiedev #ripper #shooter #topdown #unheard #unheardsoftware