Roguelike, step 6: monsters and movement

Previous tutorial – Download files – Next tutorial

Taking turns – Think before you act – How to do “What to do?” – Walking with a purpose – Attacking, the one thing we can agree on 

Taking turns

I think of Game:checkMove() as our main game loop.  After all, this is where the program reacts to whatever the player wants to do and leads to calls to every other function from there.  Which means, if we’re going to give the monsters a chance to act, we need to modify Game:checkMove() to make it happen.

function Game:checkMove(dx, dy)

  local entry, layer = + dx, self.hero.y + dy)
  if layer == LAYER_ENVIRONMENT then 
    local tile = manual:getEntry("environment", entry)
    if tile.blocked ~= true then, dx, dy)
      self.msg:add("that's a " .., MSG_DESCRIPTION) 
  elseif layer == LAYER_MONSTERS then 
    self:attackMonster(self.hero.x + dx, self.hero.y + dy)
  else, dx, dy)

After the hero either moves or attacks, let’s create the new function Game:turnOver() that let’s the program and the monsters react to what the hero does.  The core of Game:turnOver() is simply looping through the self.monsters.list and giving each monster a chance to act.

function Game:turnOver()

  --each monster gets a turn
  for id, m in pairs(self.monsters.list) do
    if m then
      --update their state based on current conditions
      self.monsters:updateState(m, id, self.hero)
      -- create a 500ms delay before calling the AI
      Timer.delayedCall(500, function() 
      --at the end of the turn, check for hero death 

In the for-loop, the program decides two things.  First, it calls updateState() in the Monsters class to figure out what the monster wants to do.  Second, it calls monsterAI() to make it happen.  The subtlety here is that the monsterAI() is called within Timer.delayedCall() which allows us to slow the game down so the player can see each monster take their turns.

Think before you act

When people considering implementing intelligent monsters , there’s a couple of ways to do it.  Programming theory is fun so I encourage you to read up on it and try to implement the coolest algorithm you find for your monsters.  Here though, I’m going to forgo a lot of the theory regarding evolving or recurrent neural networks, genetic algorithms or learned information state machines.  But we should still talk about the very basics of State.  What do programmers mean by State?  State for a monster, means they get to choose from a list of options about what to do.  They can only choose one state at a time, and a State machine, our Monsters:updateState() function, implements how to change from one option to another.

I keep it pretty simple here with three states to choose from


It’s up to you to decide what causes a monster to stop attacking and start fleeing.  We’ll use these variables added to the Monster class to decide what gets a monster from one state to another.

 self.state = "move"
 self.bloodied = false
 self.seesHero = false
 self.inRange = false 
 self.friends = false

At the start of the game, all the monsters start out in the “move” state and a call to updateState() goes through the logic of what gets them out of “move” and into one of the other states.  For us, this will depend on whether they are bloodied or not, whether they can see and are in range of the hero and also if there are nearby monsters.

Other programs get way more sophisticated with state and I’m sure yours will to.  For instance, instead of the three listed above, consider


Monster behavior can get quite sophisticated quite fast.  So let’s look at a simple Monsters:updateState() function.

function Monsters:updateState(m, id, hero)

  --update the bloodied variable
  m.bloodied = (m.hp <= m.bloodiedHP)
  --update the friends variable
  for i = 1, #self.list do
    if id ~= i then
      local other = self.list[i] 
      if math.abs(m.x - other.x) <= 6 or math.abs(m.y - other.y) <= 6 then 
        m.friends = true
  --update the seesHero variable
  m.seesHero = (math.abs(m.x - hero.x) <= 6 or math.abs(m.y - hero.y) <= 6)
  --if not, just move
  if not m.seesHero then
    m.state = "move"
    --update based on their tactical role
    if m.tactics == "minion" then
      --minions flee if bloodied or alone
      if m.bloodied or not m.friends then
        m.state = "flee"
        m.state = "move"
    elseif m.tactics == "skirmisher" then
      --skirmishers flee if bloodied and alone
      if m.bloodied and not m.friends then
        m.state = "flee"
        m.state = "move"
      --skirmishers change to their melee weapon if the hero is close
      if (math.abs(m.x - hero.x) == 1 and math.abs(m.y - hero.y) == 1) then
        m.weapon = m.weapons[2]
    elseif m.tactics == "soldier" and m.bloodied then
      --soldiers change to their berserk weapon if bloodied
      m.weapon = m.weapons[2]
    --next check if the hero is in range
    m.inrange = (math.abs(m.x - hero.x) <= m.weapon.reach and 
                math.abs(m.y - hero.y) <= m.weapon.reach)

    --so far the states have been changed to flee or move. 
    --update to attack if in range.
    if m.inrange and not (m.state == 'flee') then
      m.state = 'attack'

updateState() goes through the state variables and updates them one by one.  If they can’t see the hero, state is set to “move”.  It then takes into account the monster’s tactical role and uses all the information to change to “move” or “flee”.  At the end, it switches to “attack” if they’re in range of the hero and their state isn’t “flee”.

How to do “What to do?”

After updating the state of each monster, we then go to Game:MonsterAI().  MonsterAI() checks and responds to each state separately.  First, when state == “move”.

 if monster.state == "move" then
   --dx, dy are the direction coordinates where the monster will move
   local dx, dy = 0, 0
   if monster.seesHero then
     --move towards the hero
     dx, dy =, self.hero.x, self.hero.y)
     --move randomly in one of four directions
     local directions = {{-1, 0}, {1, 0}, {0, -1}, {0, 1}}
     local successful = false
     local tries = 0
     while not successful do
       local d = directions[math.random(1, 4)]
       dx, dy = d[1], d[2]
       if not + dx, monster.y + dy) then
         successful = true
       tries = tries + 1
       if tries == 4 then successful = true end
   end, dx, dy)

