Script Structure and Setup

Category: Development

Script Structure and Setup Guide

This comprehensive guide explains how to create well-structured Lua scripts for PowBot Desktop, covering everything from basic script scaffolding to advanced features like configuration schemas and paint overlays.

Table of Contents

  1. Project Structure
  2. Manifest File
  3. Script Lifecycle
  4. Configuration System
  5. Paint System
  6. API Invocation Pattern
  7. VSCode Setup
  8. Complete Working Example
  9. Best Practices

Project Structure

PowBot scripts can be organized as either single-file scripts or multi-file projects. Multi-file projects are recommended for complex scripts with multiple features.

Single-File Structure

my-script/
├── manifest.json    # Script metadata and configuration
└── main.lua         # Main script file

Multi-File Structure

my-script/
├── manifest.json      # Script metadata
├── main.lua           # Entry point
├── modules/           # Core logic modules
│   ├── config.lua     # Configuration handling
│   ├── tasks.lua      # Task implementations
│   └── ui.lua         # Paint/UI setup
└── utils/             # Utility functions
    └── helpers.lua    # Helper functions

Creating Your Script Directory

  1. Navigate to your scripts folder (e.g., test-scripts/)
  2. Create a new folder with your script name
  3. Create manifest.json and main.lua files inside
mkdir my-script
cd my-script
touch manifest.json main.lua

Manifest File

The manifest.json file defines script metadata, dependencies, and structure.

Basic Manifest

{
  "name": "My Script",
  "version": "1.0.0",
  "description": "A simple example script",
  "author": "Your Name",
  "main": "main.lua",
  "min_api_version": "1.0.0"
}

Multi-File Manifest

For scripts with multiple modules:

{
  "name": "Advanced Script",
  "version": "2.0.0",
  "description": "A complex multi-file script",
  "author": "Your Name",
  "main": "main.lua",
  "modules": [
    "modules/config.lua",
    "modules/tasks.lua",
    "modules/ui.lua",
    "utils/helpers.lua"
  ],
  "lua_paths": [
    "modules",
    "utils"
  ],
  "min_api_version": "1.0.0"
}

Field Descriptions

FieldRequiredDescription
nameYesDisplay name shown in the UI
versionYesSemantic version (e.g., "1.0.0")
descriptionYesBrief description of what the script does
authorYesYour name or username
mainYesEntry point file (usually "main.lua")
modulesNoList of additional Lua files to load
lua_pathsNoDirectories to add to Lua's require path
min_api_versionYesMinimum required API version

Script Lifecycle

Every script must return a table with lifecycle methods. The engine calls these methods at specific times during script execution.

Lifecycle Flow

on_start() → poll() → poll() → ... → on_stop()
              ↓
        (on_pause/on_resume can interrupt)

Basic Script Template

local script = {
    name = "MyScript",
    version = "1.0.0"
}

-- Called once when script starts
-- Return true to continue, false to stop immediately
function script:on_start()
    logger:info("Script starting...")

    -- Initialize variables
    self.state = "IDLE"
    self.counter = 0

    -- Validate prerequisites
    local player = players:local_player()
    if not player then
        logger:error("No player found!")
        return false
    end

    return true
end

-- Called repeatedly while script is running (main loop)
-- This is where your automation logic lives
function script:poll()
    -- Check conditions
    local player = players:local_player()
    if not player then
        return
    end

    -- Execute logic based on state
    if self.state == "IDLE" then
        -- Do something
        self.counter = self.counter + 1
        logger:info("Counter: " .. self.counter)
    end

    -- Always include a sleep to control polling rate
    sleep(50)  -- Poll every 50ms
end

-- Called when script is paused (optional)
function script:on_pause()
    logger:info("Script paused")
end

-- Called when script resumes from pause (optional)
function script:on_resume()
    logger:info("Script resumed")
end

-- Called when script stops (optional)
-- Use for cleanup and logging final stats
function script:on_stop()
    logger:info("Script stopped. Total iterations: " .. self.counter)
end

return script

Why Use self?

The script table is passed as self to each lifecycle method. This allows you to:

  • Store persistent state across method calls
  • Track statistics and counters
  • Share data between lifecycle methods
-- Store state in script table
function script:on_start()
    self.kills = 0
    self.state = "HUNTING"
end

-- Access state in poll
function script:poll()
    if self.state == "HUNTING" then
        -- ... attack logic ...
        self.kills = self.kills + 1
    end
