Build something · 20–30 min exercise

Build a mover: plan a path and verify progress

Understand the product-specific path API, issue one walking request and avoid false success.

After this lesson

You can distinguish path found, request accepted, actual movement and arrival, and know which component owns retries.

Download DPB2 source kit ↓ 8 complete projects · source only · .NET 8 for Windows (net8.0-windows)

Preview before you move#

This is an independent lab using the supplied AcademyBot from the source kit. You do not need to implement FollowBot or complete its walkthrough first. Use the setup/loading reference if this is your first DLL build.

  1. Build and install AcademyMover and AcademyBot.
  2. Select AcademyMover and AcademyBot, enter a loaded area and Start.
  3. Open AcademyMover's configuration panel and click Preview path once.
  4. The next bot tick looks for a targetable visible area transition and requests a path toward it. No movement is sent from this preview button.
Check your result

A Planned N path points diagnostic when a suitable path exists. If there is no transition, the sample reports that and stops the attempt. A button press is not a promise that a route exists.

Use the path API for your product#

ExilePather.Reload prepares navigation data; it is not a boolean success result. Check IsReady before pathfinding.

DPB1 uses a PathfindingCommand passed by ref to FindPath. DPB2 FindPath returns a path result with Success and Path. The snippet and downloaded source switch with the selected product.

CurrentCommand exposes the latest command/result for consumers. Its type differs between DPB1 and DPB2; do not paste one product's declaration into the other project.

The sample skips points very close to the player and refuses a first candidate more than 35 units away. These are teaching limits, not a complete movement algorithm. No candidate means no action.

Where this goes: AcademyMover.TryPlan in the selected DPB2 source project. The preview caller requests ExilePather.Reload before planning.

C# · from the source kit
private bool TryPlan(Vector2i position, out Vector2i next)
{
    next = default(Vector2i);
    CurrentCommand = null;
    if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead || !ExilePather.IsReady)
    {
        Log.Info("[AcademyMover] Planning skipped: player or navigation data is not ready.");
        return false;
    }
    var start = LokiPoe.MyPosition;
    if (start.Distance(position) < 5)
    {
        Log.Info("[AcademyMover] Already close to the destination. No step requested.");
        return false; // Already close: no movement command was sent.
    }

    var command = ExilePather.FindPath(start, position);
    CurrentCommand = command;
    if (!command.Success)
    {
        Log.Info("[AcademyMover] No successful path result. No step requested.");
        return false;
    }
    var path = command.Path;
    if (path == null || path.Count == 0)
    {
        Log.Info("[AcademyMover] The returned path is empty.");
        return false;
    }
    Log.Info("[AcademyMover] Planned " + path.Count + " path points. Planning does not move the player.");
    // Follow the returned path, starting near the player. Refuse a distant first hop.
    var candidates = path.Where(point => start.Distance(point) >= 6).Take(1).ToArray();
    if (candidates.Length == 0 || start.Distance(candidates[0]) > 35)
    {
        Log.Info("[AcademyMover] No next point within the sample's 6-to-35-unit step limits.");
        return false;
    }
    next = candidates[0];
    return true;
}

MoveTowards submits one command#

The caller owns the input session. The mover does not enable a session behind the caller's back. It also needs a Move-only action bound on the skill bar.

After a valid path, the mover submits one movement request using the Move-only slot. It checks the returned UseResult and returns true only for None.

Do not replace Move-only with an arbitrary attack binding. Do not use a path result as proof that input was sent, or an accepted input as proof that the character moved.

Where this goes: AcademyMover.MoveTowards. AcademyBot never calls it; AcademyWorkshop calls it only after explicit opt-in.

