Build something · 20–30 min exercise

Build a routine: select a target, then request a skill

Separate target selection from action and make the bot/routine agreement explicit.

After this lesson

You can choose an eligible target, understand Logic IDs and submit one chosen skill with a checked result.

Download DPB1 source kit ↓ 8 complete projects · source only · .NET Framework 4.8 (net48)

Start with target selection only#

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 AcademyRoutine and AcademyBot using the kit.
  2. Select AcademyRoutine as the routine and AcademyBot as the bot.
  3. Start in a loaded area. If an eligible hostile is available, watch for a Candidate line.
  4. Do not expect attacks: AcademyBot sends only academy.inspect-target.
Check your result

One candidate diagnostic at most every two seconds. No candidate is also a valid result when no eligible object is currently visible.

Filter before choosing the nearest target#

GetObjectsByType<Monster> gives currently known objects of that type. Filter to alive hostiles that can be targeted, then choose the nearest. Check the result for null.

This is a teaching selector, not a production combat strategy. Distance alone does not establish line of sight, skill range, safe positioning, target priority or affordability.

The observation timer belongs to the routine instance. Unknown IDs return Unprovided so a caller can try another behavior.

Where this goes: AcademyRoutine.Logic. The attack-once branch delegates to a separate method; the ordinary AcademyBot calls only the observation ID.

C# · from the source kit
public Task<LogicResult> Logic(Logic logic)
{
    if (logic.Id == "academy.attack-once")
        return Task.FromResult(AttackOnce());
    // AcademyBot sends this sample-specific ID. Other bots may use different IDs.
    if (logic.Id != "academy.inspect-target" || !_interval.IsFinished)
        return Task.FromResult(LogicResult.Unprovided);
    _interval.Reset();
    if (!LokiPoe.IsInGame || LokiPoe.Me == null)
        return Task.FromResult(LogicResult.Unprovided);

    var target = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
        .Where(monster => monster.IsAliveHostile && monster.IsTargetable)
        .OrderBy(monster => monster.Distance)
        .FirstOrDefault();
    if (target == null)
        return Task.FromResult(LogicResult.Unprovided);

    Log.Info("[AcademyRoutine] Candidate: " + target.Name + ". No attack performed.");
    return Task.FromResult(LogicResult.Provided);
}

Use a configured skill once#

Enter the desired DPB skill-slot number in the routine's configuration panel. It is a slot number, not a key code or a skill ID. The default is invalid (-1), so the sample cannot guess an attack skill.

The method checks loaded state, a living player, an existing input session, a nearby eligible target and a usable skill. The 30-unit target cap is a conservative sample filter, not a declaration of your skill's range.

UseAt submits the request and returns a reason if it cannot do so. None means accepted at this API boundary. The sample does not infer a hit, damage, completion of an animation or a kill.

Where this goes: AcademyRoutine.AttackOnce. Only the explicit academy.attack-once Logic ID reaches this method.

C# · from the source kit
private LogicResult AttackOnce()
{
    // Take one settings snapshot: a UI edit must not change the slot mid-request.
    var slot = _skillSlot;
    if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
        !LokiPoe.ProcessHookManager.IsEnabled)
    {
        Log.Info("[AcademyRoutine] Skill request skipped: loaded living player and caller-owned input session required.");
        return LogicResult.Unprovided;
    }
    if (slot < 1 || slot > 13)
    {
        Log.Info("[AcademyRoutine] Choose a skill slot from 1 to 13 in the routine panel first.");
        return LogicResult.Unprovided;
    }
    var target = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
        .Where(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= 30)
        .OrderBy(monster => monster.Distance).FirstOrDefault();
    if (target == null) { Log.Info("[AcademyRoutine] No eligible target within 30 units."); return LogicResult.Unprovided; }
    var skill = LokiPoe.InGameState.SkillBarHud.Slot(slot);
    if (skill == null || !skill.CanUse())
    {
        Log.Info("[AcademyRoutine] The chosen slot has no usable skill. Check binding, resources and cooldown.");
        return LogicResult.Unprovided;
    }
    var result = LokiPoe.InGameState.SkillBarHud.UseAt(slot, true, target.Position);
    Log.Info("[AcademyRoutine] Skill request: " + result + ". This is not a hit or kill confirmation.");
    return result == LokiPoe.InGameState.UseResult.None ? LogicResult.Provided : LogicResult.Unprovided;
}
Check your result

In the opt-in workshop, a button press logs the API result. An empty slot, cooldown, missing resources, no target or missing session means no successful request; it is not a reason to retry endlessly.

Make a routine compatible with a real bot#

Read the calling bot's Logic IDs and expected results before adapting the sample. A routine that only understands academy.inspect-target cannot silently satisfy a different bot's combat contract.

Start with one ordinary bound skill and a visible target. Add range, resource and cooldown rules one at a time. Keep unknown IDs unhandled and preserve a no-action path.

For repeated combat, the bot must schedule future decisions and verify fresh state. Do not hide an unbounded combat loop inside one Logic call. Do not keep attacking a wrapper that was selected before a loading screen.

  1. Change the observation selector to a rule you can explain, such as a smaller distance cap.
  2. Test its log output first.
  3. Use the one-shot workshop only after the selected target and skill slot are correct.
  4. Record the result code separately from the observed game outcome.
  5. Add repeated decisions only after stop behavior and failure handling are reliable.

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.