end

-- Access state in cleanup
function script:on_stop()
    logger:info("Total kills: " .. self.kills)
end

Important Notes

  • on_start() must return true to continue or false to abort startup
  • poll() is called continuously - always include sleep() to prevent excessive CPU usage
  • on_stop() errors don't halt script cleanup - they're logged and ignored
  • Use sleep(50) or sleep(100) as a baseline polling rate

Configuration System

PowBot uses a flexible UI configuration system where scripts define their UI layout and receive configuration values.

How Configuration Works

  1. Script defines a configuration schema via on_config_request()
  2. Engine renders UI based on the schema
  3. User interacts with UI controls
  4. Engine calls on_config_changed() when values change
  5. Script accesses values through the global options table

Configuration Callbacks

-- Called when engine requests the configuration schema
-- Parameter: current values from the UI (may be nil on first call)
function script:on_config_request(values)
    return {
        version = "1.0",
        layout = {
            type = "vstack",
            children = {
                -- UI components here
            }
        }
    }
end

-- Called when user changes configuration
-- Parameter: new values from the UI
-- Return: updated schema (can be same as on_config_request)
function script:on_config_changed(values)
    logger:info("Config changed!")
    return self:on_config_request(values)
end

Legacy Configuration (Backwards Compatibility)

Note: For backwards compatibility, PowBot still supports the legacy config_schema table property. However, the function-based on_config_request() approach is strongly recommended for new scripts as it provides:

  • Dynamic UI generation based on current values
  • Conditional UI elements
  • Better validation and error handling
  • Support for button clicks and advanced interactions

Legacy approach (still works, but not recommended):

local script = {
    name = "LegacyScript",
    version = "1.0.0",

    -- Legacy: Static config schema as a table property
    config_schema = {
        {
            key = "location",
            label = "Mining Location",
            type = "select",
            default = "varrock_east",
            options = {
                { value = "varrock_east", label = "Varrock East Mine" },
                { value = "lumbridge", label = "Lumbridge Mine" }
            }
        }
    }
}

-- Access still uses 'options' table
function script:on_start()
    logger:info("Location: " .. options.location)
    return true
end

If a script defines on_config_request(), it takes precedence over config_schema. Only use config_schema for simple, static configurations or when maintaining older scripts.

Configuration Schema Example

function script:on_config_request(values)
    return {
        version = "1.0",
        layout = {
            type = "vstack",  -- Vertical stack layout
            children = {
                {
                    type = "section",
                    label = "General Settings",
                    description = "Basic configuration options",
                    children = {
                        {
                            type = "select",
                            id = "location",
                            label = "Mining Location",
                            description = "Choose where to mine",
                            props = {
                                options = {
                                    { value = "varrock_east", label = "Varrock East Mine" },
                                    { value = "lumbridge", label = "Lumbridge Swamp Mine" },
                                    { value = "falador", label = "Falador Mine" }
                                },
                                default = "varrock_east"
                            }
                        },
                        {
                            type = "checkbox",
                            id = "power_mining",
                            label = "Power Mining",
                            description = "Drop ores instead of banking",
                            props = { default = false }
                        },
                        {
                            type = "number",
                            id = "min_health",
                            label = "Minimum Health %",
                            description = "Stop script if health falls below this",
                            props = {
                                default = 20,
                                min = 0,
                                max = 100
                            }
                        }
                    }
                }
            }
        }
    }
end

Available UI Components

TypeDescriptionProps
sectionGroups related controlslabel, description, children
selectDropdown menuoptions, default
checkboxBoolean toggledefault
numberNumeric inputdefault, min, max
textText inputdefault, placeholder
gridGrid layoutcolumns, children
buttonClickable button(handled by on_button_clicked)

Accessing Configuration Values

Use the global options table to read current configuration:

function script:on_start()
    -- Access configuration values
    local location = options.location or "varrock_east"
    local power_mining = options.power_mining or false
    local min_health = options.min_health or 20

    logger:info("Location: " .. location)
    logger:info("Power mining: " .. tostring(power_mining))
    logger:info("Min health: " .. min_health .. "%")

    return true
end

function script:poll()
    -- Configuration values can be read anytime
    if options.power_mining then
        -- Drop ores
    else
        -- Bank ores
    end
end

Creating a Config Module

For complex scripts, extract configuration into a separate module:

-- modules/config.lua
local config = {}