If the monsters sees the hero, then call WorldMap:whichWay() to figure out which direction to step.  More on whichWay() shortly.  Meanwhile, if they don’t see the hero, they move randomly.  This is accomplished by picking one of the four directions and seeing if the tile is blocked.  At the end, call WorldMap:moveMonster() to actually make the move.

function WorldMap:moveMonster(m, dx, dy)

  --get the array and entry
  local array = self.mapArrays[LAYER_MONSTERS]
  --erase the monster in the array and TileMap
  array[index(m.x, m.y)] = 0
  self.mapLayers[LAYER_MONSTERS]:clearTile(m.x, m.y)
  --place the monster at the new position
  array[index(m.x + dx, m.y + dy)] = m.entry
  self.mapLayers[LAYER_MONSTERS]:setTile(m.x + dx, m.y + dy, m.entry, 1)

  --remove and move the HPbar
  array = self.mapArrays[LAYER_HP]
  array[index(m.x, m.y)] = 0
  self.mapLayers[LAYER_HP]:clearTile(m.x, m.y) 
  array[index(m.x + dx, m.y + dy)] = m.HPbar
  self.mapLayers[LAYER_HP]:setTile(m.x + dx, m.y + dy, m.HPbar, 1)

  --update x and y
  m.x = m.x + dx
  m.y = m.y + dy

moveMonster() ensures that all the variables that need to be updated are updated.  This includes monster.x and monster.y, but also the affected arrays in mapArrays and the TileMap layers in mapLayers.

If the hero is in sight, then WorldMap:whichWay() results in a step towards the hero.  Let’s create that function next.

Walking with a purpose

whichWay() requires some thought because something could be in a way of the desired target.  If the monster m wants to get to the target t in the example below, the direction (dx, dy) = (-1, 0) will get them there but only if the wall isn’t in the way.

 . . . . .
 . t W m .
 . . . . .

Here, the monster should try (0,-1) or (0,1) if (-1,0) is blocked.  If both of those directions are also blocked, stepping backward with (1,0) should also be considered.  A list of possible moves for each direction is needed then.

