Lua API
These are the main calls, and each one works in the current evolve version.
start
1A script is a .lua file in the scripts folder: in the game menu, Settings › Scripts › «Open folder» opens it. It returns a table of callbacks and re-reads on save. Lua 5.4, one shared VM for every script, callbacks run one at a time.
- 1Draw in on_draw, play in on_update. renderer.* outside the frame draws nothing, and orders.* inside on_draw leave by the wrong path.
- 2An entity from entities.* is good for one frame: check e:valid() first, and take the list itself from entities.* again.
- 3The schema walk takes the first ~10 seconds after launch. Position, team and health read instantly; armor, abilities and gamerules answer 0 until it finishes.
- 4While client.local_hero() is empty every team filter reads as enemy — get the hero first, target second.
- 5Coordinates are Source world units: the map runs about -8288..8288 on X and Y, and a point is vec(x, y, z).
client.local_hero()→ Entity|nilThe local player's hero. nil until you are in a match.
client.screen_size()→ number, numberWindow width and height in pixels.
client.cursor_world()→ Vector|nilThe point under the cursor on the hero's z-plane.
globals.curtime()→ numberGame time in seconds. The clock for any timer.
globals.tickcount()→ numberThe client's real 30 Hz tick, monotonic.
Watch outKeeps counting while game time is paused.
vec(x, y, z)→ VectorA point or a direction. vec2, angle and color(r, g, b, a) sit next to it — components are 0..255.
log.info(msg)A line in evolve.log. The first tool when a script goes quiet.
-- scripts/hello.lua
local s = {}
function s.on_update() -- game logic
local me = client.local_hero()
if not (me and me:valid()) then return end
end
function s.on_draw() -- renderer.* only
local me = client.local_hero()
if not (me and me:valid()) then return end
local x, y, on = renderer.world_to_screen(me:position())
if on then
renderer.text(font_default, x, y - 40, color(255, 255, 255, 255), "hello")
end
end
return spacks
2A pack is a folder: pack.json, an entry, modules, images and data. The same folder runs dropped into scripts/, and it is what you upload to the store as a zip (Author dashboard › Versions).
- 1pack.json: version is required (like 1.2.0), entry is the file that starts it (main.lua by default), name is its name in the script list, evolve is the build it was written for.
- 2The folder's name is the pack's name. Modules load from it: require("my_pack.lib.util") reads lib/util.lua. In the store the folder is named after the listing's slug.
- 3A path starting with ./ is your pack's folder: renderer.load_image("./art/icon.png"), menu icons "./art/…", fs.read("./data/x.json"). You never need the folder's name.
- 4A pack may hold .lua, images .png .jpg .bmp .tga .gif (up to 4096 px a side) and data .json .txt .md .csv. The zip is up to 16 MB, up to 1500 files.
- 5On upload every .lua is compiled and every image and json is opened. The answer lists every problem at once, with the file and the line, and an admin reads the version after that.
renderer.load_image(path)→ numberAn image by path: "./art/x.png" from your own pack, "panorama/images/…" from the game. Returns a handle or -1; cached by path.
Watch outLoad once when the script loads, not every frame. After a script reload a changed file is picked up on its own.
renderer.load_image_bytes(bytes [, key])→ numberAn image from bytes the script already holds (fs.read, http). Cached by key, or by the content without one.
-- scripts/my_pack/pack.json : { "version": "1.0.0", "name": "My Pack" }
-- scripts/my_pack/main.lua
local util = require("my_pack.lib.util") -- scripts/my_pack/lib/util.lua
local icon = renderer.load_image("./art/icon.png")
local cfg = json.decode(fs.read("./data/defaults.json") or "{}")
local s = {}
function s.on_draw()
local me = client.local_hero()
if not (me and me:valid()) or icon < 0 then return end
local x, y, on = renderer.world_to_screen(me:position())
if on then renderer.image(icon, x - 16, y - 80, 32, 32) end
end
return sevents
3Callbacks. Anything arriving from an engine thread is buffered and drained by the frame pump, so a handler runs alongside on_update and may call the whole API.
- 1Two ways to subscribe: return a { on_update = fn } table from the file, or call events.register. Both live until the script reloads.
- 2Event payloads are valid only inside the callback — read them now, don't stash the table for later.
- 3A handler that throws is detached with a traceback; diag.errors() shows it inside the client.
events.register(name, fn)Subscribe to a callback.
- namestring
- The callback name, e.g. "on_update".
- fnfunction
- The handler. Its arguments depend on the event.
"on_update" fn()A logic tick, every frame. Where orders.* and all game logic belong.
"on_draw" fn()The frame. renderer.* works here and only here.
"on_game_event" fn(name, data)Any native event: there is no per-name subscription, everything arrives.
- namestring
- The engine event name, e.g. "dota_player_gained_level".
- datatable
- Every field of the event by name, snapshotted when it fired.
Watch outDelivered by the frame pump, at most one frame after it fired, in fire order.
"on_key" fn(vk, down)A raw key from wndproc.
- vknumber
- The Windows virtual-key code.
- downboolean
- Press or release.
Watch outThe return value is ignored: a script cannot swallow a key from the game.
"on_projectile" fn(p)A tracking projectile was created.
- ptable
- handle, source, target, ability, move_speed, is_attack, dodgeable, expire_time, max_impact_time.
Watch outThe entity fields resolve at delivery: anything that died inside that frame arrives as nil.
"on_modifier_gained" fn(npc, mod)A modifier appeared. Its pair is on_modifier_lost(npc, name).
- npcEntity
- Who got it.
- modtable
- name, stacks, duration and the rest of the modifier fields.
"on_prepare_unit_orders" fn(o)An order is leaving for the server. Return false to block it; o.synthetic separates a script's order from a human click.
- otable
- order, target, position, ability, unit, queue, issuer, synthetic.
Watch outThe only callback still run inline on the game thread. The engine waits 2 ms for the VM and, failing that, lets the order through unvetoed — count those in events.veto_timeouts().
timers.after(sec, fn)→ numberA one-shot timer, returns its id. timers.every(sec, fn) and timers.clear(id) sit next to it.
- secnumber
- How many seconds of game time from now.
- fnfunction
- What to run.
events.dropped()→ numberHow many events were lost to a full queue. Should stay 0.
local s = {}
function s.on_projectile(p)
local me = client.local_hero()
if not (me and me:valid()) then return end
if p.target == me and p.dodgeable then
log.info("incoming, impact in " ..
string.format("%.2f", p.max_impact_time - globals.curtime()))
end
end
return sentities
4The entity lists. The snapshot refreshes about 20 times a second and userdata is interned, so an entity works as a table key.
- 1A filter is a plain table: { team = "enemy", alive = true, around = pos, radius = 600, class = "creep" }. class matches a substring of the class name.
- 2team is the string "enemy" or "ally", not a number.
- 3About half the live neutrals on the map are a camp's pre-spawned next wave: invulnerable, and filtered out with e:is_waiting_to_spawn().

