Dungeon 82
In my roles as Product Owner and Chief Game Designer, I have thrown me in my role as Programmer a curve: We want me to make the system turn-based.
Most credible combat systems seem to be turn-based, with the bad guys and good gals taking turns. So we (the Dungeon team) believe that our combat should be turn-based. That being the case, it seems to make sense to have the system be always turn based, with the player doing something, then the monsters, and so on.
Our design today is quite unlike that. The monsters each have a personal timer running, causing them to move at a random interval. That would have to be removed. (They also have an animation timer running, which is probably OK.) The player moves whenever the operator presses an arrow key (or WASD), without regard to anything else that’s going on.
During an Encounter, currently, the ability of both monsters and the player to move is turned off, then turned back on when the encounter stops.
I really had no expectation of doing this. It seemed to me to make more sense for the player and monsters to be asynchronous, and in particular it looks rather nice when you see one monster moving separately from another, when you’re in the room with two or more. So this change is a bit out of the blue.
For purposes of these articles, I like changes out of the blue, because they test my belief that a good design–one that is suitable for what the product does today–is always a reasonable starting point for most any design change that may become necessary. I’m sure there are exceptions, but they are rare enough that I believe we need not do much, if any, design “for the future”. I like testing that here in the Jeffries Lab, where it’s safe, no mice will be harmed, and we can see what happens.
Let’s get to it.
Turn-Based Play
What do we mean by turn-based play in our game? I think it’s pretty obvious, which means we’d likely do well to list out the characteristics we’re looking for.
- Throughout game play, the player and monsters take turns moving (and other actions that may arise).
- When it is the player’s turn to move, the monsters do not move. (We might find that we don’t like this decision: if the player stands idle, the monsters should perhaps be able to sneak up on her.)
- After the player takes an action, the monsters get a turn.
- Different monsters might take different actions, or move at different speeds.
- It is likely that the monsters will take their turn in essentially zero time, but it may be desirable for the player’s controls to be dimmed during monster action.
- It is possible that there might be monster actions that take an extended period of time. What if a gang of monsters wants to coordinate an attack? We might want to see them move a few times before the player can react. Monsters might be fast.
As usual, the product people want everything. Plus cake. I am not even surprised.
Let’s start with a look at the code that locks everyone from moving:
function GameRunner:monsterCanMove()
return not self.freezeMonsters
end
function GameRunner:startMonsters()
self.freezeMonsters = false
end
function GameRunner:stopMonsters()
self.freezeMonsters = true
end
That first function is used here:
function Monster:chooseMove()
if self.runner:monsterCanMove() then
if not self.alive then return end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
else
self:makeRandomMove()
end
end
self:setMotionTimer()
end
function Player:moveBy(aStep)
if not self.runner:monsterCanMove() then return end
self.runner:clearTiles()
self.tile = self.tile:legalNeighbor(self,aStep)
self.tile:illuminate()
end
In the case of monsters, if motion is blocked, they just set their timer again and try again later. In the case of the player, any keypresses or button touches asking for a move will be ignored. (We’re not doing anything with the Fight/Flee buttons but presumably they would be similarly conditioned.
Do we want to extend this particular logic, or remove it? I think in the case of the monsters, we’ll remove it to a higher level. Perhaps that will be true for the player as well. We might turn off the buttons, or retain the last button pressed and apply it as needed. Turning them off appeals to me more.
However, since this change will take some time, and probably multiple releases before it fully works, I think we’ll leave this code in. We’ll need it because it moderates our current Encounters, and those won’t be replaced until we figure out our new combat.
So let’s put the new feature in on top of this one, if we can figure out how.
I think the first step will be to remove the monster movement timer, and then make them move again, all at once. And let’s have an indicator (flag) that tells us it’s their turn, and we can toggle that when the player moves.
This …
function Monster:chooseMove()
if self.runner:monsterCanMove() then
if not self.alive then return end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
else
self:makeRandomMove()
end
end
self:setMotionTimer()
end
Becomes this:
function Monster:chooseMove()
if not self.alive then return end
if self:distanceFromPlayer() <= 10 then
self:moveTowardAvatar()
else
self:makeRandomMove()
end
end
We’ll find calls to setMotionTimer
and remove them, and that function:
This is right out:
function Monster:setMotionTimer(base)
if self.motionTimer then tween.stop(self.motionTimer) end
self.motionTimer = self:setTimer(self.chooseMove, base or 1.0, 0.5)
end
This …
function Monster:startAllTimers()
self:setAnimationTimer()
self:setMotionTimer()
end
Becomes this …
function Monster:startAllTimers()
self:setAnimationTimer()
end
Then there’s this:
function Monster:rest()
if self.motionTimer then
tween.stop(self.motionTimer)
self:setMotionTimer(5.0)
end
end
The effect of this function is to cause a monster who is stepped upon by another monster to pause five seconds before moving again. This will probably have to be changed. Let’s just empty the function and see what we see when this all works again.
function Monster:rest()
end
Now, if I’m not mistaken, the game will still play but monsters will not move. That turns out to be the case. If I were the branching sort of person, I’d branch now. But I’m not, so I’m going to commit: monster timers removed, monsters cannot move.
Now, fact is, we can’t really release this version, so you could argue that I should be doing it on a branch. If I were working with a team, I might put this half-feature in on a feature flag, leaving the monster timers in place. Since I have a fair chance of knowing everything that’s going on, I just won’t release a version that doesn’t work. This is not quite how a team should work, but I have an extremely small team here.
OK, now let’s see about making the monsters move. Now the motion timer told the monster to do chooseMove
, so if we’d do that somewhere else, all would be good.
I have an idea. What if, after the player completes a move, she were to tell the runner she has moved? Then the runner could do whatever is necessary, like move all the monsters. That seems reasonable. And, perhaps, once that function returns, it will be the player’s turn again. Let’s try it.
function Player:moveBy(aStep)
if not self.runner:monsterCanMove() then return end
self.runner:clearTiles()
self.tile = self.tile:legalNeighbor(self,aStep)
self.tile:illuminate()
self.runner:playerHasMoved()
end
Hm this could work.
function GameRunner:playerHasMoved()
self:moveMonsters()
end
Now that should be easy enough. GameRunner has a list of monsters.
function GameRunner:moveMonsters()
for k,m in pairs(self.monsters) do
m:chooseMove()
end
end
Can it be that this will do the job? I think it might. Let’s see.
Right away, however, we see a problem. If a monster is a few cells away from us, and we move away, it can never get any closer. That’s going to mean that all encounters are likely to be triggered by the player, unless we change how monsters can move. That’ll be fine, we can figure that out.
Curiously, I think we’ve just completed our conversion of the game to turn-based play. How about that? Commit: play is now turn-based.
Frankly, I’m Surprised
When I realized that going turn-based would probably be best if we converted the whole game to turn-based, I expected some trouble doing it. I thought there would be interlocks and switches and whatnot.
Turning off the automatic motion was easy, and that I was sure of. Just don’t do it. But when it occurred to me to just have the player tell the game she had moved, at which point it’s pretty obviously the monsters’ turn … it all just came together.
Nice. Is this a verification of my belief that design changes are relatively easy when the code is well-formed? Or were we just lucky this time?
Well, you know how I’m going to call it. Luck or skill, or a blend of each, this has gone remarkably well.
What can we do about this issue of the monsters not catching up? We could let them move twice. Or we could let them move diagonally if they want to. We could give them a chance of moving twice.
Let’s try that, it should be easy. Monsters have different speeds. Maybe we want to roll against that value, and decide how many times they can move. I’d say no more than three, and always one. No. For now, we’ll just let it be purely random. We’ll need a bit more thinking for the speed-based decision.
Let’s try this:
function GameRunner:moveMonsters()
for k,m in pairs(self.monsters) do
m:chooseMove()
if math.random() > 0.67 then m:chooseMove() end
if math.random() > 0.67 then m:chooseMove() end
end
end
That looks pretty decent. I’m not entirely sold: I think we’ll want to allow for a period to elapse with no player motion and then trigger a monster turn anyway, but we can leave that for next time.
For now: commit: monsters can move 1-3 squares per turn.
I think we’ll wrap this up with a quick Summary and maybe a look forward.
Summary
Well, that went nicely. I was expecting a bit of trouble, nothing major, but it turned out to be super easy. Just have the player object tell the game runner when a move has been made, and everything else drops out. We’ll still want the freezing logic during Encounters, but I suspect we’ll wind up adjusting that as we move to more explicit turns in combat.
I think it would be interesting to experiment with smarter monster motion. Maybe there should be a “party” of monsters, a roving band of monstrous marauders, who work in concert to wreak their evil upon unwary princesses. These despicable creatures might be able to hide and leap out, or to move to surround an innocent victim. That could be fun.
I do think we’re going to want to be able to add monsters during game play. That may call for a deferred collection, or some other such mechanism. Again, we’ll deal with it when we get there. This is the way.
One thing about this turn-based implementation that particularly surprised me was that I had been picturing that the player might no longer be able to hit motion keys as rapidly as they like, instead that they might have to wait. In retrospect, it’s pretty clear that usually, the monsters will just instantly move and it’s the player’s turn again. It’s possible that we’d like to put in a little intentional delay. You may have noticed in the movies above that the player and monsters move at the same time. It might look better with a half second delay.
Let’s Do It
You know what? It’s only 1018 and I still feel fresh. Let’s put that in. We need to put a check into the player like the one we had, saying whether it is OK for the player to move:
We have this:
function Player:moveBy(aStep)
if not self.runner:monsterCanMove() then return end
self.runner:clearTiles()
self.tile = self.tile:legalNeighbor(self,aStep)
self.tile:illuminate()
self.runner:playerHasMoved()
end
Let’s rename that method playerCanMove
, and set it to no.
function Player:moveBy(aStep)
if not self.runner:playerCanMove() then return end
self.runner:clearTiles()
self.tile = self.tile:legalNeighbor(self,aStep)
self.tile:illuminate()
self.runner:playerHasMoved()
end
function GameRunner:playerCanMove()
return not self.freezePlayer
end
function GameRunner:playerCanMove()
return not self.freezePlayer
end
Now where do we call these?
function Floater:startCrawl(stopAction)
self.stopAction = stopAction
self.yOff = self.yOffsetStart
self.buffer = {}
if self.stopAction and self.runner then self.runner:stopPlayer() end
self:fetchMessage()
end
I’m a bit worried about this because it won’t stop the monsters, but on the other hand they won’t do anything until I tell them to.
Now …
function GameRunner:playerHasMoved()
self:moveMonsters()
end
Here’s where we want our delay to be. For now, stop the player and move:
function GameRunner:playerHasMoved()
self:stopPlayer()
self:moveMonsters()
end
function GameRunner:moveMonsters()
for k,m in pairs(self.monsters) do
m:chooseMove()
if math.random() > 0.67 then m:chooseMove() end
if math.random() > 0.67 then m:chooseMove() end
end
self:startPlayer()
end
This should result in consistent game play for now.
Hm, I missed this:
Floater:56: attempt to call a nil value (method 'startMonsters')
stack traceback:
Floater:56: in method 'increment'
Floater:31: in method 'draw'
GameRunner:192: in method 'drawMessages'
GameRunner:154: in method 'draw'
Main:30: in function 'draw'
That requires this:
function Floater:increment(n)
self.yOff = self.yOff + (n or self:adjustedIncrement())
if self:linesToDisplay() > self.lineCount then
table.remove(self.buffer,1)
self.yOff = self.yOff - self.lineSize
end
if #self.buffer < self:linesToDisplay() then
self:fetchMessage()
end
if #self.buffer == 0 then
if self.stopAction and self.runner then self.runner:startPlayer() end
end
end
Now it should work as before … and it nearly does. I notice that the princess can move during a battle, though if she does, the monster can follow. But we must not be stopping the action properly somehow. No, we have this:
function Floater:startCrawl(stopAction)
self.stopAction = stopAction
self.yOff = self.yOffsetStart
self.buffer = {}
if self.stopAction and self.runner then self.runner:stopPlayer() end
self:fetchMessage()
end
Let me try this again.
No, I’m definitely moving during the crawl.
I’ve printed “starting” and “stopping” in the start/stop functions, and I am sometimes seeing two stops in a row, or two starts. There is a timing thing somewhere. And I think I know what it must be.
I think what happens is that now that a monster can move more than once, When monster motion starts, it sets the player not to move. Then it can move and then on its second or third move, start an attack. That sets the player not to move again. But at the end of monster motion, we clear the stop motion flag. That allows us to move even though the Encounter now logically should be holding down the don’t move button.
One possibility would be to make the flag a counter, ticking it up and down. That way lies danger, if someone doesn’t get a chance to undo it. Another possibility would be to have two flags, one for regular motion and the other for battle. And thinking of that tells us that if there were a bug on the counter thing, the same bug would be there for this scheme.
What this tells me is to revert and come at this again, probably tomorrow. Done.
Why revert? We were “so close”? Well, the reasons are two. First, it’s been about two hours, so it’s time for a break, and second, the defect killed my momentum. I could power through, but I suspect we’ll do better to come at the problem fresh next time.
I’ll see you then, I hope.