function WorldMap:whichWay(monster, tX, tY)

  local x, y = monster.x, monster.y
  -- the direction variables to return and the default values 0, 0
  local dx, dy = 0, 0
  -- the table of directions to try 
  local moves = nil 
  --one of these directions is true
  if x > tX and math.abs(y - tY) <= math.abs(x - tX) then 
    -- forward is west
    moves = {{-1, 0}, {0, 1}, {0, -1}, {1, 0}} 

Most of whichWay() tries to figure out, based on direction, the list of moves to test.  Once it decides on the list to use, it checks to see if each is blocked.  Note that staying still with (dx, dy) = (0, 0) is the default move if none of the others work.

 for i, movein ipairs(moves) do
   dx, dy = move[1], move[2]
   if not self:blocked(x + dx, y + dy) then
 return dx, dy

And that’s how monsters decide which way to move.  The same logic can be used to make them flee.  In fact, from MonsterAI(), the call is whichWay() is the same as if the monster.state is “flee”.

 elseif monster.state == "flee" then
   --move away from the hero
   dx, dy =, self.hero.x, self.hero.y), dx, dy)

Inside whichWay(), from the set of moves to choose from, the fourth move was also retreat.  So let’s reverse the table moves and use the new reserveMoves table to “flee” instead of “move”.

 if monster.state == "flee" then
   local reverseMoves = {}
   for i,v in ipairs(moves) do
     reverseMoves[v] = i
   moves = reverseMoves

Getting back to MonsterAI(), we’ve decided what to do for the “move”and “flee” states.  What remains is the “attack” logic.

 elseif monster.state == "attack" then
   --attack the hero
   self:basicAttack(monster, self.hero)
   --check to see if the hero died
   if self.hero.hp < 1 then
     self.msg:add("you died", MSG_DEATH)

What MonsterAI() does is simply call a basicAttack() function.  We write that next.

Attacking, the one thing we can agree on

Actually, we  already wrote it.  basicAttack() is attackMonster() except now the tables are turned.  If we copy/paste attackMonster() and write it a little more generically, we’ll have a function we can use for both attackMonster and attackHero.  attackMonster() was called with coordinates and decided to use the hero’s weapon to attack.

function Game:attackMonster(x, y) 
  local weapon = self.hero.weapon

This can be easily changed to a basicAttack() call with attacker and defender variables.

function Game:basicAttack(attacker, defender)
  local weapon = attacker.weapon

The rest of the function is just as easy to rewrite.  Doing so results in a basicAttack() function that’s good to go for our monsters.  For the hero, attackMonster() can also be quickly rewritten to take advantage of our basicAttack() function.

function Game:attackMonster(x, y)
  --find the monster being attacked and their index in monsters.list 
  local monster = nil 
  local id = 0 
  for i = 1, #self.monsters.list do
    monster = self.monsters.list[i] 
    id = i
    if monster.x == x and monster.y == y then 
  --call the attack function
  self:basicAttack(self.hero, monster)

If we run the program now, the program will journey through the chain of functions we just wrote, from the initial turnOver() to the shared basicAttack().


For this step, we introduced State and a State machine.  We then created a series of move functions so our monsters can move towards the hero before attacking, including whichWay(), our pathfinding algorithm that decided how the monsters move.  Admittedly, whichWay() is basic pathfinding 101 at best.  A monster in the following situation will never get to their target.

 . . W W .
 . t W m .
 . . W W .

Remember this simplicity when you design bigger and cooler dungeons and you see monsters getting stuck in such situations.  If that happens, you’ll have to read up on the cooler algorithms such as A* or Dijkstra and implement them inside whichWay() instead.

As is, whichWay() works to get the monsters into position to attack the hero.  Our game is now a series of

  1. Hero attacks monster.  Monster takes damage.
  2. Monsters attack hero.  Hero takes damage.
  3. Monsters die and are removed.
  4. Hero dies and isn’t removed.

So while our monsters now have a fighting chance, we still haven’t dealt with what happens when they actually defeat the hero.

Next up:  The agony of defeat (plus the sounds of battle!)