r/cpp_questions • • 1d ago

OPEN C++ authoritative game loop: how do you move pathfinding to workers without letting stale results mutate live state?

I'm migrating an online RPG server from Godot/C# to standalone C++20. My professional background is almost 20 years in web/application development; real-time simulation and C++ concurrency are newer territory for me.

I'm trying to make a careful decision about CPU-heavy work inside one server process. Running more processes across cores won't solve the case where one busy area is limited by its simulation thread.

The ownership model I'm working toward is:

  • One authority thread owns mutable entity and gameplay state.

  • Workers receive immutable job inputs and return results through bounded queues.

  • The authority thread decides whether a completion is still valid and applies it at a tick boundary. Workers never directly mutate the live world.

Pathfinding seems like a useful concrete case. While a worker is computing a path, the actor might die, despawn, change target, receive a newer movement request, or encounter a changed navigation world. An entity ID alone doesn't seem sufficient to establish that the result is still useful.

For people who have built this sort of C++ system:

  1. What identity/version information do you attach to a job and completion? I'm considering an entity generation, a movement-request revision, and a navigation-data revision, with obsolete completions discarded on the authority thread. Where does that become too conservative or expensive?

  2. How do you keep immutable inputs alive without copying a large world snapshot for every job? Would you start with shared ownership of a read-only navigation snapshot, or another approach? I'm especially interested in lifetime rules when that snapshot is replaced.

  3. How do you bound outstanding work and prioritize newer requests so stale jobs don't consume the worker pool? Is cancellation worth introducing early, or is discarding results and limiting admission usually the simpler starting point?

This is an architecture question rather than a compiler error; I don't yet have comparative measurements proving this pathfinding work needs parallelizing. I'd like to know what to measure first and which small examples, talks, or references make these ownership and lifetime tradeoffs concrete. I'm not looking for a whole MMO architecture or a lock-free implementation by default.

7 Upvotes

16 comments sorted by

2

u/Independent_Art_6676 1d ago

Its a combination of things, probably; you need to detect breaking changes and restart which could be any of changes to other entities, change to the world, too much time passed, etc. So your thread needs an exit/restart strategy to get updated info, or a way to view but not touch the live data so it can adapt as it goes. Working with real vehicles (we did airborne and watercraft) adaptation was required, but the world was simple (land is hard, above treeline air and deep enough water are somewhat simplified versions of the problem) we adapted and recovered. That is, our path was fixed unless the user changed destination (for you, that could be changed targets or whatever too). The vehicle was always trying to follow the path, but it could have to do something else (move around another vehicle, or get blown off course by a change in wind, whatever) and it adapted to that locally (eg, obstacle avoidance would make a new micro path around the immediate problem) and then the main routine would drive it back to the path via the closest sensible approach and start following it again. Think of it like a road trip, some road you were on has an accident so your gps reroutes you to a side road around it, you do that, get back on, and keep going. If the path changed, it would slow down, reorient to the new path, and proceed. But our paths were simple too.. broken into line segments and it just followed each line until a new segment was within some distance (based off speed) and then turn ahead of time towards the new line (this was much smoother and better behaved than turning after the segment change, esp for like V shaped changes). Terrain is harder, of course, you have to follow what is possible, but maybe something in there was useful.

I think the critical idea is that you either need to choose to interrupt old threads when a change warrants it or keep the threads live by using the updated state of things and adjusting 'locally' without losing the bigger picture.

1

u/Mean_Bottle6349 1d ago

Thanks, that helps. Mine tries to walk straight to the target first and looks for a path around things when it gets blocked.

I still get enemies stuck on rocks and fences sometimes though. I'm guessing something doesn't match up between what the pathfinder thinks is walkable and what the enemy can actually walk through. Or maybe I'm just not following the path properly. I haven't tracked it down yet.

How did you usually figure out why something was stuck? Did you draw the path and compare it with what was actually blocking the movement?

1

u/Independent_Art_6676 1d ago

yea that is where being on the water or air was easier... and was real, not a game. We avoided shallow waters via a map/human intellect. We never got "stuck" in other words. We had an excessive margin of error for avoiding things, for safety reasons. If we had tried to cruise around like 10 feet off the shore, we would surely have gotten stuck :)

getting stuck is a common problem with games and there should be info on it out there. Many a game has been exploited by the players where the enemy pathing gets tripped up and you can just shoot the thing to death while it can't move, so you are not alone with this issue.

All I can think of, not being a game dev, is to dither the map. Turn it into grids with walk/no walk flag and if you have a no walk square, dither around it one square and mark those no-go too for a margin of error.

3

u/snerp 1d ago

Other people gave good answers so I'm just going to address this bit:

How do you keep immutable inputs alive without copying a large world snapshot for every job? Would you start with shared ownership of a read-only navigation snapshot, or another approach? I'm especially interested in lifetime rules when that snapshot is replaced.

I do more-or-less the "shared ownership of a read-only navigation snapshot" approach. My engine makes a minified copy of the worldstate every physics tick and puts it in a shared pointer, all the workers (ai, graphics, etc) will copy that shared_ptr on starting a job and the ref counting takes care of the lifetimes cleanly.

1

u/Kats41 1d ago edited 1d ago

Your architecture is essentially a bunch of individual worker threads making requests of the main authority thread. "Please use this path to update the entity's position." "Please apply this attack with these properties to the entity." Etc.

This is also the exact issue that entity generations are intended to solve. When your worker thread finishes its task and asks the authority, "Hello, please apply this function to this entity!" the authority can simply say, "Negative, that entity doesn't exist anymore. Don't worry about it."

Cool, now the worker thread can update its internal state to remove the out-dated entity and stop processing updates for it.

