Skip to content

refactor(creature): replace polling-based pathfinding with event-driven follow system#183

Open
ramon-bernardo wants to merge 12 commits intodevfrom
fix/creature-follow
Open

refactor(creature): replace polling-based pathfinding with event-driven follow system#183
ramon-bernardo wants to merge 12 commits intodevfrom
fix/creature-follow

Conversation

@ramon-bernardo
Copy link
Member

@ramon-bernardo ramon-bernardo commented Feb 15, 2026

Pull Request Prelude

  • I have followed [proper The Forgotten Server code styling][code].
  • I have read and understood the [contribution guidelines][cont] before making this PR.
  • I am aware that this PR may be closed if the above-mentioned criteria are not fulfilled.

otland#4637
otland#4934

Changes Proposed

Refactors the creature pathfinding system from a global polling approach to an event-driven architecture, eliminating redundant task scheduling and improving performance.

Architecture Changes:

  • Removed global pathfinding polling system (pathfindingInterval, pathfindingDelay, Game::updateCreaturesPath())
  • Converted timestamp-based tracking (lastPathUpdate) to event-based scheduling (eventFollowPath)
  • Renamed forceUpdatePath()updateFollowPath() with event-driven behavior

Follower Management Fixes:

  • setFollowCreature() now removes creature from previous target's follower list before switching
  • removeFollowCreature() properly detaches creature from target's follower list
  • Separated attack and follow behaviors - attacking no longer auto-registers as follower

Benefits:

  • Prevents duplicate walk tasks and concurrent path updates
  • Reduces dispatcher queue load and CPU overhead
  • Ensures at most one pending path update per creature
  • More predictable and maintainable pathfinding behavior

Technical Details:

  • Removed redundant dispatcher tasks from player actions and creature movement handlers
  • Added proper null checks in updateFollowCreaturePath()
  • Event cancellation before scheduling prevents race conditions
  • Dual call sites in onCreatureMove() and updateFollowersPaths() handle both normal walking and direct Game::moveCreature() paths (teleportation, admin commands)

Summary by CodeRabbit

  • Refactor

    • Optimized creature pathfinding and movement behavior through internal improvements to path-update mechanisms.
  • Chores

    • Removed pathfinding interval and delay configuration options from settings.

@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaced immediate per-step path recalculation with a scheduled follow-path event: forceUpdatePath() was renamed/refactored to updateFollowPath() which cancels/schedules a follow-path event and early-exits if no follow target; follower add/remove moved into follow-setting routines; game-level periodic path task removed.

Changes

Cohort / File(s) Summary
Creature implementation
src/creature.cpp
Replaced forceUpdatePath() with updateFollowPath() that clears eventFollowPath, early-exits without a follow target, and schedules periodic follow-path updates via the scheduler. Moved per-step update timing in onWalk(), updated updateFollowersPaths(), updateFollowCreaturePath(), setAttackedCreature(), setFollowCreature(), and removeFollowCreature() to use explicit follower lifecycle and the new scheduling approach.
Creature public API / state
src/creature.h
Removed timestamp-based lastPathUpdate; added uint32_t eventFollowPath, inline completeEventFollowWalk(), and updateFollowPath() declaration. Public API renamed/repurposed from forceUpdatePath() to updateFollowPath().
Game implementation / API
src/game.cpp, src/game.h
Removed PATHFINDING periodic scheduling and updateCreaturesPath(size_t). Eliminated dispatcher tasks enqueuing path updates on attack/follow actions. updateCreatureWalk() now calls creature->completeEventFollowWalk() after goToFollowCreature(). Public Game::updateCreaturesPath declaration removed.
Configuration
config.lua.dist, src/configmanager.cpp, src/configmanager.h
Removed pathfindingInterval and pathfindingDelay config entries, their loading code, and corresponding enum values from ConfigManager::integer_config_t.
Player movement handling
src/player.cpp
Removed code that dispatched g_game.updateCreatureWalk from Player::onCreatureMove; moving-creature branch no longer enqueues direct creature-walk updates.

Sequence Diagram(s)

sequenceDiagram
  participant Player
  participant Creature
  participant FollowTarget as "FollowTarget (Creature)"
  participant Scheduler

  Player->>Creature: setFollowCreature(newTarget)
  Creature->>FollowTarget: remove/add follower (old/new)
  Creature->>Creature: updateFollowPath() -- cancel existing eventFollowPath
  Creature->>Scheduler: schedule follow-path event (store id in eventFollowPath)
  Scheduler-->>Creature: invoke scheduled follow-path task
  Creature->>FollowTarget: getPathTo(target)
  Creature->>Creature: goToFollowCreature()
  Creature->>Creature: completeEventFollowWalk()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I hopped and nudged the old path scheme,