AcademyRoutine/AcademyRoutine.cs
Complete file · DPB1
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Controls;
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
{
    public sealed class AcademyRoutine : IRoutine
    {
        private static readonly ILog Log = Logger.GetLoggerInstanceForType();
        private readonly WaitTimer _interval = new WaitTimer(TimeSpan.FromSeconds(2));
        private UserControl _control;
        private volatile int _skillSlot = -1;
        private volatile bool _listSkills;
        public string Name => "AcademyRoutine";
        public string Description => "Observe targets; optionally use one chosen skill when the workshop requests it.";
        public string Author => "Your name";
        public string Version => "1.0.0";
        public JsonSettings Settings => null;
        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 = "AcademyBot only observes. AcademyWorkshop can request one skill use. Enter the skill slot reported by DPB (1–13), not the keyboard key.", TextWrapping = System.Windows.TextWrapping.Wrap });
                var slot = new TextBox { Text = "-1", MaxLength = 2, Margin = new System.Windows.Thickness(0, 8, 0, 0) };
                slot.TextChanged += (sender, args) =>
                {
                    int parsed;
                    _skillSlot = int.TryParse(slot.Text, out parsed) && parsed >= 1 && parsed <= 13 ? parsed : -1;
                };
                panel.Children.Add(slot);
                var list = new Button { Content = "List bound skill slots in the log", Margin = new System.Windows.Thickness(0, 8, 0, 0) };
                list.Click += (sender, args) => _listSkills = true;
                panel.Children.Add(list);
                _control = new UserControl { Content = panel };
                return _control;
            }
        }
        public void Initialize() { }
        public void Deinitialize() { Stop(); }
        public void Start() { _listSkills = false; _interval.Stop(); Log.Info("[AcademyRoutine] Started in observation mode."); }
        public void Stop() { _listSkills = false; }
        // A tick is not automatically a request to run combat logic.
        #region InspectSkillSlots
        public void Tick()
        {
            if (!_listSkills) return;
            _listSkills = false;
            if (!LokiPoe.IsInGame || LokiPoe.Me == null) return;
            for (var slot = 1; slot <= 13; slot++)
            {
                var skill = LokiPoe.InGameState.SkillBarHud.Slot(slot);
                if (skill != null && skill.IsOnSkillBar)
                    Log.Info("[AcademyRoutine] Slot " + slot + ": " + skill.Name);
            }
        }
        #endregion
        public MessageResult Message(Message message) => MessageResult.Unprocessed;

        #region ChooseTarget
        public Task<LogicResult> Logic(Logic logic)
        {
            if (logic.Id == "academy.attack-once")
                return Task.FromResult(AttackOnce());
            // AcademyBot sends this sample-specific ID. Other bots may use different IDs.
            if (logic.Id != "academy.inspect-target" || !_interval.IsFinished)
                return Task.FromResult(LogicResult.Unprovided);
            _interval.Reset();
            if (!LokiPoe.IsInGame || LokiPoe.Me == null)
                return Task.FromResult(LogicResult.Unprovided);

            var target = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
                .Where(monster => monster.IsAliveHostile && monster.IsTargetable)
                .OrderBy(monster => monster.Distance)
                .FirstOrDefault();
            if (target == null)
                return Task.FromResult(LogicResult.Unprovided);

            Log.Info("[AcademyRoutine] Candidate: " + target.Name + ". No attack performed.");
            return Task.FromResult(LogicResult.Provided);
        }
        #endregion

        #region UseOneSkill
        private LogicResult AttackOnce()
        {
            // Take one settings snapshot: a UI edit must not change the slot mid-request.
            var slot = _skillSlot;
            if (!LokiPoe.IsInGame || LokiPoe.Me == null || LokiPoe.Me.IsDead ||
                !LokiPoe.ProcessHookManager.IsEnabled)
            {
                Log.Info("[AcademyRoutine] Skill request skipped: loaded living player and caller-owned input session required.");
                return LogicResult.Unprovided;
            }
            if (slot < 1 || slot > 13)
            {
                Log.Info("[AcademyRoutine] Choose a skill slot from 1 to 13 in the routine panel first.");
                return LogicResult.Unprovided;
            }
            var target = LokiPoe.ObjectManager.GetObjectsByType<Monster>()
                .Where(monster => monster.IsAliveHostile && monster.IsTargetable && monster.Distance <= 30)
                .OrderBy(monster => monster.Distance).FirstOrDefault();
            if (target == null) { Log.Info("[AcademyRoutine] No eligible target within 30 units."); return LogicResult.Unprovided; }
            var skill = LokiPoe.InGameState.SkillBarHud.Slot(slot);
            if (skill == null || !skill.CanUse())
            {
                Log.Info("[AcademyRoutine] The chosen slot has no usable skill. Check binding, resources and cooldown.");
                return LogicResult.Unprovided;
            }
            var result = LokiPoe.InGameState.SkillBarHud.UseAt(slot, true, target.Position);
            Log.Info("[AcademyRoutine] Skill request: " + result + ". This is not a hit or kill confirmation.");
            return result == LokiPoe.InGameState.UseResult.None ? LogicResult.Provided : LogicResult.Unprovided;
        }
        #endregion
    }
}

Keep building

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

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

SHA-256: f3d229e4e090feadc8713639c7e4c62fe3c6fb5aa54460af87829e0369ba5e95

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.