function config:get_schema(values)
    return {
        version = "1.0",
        layout = {
            -- ... schema definition ...
        }
    }
end

function config:get()
    return {
        location = options.location or "varrock_east",
        power_mining = options.power_mining or false,
        min_health = options.min_health or 20
    }
end

return config
-- main.lua
local config = require("modules.config")

function script:on_config_request(values)
    return config:get_schema(values)
end

function script:on_start()
    local cfg = config:get()
    logger:info("Location: " .. cfg.location)
    return true
end

Paint System

The paint system creates visual overlays on the game client showing script status, statistics, and tracked skills.

How Paint Works

  1. Create a paint builder with paint:builder()
  2. Configure position, size, colors
  3. Add static text, tracked skills, and dynamic content
  4. Call build() to register the paint
  5. Engine automatically updates the overlay each frame

Basic Paint Example

function script:on_start()
    -- Create paint overlay
    local builder = paint:builder()

    -- Configure appearance
    builder:position(10, 50)              -- X, Y position on screen
    builder:size(300, 150)                -- Width, height
    builder:bg_color(0, 0, 0, 180)        -- Background: black, 70% opacity
    builder:text_color(255, 255, 255, 255) -- Text: white, 100% opacity

    -- Add runtime tracker (shows how long script has been running)
    builder:add_runtime()

    -- Add static label
    builder:add_text("My Awesome Script v1.0")

    -- Build and register
    self.paint_id = builder:build()

    return true
end

function script:on_stop()
    -- Clean up paint when script stops
    if self.paint_id then
        paint:remove(self.paint_id)
    end
end

Tracking Skills

Track XP gains and levels for specific skills:

builder:track_skill("Mining")
builder:track_skill("Woodcutting")
builder:track_skill("Hitpoints")

The engine automatically displays:

  • Current level
  • XP gained since script started
  • XP per hour
  • Time to next level

Dynamic Content with Functions

Use functions to display values that change during script execution:

function script:on_start()
    self.ores_mined = 0
    self.trips = 0

    local builder = paint:builder()
    builder:position(10, 50)
    builder:size(300, 200)
    builder:bg_color(0, 0, 0, 180)
    builder:text_color(255, 255, 255, 255)

    builder:add_runtime()
    builder:track_skill("Mining")

    -- Add labeled dynamic values
    builder:add_labeled("State:", function()
        return self.state or "Unknown"
    end)

    builder:add_labeled("Ores mined:", function()
        return tostring(self.ores_mined)
    end)

    builder:add_labeled("Bank trips:", function()
        return tostring(self.trips)
    end)

    self.paint_id = builder:build()
    return true
end

function script:poll()
    -- Update counters - paint automatically refreshes
    if self.state == "MINING" then
        self.ores_mined = self.ores_mined + 1
    end
end

Paint Builder API

MethodParametersDescription
position(x, y)x: number, y: numberSet paint position in pixels
size(w, h)w: number, h: numberSet paint dimensions
bg_color(r, g, b, a)RGBA (0-255)Set background color
text_color(r, g, b, a)RGBA (0-255)Set text color
add_runtime()-Add script runtime display
track_skill(name)name: stringTrack skill XP and level
add_text(text)text: stringAdd static text line
add_labeled(label, fn)label: string, fn: functionAdd dynamic value with label
build()-Register paint and return ID

Creating a UI Module

For cleaner code, extract paint setup into a module:

-- modules/ui.lua
local ui = {}

function ui:create_paint(script)
    local builder = paint:builder()
    builder:position(10, 50)
    builder:size(350, 200)
    builder:bg_color(0, 0, 0, 180)
    builder:text_color(255, 255, 255, 255)

    builder:add_runtime()
    builder:track_skill("Mining")

    builder:add_labeled("State:", function()
        return script.state or "Unknown"
    end)

    builder:add_labeled("Ores mined:", function()
        return tostring(script.ores_mined or 0)
    end)

    return builder:build()
end

return ui
-- main.lua
local ui = require("modules.ui")

function script:on_start()
    self.paint_id = ui:create_paint(self)
    return true
end

API Invocation Pattern

CRITICAL: PowBot APIs are UserData objects that must be invoked with the colon (:) operator, not the dot (.) operator.

Why Use Colons?

In Lua, the colon operator automatically passes the object as the first self parameter:

-- These are equivalent:
npcs:find_all(filters)
npcs:find_all(npcs, filters)