Now events hum softly like a stream,
I add and drop who follows me,
A scheduled step, then roam with glee,
Carrots mark the runnable dream. 🥕

🚥 Pre-merge checks | ✅ 3 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately describes the main refactoring: replacing polling-based pathfinding with an event-driven follow system, which aligns with the primary changes across multiple files.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into dev

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/creature-follow

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/creature.cpp (1)

798-805: ⚠️ Potential issue | 🟠 Major

Pre-existing: the condition on line 799 appears inverted compared to onWalk (line 183).

In onWalk, lastPathUpdate < OTSYS_TIME() means "the delay has elapsed, dispatch a task." Here, the same condition causes a continue (skip), meaning followers whose timer expired are skipped, while followers with a pending future timestamp get a redundant task dispatched. This seems backwards and would cause the exact kind of duplicate/unnecessary task scheduling this PR aims to eliminate.

Should this be >= instead of <?

-		if (follower->lastPathUpdate < OTSYS_TIME()) {
+		if (follower->lastPathUpdate >= OTSYS_TIME()) {
 			continue;
 		}

Based on learnings, lastPathUpdate stores a future timestamp representing when the next path update should occur, not when the last update happened (a rename to nextPathUpdate was previously suggested for clarity).

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/creature.cpp`:
- Line 799: Remove the unused local variable declaration "const auto size =
followers.size();" in src/creature.cpp (it was left from the removed
per-follower scheduling logic); delete that line so there are no unused
variables referencing followers, ensuring no other code expects "size" in the
surrounding function or block.
🧹 Nitpick comments (1)
src/creature.h (1)

396-396: Consider renaming lastPathUpdate to nextPathUpdate.

This variable stores a future timestamp (when the next update is allowed), not the time of the last update. The current name is misleading. Based on learnings, the maintainer has previously agreed to rename lastPathUpdatenextPathUpdate in both src/creature.h and src/creature.cpp (from PR #166).

♻️ Suggested rename

In src/creature.h:

-	int64_t lastPathUpdate = 0;
+	int64_t nextPathUpdate = 0;

And correspondingly in src/creature.cpp (line 136 and 141):

-	if (lastPathUpdate >= OTSYS_TIME()) {
+	if (nextPathUpdate >= OTSYS_TIME()) {
-	lastPathUpdate = OTSYS_TIME() + getNumber(ConfigManager::PATHFINDING_DELAY);
+	nextPathUpdate = OTSYS_TIME() + getNumber(ConfigManager::PATHFINDING_DELAY);

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/creature.cpp (1)

751-759: Consider canceling eventFollowPath when unfollowing.

After followCreature.reset(), any pending scheduled event from updateFollowPath() becomes an unnecessary no-op. While it's harmless (the handler will find no follow creature and bail out), explicitly canceling it here would be more consistent with the cleanup intent and avoid a wasted dispatcher task.

♻️ Suggested cleanup
 void Creature::removeFollowCreature()
 {
 	if (const auto& oldFollow = getFollowCreature()) {
 		oldFollow->removeFollower(asCreature());
 	}
 
+	if (eventFollowPath != 0) {
+		g_scheduler.stopEvent(eventFollowPath);
+		eventFollowPath = 0;
+	}
+
 	followCreature.reset();
 	onUnfollowCreature();
 }

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/creature.cpp (1)

343-346: Minor redundancy: updateFollowPath() may be called twice per followed creature's step.

When creature B moves, follower A calls updateFollowPath() here (via onCreatureMove), and then again when B's onWalk()updateFollowersPaths() iterates B's followers. The second call harmlessly cancels and replaces the first event, so correctness is fine — just a minor inefficiency.

You could consider guarding one of the two call sites, but given that updateFollowPath() is idempotent (cancel + reschedule), this is low priority.

@ramon-bernardo
Copy link
Member Author

🧹 Nitpick comments (1)

src/creature.cpp (1)> 343-346: Minor redundancy: updateFollowPath() may be called twice per followed creature's step.

When creature B moves, follower A calls updateFollowPath() here (via onCreatureMove), and then again when B's onWalk()updateFollowersPaths() iterates B's followers. The second call harmlessly cancels and replaces the first event, so correctness is fine — just a minor inefficiency.
You could consider guarding one of the two call sites, but given that updateFollowPath() is idempotent (cancel + reschedule), this is low priority.

@coderabbitai Not always when calling onCreatureMove does it call onWalk beforehand; note that we can use Game:moveCreature, which I see as requiring the updateFollowPath

@ramon-bernardo
Copy link
Member Author

@coderabbitai review the changes, check quality and rewrite pull request name/summay according to changes, again

@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

Tip

For best results, initiate chat on the files or code changes.

@ramon-bernardo, excellent point! You're absolutely right — I didn't account for the Game::moveCreature() code path. When movement happens via Game::moveCreature() directly (without going through onWalk()), the updateFollowPath() call in onCreatureMove() is indeed necessary to ensure followers react to the target's position change.

Thank you for the clarification — the dual call sites are intentional and handle different movement entry points correctly. My nitpick was incomplete. 👍


✏️ Learnings added
Learnt from: ramon-bernardo
URL: https://github.com/atlas-kit/atlas/pull/183

Timestamp: 2026-02-15T21:09:54.022Z
Learning: In the atlas codebase, creature movement can occur via two paths: through `Creature::onWalk()` (normal walking) or directly via `Game::moveCreature()` (e.g., teleportation, admin commands). Both paths must trigger follower path updates, which is why `updateFollowPath()` is called in both `Creature::onCreatureMove()` and `Creature::updateFollowersPaths()`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: ranisalt
Repo: atlas-kit/atlas PR: 166
File: src/creature.cpp:796-803
Timestamp: 2026-01-31T11:49:01.535Z
Learning: In `src/creature.cpp` and `src/creature.h`, the variable `lastPathUpdate` stores a future timestamp representing when the next path update should occur, not when the last update happened. The maintainer prefers renaming it to `nextPathUpdate` for clarity.

@coderabbitai
Copy link

coderabbitai bot commented Feb 15, 2026

@ramon-bernardo, I'll review the changes again and provide updated PR title/summary recommendations.

Code Quality Assessment ✅

The implementation remains solid with excellent architectural improvements:

  • Event-driven architecture - Replaces polling with scheduled events
  • Proper resource management - Event cleanup and follower list maintenance
  • Performance optimization - Eliminates global pathfinding overhead
  • Clear separation of concerns - Attack vs. follow behaviors now distinct
  • Thread-safety - Proper event cancellation prevents race conditions

No issues found. The code is production-ready.


Recommended PR Title

refactor(creature): replace polling-based pathfinding with event-driven follow system

Alternative:

refactor(creature): optimize follower pathfinding with event-based scheduling and proper cleanup

Recommended PR Summary

Refactors the creature pathfinding system from a global polling approach to an event-driven architecture, eliminating redundant task scheduling and improving performance.

**Architecture Changes:**
- Removed global pathfinding polling system (`pathfindingInterval`, `pathfindingDelay`, `Game::updateCreaturesPath()`)
- Converted timestamp-based tracking (`lastPathUpdate`) to event-based scheduling (`eventFollowPath`)
- Renamed `forceUpdatePath()``updateFollowPath()` with event-driven behavior

**Follower Management Fixes:**
- `setFollowCreature()` now removes creature from previous target's follower list before switching
- `removeFollowCreature()` properly detaches creature from target's follower list
- Separated attack and follow behaviors - attacking no longer auto-registers as follower

**Benefits:**
- Prevents duplicate walk tasks and concurrent path updates
- Reduces dispatcher queue load and CPU overhead
- Ensures at most one pending path update per creature
- More predictable and maintainable pathfinding behavior

**Technical Details:**
- Removed redundant dispatcher tasks from player actions and creature movement handlers
- Added proper null checks in `updateFollowCreaturePath()`
- Event cancellation before scheduling prevents race conditions
- Dual call sites in `onCreatureMove()` and `updateFollowersPaths()` handle both normal walking and direct `Game::moveCreature()` paths (teleportation, admin commands)

Would you like me to update the PR title and description with these recommendations using the GitHub CLI?


🧠 Learnings used
Learnt from: ranisalt
Repo: atlas-kit/atlas PR: 166
File: src/creature.cpp:796-803
Timestamp: 2026-01-31T11:49:01.535Z
Learning: In `src/creature.cpp` and `src/creature.h`, the variable `lastPathUpdate` stores a future timestamp representing when the next path update should occur, not when the last update happened. The maintainer prefers renaming it to `nextPathUpdate` for clarity.

Learnt from: ramon-bernardo
Repo: atlas-kit/atlas PR: 0
File: :0-0
Timestamp: 2026-02-15T21:09:54.022Z
Learning: In the atlas codebase, creature movement can occur via two paths: through `Creature::onWalk()` (normal walking) or directly via `Game::moveCreature()` (e.g., teleportation, admin commands). Both paths must trigger follower path updates, which is why `updateFollowPath()` is called in both `Creature::onCreatureMove()` and `Creature::updateFollowersPaths()`.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ramon-bernardo ramon-bernardo changed the title fix(creature): prevent duplicate walk tasks by refining follower management refactor(creature): replace polling-based pathfinding with event-driven follow system Feb 15, 2026
@ramon-bernardo
Copy link
Member Author

These changes seem like the best of both worlds.

By using scheduled events, we avoid flooding the dispatcher with tasks; for instance, when a creature moves multiple times within EVENT_CHECK_CREATURE_INTERVAL (currently 100ms, but adjustable), or when a pathing recalculation is triggered during that window time. We aren't just piling up tasks, and we aren't bound by a global interval either.

The creatures feel much more 'alive' now; however, this does come with an increased CPU cost.

I haven't quite mastered the best way to profile this accurately yet; @gesior @NRH-AA @ranisalt would you mind sharing your feedback here? Similar to how PRs are handled on otland

@atlas-kit atlas-kit deleted a comment from coderabbitai bot Feb 15, 2026
@gesior
Copy link

gesior commented Feb 17, 2026

Looks better than previous code.

What most of big OTSes use:
game.cpp function checkFollow that is called every 50 ms, it processes set checkFollowSet.
It picks from that set creatures which has 'next step' within 50 ms - to skip creatures that won't move in next 50 ms, we want to get walk path milliseconds before creature makes step, so there is lower chance that something step into this path - and put them into another set. Then it process that set and find path for each creature from it.
Almost every call to goToFollowCreature/function that calls it in creature.cpp/game.cpp is replaced with adding Creature to checkFollowSet.
That way you have super fast monsters reaction, but path for given creature is recalculated maximum 1 time per step.
It also reduces number of dispatcher/scheduler tasks heavily.

And to save CPU, goToFollowCreature is split into 2 functions. goToFollowCreature calculates path and goToFollowCreatureContinue executes real walking code (code that can modify tile/map/creature). goToFollowCreature does not modify anything in tile/map/creature, so it can be run in parallel on multiple CPU cores. Even cheapest dedics and VPSes have 4 cores and path finding on high exp evo servers uses 20-40% of CPU used by OTS, so by running it on 4-16 cores you can save a lot of CPU on dispatcher thread.

OTS Stats from fast exp evo with 600+ online:

[17/2/2026 15:14:17]
Thread: 1 Cpu usage: 81.1385% Idle: 18.8826% Other: -0.0210705% Players online: 0
 Time (ms)     Calls     Rel usage %    Real usage % Description
      8684       300       35.67780%       28.94842% std::bind(&Game::checkCreatures, this)
      5526    401353       22.70212%       18.42015% std::bind(&Game::checkCreatureWalk, &g_game, id)
      2376    171826        9.76364%        7.92207% std::bind(&Game::updateCreatureWalkFromCreatureMove, &g_game, getID())
      1975     44696        8.11405%        6.58362% &Game::playerSay
       862     40726        3.54253%        2.87435% &Game::playerUseItemEx
       831       120        3.41683%        2.77237% std::bind(&Game::checkDecay, this)
       819     31878        3.36501%        2.73032% &Game::playerMove
       776     28051        3.19004%        2.58835% &Game::playerUseBattleWindow

171.826 events updateCreatureWalkFromCreatureMove called from 401.353 monster/player steps in 30 seconds - they all can be converted to just 20 calls per second to checkFollow.
I will update this OTS engine soon to 'multithread path finding' and post updated OTS Stats.

This is official Kondra code for that - a bit messy, as you have both codes in 1 function that is called with true/false to change logic between dispatcher thread and workers thread:

void Game::checkFollow(bool thread) {
	static std::mutex checkFollowMutex;
	static std::set<Creature *>::iterator it;
	static std::set<Creature *> checkFollowSetNew;

	if (!thread) {
		g_scheduler.addEvent(createSchedulerTask(EVENT_PATH_FINDING, std::bind(&Game::checkFollow, this, false)));
		checkFollowSetNew = std::move(checkFollowSet);
		checkFollowSet.clear();

		auto it2 = checkFollowSetNew.begin();
		int skipped = 0;
		while (it2 != checkFollowSetNew.end()) {
			// queue walking creatures path finding for next checkFollow
			if (!(*it2)->isRemoved() && (*it2)->getWalkDelay() > EVENT_PATH_FINDING) {
				checkFollowSet.insert((*it2));
				it2 = checkFollowSetNew.erase(it2);
				skipped++;
			} else {
				++it2;
			}
		}

		it = checkFollowSetNew.begin();
		g_pathfinding.runTask(std::bind(&Game::checkFollow, this, true));

		for (auto &creature : checkFollowSetNew) {
			creature->goToFollowCreatureContinue();
			ReleaseCreature(creature);
		}
	} else {
		while (true) {
			checkFollowMutex.lock();
			if (it == checkFollowSetNew.end()) {
				checkFollowMutex.unlock();
				break;
			}
			Creature *creature = *it;
			++it;
			checkFollowMutex.unlock();
			creature->goToFollowCreature();
		}
	}
}

virtual void onAttacking(uint32_t) {}

virtual void forceUpdatePath();
void updateFollowPath();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider marking this final if it shouldn't be overridden

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants