Build something · 20 min exercise

Add settings and a simple configuration panel

Persist a bounded setting, bind WPF controls and clean up event subscriptions.

After this lesson

A user can change the log prefix and interval, save them and reload the same values on the next client start.

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

Run the settings example#

  1. Build AcademySettings and AcademyBot from the source kit.
  2. With DPB closed, copy their matching output folders into Plugins.
  3. Start DPB, enable AcademySettings and select AcademyBot. AcademyObserver is optional.
  4. Open AcademySettings's configuration panel, choose a prefix and interval, and click Save settings.
  5. Start AcademyBot in a loaded area and observe the throttled position log.
Check your result

The chosen prefix appears in the log at the selected interval. Save, close and restart the test client: the controls should load the saved values.

Keep persistent data in JsonSettings#

The file lives at Settings/Academy/Observer.json relative to the client's settings root. It is a sample-wide file, not per character or per profile. For a real extension choose an explicit scope and stable, non-secret filename.

JsonSettings applies DefaultValue attributes and loads saved values in its constructor. Keep defaults in those attributes and do not reset loaded properties in your derived constructor body, which runs afterward.

Clamp values in the model, not only in the UI. A user can edit the JSON file outside the application. Prefix strips newlines and is limited to 40 characters; the interval stays between one and sixty seconds.

Where this goes: ObserverSettings at the end of AcademySettings/AcademySettings.cs. AcademySettings.Settings returns this same instance.

C# · from the source kit
public sealed class ObserverSettings : JsonSettings
{
    private string _prefix;
    private int _intervalSeconds;
    public ObserverSettings() : base(GetSettingsFilePath("Academy", "Observer.json")) { }

    // JsonSettings applies DefaultValue and loads saved values in its constructor.
    // Keep defaults in the attributes; do not reset loaded values in the constructor body.
    [DefaultValue("Academy")]
    public string Prefix
    {
        get => _prefix;
        set
        {
            var clean = (value ?? "Academy").Replace("\r", " ").Replace("\n", " ");
            _prefix = clean.Length > 40 ? clean.Substring(0, 40) : clean;
            NotifyPropertyChanged(() => Prefix);
        }
    }
    [DefaultValue(5)]
    public int IntervalSeconds
    {
        get => _intervalSeconds;
        set { _intervalSeconds = Math.Max(1, Math.Min(60, value)); NotifyPropertyChanged(() => IntervalSeconds); }
    }
}

Expose a small WPF control#

This is WPF inside the Windows client, not HTML from the website. DataContext points to the settings instance and TwoWay bindings update its properties.

The Save button only writes configuration; it does not read the game. Write failures are logged. Do not store credentials, license values or game-account secrets in a tutorial settings file.

For game actions from UI, follow the workshop pattern: a button queues intent; Tick or the coroutine validates and executes it in the normal bot context.

Where this goes: AcademySettings.Control. The control is created once, then reused.

C# · from the source kit
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 AcademySettings and run AcademyBot. Change the prefix and interval, then Save. Settings are independent of the game.", TextWrapping = System.Windows.TextWrapping.Wrap });
        panel.Children.Add(new TextBlock { Text = "Log prefix (up to 40 characters)" });
        var prefix = new TextBox { MaxLength = 40 };
        prefix.SetBinding(TextBox.TextProperty, new Binding(nameof(ObserverSettings.Prefix)) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged });
        panel.Children.Add(prefix);
        panel.Children.Add(new TextBlock { Text = "Interval in seconds (1–60)" });
        var interval = new Slider { Minimum = 1, Maximum = 60, TickFrequency = 1, IsSnapToTickEnabled = true };
        interval.SetBinding(Slider.ValueProperty, new Binding(nameof(ObserverSettings.IntervalSeconds)) { Mode = BindingMode.TwoWay });
        panel.Children.Add(interval);
        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("[AcademySettings] Settings saved."); }
            catch (IOException error) { Log.Warn("[AcademySettings] Could not write settings.", error); }
            catch (UnauthorizedAccessException error) { Log.Warn("[AcademySettings] Settings folder is not writable.", error); }
        };
        panel.Children.Add(save);
        _control = new UserControl { Content = panel };
        return _control;
    }
}
Check your result

Editing the prefix updates the model; Save writes it to disk. A read-only settings directory produces a diagnostic instead of an unhandled button-click error.

Unsubscribe with the same handler#

Use a named handler so the unsubscribe removes the same delegate that was added. Guard repeated Enable calls to avoid duplicate registrations.

An event may arrive outside your bot tick or the WPF UI thread. Keep the handler short; queue work or use the appropriate UI dispatcher when needed. This sample only logs the event.

Where this goes: AcademySettings.Enable / Disable. Deinitialize calls Disable as a final cleanup path.

C# · from the source kit
public void Enable()
{
    if (_enabled) return;
    _enabled = true;
    BotManager.OnBotChanged += OnBotChanged;
}
public void Disable()
{
    if (!_enabled) return;
    _enabled = false;
    BotManager.OnBotChanged -= OnBotChanged;
}
private void OnBotChanged(object sender, EventArgs args)
{
    // Event thread: keep this short. Do not read game objects or touch WPF controls.
    Log.Info("[AcademySettings] Selected bot changed.");
}
Check your result