entities.all()→ Entity[]Every entity in the client snapshot.
entities.heroes(team)→ Entity[]Heroes, optionally from one side only.
- teamstring?
- "enemy", "ally", or nothing for all of them.
entities.npcs(filter)→ Entity[]Units by filter: creeps, illusions, summons, towers.
- filtertable?
- team / alive / around / radius / class.
entities.in_radius(pos, radius, filter)→ Entity[]Everything standing inside the circle.
- posVector
- The centre of the circle.
- radiusnumber
- Radius in world units.
- filtertable?
- The same filter npcs takes.
entities.closest(pos, filter)→ Entity|nilThe nearest unit, excluding yourself.
- posVector
- Where to measure from.
- filtertable?
- The same filter npcs takes.
entities.by_class(name)→ Entity[]Exact class identity plus everything derived from it — not a name guess.
- namestring
- The C++ class, e.g. "C_DOTA_BaseNPC_Tower".
entities.trees()→ Entity[]Trees. entities.runes() and entities.physical_items() — items lying on the ground — sit next to it.
entities.get(index)→ Entity|nilAn entity by index; entities.from_handle(h) takes a handle.
- indexnumber
- The entity index.
local s = {}
function s.on_draw()
local me = client.local_hero()
if not (me and me:valid()) then return end
for _, h in ipairs(entities.heroes("enemy")) do
if h:valid() and h:is_alive() and not h:is_dormant() then
renderer.circle_3d(h:position(), h:attack_range(), color(255, 80, 80, 120))
end
end
end
return sentity
5The entity object. One type for a hero, a creep, an ability and an item — which methods mean anything depends on what it is.
- 1e:valid() comes first. An entity that is gone answers with defaults, not an error.
- 2An entity in fog (e:is_dormant()) is frozen at its last seen state: position, health and inventory are stale.
- 3Key a table by e:handle() — an index gets reused, a handle carries a serial too.
e:valid()→ booleanThe entity is still in the game.
e:position()→ VectorWorld position. e:origin() is the same call.
e:health()→ numberCurrent health. max_health(), health_pct() (0..100), mana() and mana_pct() sit next to it.
e:is_alive()→ booleanAlive. is_enemy(), is_ally(), is_illusion(), is_hero() and is_dormant() sit next to it.
e:distance(target)→ numberDistance in world units.
- targetVector|Entity
- A point or another entity.
e:ideal_speed()→ numberExact current move speed with every modifier folded in: boots, haste, slows, auras.
Watch oute:move_speed() is the networked base stat with no modifiers. Movement wants ideal_speed.
e:effective_bat()→ numberBase attack time with modifiers folded in — what an attack timeline is built from. e:aps() and e:seconds_per_attack() sit next to it.
e:is_stunned()→ booleanA unit state bit. is_silenced, is_rooted, is_hexed, is_disarmed, is_magic_immune and is_invulnerable read the same way.
Watch outThe bits answer yes or no and carry no clock. How long is left comes from the modifiers, not from the bit.
e:modifiers()→ table[]Every modifier: name, class, stacks, duration, creation_time, die_time, ability, caster, parent, is_aura.
Watch outparent is the buffed unit, and for a debuff that is not the caster.
e:has_modifier(name)→ booleanA quick check for one modifier; e:get_modifier(name) hands back its table.
- namestring
- The internal name, e.g. "modifier_bloodseeker_thirst".
e:handle()→ numberThe full 32-bit handle: index plus serial. A safe key across frames.
e:visible_to_enemy()→ booleanWhether the enemy sees you. Reliable for your own units.
abilities
6Abilities and items are entities too, with their own methods: cooldown, mana, range, cast point and the KV data behind them.
- 1Cooldown, mana and cast phase replicate to the owner only. On someone else's hero an ability always looks ready — that is "we were not told", not "off cooldown".
- 2e:get_ability(name) hands back an invalid entity on a miss, not nil, and scans the snapshot on every call. Resolve once and keep it in a local.
- 3Whether a spell goes through BKB is ab:immunity_type() == 1, not a guess from the name.

