Skip to main content

Adding a Very Simple AI to Units

This tutorial will cover how to issue very simple orders to units. This tutorial uses a move order to make a unit wander inside an area randomly, and a cast order to make a unit cast an untargeted spell randomly.

This tutorial assumes a basic knowledge of lua scripting.

Drawbacks

  • This technique should not be used for units which need to perform more than one kind of order each. If a more advanced AI is required, you should check holdout_example's lua ai scripts.
  • Some functionality is hard-coded into this script. If you want to iterate on your game and change the behaviour often, I would suggest having some global constants or loading in the values from an external KV file. Doing this allows you to keep all the values in one place.

References

I've copied some units from holdout_example for testing, and copied Berserkers Call from Spell Library.

If you need help on making your own units or abilities, Noya's documentation is an excellent resource: Datadriven Units DataDriven Ability Breakdown - Documentation

Hammer Setup

In Hammer, I've placed an info_target entity named "spawn_loc_test" which can be found in lua. This allows me to place the units spawn location in Hammer without changing the lua scripts around. If you wish to do this, give each entity a unique name and place them where you want the spawn point on your map.

Lua Setup

In the InitGameMode() function we do a few things: seed the random number generator, create an empty table in order to keep track of every unit with behaviour, spawn some units, and set a thinker function up.

Settings which aren't relevant to this tutorial have been omitted, but in this function you can set up things like GameRules for your game mode.

function CAITesting:InitGameMode()
print( "Loading AI Testing Game Mode." )
-- SEEDING RNG IS VERY IMPORTANT
math.randomseed(Time())

-- Set up a table to hold all the units we want to spawn
self.UnitThinkerList = {}

-- Spawn some units
for i = 1,5 do
self:SpawnAIUnitWanderer()
end
for i = 1,3 do
self:SpawnAIUnitCaster()
end

-- Set the unit thinker function
GameRules:GetGameModeEntity():SetThink( "OnUnitThink", self, "UnitThink", 1 )
end

Spawning a Wanderer

This function will spawn a unit with wandering behaviour. The bounds which the unit wanders between are hard coded. An easy way to determine these bounds is to spawn a simple entity in Hammer (such as info_target), move it about and read the coordinates. In this example, my info_target entity is named "spawn_loc_test".

function CAITesting:SpawnAIUnitWanderer()
--Start an iteration finding each entity with this name
--If you've named everything with a unique name, this will return your entity on the first go
local spawnVectorEnt = Entities:FindByName(nil, "spawn_loc_test")

-- GetAbsOrigin() is a function that can be called on any entity to get its location
local spawnVector = spawnVectorEnt:GetAbsOrigin()

-- Spawn the unit at the location on the neutral team
local spawnedUnit = CreateUnitByName("npc_dota_creature_kobold_tunneler", spawnVector, true, nil, nil, DOTA_TEAM_NEUTRALS)

-- make this unit passive
spawnedUnit:SetIdleAcquire(false)

-- Add some variables to the spawned unit so we know its intended behaviour
-- You can store anything here, and any time you get this entity the information will be intact
spawnedUnit.ThinkerType = "wander"
spawnedUnit.wanderBounds = {}
spawnedUnit.wanderBounds.XMin = -768
spawnedUnit.wanderBounds.XMax = 768
spawnedUnit.wanderBounds.YMin = -64
spawnedUnit.wanderBounds.YMax = 768

-- Add a random amount to the game time to randomise the behaviour a bit
spawnedUnit.NextOrderTime = GameRules:GetGameTime() + math.random(5, 10)

-- finally, insert the unit into the table
table.insert(self.UnitThinkerList, spawnedUnit)
end

Spawning a Caster

This function will spawn a unit with casting behaviour. The bounds which the unit is spawned in are hard coded. The spell is an untargeted spell which requires no additional variables to cast.

function CAITesting:SpawnAIUnitCaster()
-- Generate a random location inside the neutrals area
local spawnVector = Vector(math.random(-768, 768), math.random(-64, 768), 128)