Changing the selected bot produces one diagnostic while the plugin is enabled and none after it is disabled.

Make one setting of your own#

  1. Add a bounded integer or a boolean property to ObserverSettings, with an appropriate DefaultValue.
  2. Add a WPF control bound to it.
  3. Use it in a small read-only behavior such as choosing whether position is logged.
  4. Verify default, edited, saved and reloaded values. Also edit the JSON to an out-of-range number and check your clamp.
  5. Keep configuration writes off the frequent tick path.
Keep in mind

Changes to settings are a separate concern from whether the bot is running. Avoid keeping long-lived game objects in your settings model.

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.

AcademySettings/AcademySettings.cs
Complete file · DPB2
using System;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
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 log4net;
using Message = DreamPoeBot.Loki.Bot.Message;

namespace DeveloperAcademy
{
    public sealed class AcademySettings : IPlugin, ITickEvents
    {
        private static readonly ILog Log = Logger.GetLoggerInstanceForType();
        private readonly Stopwatch _interval = Stopwatch.StartNew();
        private ObserverSettings _settings;
        private UserControl _control;
        private bool _enabled;
        public string Name => "AcademySettings";
        public string Description => "A settings, WPF binding and event-cleanup example. Read-only game observation.";
        public string Author => "Your name";
        public string Version => "1.0.0";
        private ObserverSettings Configuration => _settings ?? (_settings = new ObserverSettings());
        public JsonSettings Settings => Configuration;

        #region SettingsControl
        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 AcademySettings and run AcademyBot. Change the prefix and interval, then Save. Settings are independent of the game.", TextWrapping = System.Windows.TextWrapping.Wrap });
                panel.Children.Add(new TextBlock { Text = "Log prefix (up to 40 characters)" });
                var prefix = new TextBox { MaxLength = 40 };
                prefix.SetBinding(TextBox.TextProperty, new Binding(nameof(ObserverSettings.Prefix)) { Mode = BindingMode.TwoWay, UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged });
                panel.Children.Add(prefix);
                panel.Children.Add(new TextBlock { Text = "Interval in seconds (1–60)" });
                var interval = new Slider { Minimum = 1, Maximum = 60, TickFrequency = 1, IsSnapToTickEnabled = true };
                interval.SetBinding(Slider.ValueProperty, new Binding(nameof(ObserverSettings.IntervalSeconds)) { Mode = BindingMode.TwoWay });
                panel.Children.Add(interval);
                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("[AcademySettings] Settings saved."); }
                    catch (IOException error) { Log.Warn("[AcademySettings] Could not write settings.", error); }
                    catch (UnauthorizedAccessException error) { Log.Warn("[AcademySettings] Settings folder is not writable.", error); }
                };
                panel.Children.Add(save);
                _control = new UserControl { Content = panel };
                return _control;
            }
        }
        #endregion
        public void Initialize() { _ = Configuration; }
        public void Deinitialize() { Disable(); }

        #region SubscribeAndCleanUp
        public void Enable()
        {
            if (_enabled) return;
            _enabled = true;
            BotManager.OnBotChanged += OnBotChanged;
        }
        public void Disable()
        {
            if (!_enabled) return;
            _enabled = false;
            BotManager.OnBotChanged -= OnBotChanged;
        }
        private void OnBotChanged(object sender, EventArgs args)
        {
            // Event thread: keep this short. Do not read game objects or touch WPF controls.
            Log.Info("[AcademySettings] Selected bot changed.");
        }
        #endregion
        public void Tick()
        {
            if (!_enabled || _interval.Elapsed.TotalSeconds < Configuration.IntervalSeconds) return;
            _interval.Restart();
            if (!LokiPoe.IsInGame || LokiPoe.Me == null) return;
            Log.Info("[" + Configuration.Prefix + "] Player position: " + LokiPoe.MyPosition);
        }
        public MessageResult Message(Message message) => MessageResult.Unprocessed;
        public Task<LogicResult> Logic(Logic logic) => Task.FromResult(LogicResult.Unprovided);
    }

    #region PersistedSettings
    public sealed class ObserverSettings : JsonSettings
    {
        private string _prefix;
        private int _intervalSeconds;
        public ObserverSettings() : base(GetSettingsFilePath("Academy", "Observer.json")) { }

        // JsonSettings applies DefaultValue and loads saved values in its constructor.
        // Keep defaults in the attributes; do not reset loaded values in the constructor body.
        [DefaultValue("Academy")]
        public string Prefix
        {
            get => _prefix;
            set
            {
                var clean = (value ?? "Academy").Replace("\r", " ").Replace("\n", " ");
                _prefix = clean.Length > 40 ? clean.Substring(0, 40) : clean;
                NotifyPropertyChanged(() => Prefix);
            }
        }
        [DefaultValue(5)]
        public int IntervalSeconds
        {
            get => _intervalSeconds;
            set { _intervalSeconds = Math.Max(1, Math.Min(60, value)); NotifyPropertyChanged(() => IntervalSeconds); }
        }
    }
    #endregion
}
AcademyBot/AcademyBot.cs
Complete file · DPB2
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 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.