C# · from the source kit
public bool MoveTowards(Vector2i position, params dynamic[] user)
{
    // The caller owns the input session. A mover must not enable it behind its back.
    if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
        !LokiPoe.ProcessHookManager.IsEnabled) return false;
    var area = LokiPoe.LocalData.AreaHash;
    var areaId = LokiPoe.CurrentWorldArea?.Id;
    if (area == 0 || areaId == null) return false;
    Vector2i next;
    if (!TryPlan(position, out next)) return false;
    if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
        LokiPoe.LocalData.AreaHash != area || LokiPoe.CurrentWorldArea?.Id != areaId ||
        !LokiPoe.ProcessHookManager.IsEnabled) return false;
    var move = LokiPoe.InGameState.SkillBarHud.LastBoundMoveSkill;
    if (move == null || !move.CanUse())
    {
        Log.Info("[AcademyMover] Bind the Move-only action to the skill bar first.");
        return false;
    }
    var result = LokiPoe.InGameState.SkillBarHud.UseAt(move.Slot, false, next);
    Log.Info("[AcademyMover] Movement request: " + result);
    return result == LokiPoe.InGameState.UseResult.None;
    // True means the request was accepted, NOT that the destination was reached.
}
Check your result

A successful API request produces Movement request: None. The workshop then measures the player again and reports displacement and remaining distance. Zero displacement is still possible.

A useful mover needs an honest caller#

  • Already close: check remaining distance before asking for another step. The sample's close threshold is five units.
  • Not ready or no route: return without input; let the owner decide whether and when a new attempt is appropriate.
  • Request accepted: yield, then compare fresh position or distance. Do not increment a success counter solely because the API returned None.
  • No progress: cap retries and log the reason. Repeating the same command indefinitely is not recovery.
  • Area changed, death or disconnect: abandon stale destinations and reacquire state.
  • Stop: clear pending requests and release only input owned by your workflow.
Keep in mind

AcademyMover intentionally does not implement skill-based travel, obstacle learning, automatic area transitions or a full stuck-recovery strategy. You get a working one-step foundation and the contract to extend, not a claim of production navigation.

Complete source files

These are the exact C# files in the DPB2 download. Use the complete ZIP for project settings, references, build commands and installation instructions.

AcademyMover/AcademyMover.cs
Complete file · DPB2
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Controls;
using DreamPoeBot.Common;
using DreamPoeBot.Loki.Bot;
using DreamPoeBot.Loki.Bot.Pathfinding;
using DreamPoeBot.Loki.Common;
using DreamPoeBot.Loki.Game;
using DreamPoeBot.Loki.Game.Objects;
using log4net;
using Message = DreamPoeBot.Loki.Bot.Message;
using DreamPoeBot.Loki.Bot.Pathfinding.RDWrapper.Types;

namespace DeveloperAcademy
{
    public sealed class AcademyMover : IPlayerMover
    {
        private static readonly ILog Log = Logger.GetLoggerInstanceForType();
        private UserControl _control;
        private volatile bool _previewRequested;
        public string Name => "AcademyMover";
        public string Description => "Preview paths, or send one walking command when called by the opt-in workshop.";
        public string Author => "Your name";
        public string Version => "1.0.0";
        public JsonSettings Settings => null;
        public PathResult? CurrentCommand { get; private set; }
        public UserControl Control
        {
            get
            {
                if (_control != null) return _control;
                var panel = new StackPanel { Margin = new System.Windows.Thickness(12) };
                panel.Children.Add(new TextBlock { Text = "While AcademyBot runs, preview a path to a visible transition. No movement is performed.",
                    TextWrapping = System.Windows.TextWrapping.Wrap });
                var button = new Button { Content = "Preview path once", Margin = new System.Windows.Thickness(0, 10, 0, 0) };
                // The UI thread only queues a request. Read game state on the next bot tick.
                button.Click += (sender, args) => _previewRequested = true;
                panel.Children.Add(button);
                _control = new UserControl { Content = panel };
                return _control;
            }
        }
        public void Initialize() { }
        public void Deinitialize() { Stop(); }
        public void Start() { Stop(); Log.Info("[AcademyMover] Started in preview mode."); }
        public void Stop() { _previewRequested = false; CurrentCommand = null; }
        public MessageResult Message(Message message) => MessageResult.Unprocessed;
        public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);

