One project, three checkpoints#
We will build your own FollowBot for DPB1, not install a finished bot. This page creates the host; part 2 adds priorities and plugin integration; part 3 locates the party leader and follows them in the same loaded area. Each page gives selected fragments and an implementation exercise, not a complete class to paste.
The pattern is the one used by real task-driven bots: DPB drives the bot; the bot drives its chosen components; tasks decide what deserves the current turn; the mover requests movement. A standalone bot does not have to use this pattern, but it is useful when extensions must cooperate.
Unlike the independent Academy labs, this is your own project. Build and load one small change at a time; the setup reference is optional if you already know that workflow. There is no finished FollowBot in the download.
- First success: your bot appears in the selector and logs one Start and one Stop.
- Finished learning milestone: follow a visible party leader with distance hysteresis, bounded attempts and cancellation.
- Not in this milestone: automatic login, party invitations, portals, resurrection, loot or autonomous combat. Those must not happen accidentally.
Create a library, not an application#
- Create a C# class-library project named YourFollowBot, outside the client installation. Use .NET Framework 4.8 (net48), WPF and x64. DPB1 needs the .NET Framework 4.8 targeting pack; DPB2 needs .NET 8 Windows desktop support.
- Use the project-reference setup in the setup guide: reference your matching DreamPoeBot.exe, log4net.dll and Newtonsoft.Json.dll; set Copy Local / Private to false. Do not reference a third-party bot DLL.
- Create FollowBot.cs with a public class implementing IBot. Let the IDE generate the interface members, then implement them using the checklist below. Do not leave NotImplementedException in any member.
- Build a library DLL. With your test client closed, place it at Plugins/YourFollowBot/YourFollowBot.dll. Restart the client and select the Name you supplied in the bot selector. This is a bot selection, not a plugin checkbox.
Your bot is discoverable without shipping the client assemblies beside it. Discovery alone does not mean the execution loop exists.
Give each member a job#
- Name, Description, Author, Version: identify your own bot. Use an original name, not the name of an installed bot.
- Settings / Control: initially null is acceptable for the read-only milestone. Before movement, add FollowEnabled (false by default), a Stop/Start rearm workflow and a visible status using the settings guide.
- Initialize / Deinitialize: extension lifetime only. Do not read a character here; Deinitialize should stop any owned run and remove subscriptions.
- Fields: one TaskManager, one OwnedLoop helper and per-session state. OwnedLoop holds the coroutine and its running flag; do not create a second coroutine in your bot.
- Start / Tick / Stop: own the run, as described below. Message / Logic: route requests to the task manager using part 2. Until then return Unprocessed / Unprovided.
- OwnedLoop runs the async pass you give it, then yields. Your IBot.Tick calls the helper once; no background thread is created.
The names in the fragments are ordinary helper names, not methods supplied by DPB. Keep helpers in your FollowBot class or in a small helper class you call explicitly.
Checkpoint A: discover a bot that only logs#
- Implement IBot metadata and configuration members as described above. Initially use null for Settings and Control; unknown Message/Logic requests return Unprocessed/Unprovided.
- Make Start set a private _started flag and log a unique build marker. A repeated Start must be rejected or ignored explicitly. Tick returns immediately when !_started.
- Make Stop clear _started first and log only when a run was active. Deinitialize calls Stop. Do not start plugins, tasks, routines, a mover or game input yet.
- Build, install and select YOUR bot. Start → Stop → Stop → Start must produce two start logs and one stop log, with no movement. This first result proves discovery and lifecycle wiring, not the later follower behavior.
You can identify your new DLL from its log marker and stop it harmlessly. Only continue once this checkpoint works.
Every later checkpoint builds on this same class. If you already have a reliable IBot host, compare the ownership rules and skip the introductory logging exercise.
Checkpoint B: own one loop that yields#
Add this small helper beside your bot class. It owns only a coroutine; it is not a second bot and does not start other components.
First pass it a read-only action that increments a counter and returns Task.CompletedTask. A lambda such as () => { _passes++; return Task.CompletedTask; } is a function the helper will call later, not a background job.
- Add one readonly OwnedLoop field to your bot. In Start, after the duplicate-start check, call its Start with the counter pass; set _started only after success.
- In Tick, return when !_started; otherwise call the helper's Tick once inside try/catch. On failure call your bot's Stop, log the exception and stop the selected bot through BotManager.Stop.
- In Stop, clear _started before calling the helper's Stop. Make Deinitialize use this same cleanup path. Do not replace the helper's yielding loop with Task.Run.
- Count passes without logging every frame. Stop and call Tick again: the counter must not change. Start again: one active loop, not two.
Where this goes: A separate OwnedLoop.cs in your YourFollowBot project, in the same namespace as your bot. Imports: System, System.Threading.Tasks, DreamPoeBot.Loki.Coroutine.
// Owns only the cooperative loop, not plugins, tasks, input or a complete bot.
internal sealed class OwnedLoop
{
private Coroutine _loop;
private bool _running;
public void Start(Func<Task> pass)
{
if (_running) throw new InvalidOperationException("Stop before starting again.");
if (pass == null) throw new ArgumentNullException(nameof(pass));
_running = true;
try { _loop = new Coroutine(() => Run(pass)); }
catch { _running = false; throw; }
}
private async Task Run(Func<Task> pass)
{
while (_running)
{
await pass();
await Coroutine.Yield();
}
}
public void Tick()
{
if (!_running) return;
try
{
if (_loop.IsFinished) throw new InvalidOperationException("Loop ended unexpectedly.");
_loop.Resume();
}
catch { Stop(); throw; } // Host must also clean up its components.
}
public void Stop()
{
_running = false;
var loop = _loop;
_loop = null;
loop?.Dispose();
}
}The coroutine cooperates with DPB and stops even if its current pass is suspended. No component or input work has been introduced yet.
Add an await Coroutine.Yield() inside the counter pass. Observe that the same pass now spans ticks; Stop must still prevent its continuation.
Checkpoint C: add components without losing ownership#
Only now add the TaskManager and component lifecycle. Keep an explicit list of cleanup callbacks for this run. Register a cleanup BEFORE invoking the corresponding Start, because Start may fail after partly initializing the component.
Take a snapshot of enabled plugins and the selected routine/mover for cleanup, and keep that selection fixed until Stop. Manager Stop loops can abort on the first exception: wrapping one manager call is not enough to guarantee every component is attempted.
For the first read-only checkpoint, disable unrelated plugins and use passive components. A general follower must not silently select extensions by Academy class names.
- Start: clear old session values, Reset the task manager and register the base task list. Validate required configuration before starting components.
- Start enabled plugins first, allowing their Start callbacks to insert tasks into that same manager. Then start the supported routine/mover and, finally, TaskManager.Start. Capture the final task list for individual cleanup BEFORE starting those tasks.
- After successful startup, call OwnedLoop.Start(() => RunOnePass(_tasks, this)). Mark the host started only when all required startup work succeeded.
- Tick: invalidate stale context on loading, death, pause or an area change BEFORE components run. Then tick the supported components and task manager, followed by OwnedLoop.Tick.
- If Start partly succeeds or Tick fails, use the same Stop path as normal shutdown. Request BotManager.Stop for a fatal tick failure so the selected bot does not appear to remain running.
A probe plugin can insert a task before TaskManager.Start. A failed startup does not leave a live coroutine or an untracked component.
Try every cleanup, even if one fails#
The helper returns errors instead of letting the first failure skip later cleanup. A callback is an Action, for example () => task.Stop(). For tasks and plugins, supply one callback per captured instance, not one manager-wide Stop callback.
In your bot's Stop: clear _started and old intent first, detach the cleanup list so repeated Stop cannot run it twice, then call StopEach. Put OwnedLoop.Stop first, followed by the owned task and component callbacks. For the later input milestone, include input release as the final callback. Log the collected errors afterward.
For partial startup, retain callbacks only for components whose Start was attempted, including the one that threw. Stop implementations must tolerate partial initialization. Do not clear the list until it has been detached for cleanup.
Where this goes: A static helper inside your bot (or a helper class). Imports: System, System.Collections.Generic. Supply callbacks for the exact component instances owned by the run.
public static IReadOnlyList<Exception> StopEach(params Action[] cleanup)
{
var errors = new List<Exception>();
foreach (var stop in cleanup)
{
try { stop(); }
catch (Exception error) { errors.Add(error); }
}
return errors; // Log AFTER all cleanup attempts, not instead of them.
}A deliberate exception in one probe Stop is recorded, while the next probe still stops. Stop before Start and repeated Stop have no effect.
Pass three callbacks that append A, throw, and append C. Check that A and C both appear and exactly one exception is returned.
Allow a plugin to handle a turn#
The first provider returning Provided owns this hook call. Later plugins are not called for that same hook. Unprovided lets the next plugin try.
hook_ingame is a shared convention in many existing bots, not a built-in automatic event. DPB will not emit it for you. A general host can also dispatch hook_login_screen and hook_character_selection in the corresponding states; leave those disabled in this lesson so no login plugin acts unexpectedly.
Where this goes: Add RunHook as a helper called by your bot. Imports: System.Linq, System.Threading.Tasks, DreamPoeBot.Loki.Bot.
public static async Task<bool> RunHook(string id, object sender)
{
var request = new Logic(id, sender);
foreach (var plugin in PluginManager.EnabledPlugins.ToArray())
if (await plugin.Logic(request) == LogicResult.Provided)
return true;
return false;
}Use two read-only probe plugins. Have the first return Provided, then Unprovided. Observe whether the second is reached. Do not enable an unrelated action plugin to test dispatch.
Run one pass, then yield to DPB#
This pass preserves the familiar ordering: ready game → plugin hook → prioritized tasks. An unavailable or dead player is not a reason to throw. Recheck readiness after an awaited hook because the world may have changed.
Keep context invalidation in Tick as well: a pending async task can span many ticks before another pass starts. Every action task must also revalidate its own destination before sending input.
The fragment does not open an input session or move. Start with an empty task list or a read-only report task. OwnedLoop performs the yield even when this pass has no work. Your bot's _started flag controls its lifecycle; the helper's running flag controls its single coroutine.
Where this goes: Add RunOnePass beside RunHook in your bot. Imports: System.Threading.Tasks, DreamPoeBot.Loki.Bot and DreamPoeBot.Loki.Game. Pass () => RunOnePass(_tasks, this) to your existing OwnedLoop instance's Start method. OwnedLoop already awaits each pass, yields and owns the coroutine running flag. Do not add another loop, coroutine or Yield around this call; IBot.Tick still calls the same helper's Tick once.
public static async Task RunOnePass(TaskManager tasks, object sender)
{
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
LokiPoe.LocalData.AreaHash == 0 || LokiPoe.InstanceInfo.IsGamePaused) return;
if (await RunHook("hook_ingame", sender)) return;
// A hook may await through a loading screen. Read readiness again.
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
LokiPoe.LocalData.AreaHash == 0 || LokiPoe.InstanceInfo.IsGamePaused) return;
await tasks.Run(TaskGroup.Enabled, RunBehavior.UntilHandled);
}Read-only hooks/tasks run only when a living player is loaded and the game is not paused. Stop ends the loop, including while a hook is suspended.
Checkpoint 1: prove your host, not somebody else's DLL#
- Log a unique build marker in Start, a throttled heartbeat in Tick and a Stop message. Rebuild and replace the DLL with the client closed; confirm YOUR marker.
- Start without entering the game: the host stays idle. Enter a loaded area: the read-only heartbeat/pass runs. Leave the area: old targets are discarded.
- Stop, wait and Start again. There must be no heartbeat from a stopped run and no doubled loop.
- Introduce a controlled failure in one read-only test component. Confirm the other components still receive cleanup; remove that failure before continuing.
A responsive, selected IBot with an owned lifecycle. No movement, skill use or automatic login yet.
The published fragments are compiled against the reviewed public clients. These session checks are for your deliberate test environment; they are not claimed as live gameplay results.
Continue with the same project#
Do not start a separate teaching bot for tasks. Keep YourFollowBot and add the task manager contract in part 2; then implement FollowLeaderTask in part 3.
The older AcademyBot/AcademyTasksBot downloads remain small isolated API labs used by other lessons. They are not this FollowBot, do not implement its integration contract and are not a shortcut to a finished follower.