everything you can read and do from a Lua script — the POEMEMORY reference
what scripting is, and the one rule that shapes everything
A script is a small Lua program that runs against your live game data. It reads the game through one read-only object — POEMEMORY — and reacts: pop a toast, play a sound, draw a box on a monster, or (only if you opt in) press a key or click the mouse.
Scripts are plain .lua files in settings_overlay/scripting/, managed from the SCRIPTING tab in the dashboard (enable/disable, order, edit, live log). Drop a file in the folder or hit + new.
This is the Overlay's Lua, not the launcher's AutoHotkey. The assistant launcher has its own, separate scripting system, documented under In-depth Scripting and AI Agent Scripting on the main site. The two share nothing but the word — different language, different data, different abilities. Everything on this page belongs to Overlay POE.
POEMEMORY has no setters.tick(), and it runs on a loop (~20×/second by default; adjustable in the tab).a 2-minute primer — enough Lua to be productive
Lua is small and readable. Here's essentially everything you need:
-- comments start with two dashes
local x = 10 -- a variable (use "local")
if x >= 5 and x ~= 7 then -- and / or / not ; "not equal" is ~=
log("x is " .. x) -- ".." joins strings
end
for i = 1, 3 do end -- numeric loop
for _, m in ipairs(someList) do end -- loop a list (array)
function greet(who) -- a function
return "hi " .. who
end
POEMEMORYPOEMEMORY, Input and Mouse are objects. Reading a value (a property) uses a dot and no parentheses. Calling a function (a method) uses parentheses; methods on an object take a colon (a dot also works):
local lvl = POEMEMORY.Player.Level -- property: just a value local hp = POEMEMORY.Player.Life.Pct -- nested property local near = POEMEMORY.World.Monsters:CountWithin(30) -- method: parentheses (colon)
Rule of thumb: if it has () in this reference, it's a method — call it. If it doesn't, it's a value — read it.
tick() loop & remembering thingsDefine function tick() ... end. It runs every tick. Anything you declare outside tick() persists across ticks — that's how you keep state (like a timestamp):
local last = 0 -- lives across ticks function tick() if now() - last < 1000 then return end -- not yet a second? bail last = now() -- ... this block runs at most once per second end
sleep (it would freeze the engine), and the sandbox has no os.clock. To pace yourself, use now() (milliseconds) with the pattern above.Some methods return nothing (nil) when there's nothing to give — e.g. Nearest() when no monster is loaded. Guard it:
local m = POEMEMORY.World.Monsters:Nearest() if m then -- only use m when it exists log(m.Metadata) end
you — vitals, level, position
true while you have positive life..Es is energy shield. .Rage works on both games even though they store it differently — see the note under the pool fields..Life / .Mana / .Es / .Rage).Es and .Rage (true only when you actually have one)..Rage.Current reads correctly on both, but on PoE1 .Max and .Pct are 0, because the game exposes no maximum there. So use .Current for rage, not .Pct.if POEMEMORY.Player.Life.Pct < 50 then notify("low life!", "ff4040") end
if POEMEMORY.Player.Es.Present and POEMEMORY.Player.Es.Pct < 25 then notify("ES down") end
what's currently on you — auras, flasks, ailments, and charge stacks
power_charge, frenzy_charge, endurance_charge, ghost_dance_stacks, mana_leech… Matching is case-insensitive, so don't lose time to capitalisation. Not sure what a buff is called? Print them — see the bottom of this card.POEMEMORY.Playertrue while that buff is on you.:Charges — see the note below; pick the wrong one and your number is quietly wrong.math.huge — infinity.:BuffTimeLeft.nil when it isn't on you.ipairs.power_charge.math.huge when permanent.math.huge here even while they are visibly counting down — the game stores no fixed total for them. Never judge “permanent” from this field..TimeLeft only — an infinite .TotalTime does not make a buff permanent..PercentTimeLeft actually means something — a real countdown and a real total. false for permanent buffs and for charges. When it is false, read .TimeLeft.TimeLeft reached 0;:Charges(...) or :HasBuff(...) every tick. Never assume a big TimeLeft means it's still there next tick.:Charges(id), and for the repeating kind read :BuffCount(id), which counts entries. Live example: on PoE1 life_leech showed up as 15 separate entries, each with a count of 1.Run this once with the buff active, read the log, then copy the id into your real script.
-- print everything on me, once per second
local last = 0
function tick()
if now() - last < 1000 then return end
last = now()
for _, b in ipairs(POEMEMORY.Player:Buffs()) do
log(b.Id .. " x" .. b.Charges .. " " .. (b.IsPermanent and "permanent" or string.format("%.1fs", b.TimeLeft)))
end
log("----")
end
-- the everyday uses
if POEMEMORY.Player:Charges("power_charge") >= 3 then notify("3 power charges!", "c792ea") end
if not POEMEMORY.Player:HasBuff("ghost_dance_stacks") then notify("ghost dance down") end
local t = POEMEMORY.Player:BuffTimeLeft("flask_effect_mana")
if t > 0 and t < 1.5 then notify("mana flask expiring") end
what is in each belt slot, and how many charges it holds
nil from :Flask() and 0 from :FlaskCharges(), so you can test either way.:Flask(3) may well be a charm. Read .Name or .BaseId if the difference matters to your script. PoE1 has no charms, so there it is flasks only.POEMEMORY.Playernil when the slot is empty.ipairs.Hallowed Life Flask or Silver Charm.FlaskUtilitySilver (PoE1) or FourCharm7 (PoE2). It does not change with your game language, so match on this if your script has to work anywhere..BaseMax. Can read above 100 — that is correct, not a bug..Charges knows about your mods.BaseMax and .BaseUseCost come from the game's shared base-type data, which every flask of that type reads from. Your flask's own modifiers are not in there..BaseMax says — measured up to 3x the base value — and a flask with "-X% charges used" really costs less than .BaseUseCost says..Pct can go over 100, and .Uses/.CanUse can say "not yet" when the flask is in fact ready. They are wrong in the safe direction only. For an exact test, compare .Charges against a number you measured on your own flask.Easiest way: open the dashboard's SCRIPTING tab and press show character current values — the popup lists your belt slot by slot, with names and charges. Or print it:
-- print the whole belt, once per second
local last = 0
function tick()
if now() - last < 1000 then return end
last = now()
for _, f in ipairs(POEMEMORY.Player:Flasks()) do
log("[" .. f.Slot .. "] " .. f.Name .. " " .. f.Charges .. "/" .. f.BaseMax)
end
log("----")
end
-- the everyday uses
if POEMEMORY.Player:FlaskCharges(1) < 10 then notify("life flask nearly empty", "ff5555") end
local f = POEMEMORY.Player:Flask(2)
if f and f.CanUse then notify(f.Name .. " ready", "7ee787") end
-- find a flask by name instead of by slot
for _, x in ipairs(POEMEMORY.Player:Flasks()) do
if x.Name:find("Quicksilver") and x.Charges == 0 then notify("quicksilver empty") end
end
where you are right now
.Code to match on).true in a farmable combat zone (a map).true in a town/hideout. Handy to disable automation while you're safe.if POEMEMORY.Area.InTown then return end -- do nothing in town
if POEMEMORY.Area.Code:find("Cavern") then notify("cavern map!") end
the enemies loaded around you
nil if none are loaded.ipairs.-- warn on a swarm
if POEMEMORY.World.Monsters:CountWithin(35) >= 8 then notify("swarm!", "ff5555") end
-- box every unique nearby
for _, m in ipairs(POEMEMORY.World.Monsters:Within(45)) do
if m.IsUnique then m:Mark("a020f0") end
end
what :Nearest() / :Within() / :Matching() give you
A handle is a frozen snapshot of one monster for this tick. The same monster keeps the same .Id across ticks, so you can track it.
"Normal" / "Magic" / "Rare" / "Unique".Mouse takes. Only valid when .OnScreen.true when the monster projects on screen. Always check before using ScreenX/ScreenY.Monsters queries, but exposed here too).true if any of the monster's mod ids contains text (case-insensitive)."ffcc00"). It stays glued to the monster while you keep calling it. Size defaults to 70×70 with a 3px edge.local m = POEMEMORY.World.Monsters:Nearest()
if m and m.Rarity == "Rare" and m.Distance < 25 then
m:Mark("ff9030") -- highlight it
log(m.Metadata .. " " .. math.floor(m.Life.Pct) .. "%")
end
the safe outputs — no gate, available to every script
"ff5555" (optional). Calling it again with the same text just keeps that toast up rather than stacking.alert.wav. Uses your global alert volume, and is cooldown-limited so it can't machine-gun.sleep). See the once-per-second pattern.notify("rare pack ahead", "ffcc00")
sound(2)
log("player at " .. POEMEMORY.Player.X .. ", " .. POEMEMORY.Player.Y)
the gated outputs — single, discrete keystrokes and clicks
.ScreenX/.ScreenY."left" (default) / "right" / "middle".-- right-click the closest on-screen monster within melee range, once/sec
local MELEE = 20
local last = 0
function tick()
if now() - last < 1000 then return end
last = now()
local m = POEMEMORY.World.Monsters:Nearest()
if m and m.OnScreen and m.Distance <= MELEE then
Mouse.ClickAt(m.ScreenX, m.ScreenY, "right")
end
end
what to pass to Input.* and Mouse.*
Single letters and digits are themselves: "q", "1". Named keys:
the things that trip people up
notify("d "..math.floor(m.Distance)) and read it at the distance you care about..OnScreen.World.Monsters sees what's loaded near you, not the far side of the map.tick() light.Code over .Name:Charges(id) every tick — a 21-second power charge can be gone one tick after you read 19 seconds left.:BuffTimeLeft returns math.huge for auras and reservations. Use .IsPermanent rather than comparing numbers, or your "expiring soon" test will never fire..TotalTime as infinity while still counting down for real. Judge permanence from .TimeLeft or .IsPermanent — never from the total, or expiring charges will look permanent and sit at 100%..Charges knows about your flask's own mods. .BaseMax and .BaseUseCost come from shared base-type data, so .Pct can exceed 100 and .CanUse can say "no" on a flask that is actually ready. Both err on the safe side, never the risky one.