The idea: tell me when enemies are getting close#
We will build a plugin called AcademyNearbyAlert. It checks roughly every two seconds and writes an alert when at least three eligible enemies are within 30 game units. Later you will choose those numbers in a settings panel.
You only need to recognize a class, a method and an if statement. Start with the supplied project, change one piece, then check what changed. Leave the supporting AcademyBot alone: it calls your plugin while Start is active. The Message and Logic methods are required boilerplate, not concepts you need to master for this exercise.
- One file to edit: AcademyNearbyAlert/AcademyNearbyAlert.cs.
- Five checkpoints: run → count → decide → remember → configure. Stop after any step; it is still a working project.
- No movement, attacks, automatic login or game input. An alert is a log message, not a survival system.
The code and every checkpoint are compile-checked for both products. The decision rule is unit-tested. The expected game logs below are checks for your own test session, not a claim that we executed your client.
Get the small lesson kit#
- Choose DPB1 or DPB2 above, then download the lesson kit. It contains only your plugin, a ready-made host and five complete checkpoints — not the whole advanced library.
- Use Windows x64, an editor and the .NET 8 SDK. For DPB1 also install the .NET Framework 4.8 Developer Pack; DPB2 uses .NET 8 Windows desktop targeting support.
- Use your complete, matching DPB1 installation. The project references its DreamPoeBot.exe, log4net.dll and Newtonsoft.Json.dll. The kit intentionally supplies no client binaries.
- Extract into a writable development folder outside the client installation. Open AcademyNearbyAlert/AcademyNearbyAlert.cs. Keep NearbyAlertRule.cs beside it; we will explain that helper at step 4.
- Open PowerShell in the extracted folder containing Build.ps1 and Directory.Build.props. Replace C:\DPB in the commands with your actual installation folder.
Build the two supplied projects#
In this focused kit, All builds only AcademyNearbyAlert and AcademyBot. The helper compiles; it does not install anything or launch a client. Expect two Build succeeded results and DLLs under artifacts/Plugins/<project name>.
If PowerShell blocks the script, use the equivalent direct commands in the next section rather than disabling security policy. Read the first build error if compilation fails.
Where this goes: PowerShell in the extracted lesson-kit root, not inside the client or a project subfolder.
.\Build.ps1 -DpbDirectory 'C:\DPB'artifacts/Plugins/AcademyNearbyAlert/AcademyNearbyAlert.dll and artifacts/Plugins/AcademyBot/AcademyBot.dll exist. A successful build is the first checkpoint, not yet proof of a running plugin.
If scripts are blocked: the same build without the helper#
Where this goes: Same extracted lesson-kit root. Skip this if the helper already built both projects.
dotnet build .\AcademyNearbyAlert\AcademyNearbyAlert.csproj -c Release '-p:DpbDirectory=C:\DPB'
dotnet build .\AcademyBot\AcademyBot.csproj -c Release '-p:DpbDirectory=C:\DPB'If your DPB1 executable was renamed, add -DpbAssembly 'C:\DPB\YourClient.exe' to the helper or -p:DpbAssembly=... to dotnet build. DPB2 references DreamPoeBot.dll, not the launcher executable.
Let DPB load your plugin#
- Stop and close your test DPB normally before replacing DLLs.
- Copy the two output folders AcademyNearbyAlert and AcademyBot from artifacts/Plugins into the client's Plugins folder. Do not copy the source folders or client reference DLLs.
- Check the final paths: Plugins/AcademyNearbyAlert/AcademyNearbyAlert.dll and Plugins/AcademyBot/AcademyBot.dll. Avoid an extra nested folder.
- Restart DPB and use your normal connection workflow. Enable AcademyNearbyAlert in the plugin list and select AcademyBot in the bot selector BEFORE pressing Start. Stop/start the bot after changing enabled components.
- Show Info logs (Full or Debug-Info-Warn on the reviewed clients; Debug-only can hide these messages). Enter a loaded area with a living character and press Start.
First [NearbyAlert] Enabled. Select AcademyBot, then Start. Then the AcademyBot Started line. Step 1 should now produce its periodic message.
AcademyBot only drives its named teaching components. Enabling a plugin alone does not schedule Tick; another installed bot is not guaranteed to call it.
1. See your own code run#
Tick is a short check called repeatedly by the supplied bot. The first if skips work while disabled or before the two-second timer finishes. Reset starts the next interval.
The second if skips menus, loading and a dead or unavailable character. return means do nothing on this check; it is not an error. Log.Info writes a visible message. These guards and the timer stay in every later step.
Where this goes: Already present as Tick() in AcademyNearbyAlert/AcademyNearbyAlert.cs. Do not paste a second Tick method.
public void Tick()
{
if (!_enabled || !_interval.IsFinished) return;
_interval.Reset();
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead) return;
Log.Info("[NearbyAlert] I am running. Ready to count enemies.");
}[NearbyAlert] I am running. Ready to count enemies. appears about every two seconds while the bot runs in a ready game state. Stop ends the checks. If silent, check Info logging, enabled plugin and selected/running AcademyBot before editing the guards.
Change only I am running to My first alert is alive. Use the rebuild loop below. Seeing your new text proves that DPB loaded YOUR updated DLL.
Stuck? Open the complete file for this step
Replace AcademyNearbyAlert/AcademyNearbyAlert.cs with this entire file. Keep NearbyAlertRule.cs beside it; do not add a second copy of the plugin class.
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Data;
using DreamPoeBot.Loki.Bot;
using DreamPoeBot.Loki.Common;
using DreamPoeBot.Loki.Game;
using DreamPoeBot.Loki.Game.Objects;
using log4net;
using Message = DreamPoeBot.Loki.Bot.Message;
namespace DeveloperAcademy
{
// This is the finished lesson. The download opens at step 1; all five
// complete checkpoints are in Lessons/NearbyAlert. No game input is sent.
public sealed class AcademyNearbyAlert : IPlugin, ITickEvents, IStartStopEvents
{
private static readonly ILog Log = Logger.GetLoggerInstanceForType();
private readonly WaitTimer _interval = new WaitTimer(TimeSpan.FromSeconds(2));
private readonly NearbyAlertRule _alert = new NearbyAlertRule();
private UserControl _control;
private bool _enabled;
public string Name => "AcademyNearbyAlert";
public string Description => "Learn to observe, count, decide and remember. No movement or combat.";
public string Author => "Your name";
public string Version => "1.0.0";
#region NearbyConfiguration
public JsonSettings Settings => null;
public UserControl Control => _control ?? (_control = new UserControl
{
Content = new TextBlock
{
Text = "Enable AcademyNearbyAlert, select AcademyBot, then Start. Watch the Info log.",
TextWrapping = System.Windows.TextWrapping.Wrap,
Margin = new System.Windows.Thickness(12)
}
});
#endregion
public void Initialize() { _ = Settings; }
public void Deinitialize() { Disable(); }
public void Enable() { _enabled = true; _alert.Reset(); Log.Info("[NearbyAlert] Enabled. Select AcademyBot, then Start."); }
public void Disable() { _enabled = false; _alert.Reset(); }
public void Start() { _alert.Reset(); _interval.Stop(); }
public void Stop() { _alert.Reset(); }
#region NearbyTick
public void Tick()
{
if (!_enabled || !_interval.IsFinished) return;
_interval.Reset();
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead) return;
Log.Info("[NearbyAlert] I am running. Ready to count enemies.");
}
#endregion
public MessageResult Message(Message message) => MessageResult.Unprocessed;
public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);
}
}
The full file for every step is also in Lessons/NearbyAlert/Step-01 through Step-05 inside the download. Recovery means replacing the existing AcademyNearbyAlert.cs, not adding a second class file.
Use this loop after every edit#
- Save AcademyNearbyAlert.cs, then build with this command (or the direct dotnet build command for this project above).
- Stop and close DPB. Replace Plugins/AcademyNearbyAlert/AcademyNearbyAlert.dll with the new DLL from artifacts/Plugins/AcademyNearbyAlert.
- Restart DPB, enable the plugin, select AcademyBot and Start in a ready area. Check the expected output for the current step.
Where this goes: The extracted kit root. AcademyBot does not need rebuilding when only the plugin changes.
.\Build.ps1 -DpbDirectory 'C:\DPB' -Project AcademyNearbyAlertThe changed behavior appears. If you still see the old text, check the destination installation and DLL before changing code again.
2. Count nearby enemies#
radius is the maximum distance, in game units — not pixels. GetObjectsByType<Monster>() gives the currently loaded monster objects. Count(...) keeps only objects matching the condition and returns their number.
Read monster => ... as: for each monster, check the rule on the right. && means all three checks must be true: alive and hostile, targetable, and no farther away than radius. This is a direct count; there is no routine, target selection or asynchronous method to adapt.
Where this goes: Replace the entire existing Tick() method in AcademyNearbyAlert.cs with this one. The required using System.Linq and game-object imports are already in your file.
public void Tick()
{
if (!_enabled || !_interval.IsFinished) return;
_interval.Reset();
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead) return;
var radius = 30;
var count = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
.Count(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= radius);
Log.Info("[NearbyAlert] Nearby enemies: " + count);
}[NearbyAlert] Nearby enemies: 0 is valid in town, outside range or when no eligible monster is loaded. When eligible enemies are nearby, the count changes on later checks. This is not the total monster count for the map.
Change radius from 30 to 15, rebuild and compare in a similar situation. Which objects stop qualifying? Restore 30 before the next step. If it stays at 0, keep the guards; do not assume every visible object is an eligible enemy.
Stuck? Open the complete file for this step
Replace AcademyNearbyAlert/AcademyNearbyAlert.cs with this entire file. Keep NearbyAlertRule.cs beside it; do not add a second copy of the plugin class.
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Data;
using DreamPoeBot.Loki.Bot;
using DreamPoeBot.Loki.Common;
using DreamPoeBot.Loki.Game;
using DreamPoeBot.Loki.Game.Objects;
using log4net;
using Message = DreamPoeBot.Loki.Bot.Message;
namespace DeveloperAcademy
{
// This is the finished lesson. The download opens at step 1; all five
// complete checkpoints are in Lessons/NearbyAlert. No game input is sent.
public sealed class AcademyNearbyAlert : IPlugin, ITickEvents, IStartStopEvents
{
private static readonly ILog Log = Logger.GetLoggerInstanceForType();
private readonly WaitTimer _interval = new WaitTimer(TimeSpan.FromSeconds(2));
private readonly NearbyAlertRule _alert = new NearbyAlertRule();
private UserControl _control;
private bool _enabled;
public string Name => "AcademyNearbyAlert";
public string Description => "Learn to observe, count, decide and remember. No movement or combat.";
public string Author => "Your name";
public string Version => "1.0.0";
#region NearbyConfiguration
public JsonSettings Settings => null;
public UserControl Control => _control ?? (_control = new UserControl
{
Content = new TextBlock
{
Text = "Enable AcademyNearbyAlert, select AcademyBot, then Start. Watch the Info log.",
TextWrapping = System.Windows.TextWrapping.Wrap,
Margin = new System.Windows.Thickness(12)
}
});
#endregion
public void Initialize() { _ = Settings; }
public void Deinitialize() { Disable(); }
public void Enable() { _enabled = true; _alert.Reset(); Log.Info("[NearbyAlert] Enabled. Select AcademyBot, then Start."); }
public void Disable() { _enabled = false; _alert.Reset(); }
public void Start() { _alert.Reset(); _interval.Stop(); }
public void Stop() { _alert.Reset(); }
#region NearbyTick
public void Tick()
{
if (!_enabled || !_interval.IsFinished) return;
_interval.Reset();
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead) return;
var radius = 30;
var count = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
.Count(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= radius);
Log.Info("[NearbyAlert] Nearby enemies: " + count);
}
#endregion
public MessageResult Message(Message message) => MessageResult.Unprocessed;
public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);
}
}
3. Decide when the count matters#
threshold is your rule: three or more enemies deserves a warning. The new if compares count with threshold. Below it, the plugin stays quiet. At or above it, the log describes the count and radius.
We have separated observation (counting) from a decision (should I warn?). This pattern also works for low inventory space or a health threshold, with a different observation.
Where this goes: Replace Tick() in the same AcademyNearbyAlert.cs file. Rebuild and replace the DLL using the same loop.
public void Tick()
{
if (!_enabled || !_interval.IsFinished) return;
_interval.Reset();
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead) return;
var threshold = 3;
var radius = 30;
var count = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
.Count(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= radius);
if (count >= threshold)
Log.Info("[NearbyAlert] Watch out: " + count + " enemies within " + radius + " units.");
}At count 2: no warning. At count 3 or 5: [NearbyAlert] Watch out: ... . If the count stays above the threshold, this intermediate version repeats every two seconds. We fix that in the next step.
Set threshold to 1, rebuild and check whether a single eligible nearby enemy triggers it. Restore 3 afterward. If unsure why there is no warning, temporarily use step 2 to see the raw count.
Stuck? Open the complete file for this step
Replace AcademyNearbyAlert/AcademyNearbyAlert.cs with this entire file. Keep NearbyAlertRule.cs beside it; do not add a second copy of the plugin class.
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Data;
using DreamPoeBot.Loki.Bot;
using DreamPoeBot.Loki.Common;
using DreamPoeBot.Loki.Game;
using DreamPoeBot.Loki.Game.Objects;
using log4net;
using Message = DreamPoeBot.Loki.Bot.Message;
namespace DeveloperAcademy
{
// This is the finished lesson. The download opens at step 1; all five
// complete checkpoints are in Lessons/NearbyAlert. No game input is sent.
public sealed class AcademyNearbyAlert : IPlugin, ITickEvents, IStartStopEvents
{
private static readonly ILog Log = Logger.GetLoggerInstanceForType();
private readonly WaitTimer _interval = new WaitTimer(TimeSpan.FromSeconds(2));
private readonly NearbyAlertRule _alert = new NearbyAlertRule();
private UserControl _control;
private bool _enabled;
public string Name => "AcademyNearbyAlert";
public string Description => "Learn to observe, count, decide and remember. No movement or combat.";
public string Author => "Your name";
public string Version => "1.0.0";
#region NearbyConfiguration
public JsonSettings Settings => null;
public UserControl Control => _control ?? (_control = new UserControl
{
Content = new TextBlock
{
Text = "Enable AcademyNearbyAlert, select AcademyBot, then Start. Watch the Info log.",
TextWrapping = System.Windows.TextWrapping.Wrap,
Margin = new System.Windows.Thickness(12)
}
});
#endregion
public void Initialize() { _ = Settings; }
public void Deinitialize() { Disable(); }
public void Enable() { _enabled = true; _alert.Reset(); Log.Info("[NearbyAlert] Enabled. Select AcademyBot, then Start."); }
public void Disable() { _enabled = false; _alert.Reset(); }
public void Start() { _alert.Reset(); _interval.Stop(); }
public void Stop() { _alert.Reset(); }
#region NearbyTick
public void Tick()
{
if (!_enabled || !_interval.IsFinished) return;
_interval.Reset();
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead) return;
var threshold = 3;
var radius = 30;
var count = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
.Count(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= radius);
if (count >= threshold)
Log.Info("[NearbyAlert] Watch out: " + count + " enemies within " + radius + " units.");
}
#endregion
public MessageResult Message(Message message) => MessageResult.Unprocessed;
public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);
}
}
4. Warn once, then wait for a new crossing#
The decision now uses: _alert.ShouldNotify(count, threshold). The helper remembers whether the last checked count was already above the threshold. It returns true only when the condition becomes true.
Counts 0 → 2 → 3 → 5 → 2 → 3 produce two alerts: on the two crossings to 3, not on 5. A first sample already above the threshold also alerts. The next section shows all of the helper; nothing is hidden in a framework.
The readiness block resets memory when a tick observes loading, no character or death. EnterArea also rearms it when the area hash or zone ID changes, even if the loading screen passed between ticks. Start, Stop and Disable reset it too.
Where this goes: Replace Tick() again. The _alert field and NearbyAlertRule.cs helper were included from step 1; now we use them.
public void Tick()
{
if (!_enabled) return;
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead)
{
_alert.Reset();
return;
}
if (!_interval.IsFinished) return;
_interval.Reset();
var area = LokiPoe.CurrentWorldArea;
var hash = LokiPoe.LocalData.AreaHash;
if (area == null || hash == 0) return;
_alert.EnterArea(hash, area.Id);
var threshold = 3;
var radius = 30;
var count = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
.Count(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= radius);
if (_alert.ShouldNotify(count, threshold))
Log.Info("[NearbyAlert] Watch out: " + count + " enemies within " + radius + " units.");
}Staying above the threshold no longer spams the log. A checked count below it rearms the alert; rising to the threshold produces a new line. Stop then Start also rearms it.
Predict the result for counts 4, 4, 1, 4 before reading the rule below: the first and last checks alert. Then change the warning text to your own wording and confirm the new artifact is loaded.
Stuck? Open the complete file for this step
Replace AcademyNearbyAlert/AcademyNearbyAlert.cs with this entire file. Keep NearbyAlertRule.cs beside it; do not add a second copy of the plugin class.
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Data;
using DreamPoeBot.Loki.Bot;
using DreamPoeBot.Loki.Common;
using DreamPoeBot.Loki.Game;
using DreamPoeBot.Loki.Game.Objects;
using log4net;
using Message = DreamPoeBot.Loki.Bot.Message;
namespace DeveloperAcademy
{
// This is the finished lesson. The download opens at step 1; all five
// complete checkpoints are in Lessons/NearbyAlert. No game input is sent.
public sealed class AcademyNearbyAlert : IPlugin, ITickEvents, IStartStopEvents
{
private static readonly ILog Log = Logger.GetLoggerInstanceForType();
private readonly WaitTimer _interval = new WaitTimer(TimeSpan.FromSeconds(2));
private readonly NearbyAlertRule _alert = new NearbyAlertRule();
private UserControl _control;
private bool _enabled;
public string Name => "AcademyNearbyAlert";
public string Description => "Learn to observe, count, decide and remember. No movement or combat.";
public string Author => "Your name";
public string Version => "1.0.0";
#region NearbyConfiguration
public JsonSettings Settings => null;
public UserControl Control => _control ?? (_control = new UserControl
{
Content = new TextBlock
{
Text = "Enable AcademyNearbyAlert, select AcademyBot, then Start. Watch the Info log.",
TextWrapping = System.Windows.TextWrapping.Wrap,
Margin = new System.Windows.Thickness(12)
}
});
#endregion
public void Initialize() { _ = Settings; }
public void Deinitialize() { Disable(); }
public void Enable() { _enabled = true; _alert.Reset(); Log.Info("[NearbyAlert] Enabled. Select AcademyBot, then Start."); }
public void Disable() { _enabled = false; _alert.Reset(); }
public void Start() { _alert.Reset(); _interval.Stop(); }
public void Stop() { _alert.Reset(); }
#region NearbyTick
public void Tick()
{
if (!_enabled) return;
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead)
{
_alert.Reset();
return;
}
if (!_interval.IsFinished) return;
_interval.Reset();
var area = LokiPoe.CurrentWorldArea;
var hash = LokiPoe.LocalData.AreaHash;
if (area == null || hash == 0) return;
_alert.EnterArea(hash, area.Id);
var threshold = 3;
var radius = 30;
var count = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
.Count(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= radius);
if (_alert.ShouldNotify(count, threshold))
Log.Info("[NearbyAlert] Watch out: " + count + " enemies within " + radius + " units.");
}
#endregion
public MessageResult Message(Message message) => MessageResult.Unprocessed;
public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);
}
}
Enemy counts are sampled about every two seconds, so brief crossings can be missed. Hash plus zone ID handles ordinary area changes, not every special transfer. Some same-template destinations can share both; use a loading or arrival check when building a travel workflow.
The tiny piece of memory behind step 4#
_wasAbove is one bool that survives between checks. ! means not: warn when the count is high AND it was not high last time. Then store the current condition for the next check.
EnterArea remembers the supplied hash and zone ID. If either changes, the previous area's count must not suppress an alert here. Reset clears both that identity and the previous condition. This helper reads no game objects: numbers and IDs are enough to test it.
Where this goes: Already supplied in AcademyNearbyAlert/NearbyAlertRule.cs. Read it here; no paste or extra project is needed.
namespace DeveloperAcademy
{
// A decision with memory; no DPB dependency, so the actual rule can be unit-tested.
internal sealed class NearbyAlertRule
{
private bool _wasAbove;
private uint? _areaHash;
private string _areaId = "";
public void EnterArea(uint hash, string id)
{
if (_areaHash == hash && _areaId == id) return;
_areaHash = hash;
_areaId = id;
_wasAbove = false; // Counts in another area must not suppress this area's alert.
}
public bool ShouldNotify(int count, int threshold)
{
var isAbove = count >= threshold;
var notify = isAbove && !_wasAbove;
_wasAbove = isAbove;
return notify;
}
public void Reset() { _wasAbove = false; _areaHash = null; _areaId = ""; }
}
}
For 0, 2, 3, 5, 2, 3 at threshold 3, ShouldNotify returns false, false, true, false, false, true. You should now be able to explain every line of the rule.
5. Choose your numbers without rebuilding#
The behavior stays the same; the two fixed numbers now come from Configuration. The complete checkpoint adds two sliders and a Save button. Open the AcademyNearbyAlert configuration panel after rebuilding and installing.
Threshold defaults to 3 and is bounded to 1–20. Radius defaults to 30 and is bounded to 5–80. The panel shows the selected numbers. Changes affect the next check; Save writes Settings/Academy/NearbyAlert.json in this client so the choices survive a restart.
You do not need to master WPF to finish this step. In the complete file, NearbyConfiguration builds the panel; NearbySettings holds the two properties. The separate settings guide explains those pieces when you want to change the UI.
Where this goes: For this step replace the WHOLE AcademyNearbyAlert.cs with Lessons/NearbyAlert/Step-05/AcademyNearbyAlert.cs (or the complete checkpoint below). Tick alone is not enough: this checkpoint also adds the settings class and panel. Keep NearbyAlertRule.cs.
public void Tick()
{
if (!_enabled) return;
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead)
{
_alert.Reset();
return;
}
if (!_interval.IsFinished) return;
_interval.Reset();
var area = LokiPoe.CurrentWorldArea;
var hash = LokiPoe.LocalData.AreaHash;
if (area == null || hash == 0) return;
_alert.EnterArea(hash, area.Id);
var threshold = Configuration.Threshold;
var radius = Configuration.Radius;
var count = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
.Count(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= radius);
if (_alert.ShouldNotify(count, threshold))
Log.Info("[NearbyAlert] Watch out: " + count + " enemies within " + radius + " units.");
}Set threshold to 1 and radius to 15, click Save, close and restart the test client. The panel should retain 1 and 15. Start AcademyBot and check the alert uses the chosen radius. A save failure produces a diagnostic instead of an unhandled button error.
Choose your own threshold and radius, then save them. Can you explain why changing these no longer requires building a DLL, while changing the warning text still does? Try adding your own label to the panel next.
Stuck? Open the complete file for this step
Replace AcademyNearbyAlert/AcademyNearbyAlert.cs with this entire file. Keep NearbyAlertRule.cs beside it; do not add a second copy of the plugin class.
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Data;
using DreamPoeBot.Loki.Bot;
using DreamPoeBot.Loki.Common;
using DreamPoeBot.Loki.Game;
using DreamPoeBot.Loki.Game.Objects;
using log4net;
using Message = DreamPoeBot.Loki.Bot.Message;
namespace DeveloperAcademy
{
// This is the finished lesson. The download opens at step 1; all five
// complete checkpoints are in Lessons/NearbyAlert. No game input is sent.
public sealed class AcademyNearbyAlert : IPlugin, ITickEvents, IStartStopEvents
{
private static readonly ILog Log = Logger.GetLoggerInstanceForType();
private readonly WaitTimer _interval = new WaitTimer(TimeSpan.FromSeconds(2));
private readonly NearbyAlertRule _alert = new NearbyAlertRule();
private UserControl _control;
private bool _enabled;
public string Name => "AcademyNearbyAlert";
public string Description => "Learn to observe, count, decide and remember. No movement or combat.";
public string Author => "Your name";
public string Version => "1.0.0";
#region NearbyConfiguration
private NearbyAlertSettings _settings;
private NearbyAlertSettings Configuration => _settings ?? (_settings = new NearbyAlertSettings());
public JsonSettings Settings => Configuration;
public UserControl Control
{
get
{
if (_control != null) return _control;
var panel = new StackPanel { Margin = new System.Windows.Thickness(12), DataContext = Configuration };
panel.Children.Add(new TextBlock { Text = "Enable AcademyNearbyAlert and select AcademyBot. Changes apply to the next check; Save keeps them after restarting.", TextWrapping = System.Windows.TextWrapping.Wrap });
AddSlider(panel, "Alert at this many enemies (1–20)", nameof(NearbyAlertSettings.Threshold), 1, 20);
AddSlider(panel, "Distance in game units (5–80)", nameof(NearbyAlertSettings.Radius), 5, 80);
var save = new Button { Content = "Save settings", Margin = new System.Windows.Thickness(0, 8, 0, 0) };
save.Click += (sender, args) =>
{
try { Configuration.Save(); Log.Info("[NearbyAlert] Settings saved."); }
catch (IOException error) { Log.Warn("[NearbyAlert] Could not write settings.", error); }
catch (UnauthorizedAccessException error) { Log.Warn("[NearbyAlert] Settings folder is not writable.", error); }
};
panel.Children.Add(save);
return _control = new UserControl { Content = panel };
}
}
private static void AddSlider(StackPanel panel, string label, string property, int minimum, int maximum)
{
panel.Children.Add(new TextBlock { Text = label });
var value = new TextBlock();
value.SetBinding(TextBlock.TextProperty, new Binding(property));
panel.Children.Add(value);
var slider = new Slider { Minimum = minimum, Maximum = maximum, TickFrequency = 1, IsSnapToTickEnabled = true };
slider.SetBinding(Slider.ValueProperty, new Binding(property) { Mode = BindingMode.TwoWay });
panel.Children.Add(slider);
}
#endregion
public void Initialize() { _ = Settings; }
public void Deinitialize() { Disable(); }
public void Enable() { _enabled = true; _alert.Reset(); Log.Info("[NearbyAlert] Enabled. Select AcademyBot, then Start."); }
public void Disable() { _enabled = false; _alert.Reset(); }
public void Start() { _alert.Reset(); _interval.Stop(); }
public void Stop() { _alert.Reset(); }
#region NearbyTick
public void Tick()
{
if (!_enabled) return;
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead)
{
_alert.Reset();
return;
}
if (!_interval.IsFinished) return;
_interval.Reset();
var area = LokiPoe.CurrentWorldArea;
var hash = LokiPoe.LocalData.AreaHash;
if (area == null || hash == 0) return;
_alert.EnterArea(hash, area.Id);
var threshold = Configuration.Threshold;
var radius = Configuration.Radius;
var count = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
.Count(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= radius);
if (_alert.ShouldNotify(count, threshold))
Log.Info("[NearbyAlert] Watch out: " + count + " enemies within " + radius + " units.");
}
#endregion
public MessageResult Message(Message message) => MessageResult.Unprocessed;
public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);
}
#region NearbySettings
public sealed class NearbyAlertSettings : JsonSettings
{
private int _threshold;
private int _radius;
public NearbyAlertSettings() : base(GetSettingsFilePath("Academy", "NearbyAlert.json")) { }
// Defaults are applied by JsonSettings before loading saved values.
[DefaultValue(3)]
public int Threshold
{
get => _threshold;
set { _threshold = Math.Max(1, Math.Min(20, value)); NotifyPropertyChanged(() => Threshold); }
}
[DefaultValue(30)]
public int Radius
{
get => _radius;
set { _radius = Math.Max(5, Math.Min(80, value)); NotifyPropertyChanged(() => Radius); }
}
}
#endregion
}
Changing a slider while the alert condition stays true does not produce another warning. Dropping below the threshold on a check or Stop/Start rearms it. This teaching settings file is shared by this sample within the client, not separate per character.
You can now build a small behavior of your own#
You have used the same four building blocks throughout: read something, decide whether it matters, remember enough to avoid repeating yourself, and expose a choice. Keep the timer and readiness guards when you experiment.
For a next read-only feature, report when your carried inventory reaches a chosen number of item entries. First print the observed count, then add a threshold, then remember whether you already warned. The inventory recipe explains that item-entry count is NOT free space or total stack quantity: check the meaning before writing your rule.
Movement and skill use are a separate step with real consequences. The action workshop starts with an explicitly permitted single request, not an unattended loop. Use it only after you can build, install and verify your own changes.
Complete source files
These are the exact C# files in the DPB1 download. Use the complete ZIP for project settings, references, build commands and installation instructions.
AcademyNearbyAlert/AcademyNearbyAlert.cs
using System;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Controls;
using System.Windows.Data;
using DreamPoeBot.Loki.Bot;
using DreamPoeBot.Loki.Common;
using DreamPoeBot.Loki.Game;
using DreamPoeBot.Loki.Game.Objects;
using log4net;
using Message = DreamPoeBot.Loki.Bot.Message;
namespace DeveloperAcademy
{
// This is the finished lesson. The download opens at step 1; all five
// complete checkpoints are in Lessons/NearbyAlert. No game input is sent.
public sealed class AcademyNearbyAlert : IPlugin, ITickEvents, IStartStopEvents
{
private static readonly ILog Log = Logger.GetLoggerInstanceForType();
private readonly WaitTimer _interval = new WaitTimer(TimeSpan.FromSeconds(2));
private readonly NearbyAlertRule _alert = new NearbyAlertRule();
private UserControl _control;
private bool _enabled;
public string Name => "AcademyNearbyAlert";
public string Description => "Learn to observe, count, decide and remember. No movement or combat.";
public string Author => "Your name";
public string Version => "1.0.0";
#region NearbyConfiguration
public JsonSettings Settings => null;
public UserControl Control => _control ?? (_control = new UserControl
{
Content = new TextBlock
{
Text = "Enable AcademyNearbyAlert, select AcademyBot, then Start. Watch the Info log.",
TextWrapping = System.Windows.TextWrapping.Wrap,
Margin = new System.Windows.Thickness(12)
}
});
#endregion
public void Initialize() { _ = Settings; }
public void Deinitialize() { Disable(); }
public void Enable() { _enabled = true; _alert.Reset(); Log.Info("[NearbyAlert] Enabled. Select AcademyBot, then Start."); }
public void Disable() { _enabled = false; _alert.Reset(); }
public void Start() { _alert.Reset(); _interval.Stop(); }
public void Stop() { _alert.Reset(); }
#region NearbyTick
public void Tick()
{
if (!_enabled || !_interval.IsFinished) return;
_interval.Reset();
if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead) return;
Log.Info("[NearbyAlert] I am running. Ready to count enemies.");
}
#endregion
public MessageResult Message(Message message) => MessageResult.Unprocessed;
public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);
}
}
AcademyBot/AcademyBot.cs
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Controls;
using DreamPoeBot.Loki.Bot;
using DreamPoeBot.Loki.Common;
using DreamPoeBot.Loki.Coroutine;
using DreamPoeBot.Loki.Game;
using log4net;
using Message = DreamPoeBot.Loki.Bot.Message;
namespace DeveloperAcademy
{
public sealed class AcademyBot : IBot
{
private static readonly ILog Log = Logger.GetLoggerInstanceForType();
private Coroutine _loop;
private UserControl _control;
private IPlugin[] _plugins = Array.Empty<IPlugin>();
private IRoutine _routine;
private IPlayerMover _mover;
public string Name => "AcademyBot";
public string Description => "A teaching host: run the Academy components without game input.";
public string Author => "Your name";
public string Version => "1.0.0";
public JsonSettings Settings => null;
public UserControl Control => _control ?? (_control = new UserControl
{
Content = new TextBlock { Text = "Enable AcademyNearbyAlert or AcademyObserver. Optionally select AcademyRoutine and AcademyMover. This bot only drives named teaching components.",
TextWrapping = System.Windows.TextWrapping.Wrap, Margin = new System.Windows.Thickness(12) }
});
public void Initialize() { }
public void Deinitialize() { Stop(); }
#region BotLifecycle
public void Start()
{
Stop();
// Keep the instances we started, so Stop never targets a later selection.
_plugins = Observers();
var selectedRoutine = RoutineManager.Current;
_routine = selectedRoutine?.Name == "AcademyRoutine" ? selectedRoutine : null;
var selectedMover = PlayerMoverManager.Current;
_mover = selectedMover?.Name == "AcademyMover" ? selectedMover : null;
try
{
foreach (var plugin in _plugins)
(plugin as IStartStopEvents)?.Start();
_routine?.Start();
_mover?.Start();
_loop = new Coroutine(Run);
}
catch { Stop(); throw; }
Log.Info("[AcademyBot] Started. Observation only; no movement or combat.");
}
public void Tick()
{
if (_loop == null) return;
try
{
foreach (var plugin in _plugins)
(plugin as ITickEvents)?.Tick();
_routine?.Tick();
_mover?.Tick();
if (_loop != null && !_loop.IsFinished)
_loop.Resume();
}
catch { Stop(); throw; }
}
public void Stop()
{
var loop = _loop;
_loop = null;
try { loop?.Dispose(); }
finally
{
var plugins = _plugins;
var routine = _routine;
var mover = _mover;
_plugins = Array.Empty<IPlugin>();
_routine = null;
_mover = null;
foreach (var plugin in plugins) StopComponent(plugin as IStartStopEvents);
StopComponent(routine);
StopComponent(mover);
}
}
private static void StopComponent(IStartStopEvents component)
{
try { component?.Stop(); }
catch (Exception error) { Log.Warn("[AcademyBot] A component failed to stop; continuing cleanup.", error); }
}
#endregion
#region BotLoop
private async Task Run()
{
while (true)
{
if (LokiPoe.IsInGame && LokiPoe.Me != null)
{
if (_routine != null)
await _routine.Logic(new Logic("academy.inspect-target", this));
}
// Return control to DPB between iterations. Never use Thread.Sleep here.
await Coroutine.Yield();
}
}
#endregion
// Intentional teaching scope: do not drive unrelated installed extensions.
// This name check is not an authentication or trust boundary.
private static IPlugin[] Observers() => PluginManager.EnabledPlugins
.Where(plugin => plugin.Name == "AcademyObserver" || plugin.Name == "AcademySettings" || plugin.Name == "AcademyNearbyAlert").ToArray();
public MessageResult Message(Message message) => MessageResult.Unprocessed;
public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);
}
}
Keep building
Look up a specific DPB1 API →Download integrity and validation scope
Source kit: 14 files, 15,427 bytes. No client binaries or credentials.
SHA-256: e05ddfd50a331b63f0fd684e5bb28451957eeddf7ce5682269ed6cbd3ee087aa
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.