-- But only the colon version works with PowBot APIs!

Correct Usage

-- ✓ CORRECT - Use colons for API methods
local player = players:local_player()
local all_npcs = npcs:find_all({ name = "Cow" })
local rocks = game_objects:find({ name_contains = "Rock" })
inventory:count()
bank:opened()

-- ✓ CORRECT - Chain method calls with colons
if player:hp() < 20 then
    logger:error("Low health!")
end

-- ✓ CORRECT - Use on returned objects too
local nearest_npc = npcs:find_nearest({ name = "Guard" })
if nearest_npc then
    nearest_npc:interact({ action = "Attack" })
end

Common Mistakes

-- ✗ WRONG - Using dot operator
local player = players.local_player()  -- ERROR!
npcs.find_all({ name = "Cow" })        -- ERROR!
inventory.count()                      -- ERROR!

-- ✗ WRONG - Mixing dots and colons
players.local_player():hp()            -- ERROR!

-- ✗ WRONG - Calling without object
local_player()                         -- ERROR!
find_all({ name = "Cow" })             -- ERROR!

Why This Matters

PowBot's Lua API is implemented using mlua's UserData system. UserData methods require the object reference as the first parameter, which the colon operator provides automatically. Using the dot operator causes:

  • Type errors: "attempt to call a nil value"
  • Missing parameter errors: "expected 2 arguments, got 1"
  • Crashes: if the native code expects a valid object reference

API Call Examples

-- Players API
local player = players:local_player()
if player then
    local name = player:name()
    local hp = player:hp()
    local x = player:x()
    local is_busy = player:is_busy()
end

-- NPCs API
local npcs_list = npcs:find_all({ name = "Cow", not_in_combat = true })
for _, npc in ipairs(npcs_list) do
    logger:info(npc:name())
    npc:interact({ action = "Attack" })
end

-- Game Objects API
local nearest_rock = game_objects:find_first({
    name_contains = "Copper",
    within_distance_of_local = 10
})
if nearest_rock then
    nearest_rock:interact({ action = "Mine" })
end

-- Inventory API
if inventory:full() then
    logger:info("Inventory is full")
end
local item_count = inventory:count({ name = "Iron ore" })

-- Bank API
if bank:opened() then
    bank:deposit_all()
    bank:close()
end

-- Movement API
movement:walk_to({ x = 3200, y = 3200, floor = 0 })
if movement:is_moving() then
    logger:info("Walking...")
end

Remember

Always use colons (:) when calling PowBot API methods!


VSCode Setup

Enable auto-complete and IntelliSense for PowBot APIs in Visual Studio Code.

Step 1: Install Lua Extension

Install the Lua Language Server extension by sumneko:

  1. Open VSCode
  2. Press Ctrl+Shift+X (or Cmd+Shift+X on Mac)
  3. Search for "Lua"
  4. Install "Lua" by sumneko

Step 2: Configure Workspace

Create or edit .vscode/settings.json in your project root:

{
    "Lua.workspace.library": [
        "${workspaceFolder}/docs/api"
    ],
    "Lua.diagnostics.globals": [
        "logger",
        "players",
        "npcs",
        "game_objects",
        "ground_items",
        "inventory",
        "bank",
        "bank_cache",
        "deposit_box",
        "grand_exchange",
        "chat",
        "menu",
        "tabs",
        "equipment",
        "skills",
        "viewport",
        "keyboard",
        "mouse",
        "camera",
        "components",
        "combat",
        "prayer",
        "magic",
        "quests",
        "paint",
        "stats",
        "varpbits",
        "item_prices",
        "events",
        "game",
        "trade",
        "store",
        "worlds",
        "movement",
        "breaks",
        "sleep",
        "wait_until",
        "wait_until_idle",
        "wait_while_busy",
        "options",
        "Area",
        "game_hwnd",
        "scripts",
        "daemons",
        "print",
        "script_manager"
    ],
    "Lua.runtime.version": "Lua 5.4"
}

Step 3: Verify Setup

Open a Lua file and start typing an API name. You should see:

  • Auto-complete suggestions
  • Method signatures
  • Parameter types
  • Documentation from comments

Example:

-- Type "players:" and you'll see auto-complete with:
--   - local_player()
--   - find_all()
--   - find_nearest()
--   - etc.

local player = players:local_player()