        public void Tick()
        {
            if (!_previewRequested) return;
            _previewRequested = false;
            if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead)
            {
                Log.Info("[AcademyMover] Preview skipped: enter a loaded area with a living player.");
                return;
            }
            var target = LokiPoe.ObjectManager.GetObjectsByType<AreaTransition>()
                .Where(item => item.IsTargetable).OrderBy(item => item.Distance).FirstOrDefault();
            if (target == null)
            {
                Log.Info("[AcademyMover] No targetable transition is currently available.");
                return;
            }
            ExilePather.Reload();
            Vector2i next;
            TryPlan(target.Position, out next);
        }

        #region PlanPath
        private bool TryPlan(Vector2i position, out Vector2i next)
        {
            next = default(Vector2i);
            CurrentCommand = null;
            if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead || !ExilePather.IsReady)
            {
                Log.Info("[AcademyMover] Planning skipped: player or navigation data is not ready.");
                return false;
            }
            var start = LokiPoe.MyPosition;
            if (start.Distance(position) < 5)
            {
                Log.Info("[AcademyMover] Already close to the destination. No step requested.");
                return false; // Already close: no movement command was sent.
            }

            var command = ExilePather.FindPath(start, position);
            CurrentCommand = command;
            if (!command.Success)
            {
                Log.Info("[AcademyMover] No successful path result. No step requested.");
                return false;
            }
            var path = command.Path;
            if (path == null || path.Count == 0)
            {
                Log.Info("[AcademyMover] The returned path is empty.");
                return false;
            }
            Log.Info("[AcademyMover] Planned " + path.Count + " path points. Planning does not move the player.");
            // Follow the returned path, starting near the player. Refuse a distant first hop.
            var candidates = path.Where(point => start.Distance(point) >= 6).Take(1).ToArray();
            if (candidates.Length == 0 || start.Distance(candidates[0]) > 35)
            {
                Log.Info("[AcademyMover] No next point within the sample's 6-to-35-unit step limits.");
                return false;
            }
            next = candidates[0];
            return true;
        }
        #endregion

        #region MoveOneStep
        public bool MoveTowards(Vector2i position, params dynamic[] user)
        {
            // The caller owns the input session. A mover must not enable it behind its back.
            if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
                !LokiPoe.ProcessHookManager.IsEnabled) return false;
            var area = LokiPoe.LocalData.AreaHash;
            var areaId = LokiPoe.CurrentWorldArea?.Id;
            if (area == 0 || areaId == null) return false;
            Vector2i next;
            if (!TryPlan(position, out next)) return false;
            if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
                LokiPoe.LocalData.AreaHash != area || LokiPoe.CurrentWorldArea?.Id != areaId ||
                !LokiPoe.ProcessHookManager.IsEnabled) return false;
            var move = LokiPoe.InGameState.SkillBarHud.LastBoundMoveSkill;
            if (move == null || !move.CanUse())
            {
                Log.Info("[AcademyMover] Bind the Move-only action to the skill bar first.");
                return false;
            }
            var result = LokiPoe.InGameState.SkillBarHud.UseAt(move.Slot, false, next);
            Log.Info("[AcademyMover] Movement request: " + result);
            return result == LokiPoe.InGameState.UseResult.None;
            // True means the request was accepted, NOT that the destination was reached.
        }
        #endregion
    }
}

Keep building

Look up a specific DPB2 API →
Download integrity and validation scope

Source kit: 26 files, 30,744 bytes. No client binaries or credentials.

SHA-256: b8877783cae166a0499a0bb400cfe835a9f2db2cbe20e328c36760e3ce32ad59

Compile baseline: DPB1 0.3.29.47 / DPB2 0.4.5.88. Compilation is not a live game test. Follow the lesson's manual checks in your own test setup.