hero:get_ability(name)→ EntityAn ability by name.
- namestring
- The internal name, e.g. "lion_impale".
hero:abilities()→ Entity[]Every ability on the hero, in slot order.
Watch outScans the snapshot on every call — cache it instead of calling it inside a draw loop.
hero:get_item(slot)→ EntityThe item in a slot; hero:items() gives the whole inventory.
- slotnumber
- Inventory slot, counted from zero: 0..5 is what the hero carries, backpack and stash follow.
ab:cooldown()→ numberRemaining cooldown in seconds, 0 means ready.
ab:is_ready()→ booleanLevel above zero and cooldown expired.
ab:mana_cost()→ numberCost at the current level; ab:ability_level() gives the level itself.
ab:cast_range()→ numberCast range with every bonus folded in.
ab:cast_point()→ numberCast point in seconds — the delay between the order and the effect.
ab:special(key, level)→ numberAny number out of the ability data, with talents and aghanim's folded in.
- keystring
- A key from AbilityValues, e.g. "duration" on lion_impale. The keys come from the ability data, not from a guess.
- levelnumber?
- The level; -1 or nothing means the current one.
Watch outOn an unleveled ability the current level answers 0 — pass the level explicitly: ab:special("duration", 0).
ab:immunity_type()→ number|nil0 none, 1 pierces on enemies, 2 does not, 3 and 4 are the same for allies.
Watch outnil means "could not read it", which is not the same as 0.
ab:dispellable_type()→ number|nil0 none, 1 strong dispel, 2 basic, 3 no.
it:item_charges()→ numberItem charges: bottle, wand, urn. Ability charges are ab:charges() instead.
local s = {}
local hex
function s.on_update()
local me = client.local_hero()
if not (me and me:valid()) then return end
hex = hex or me:get_ability("lion_voodoo")
if not (hex and hex:valid() and hex:is_ready()) then return end
if me:mana() < hex:mana_cost() then return end
for _, h in ipairs(entities.heroes("enemy")) do
if h:is_alive() and not h:is_magic_immune()
and me:distance(h) < hex:cast_range() then
orders.cast_target(hex, h)
return
end
end
end
return sorders
7Real engine orders, down the same path a mouse click takes. Send them from the frame, inside on_update.
- 1Every order takes an optional trailing queue argument: orders.move_to(pos, true) queues like a shift-click.
- 2An order from another thread — a GC callback, a net hook, your own thread — must be buffered and leave from on_update. Calling the engine off-thread deadlocks the client on a mutex.
- 3An order every frame is not faster, it is spam: the server acts on the last one, and the order stream is visible in a replay.