-- Type "player:" and you'll see:
--   - name()
--   - hp()
--   - max_hp()
--   - x(), y(), floor()
--   - is_busy()
--   - etc.

Step 4: Add Type Annotations (Optional)

For better IntelliSense, add type annotations to your script:

---@type table
local script = {
    name = "MyScript",
    version = "1.0.0"
}

---@param self table
---@return boolean
function script:on_start()
    return true
end

---@param self table
function script:poll()
    local player = players:local_player()
    if not player then return end

    -- IntelliSense now knows player's methods
    local hp = player:hp()
end

return script

Troubleshooting

Problem: No auto-complete appears

Solution:

  1. Ensure docs/api/pow_api.d.lua exists in your project
  2. Reload VSCode window (Ctrl+Shift+P → "Developer: Reload Window")
  3. Check that Lua extension is enabled

Problem: "Undefined global" warnings

Solution: Add the global name to Lua.diagnostics.globals in settings.json


Complete Working Example

Here's a complete, production-ready script demonstrating all concepts:

manifest.json

{
  "name": "Tutorial Miner",
  "version": "1.0.0",
  "description": "A complete example showing script structure, configuration, and paint",
  "author": "Tutorial",
  "main": "main.lua",
  "min_api_version": "1.0.0"
}

main.lua

-- Tutorial Miner - Complete Example Script
-- Demonstrates: lifecycle, configuration, paint, and API usage

local script = {
    name = "TutorialMiner",
    version = "1.0.0",

    -- State machine
    state = "CHECK_INVENTORY",

    -- Statistics
    ores_mined = 0,
    trips_made = 0,

    -- UI
    paint_id = nil
}

-- Configuration schema
function script:on_config_request(values)
    return {
        version = "1.0",
        layout = {
            type = "vstack",
            children = {
                {
                    type = "section",
                    label = "Mining Settings",
                    description = "Configure your mining behavior",
                    children = {
                        {
                            type = "select",
                            id = "ore_type",
                            label = "Ore Type",
                            description = "Which ore to mine",
                            props = {
                                options = {
                                    { value = "copper", label = "Copper ore" },
                                    { value = "tin", label = "Tin ore" },
                                    { value = "iron", label = "Iron ore" }
                                },
                                default = "copper"
                            }
                        },
                        {
                            type = "checkbox",
                            id = "power_mine",
                            label = "Power Mining",
                            description = "Drop ores instead of banking",
                            props = { default = false }
                        }
                    }
                }
            }
        }
    }
end

function script:on_config_changed(values)
    logger:info("Configuration updated!")
    return self:on_config_request(values)
end

-- Startup
function script:on_start()
    logger:info("Tutorial Miner starting...")

    -- Validate player exists
    local player = players:local_player()
    if not player then
        logger:error("No player found!")
        return false
    end

    -- Log configuration
    local ore_type = options.ore_type or "copper"
    local power_mine = options.power_mine or false

    logger:info("Ore type: " .. ore_type)
    logger:info("Power mining: " .. tostring(power_mine))

    -- Initialize statistics
    self.ores_mined = 0
    self.trips_made = 0

    -- Create paint overlay
    self:create_paint()

    logger:info("Tutorial Miner started successfully!")
    return true
end

-- Create paint overlay
function script:create_paint()
    local builder = paint:builder()

    -- Configure appearance
    builder:position(10, 50)
    builder:size(300, 180)
    builder:bg_color(0, 0, 0, 180)
    builder:text_color(255, 255, 255, 255)

    -- Add content
    builder:add_runtime()
    builder:track_skill("Mining")

    builder:add_labeled("State:", function()
        return self.state or "Unknown"
    end)

    builder:add_labeled("Ores mined:", function()
        return tostring(self.ores_mined)
    end)

    builder:add_labeled("Bank trips:", function()
        return tostring(self.trips_made)
    end)

    builder:add_labeled("Ore type:", function()
        return options.ore_type or "copper"
    end)

    -- Build and store ID
    self.paint_id = builder:build()
    logger:info("Paint created with ID: " .. tostring(self.paint_id))
end

-- Main loop
function script:poll()
    local player = players:local_player()
    if not player then
        return
    end

    -- State machine
    if self.state == "CHECK_INVENTORY" then
        self:handle_check_inventory()

    elseif self.state == "DROP_ORES" then
        self:handle_drop_ores()

    elseif self.state == "WALK_TO_BANK" then
        self:handle_walk_to_bank()

    elseif self.state == "BANK_ORES" then
        self:handle_bank_ores()

    elseif self.state == "MINE_ORE" then
        self:handle_mine_ore()
    end

    -- Control polling rate
    sleep(50)