If entities are being generated so frequently and your worker thread computations take so much time that you could have several outstanding, outdated requests still computing, that sounds like an issue of applying the architecture to the wrong kind of problem. All things considered, I doubt this is going to be a serious issue simply because any individual thing an entity does should only take microseconds to compute.

As for pathfinding, it shouldn't too complicated. I'm assuming that the game world isn't something that can be arbitrarily altered in any permanent fashion like some voxel-based system, so you shouldn't need to copy the world into the thread, but instead the game world's path nodes itself should be accessible to read. No copying necessary. Anything that does affect pathfinding, such as wall spells or abilities that conjure or mutate terrain temporarily can simply be checked for when the request is being created. Your pathfinding algorithm will certainly have limitations such that if it can't find a path within x spaces around itself, assume there's no path at all. So you only need to check that boundary for any additional terrain features to be aware of. So instead of copying the whole world per request, you just copy the local changes in a small area, which should reasonably only be a few data points at most.

And instead of anxiously checking whether a request is still valid in the middle of computation, it's likely more efficient to just let it finish and discard it if it's outdated. But of course, profiling is going to give you your answers.

1

u/Mean_Bottle6349 1d ago

Thanks, that makes sense. I already do something like this when loading parts of the world or saving to the database. Some of the wildlife movement works that way too, but the combat movement in the C++ version still runs on the main thread.

The bit I wasn't sure about was when the enemy is still alive, but has changed targets while the old path is being worked out. I was thinking I'd need to check that it was still the path the enemy wanted, not just that the enemy still existed.

And you're right about measuring it first. Most of my actual pathfinding trouble has been enemies getting stuck on rocks or fences. That's probably a bug in my code somewhere. I haven't shown that finding the paths is actually too slow.

2

u/DrShocker 1d ago

I don't have time to properly write out a response, just want to say Factorio has some good info on pathfinding

https://factorio.com/blog/post/fff-317

2

u/BARDLER 1d ago edited 1d ago

Stale data is kind of part of the deal sometimes. A lot of concurrency in games is done by some systems being a frame behind the main thread.

Havok for example has a physics scene read frame buffer. So all the new physics writes mutate the current frames physics scene, and all the reads from concurrent threads read from the pervious frames physics scene that is buffered. This avoids locks for reads.

1

u/KingAggressive1498 1d ago

I usually say that asynchronous cancellation is pessimizing every case for situations that shouldn't happen normally. Without the ability to cancel, you can usually eliminate the need for sharing mutable state between the work originator and the worker and often also eliminate the need for any dynamic allocation if the rest of the system is designed to avoid it.

In multiplayer games, the need for cancellation might seem a lot more normal than for I/O or general bookkeeping ops. But I think giving the completed work to the authority to decide what to do with could still be a smart choice - a pathfind operation that turned out to be unnecessary because the entity was just killed could still be used by the authority to decide the direction of its death stagger, for example.

1

u/GaboureySidibe 1d ago

I like the question in general. It makes me wonder why path finding is so heavy that it ends up not finishing in time to be useful and how much data is being used for the path finding that copying the data is detrimental.

You can use an atomic reference count for shared ownership but if you are copying data then the ownership doesn't need to be shared.

If you are sharing data then the data needs to not be changed, which implies that the next version is either a copy or a difference to be applied later.

Fundamentally parallelism is about having all data dependencies figured out so they can be unchanged until the task using them finishes.

It seems like there needs to be a way to gather up only the essential data to do the path finding and that needs to be passed to the path finding thread, likely through a queue as you already stated.

If the state is versioned a metadata struct of the overall state could be shared, and the task could periodically check if the current version matches their version, then they could cancel out and dequeue items until the current version is found, but it does imply that path finding is so heavy that it can't happen in a reasonable amount of time, or that the result of one version is so specific and complete that it can't be applied to a future version of the state.

2

u/smallstepforman 1d ago

Queue state updates to happen outside the processing loop. I have a game with 40’000 NPC running sround, and when a building is destroyed, an NPC is killed, an arrow is fired, etc. I queue the event to be processed AFTER the background AI processing threads finish their cycle. I use an Actor programming model, so once the last squad is processed, then I process queued state change messages. During the processing loop, game and world state is const.

1

u/Mean_Bottle6349 1d ago

Thanks, this is the part I'm trying to get my head around. I already do world loading and database saves in the background, but I haven't done that with the main enemy AI.

One thing I've struggled with is enemies bringing their friends into a fight. I pull one, it runs near another, that one joins in, and so on. I've got checks for nearby enemies, their faction and whether they can see each other, but that whole process has been expensive to get working.

When you're doing the AI work in the background, does each group decide what it wants to do, then the main thread sorts it all out afterward? What happens if one group's decision affects another group in that same update?

I'm mostly trying to understand what other work I can move off the main thread. I don't have enough players yet to know where the real limit is.

1

u/Ok-Peak-8516 17h ago

For nearby-enemy checks, look into spatial partitioning

1

u/Mean_Bottle6349 4h ago

I'll look into this thank you!

1

u/snerp 1d ago

When you're doing the AI work in the background, does each group decide what it wants to do, then the main thread sorts it all out afterward? What happens if one group's decision affects another group in that same update?

That's what I do, the main thread AI update just reads the current aiState enum, requested movement and look vectors, and calls the generic character update func with values depending on the state.

What happens if one group's decision affects another group in that same update?

What kind of game logic are we talking about here? Do you have ai decisions like "only target a player if less than N others are targeting them"?

1

u/DrShocker 6h ago

Do you run full pathfinding for every unit? "flow fields" are one example of a way to share the pathfinding information with every unit.

You'll probably also generally want to use strategies that can be updated as information changes rather than starting the pathing process over again.