orders.move_to(pos, queue)A move order. attack_move(pos) and move_to_target(entity) sit next to it.
- posVector
- Where to walk.
- queueboolean?
- Queue instead of replacing.
orders.attack(target, queue)Attack a target.
- targetEntity
- What to attack.
- queueboolean?
- Queue it.
orders.cast_target(ability, target, queue)Cast at a unit.
- abilityEntity
- The ability or item.
- targetEntity
- The cast target.
- queueboolean?
- Queue it.
orders.cast_position(ability, pos, queue)Cast at a position.
- abilityEntity
- The ability or item.
- posVector
- The cast point.
- queueboolean?
- Queue it.
orders.cast_no_target(ability, queue)A no-target cast; cast_toggle(ability) flips a toggle.
- abilityEntity
- The ability or item.
- queueboolean?
- Queue it.
orders.use_item(item, target)Use an item.
- itemEntity
- An item from the inventory.
- targetEntity|Vector?
- The target or point, if the item takes one.
orders.stop()Stop; orders.hold() holds position.
orders.unit_move_to(unit, pos, queue)An order for a controlled unit rather than your hero; unit_attack works the same.
- unitEntity
- Who acts: a summon, the courier, a second meepo.
- posVector
- Where to walk.
- queueboolean?
- Queue it.
orders.raw(type, opts)The full dotaUnitOrder_t set, for anything the named calls do not cover.
- typenumber
- The order code from orders.types.
- optstable
- target, position, ability, unit, queue, issuer, show_effects.
renderer
8An immediate d3d11 renderer. Anything drawn in on_draw lives one frame; a world primitive can instead be registered once and reprojected by C++.
- 1Colour is color(r, g, b, a), 0..255. Text and measurement take the font first; the global font_default is already there.
- 2world_to_screen returns three values, and the third is "the point is on screen at all". Skip it and things behind you draw mirrored.
- 3Pre-warm a font: metrics taken before the atlas rebuild hand back the 47px fallback, and a layout sized from them will be wrong.
renderer.world_to_screen(pos)→ number, number, booleanThe projection: x, y and whether it is on screen.
- posVector
- A world point.
renderer.text(font, x, y, col, str, align, shadow)Text on screen; renderer.measure_text(font, str) gives its width and height.
- fontFont
- The font; the global font_default will do.
- x, ynumber
- Screen coordinates.
- colColor
- The colour.
- strstring
- The text.
- alignstring?
- A combination of "c" centre, "r" right, "v" vertical.
- shadowboolean?
- A shadow behind the text.
renderer.load_font(name, size, weight, flags)→ FontA font, cached by its parameters — calling it again is safe.
- namestring
- A system font name.
- sizenumber
- Size in pixels.
- weightnumber?
- Weight.
- flagsnumber?
- Style flags.
renderer.line(x1, y1, x2, y2, col, thick)A line. rect, rect_outline, rounded_rect, triangle, poly and gradient sit next to it.
renderer.circle_filled(x, y, r, col, segs)A filled screen circle; renderer.circle draws the outline.
renderer.circle_3d(center, radius, col, segs, thick)A ring on the ground — a range indicator like the game's own.
- centerVector
- The centre in the world.
- radiusnumber
- Radius in world units.
renderer.load_image(path)→ ImageAn image; renderer.image(handle, x, y, w, h, tint) draws it.
- pathstring
- A file on disk or a panorama resource from the game itself.
renderer.world_circle(id, center, radius, col, opts)A world shape C++ then carries: register it on the tick, it is reprojected every frame. world_line, world_poly, world_text, world_image and world_clear sit next to it.
- idstring
- The shape's id: the same string replaces the previous one.
- centerVector
- The centre.
- radiusnumber
- Radius in world units.
- colColor
- The colour.
- optstable?
- ttl (0.25 s by default), thickness, segments, filled, layer.
Watch outRegistering is not drawing: call it from on_update, and it lands under whatever on_draw draws.
local s = {}
local font = renderer.load_font("Verdana", 13)
function s.on_draw()
local me = client.local_hero()
if not (me and me:valid()) then return end
for _, h in ipairs(entities.heroes("enemy")) do
if h:is_alive() then
local x, y, on = renderer.world_to_screen(h:position())
if on then
local w = renderer.measure_text(font, h:health() .. "")
renderer.text(font, x - w / 2, y - 60, color(255, 90, 90, 255), h:health() .. "")
end
end
end
end
return sui
9The menu. Controls are declared once at file scope, survive a hot reload, and give their value through :get().
- 1The first argument is "tab.group", split on the first dot; with no dot the control lands in the group named general.
- 2A setting's key is "tab.group.label". Rename the label and every value saved under it is gone; for cosmetics there is c:caption().
- 3For combo and listbox :get() answers a zero-based index: options[c:get() + 1].
ui.checkbox(group, label, default)→ ControlA checkbox.
- groupstring
- "tab.group".
- labelstring
- The label, which is also part of the setting key.
- defaultboolean
- The value on a first run.
ui.slider_int(group, label, min, max, default, opts)→ ControlAn integer slider; ui.slider_float adds a decimals argument.
- min, maxnumber
- The bounds.
- optstable?
- { unit = " ms", min_text = "Off", max_text = "Max" }.
ui.combo(group, label, items, default_index)→ ControlA dropdown; ui.multiselect and ui.listbox work the same way.
- itemsstring[]
- The options.
- default_indexnumber
- A zero-based index.
ui.hotkey(group, label, key, mode)→ ControlA key binding. :get() answers "held right now", :key() gives the code bound to it.
- keynumber
- The default virtual-key code.
- modestring?
- "hold", "toggle", "always" or "off".
c:get()The current value; c:set(v) writes it.
c:on_change(fn)→ ControlReact to a change. Returns the control, so calls chain.
- fnfunction
- Called with no arguments — read the value with :get().
c:depend(other, expected, mode)→ ControlTie a control to another one.
- otherControl
- The parent control.
- expectednumber?
- The value that keeps this one live, 1 by default.
- modestring?
- "grey" shows it greyed, "hide" takes it off the page.
local enabled = ui.checkbox("Draw.ESP", "Enemy circles", true)
local radius = ui.slider_int("Draw.ESP", "Radius", 100, 900, 400, { unit = " u" })
radius:depend(enabled)
local s = {}
function s.on_draw()
if not enabled:get() then return end
for _, h in ipairs(entities.heroes("enemy")) do
if h:is_alive() then
renderer.circle_3d(h:position(), radius:get(), color(255, 80, 80, 120))
end
end
end
return sml
10In-process ONNX, synchronous or on a worker thread. A model is an ordinary file next to the script; the core knows nothing about it.
- 1onnxruntime.dll has to sit next to evolve.dll. ml.available() says whether it does, ml.gpu_available() whether DirectML is there.
- 2net:run is synchronous and costs 0.2-0.5 ms. Anything heavier goes to net:submit and comes back from net:poll a frame later.
- 3history hands back raw world values. Normalising them is the script's job, not the buffer's.
ml.load(path, provider)→ Model|nilA model, cached by path and provider. nil on failure, with the reason in ml.last_error().
- pathstring
- The .onnx file.
- providerstring?
- "cpu" by default, "gpu" or "auto".
net:run(input)→ Tensor|nilA synchronous run.
- inputTensor|Tensor[]
- One tensor, or a list matching the input count.
net:submit(input)An async run on a worker thread; net:poll() takes the result, net:busy() says it is still running.
ml.tensor(rows, cols)→ TensorA zeroed tensor. Indices are 1-based: t:set(r, c, v), t:get(r, c), t:table().
- rowsnumber
- Row count.
- colsnumber?
- Column count; without it you get a vector.
history.tensor(entity, frames)→ Tensor|nilA [frames, 11] motion buffer, zero-padded at the front. The channels are listed in history.CH.
- entityEntity
- Who to read.
- framesnumber
- How many frames of history, oldest first.
history.set_rate(hz)The rate of the shared buffer. history.track_all(true) widens it from heroes to every unit, which costs noticeably more.
- hznumber
- The sampling rate, 20 by default.
data
11Files, settings, the log and JSON. What a script needs to survive a client restart.
- 1Keep your own files in fs.datadir() and find a package's siblings from fs.scriptdir(). Don't recover a path from the chunk name in an error: Lua truncates it at 60 characters.
- 2For anything written all game long there is fs.append. A read-modify-write loop through fs.write is quadratic in the file size and loses everything if it dies mid-write.
log.info(msg)A line in evolve.log; log.warn and log.error sit next to it.
fs.read(name)→ string|nilRead a whole file; fs.exists and fs.listdir sit next to it.
- namestring
- The file path.
fs.append(name, str)Append, creating the parent directory. How to keep a log across a match.
fs.datadir()→ stringThe client's data/ directory, already created. fs.scriptdir() gives the absolute path to scripts/.
config.write_int(key, value)The script's typed store: read_int, read_float, read_bool, read_string and their write pairs, plus config.has and config.delete.
- keystring
- Your own key, unrelated to the menu.
- valuenumber
- The value.
config.get(path)A menu control's value by path; config.set(path, v) writes it.
- pathstring
- "tab.group.label" — the same key config.export() uses.
Watch outIt raises when the path names nothing, deliberately: otherwise a typo in the path silently looks like a setting that is off.
json.encode(value)→ stringA table to a string; json.decode(str) goes back.
diag.errors()→ table[]Script errors, folded to one row per distinct failure: event, script, msg, traceback, count. diag.profile(), with the time each handler costs, sits next to it.