end

-- State: Check inventory
function script:handle_check_inventory()
    if inventory:full() then
        if options.power_mine then
            self.state = "DROP_ORES"
        else
            self.state = "WALK_TO_BANK"
        end
    else
        self.state = "MINE_ORE"
    end
end

-- State: Drop ores (power mining)
function script:handle_drop_ores()
    local ore_type = options.ore_type or "copper"
    local ore_name = ore_type:sub(1,1):upper() .. ore_type:sub(2) .. " ore"

    local ores = inventory:find_all({ name = ore_name })
    for _, ore in ipairs(ores) do
        ore:interact({ action = "Drop" })
        sleep(600)  -- Drop delay
    end

    self.state = "CHECK_INVENTORY"
end

-- State: Walk to bank
function script:handle_walk_to_bank()
    -- This is a simplified example - real scripts would use proper pathfinding
    logger:info("Walking to bank...")

    -- Example bank tile (Lumbridge)
    movement:walk_to({ x = 3208, y = 3220, floor = 2 })

    -- Wait for movement
    sleep(2000)

    self.state = "BANK_ORES"
end

-- State: Bank ores
function script:handle_bank_ores()
    -- Open bank if not open
    if not bank:opened() then
        local booth = game_objects:find_first({
            name_contains = "Bank booth",
            within_distance_of_local = 5
        })

        if booth then
            booth:interact({ action = "Bank" })
            sleep(1000)
        end
        return
    end

    -- Deposit ores using transaction API
    local ore_type = options.ore_type or "copper"
    local ore_name = ore_type:sub(1,1):upper() .. ore_type:sub(2) .. " ore"

    if inventory:count({ name = ore_name }) > 0 then
        -- Use transaction API for reliable banking
        local result = bank:transaction()
            :deposit({ name = ore_name }, -1)  -- -1 means deposit all
            :execute()

        if result.success then
            self.trips_made = self.trips_made + 1
            logger:info("Deposited ores successfully")
        end
        sleep(600)
    end

    -- Close bank
    bank:close()
    sleep(600)

    self.state = "CHECK_INVENTORY"
end

-- State: Mine ore
function script:handle_mine_ore()
    local player = players:local_player()
    if not player then return end

    -- Wait if already mining
    if player:is_animating() then
        sleep(1000)
        return
    end

    -- Find ore to mine
    local ore_type = options.ore_type or "copper"
    local ore_name = ore_type:sub(1,1):upper() .. ore_type:sub(2)

    local rock = game_objects:find_first({
        name_contains = ore_name,
        within_distance_of_local = 10
    })

    if rock then
        if rock:interact({ action = "Mine" }) then
            logger:info("Started mining " .. ore_name)
            self.ores_mined = self.ores_mined + 1
            sleep(2000)  -- Wait for ore to deplete
        end
    else
        logger:warn("No " .. ore_name .. " rocks found nearby")
        sleep(1000)
    end

    self.state = "CHECK_INVENTORY"
end

-- Cleanup
function script:on_stop()
    logger:info("Tutorial Miner stopped")
    logger:info("Total ores mined: " .. self.ores_mined)
    logger:info("Total trips made: " .. self.trips_made)

    -- Remove paint overlay
    if self.paint_id then
        paint:remove(self.paint_id)
    end
end

return script

Best Practices

1. Use State Machines

Organize script logic into clear states:

-- ✓ GOOD - Clear state machine
if self.state == "CHECK_INVENTORY" then
    -- ...
elseif self.state == "MINE_ORE" then
    -- ...
end

-- ✗ BAD - Nested conditions
if inventory:full() then
    if bank:opened() then
        if has_ores then
            -- Deeply nested logic
        end
    end
end

2. Always Use Sleep

Prevent excessive CPU usage and rate limiting:

-- ✓ GOOD - Controlled polling rate
function script:poll()
    -- ... logic ...
    sleep(50)  -- 20 polls per second
end

-- ✗ BAD - Busy waiting
function script:poll()
    -- ... logic ...
    -- No sleep = thousands of polls per second!
end

3. Check for Nil

Always validate API responses:

-- ✓ GOOD - Nil check
local player = players:local_player()
if not player then
    return