-- Spawn the unit at the location on the neutral team
local spawnedUnit = CreateUnitByName("npc_dota_creature_gnoll_assassin", spawnVector, true, nil, nil, DOTA_TEAM_NEUTRALS)

-- make this unit passive
spawnedUnit:SetIdleAcquire(false)

-- Add some variables to the spawned unit so we know its intended behaviour
-- You can store anything here, and any time you get this entity the information will be intact
spawnedUnit.ThinkerType = "caster"
spawnedUnit.CastAbilityIndex = spawnedUnit:GetAbilityByIndex(0):entindex()

-- Add a random amount to the game time to randomise the behaviour a bit
spawnedUnit.NextOrderTime = GameRules:GetGameTime() + math.random(5, 10)

-- finally, insert the unit into the table
table.insert(self.UnitThinkerList, spawnedUnit)
end

Thinker Function

This function gets called every second. It will read each of the units and determine if they should be issued with a new order, then issue that order.

function CAITesting:OnUnitThink()
if GameRules:State_Get() == DOTA_GAMERULES_STATE_GAME_IN_PROGRESS then

local deadUnitCount = 0

-- Check each of the units in this table for their thinker behaviour
for ind, unit in pairs(self.UnitThinkerList) do

-- The first check should be to see if the units are still alive or not.
-- Keep track of how many units are removed from the table, as the indices will change by that amount
if unit:IsNull() or not unit:IsAlive() then
table.remove(self.UnitThinkerList, ind - deadUnitCount)
deadUnitCount = deadUnitCount + 1

-- Check if the game time has passed our random time for next order
elseif GameRules:GetGameTime() > unit.NextOrderTime then

if unit.ThinkerType == "wander" then
--print("thinker unit is a wanderer")
--print("time: " .. GameRules:GetGameTime() .. ". next wander: " .. unit.NextWanderTime)

-- Generate random coordinates to wander to
local x = math.random(unit.wanderBounds.XMin, unit.wanderBounds.XMax)
local y = math.random(unit.wanderBounds.YMin, unit.wanderBounds.YMax)
local z = GetGroundHeight(Vector(x, y, 128), nil)

print("wandering to x: " .. x .. " y: " .. y)

-- Issue the movement order to the unit
unit:MoveToPosition(Vector(x, y, z))


elseif unit.ThinkerType == "caster" then

-- If you want a more complicated order, use this syntax
-- Some more documentation: https://developer.valvesoftware.com/wiki/Dota_2_Workshop_Tools/Scripting/API/Global.ExecuteOrderFromTable
-- Unit order list is here: https://developer.valvesoftware.com/wiki/Dota_2_Workshop_Tools/Panorama/Javascript/API#dotaunitorder_t
-- (Ignore the dotaunitorder_t. on each one)

print("casting ability " .. EntIndexToHScript(unit.CastAbilityIndex):GetName())

local order = {
UnitIndex = unit:entindex(),
AbilityIndex = unit.CastAbilityIndex,
OrderType = DOTA_UNIT_ORDER_CAST_NO_TARGET,
Queue = false
}
ExecuteOrderFromTable(order)
end

-- Generate the next time for the order
unit.NextOrderTime = GameRules:GetGameTime() + math.random(5, 10)
end
end

-- Make sure our testing map stays on day time
if not GameRules:IsDaytime() then
GameRules:SetTimeOfDay(0.26)
end

elseif GameRules:State_Get() >= DOTA_GAMERULES_STATE_POST_GAME then
return nil
end

-- this return statement means that this thinker function will be called again in 1 second
-- returning nil will cause the thinker to terminate and no longer be called
return 1
end

Finishing Up

If you need more advanced behaviour, an AI script should be used. The method covered in this tutorial can be extended up to a point however, for example casting a ground-targeted ability in a random area would be possible using only code posted here.

The full files for this example can be found here: https://github.com/Wigguno/AITesting

If you have any questions, the ModDota Discord helpdesk channel is always happy to help.