RTS devlog #4: Collision detection and combat

4
Official Construct Team Post
Ashley's avatar
Ashley
  • 27 Oct, 2022
  • 1,722 words
  • ~7-11 mins
  • 1,790 visits
  • 2 favourites

Since the last devlog I've been working to get to a minimally playable game. Units now have turrets, they can fire at each other, and get blown up when they take too much damage. If one player wins, it's game over! And it all works multiplayer online at CommandAndConstruct.com.

Much of the game logic is relatively straightforward, at least compared to the rest of the architecture, and as ever all the code is on GitHub. The most important work was the implementation of collision detection on GameServer, and the addition of network events.

Server side collision detection

As I noted in the architecture post, using GameServer in a Web Worker means I have to write custom collision detection code. Fortunately it's not actually too complicated for a simple point-in-polygon test, which is all I need for now, to work out if a projectile hit another unit.

Even with custom collision code, a nice benefit of Construct is its visual editor provides a way to line up collision polygons in the Animation Editor. The collision polygon point positions can be read in JavaScript and posted to GameServer (see #GetConstructObjectDataFor()). So even with custom collision code, I can make use of Construct's visual editor to set up the collision shapes. The same applies for the object origin and image points (e.g. a point at the end of a turret for where projectiles should fire from).

Setting up the collision polygon for a tank platform in Construct.Setting up the collision polygon for a tank platform in Construct.

This means GameServer has the collision polygon for tank platforms and can use it for collision detection. There's two more things GameServer needs to do to be able to handle collisions though.

Rotating the collision polygon

The collision polygon sent to GameServer is unrotated. When a unit rotates, its collision polygon also needs to be rotated to correctly detect collisions. (For point-in-polygon checks, the point being checked could instead be rotated, but we'll need polygon-intersects-polygon checks later on, so we may as well handle rotating collision polygons now.)

There's a new CollisionShape class that handles this. It takes the original polygon, copies it, and can rotate it to any angle. An interesting property of the rotation algorithm, which is in the Update() method, is it only needs to calculate the sin and cos of the angle once and can apply that to all the points, saving on expensive calculations.

Again preferring object composition, the UnitPlatform class has a #collisionShape property to store its collision polygon rotated at its own angle. To avoid needlessly calculating the rotated collision polygon, the collision polygon is only actually updated once a collision check is performed in ContainsPoint() - and if it's at the same angle as last time it doesn't need to update the polygon at all.

Point-in-polygon check

For now the only kind of collision detection we need is point-in-polygon. Projectiles are treated as a single point, and if that point is inside the collision polygon, it's a hit.

CollisionShape has a ContainsPoint() method which performs the actual point-in-polygon check. This uses an algorithm that first picks a point outside the collision polygon, and then draws a line from the point being tested to the outside point. If that line crosses an odd number of collision polygon lines, then it's inside the shape. If that sounds weird, try getting a pen and paper and drawing a few shapes and seeing how it works. You can think of it like this: every time you cross a collision polygon line, you swap from inside to outside the shape, or vice versa. Since it always ends outside the shape, an even number means you also started outside the shape, and an odd number means you started inside the shape.

A key test for that algorithm is SegmentsIntersect. A line segment is the mathematical term for a line between two points; that method takes two segments and determines if they intersect, using some geometric maths that I wish I could explain, but you'll probably have to turn to a maths textbook for more details about how that works (this one is good and is free to read online!)

Firing projectiles

Unit turrets now also identify targets in range, track them, and fire projectiles on a regular basis. This is all done on the server - remember that the server runs the main game logic, and just tells clients what is happening. The server-side implementation for this is, I think, relatively straightforward. See the UnitTurret class methods #FindTarget(), #TrackTurret() and #FireProjectile(). As ever I've tried to write the code as clearly as possible and comment everything in detail.

One thing that tripped me up was that turrets have to measure range from their platform position, not the turret position. That's because they find targets based on the platform position, and the turrets are slightly offset from the platform position. Finding range from the turret could mean that a unit ends up in a position where it can fire at a unit that cannot fire back. Using the platform position for all calculations ensures it's fair and units can always fire back at something that can fire at them.

The next significant problem is: how does the server tell clients about projectiles being fired?

Network events

For one-off events, I've added a new mechanism to GameServer, called network events. The usual "game state" binary message sent out every tick (in GameServer's #SendGameStateUpdate() method) constantly updates things like the positions of units. This is also sent with unreliable transmission mode - such as a fire-and-forget UDP packet. It's streaming data and if a packet is lost, it doesn't really matter; another update should arrive soon with newer information.

Overall this is not suitable for one-off events, mainly because events should not be lost - they should be sent with reliable transmission mode, ensuring they arrive, even if late, so the client can see the event happening. Therefore one-off events are sent in a separate message with a different transmission mode (in the #SendNetworkEvents() method). Any events that happen during a tick are queued up, and if there are any events that tick, GameServer will send them off to clients in a separate message with reliable transmission.

Each type of network event that can happen is represented by a class in the networkEvents folder. Each class remembers its details and has a method to write it in binary for transmission. Currently there are three kinds of network events:

  1. FireProjectile - when a turret fires at a target, creating a projectile
  2. ProjectileHit - when a projectile collides with a unit, causing damage and destroying the projectile
  3. UnitDestroyed - when a unit has taken too much damage and blows up.

Both projectiles and units have server-assigned IDs, and the client also tracks these so it can make sure it destroys the right projectile when it receives a ProjectileHit event, and the right unit when it receives a UnitDestroyed event.

Interestingly a projectile travelling in a straight line at a fixed speed from a point is completely predictable by clients. Therefore no more information needs to be transmitted about the projectile after its creation, other than if it hits a unit. They just show the projectile flying along, and if they receive a ProjectileHit event, they destroy the projectile and show an explosion at the hit position; otherwise they just automatically destroy it when it goes out of range.

On the client side, GameClientMessageHandler has methods to receive network events and call the appropriate GameClient methods. In terms of the actual gameplay, everything about projectiles is entirely cosmetic. The client creates other cosmetic details like explosions to add extra visual feedback for the player. However these have no bearing on the gameplay at all. The server sends network events based on what is really happening, and the client just shows what's happening based on that. This also precludes the possibility clients can cheat by hacking things like collision detection. Clients don't actually do that at all; they are just showing a representation of what's happening on the server.

Finishing touches

Beyond that, the unit movement was tweaked to work a bit better (although it's definitely going to be changed again in future), and GameServer now also sends a "game over" message when one player has all their units destroyed. That means there's now a minimal, but playable, competitive game that can be won or lost! Two players can pit their tanks against each other and try to blow up all the other player's tanks without losing all of their own.

Conclusion

Pleasingly, this architecture is working well so far. It has indeed been the case that once something works in single-player mode, it does "just work" in multiplayer too, since single-player mode uses the same architecture as a multiplayer game. This makes testing much easier.

I'm happy to have got to a minimal competitive playable game. This is the foundation on which everything else will be built, and I think it's a decent starting point!

There's obviously still loads more to do. A few of the points I'll probably tackle next are:

  • Currently clients display the raw messages coming over the network. This can look choppy, especially over a poor network. Client-side interpolation is important to smooth that out.
  • The controls are limited. There needs to be mass-selection and scrolling. That will likely also require a minimap to provide an overview at all times.
  • Turret target finding, and collision detection, are inefficiently implemented, using a brute-force approach that just tries all combinations. This works for 4 units per team, but will definitely be far too slow if each side has 1000 units. A more efficient algorithm is needed.
  • 1000 units will likely also start to use a lot of bandwidth. I'll need to do some measurements and find ways to reduce the amount of data that needs to be sent.
  • Then there's still the rest of a playable game to build!

So - a good start, but early days yet! There's lots of interesting problems to solve still, and I hope to keep blogging about all of them!

Past blog posts

In case you missed them here are the past blogs about this project:

Subscribe

Get emailed when there are new posts!

  • 4 Comments

  • Order by
Want to leave a comment? Login or Register an account!
  • Nice work, I really like the fire and forget style of bullet, which greatly reduces the network traffic of many bullets moving around at once (just sending start x,y,vector of bullet vs send x,y of bullet every server tick) - Have you ever seen it go 'out of sync' between server expectation and client expectation (e.g. with subtle timing differences between CPU time on server vs client). I imagine there could be drift, but the time frame is short (bullet life) and both sides use pix/dt instead of pix/frame.

      • [-] [+]
      • 3
      • Ashley's avatar
      • Ashley
      • Construct Team Founder
      • 3 points
      • (0 children)

      I haven't really done any real testing on the networking code yet, but I definitely will at some point. Poor network conditions might cause the hit event (and resulting explosion) to not visibly line up with the bullet or the unit it hit. But I think it can mostly be covered up reasonably well by the client. For example given the bullet movement is predictable, if a "projectile fired" message arrives late due to being delayed by the network, the client can advance the bullet to compensate for the lateness, and then it's back in sync. I'll be digging in to all those details in the blogs when I get to beefing up the network code.

  • "I have to write custom collision detection code" #MadeInC3 🤡

      • [-] [+]
      • 0
      • Ashley's avatar
      • Ashley
      • Construct Team Founder
      • 0 points
      • *
      • (0 children)

      Obviously Construct has built-in collision detection and you can use that from both event sheets and JavaScript coding. But if you want to run in a separate thread you need another solution, and that's what I've done for this project. I could've instead chosen to use the built-in collision detection, but then I couldn't use multithreading, which would reduce the overall performance. I went in to detail about this architecture and its various tradeoffs in the architecture blog post.

      The fact you can even do custom collision detection in a separate thread to the runtime is actually pretty cool I think! It shows Construct's flexibility: use the built-in collisions if you like, or write your own custom implementation in a separate thread. That could then even be split off in to something like a dedicated server running in node.js. Not all tools provide that kind of range of options.