end
local hp = player:hp()

-- ✗ BAD - Assumes player exists
local player = players:local_player()
local hp = player:hp()  -- Crashes if player is nil!
end

4. Use Wait Functions

PowBot provides wait utilities that check stop conditions:

-- ✓ GOOD - Use wait_until
wait_until(5000, function()
    local player = players:local_player()
    return player and player:is_idle()
end)

-- ✓ GOOD - Use wait_until_idle
wait_until_idle(5000)

-- ✗ BAD - Manual sleep loop
for i = 1, 100 do
    sleep(50)
    if players:local_player():is_idle() then
        break
    end
end

5. Extract Complex Logic

Use modules for better organization:

-- ✓ GOOD - Modular structure
local tasks = require("modules.tasks")
tasks:mine_ore("copper")

-- ✗ BAD - Everything in main.lua
function script:poll()
    -- 500 lines of complex logic
end

6. Log Important Events

Help with debugging and monitoring:

-- ✓ GOOD - Informative logging
logger:info("Starting mining session")
logger:warn("No rocks found, waiting...")
logger:error("Failed to open bank after 3 attempts")

-- ✗ BAD - No logging
-- ... silent execution ...

7. Handle Configuration Changes

Respond to dynamic configuration updates:

function script:on_config_changed(values)
    -- Reload location data if changed
    if values.location ~= self.current_location then
        self:load_location(values.location)
    end

    return self:on_config_request(values)
end

8. Clean Up Resources

Always clean up in on_stop():

function script:on_stop()
    -- Remove paint
    if self.paint_id then
        paint:remove(self.paint_id)
    end

    -- Close interfaces
    if bank:opened() then
        bank:close()
    end

    -- Log final stats
    logger:info("Final stats: " .. self.ores_mined .. " ores")
end

9. Use Colon Operator for APIs

Critical: Always use : for API methods:

-- ✓ CORRECT
players:local_player()
npcs:find_all({ name = "Cow" })
inventory:count()

-- ✗ WRONG
players:local_player()
npcs:find_all({ name = "Cow" })

10. Test Incrementally

Build and test your script in stages:

  1. Get basic lifecycle working (on_start, poll, on_stop)
  2. Add configuration schema
  3. Implement one state at a time
  4. Add paint overlay last
  5. Test each feature before moving on

Next Steps

Now that you understand the basic script structure:

  1. Explore the API: Read through docs/api/pow_api.d.lua to see all available methods
  2. Study Examples: Look at existing scripts in test-scripts/ for patterns
  3. Build Something: Start with a simple script (woodcutting, mining) and expand from there
  4. Read Advanced Guides: Check out the Progressive Miner Walkthrough for modular architecture

Additional Resources

  • API Documentation: docs/api/pow_api.d.lua
  • Basic Scripting Guide: docs/guides/basic-scripting.md
  • Progressive Miner Walkthrough: docs/guides/progressive-miner-walkthrough.md
  • Example Scripts: test-scripts/ directory

Common Issues

Issue: Script doesn't appear in UI

Cause: Invalid manifest.json or missing required fields

Solution: Validate JSON syntax and ensure all required fields are present

Issue: "attempt to call a nil value"

Cause: Using dot operator instead of colon for API methods

Solution: Change players.local_player() to players:local_player()

Issue: Paint doesn't update

Cause: Functions not used for dynamic values

Solution: Wrap dynamic values in functions:

-- Wrong
builder:add_labeled("Count:", self.count)

-- Correct
builder:add_labeled("Count:", function()
    return tostring(self.count)
end)

Issue: Configuration changes not reflected

Cause: Not reading from options table

Solution: Always read from global options table, not cached values

Issue: Script stops unexpectedly

Cause: on_start() returning false or error in poll loop

Solution: Check logs for errors and ensure on_start() returns true


Summary

A well-structured PowBot script includes:

  1. manifest.json - Metadata and module declarations
  2. Lifecycle methods - on_start, poll, on_stop
  3. Configuration schema - Dynamic UI via on_config_request
  4. Paint overlay - Visual feedback with paint:builder()
  5. State machine - Clear, maintainable logic flow
  6. Proper API usage - Colon operator for all API methods
  7. Error handling - Nil checks and validation
  8. Resource cleanup - Removing paint and closing interfaces

Follow these patterns and best practices to create robust, maintainable automation scripts for Old School RuneScape.

Happy scripting!