Everyone thinks they know how I should do combat. There are issues that daunt me. I’m sure there’s a pony in here somewhere.

Let’s summarize D&D combat, as I understand it, because I want something along those lines. This info is based on roll20.net.

Surprise
It is possible that the monsters surprise the princess, or that the princess surprises the monsters. Surprise only happens if at least one side is trying to be stealthy. If an entity is surprised, they can’t Move or Act in the first round, nor can they React.
Initiative
One side or the other has Initiative, which determines who goes first in the encounter. Thereafter, activity proceeds in that order, round robin.
Player Turn
in D&D, when you take your turn, you can move a distance that depends on your speed, and you can take one Action (see below). In addition, you can interact with one object, e.g. draw a weapon or open a door. In D&D the DM can determine whether the interaction is free or uses up your Action.
Movement
In D&D, movement is used a lot, to gain advantage. You can break up your movement into before Action and after. Thus you might run toward a monster, attack it, and run away. You can sometimes move through another creature’s space, even if it is hostile.
Combat Actions
The player can Attack (q.v.) Cast a spell, Dash, Disengage, Dodge, Hide, get Ready, or Use an object. (Some objects can be used without using up your Action, but some require you to use it.)
Attack
To Attack, you pick a target within range, and roll d20 to see if you hit it. In D&D, characters have an “Armor Class”, and your roll must exceed that value to count as a hit. We do not presently have that concept but I suspect we need it. In D&D, a roll of 20 always hits and a roll of 1 never hits. A roll of 20 is a Critical Hit (q.v.) D&D has lots more details here.

Attacks have a range, whether hand-to-hand or a “ranged” attack such as an arrow shot or the like.

Opportunity Attack
When you or a creature are fleeing or just passing by, there can be an “Opportunity Attack” as you pass. This may be more than we want in our rather simple game.
Cover
D&D allows for “Cover”, e.g. being behind a tree or wall. Cover basically increases your Armor Class.
Damage
At last we get to the good stuff. Entities have “hit points”. We’re using our Health variable for that at present. The value can go down when you’re injured, and up when you’re healed. Generally a player has a current maximum value for hit points.

When an entity takes damage, the damage amount is subtracted from their hit points. When hit points drop to zero, bad stuff happens.

In D&D, weapons of all kinds have an associated amount of damage, specified in dice: 1d4, 2d6, whatever. When you hit with the weapon, you roll to determine the damage done. If the hit is a Critical Hit, you roll the dice twice and add.

Entities can have various vulnerabilities and resistance to different attacks.

When your hit points drop to zero, you become unconscious. If the damage of the attack is great enough (enough to consume your max health points again), you die. I’m not sure whether to allow death in our final game or not.

D&D has lots of special behavior around what happens while you have zero hit points. I’m not at all sure what, if any, applies to us.

Application to Dung Program

What does all this mean to us? I think we’ll want to allow surprise at some point, perhaps not right away. We’ll want to roll Initiative to see who goes first. We could, in principle, have the player’s move sandwiched between two or more monster moves.

When a monster gets its turn, I envision that it will always attack, but we might want them to have the option to disengage and run away. In any case, it seems straightforward enough to have a little “Combat AI” that decides among a few monster actions.

If the monster attacks, it’ll roll for a hit and if it hits roll damage. Easy enough.

When the player gets their turn, they’ll need to take action using the user interface. I envision a GUI, represented now by the “Flee” and “Fight” buttons, where they choose what to do. There could be any number of options in the GUI, such as selection of a target, a weapon, etc.

After the player has selected what to do, and perhaps touched a “go” button, the game rolls for a hit and damage and reports the result. Then it’s the next entity’s turn.

This seems simple enough. And yet, I’m kind of stuck. If I had a smarter pair than my cat, like you, I’m sure we’d get unstuck. But here’s the issue:

It’s the Damn Floater

A key design aspect of the game is the Floater, messages that waft upward from the player (or, in principle elsewhere), narrating the game. We have three cases now: the initial game narration, a one-line report when you get an addition to an attribute, and the Encounter crawl, which is a multi-line report of an encounter.

Here’s what I wish would happen during combat. Suppose the monster attacks first.

The crawl says something like:

The Serpent attacks!
The attack is good!
You suffer 5 points damage!

Now it’s the player’s turn. I think we’d like the crawl to stop while the player dithers, which takes eons in computer time. Finally the player presses all the necessary buttons, and the crawl continues:

The princess casts Create Bird!
A bird appears and attacks the Serpent!
The serpent suffers 4 points damage! The serpent tries Poison Bite!
The bite succeeds!
You are poisoned!

It’s the player’s turn again. The crawl pauses. You dither. And so on …

Now let’s recall how the Floater works at present. It is started by being given a text provider, which can be called using coroutine.resume whenever the floater wants another line of text. The floater wants a new line of text after the bottom-most line has risen high enough to make room for another line.

Now the call to resume must return essentially immediately, or the whole system will stop running, because all this happens during screen draw events. And there’s no way for the code executed to just spin: again, this would lock up the whole system.

Perhaps we have a special return, such as “wait”, which causes the Floater to keep asking, but not to display or scroll upward until text comes back. Then the combat coroutine could check to see if the player has made their selection, and if not return “wait” and when they finally get around to it, roll the dice and return to the script.

However:

I believe it was Chet Hendrickson who commented during one of our Zoom Ensemble meetings that the Floater seemed like the wrong object to be controlling game behavior, and that the control should be at some other level. I’d agree that the name suggests that it’s just an I/O device of a particularly cute kind, but that the behavior is far too significant for a mere output device.

I just don’t have a better idea. Yet.

Maybe I should turn the Floater into a pure output device and just write to it when I feel like it. And maybe it scrolls up as it wishes. If it has another line in its buffer, it’ll display it. If it hasn’t, well, things scroll off. When new lines appear, they start showing up.

That would change the current Encounter scheme into something that sort of makes sense: it would just rip through its protocol, spitting out lines to the Floater, and when it’s done, it’s done. Days later, the floater would finally display the last line.

We’d have an issue, which is that we really don’t want the player to be able to do anything while the Encounter, or a Combat round, is going on. So we probably do need to synchronize the floater and the playerCanMove flag. We could do that by putting a command into the Floater’s buffer, causing it to send a suitable message to GameRunner.

Too Much Dithering

Too much speculation. We need to do something. Let’s get a decision up in this baby and try it. If it doesn’t work, that’s by Git gave us Revert.

I’m assuming that Floater needs to be changed to run from an array. I’m assuming we can add to the array any time we wish and that that’s how events will work.

But wait. How does the initial crawl work?

    self:runCrawl(self.initialCrawl)
...

function GameRunner:initialCrawl()
    coroutine.yield("Welcome to the Dungeon.")
    coroutine.yield("Here you will find great adventure,")
    coroutine.yield("fearsome monsters, and fantastic rewards.")
    coroutine.yield("Some of you may die, but I am willing")
    coroutine.yield("to make that sacrifice.")
end

Let’s make a new crawl coroutine that deals with an array, and use it here. Then we’ll have an array Floater with little effort.

I think I want to TDD this, and I want to do it in another project so a not to contaminate this one until I’m ready.

Let’s try this:

        _:test("Single Item", function()
            local ary = {"First Line"}
            local provider = Provider()
            provider:addItems(ary)
            local msg = provider:getItem()
            _:expect(msg).is("First Line")
        end)

I’m imagining an object, Provider, to which we add items as we wish, and which provides them to a consumer via getItem. This test requires me to create Provider:

1: Single Item -- Tests:17: attempt to call a nil value (global 'Provider')

Defining the class demands addItems and I think I know how to do that much:

Provider = class()

function Provider:init()
    self.items = {}
end

function Provider:addItems(array)
    for i,v in ipairs(array) do
        table.insert(self.items, v)
    end
end

Now the test demands getItem:

1: Single Item -- Tests:19: attempt to call a nil value (method 'getItem')

Now I had in mind that I’d make this thing a coroutine, because the current Floater expects a coroutine. But maybe it can just return a line or a signal that it has no line (yet).

function Provider:getItem()
    return self.items[1]
end

The test should pass.

1: Single Item  -- OK

New test:

        _:test("Two items", function()
            local ary = { "First Line", "Second Line" }
            local provider = Provider()
            provider:addItems(ary)
            _:expect(provider:getItem()).is("First Line")
            _:expect(provider:getItem()).is("Second Line")
        end)

This should fail seeing “First Line” and expecting “Second Line”.

2: Two items  -- Actual: First Line, Expected: SecondLine

Make it work …

Now what I was going to do was to keep an index and tick it along. But once we return a line, we’ll never need to return it again. Let’s do this:

function Provider:getItem()
    return table.remove(self.items, 1)
end

We’ll remove it! We’ve invented the FIFO! Whee! I expect the test to pass.

2: Two items  -- OK

Now what should happen if the table is empty? Let’s return a wait token, and let’s allow the creator to specify it.

        _:test("waiting", function()
            local ary = { "First Line", "Second Line" }
            local provider = Provider("...wait...")
            provider:addItems(ary)
            _:expect(provider:getItem()).is("First Line")
            _:expect(provider:getItem()).is("Second Line")
            _:expect(provider:getItem()).is("...wait...")
            _:expect(provider:getItem()).is("...wait...")
        end)

This’ll fail looking for wait:

3: waiting  -- Actual: nil, Expected: ...wait...

And we implement:

function Provider:init(default)
    self.default = default or "default"
    self.items = {}
end

function Provider:getItem()
    if #self.items < 1 then return self.default end
    return table.remove(self.items, 1)
end

It seems reasonable to think of the value as a default. It’s up to the user of Provider to decide what he wants to do about it. I expect this test to run.

3: waiting  -- OK

Now let’s provide some new items and pick up reading:

        _:test("adding new items", function()
            local ary = { "First Line", "Second Line" }
            local provider = Provider("...wait...")
            provider:addItems(ary)
            _:expect(provider:getItem()).is("First Line")
            _:expect(provider:getItem()).is("Second Line")
            _:expect(provider:getItem()).is("...wait...")
            _:expect(provider:getItem()).is("...wait...")
            provider:addItems({"how", "now", "provider"})
            _:expect(provider:getItem()).is("how")
            _:expect(provider:getItem()).is("now")
            _:expect(provider:getItem()).is("provider")
            _:expect(provider:getItem()).is("...wait...")
        end)

I expect this to run.

4: adding new items  -- OK

So this is a nice little object:

Provider = class()

function Provider:init(default)
    self.default = default or "default"
    self.items = {}
end

function Provider:addItems(array)
    for i,v in ipairs(array) do
        table.insert(self.items, v)
    end
end

function Provider:getItem()
    if #self.items < 1 then return self.default end
    return table.remove(self.items, 1)
end

Let’s move it over to the Dung game and commit. Then we’ll see if we can use it and get rid of our coroutine stuff.

When I went to commit, I found an uncommitted button refactoring. So commit is: Added Provider class. Also a Button refactoring.

Now let’s see what floater does and how we can adapt it to the Provider notion. I am hopeful that this will go readily, because if it doesn’t, I’ll need another idea, and I’ve already had my idea for the day.

Here’s Floater as it stands:

function Floater:init(runner, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    self.provider = nil
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
end

function Floater:draw()
    local n = self:numberToDisplay()
    if n == 0 then return end
    pushStyle()
    fill(255)
    fontSize(20)
    local y = self:yOffset()
    local pos = self.runner:playerGraphicCenter()
    pos = pos + vec2(0,self.yOff)
    for i = 1,n do
        text(self.buffer[i], pos.x, pos.y)
        pos = pos - vec2(0,self.lineSize)
    end
    self:increment()
    popStyle()
end

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
        self.listener:stopFloater()
    end
end

function Floater:fetchMessage()
    if coroutine.status(self.provider) == "dead" then return end
    local tf, msg = coroutine.resume(self.provider)
    if tf then
        if msg ~= nil then table.insert(self.buffer, msg) end
    else
        print("error: "..msg)
        table.insert(self.buffer, "error: "..msg)
    end
end

That’s the display side of things. Here’s how we get it going:

function Floater:runCrawl(aFunction, object)
    if self:okToProceed() then
        self.provider = coroutine.create(aFunction)
        self:startCrawl(object)
    else
        print("crawl ignored while one is running")
    end
end

function Floater:okToProceed()
    return self.provider==nil or coroutine.status(self.provider) == "dead"
end

function Floater:startCrawl(listener)
    self.listener = listener or FloaterNullListener()
    self.yOff = self.yOffsetStart
    self.buffer = {}
    self.listener:startFloater()
    self:fetchMessage()
end

Now I expect that when I get the provider plugged in, the floater will display whatever’s in the array and then perpetually display wait wait wait or whatever the default is. Then we’ll figure out how to make it stop.

I’d like to have a way to change this thing in tiny increments but I don’t see it. I’ll just go ahead, but watch the clock.

It’s 1054.

function Floater:init(runner, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    self.provider = Provider("...wait...")
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
end

That much seems reasonable …

function Floater:fetchMessage()
    local msg = self.provider:getItem()
    table.insert(self.buffer, msg)
end

I took out all the end-play logic. We’ll put it in in due time, when we figure out what we need.

I think increment is probably just fine. Now to get the ball rolling:

function Floater:runCrawl(array, listener)
    self.provider:addItems(array)
    self:startCrawl(listener)
end

Now I am sure there’ll be trouble on a second attempt to start the crawl, but one thing at a time here. Let’s now convert the initial crawl and see what happens.

function GameRunner:runCrawl(array)
    self.cofloater:runCrawl(array)
end

function GameRunner:initialCrawl()
    return { "Welcome to the Dungeon.",
    "Here you will find great adventure,",
    "fearsome monsters, and fantastic rewards.",
    "Some of you may die, but I am willing",
    "to make that sacrifice."}
end

Let’s run and see what explodes. It’s 1105.

attempt to index a function value
stack traceback:
	[C]: in for iterator 'for iterator'
	Provider:13: in method 'addItems'
	Floater:63: in method 'runCrawl'
	GameRunner:286: in method 'runCrawl'
	GameRunner:85: in method 'createLevel'
	Main:18: in function 'setup'

Somehow we’ve handed a function value to our provider. the problem is in GameRunner, which used to pass in a function.

    self:runCrawl(self.initialCrawl)

Should now be:

    self:runCrawl(self:initialCrawl())

Now it works just as I wanted:

wait

It scrolls “wait” forever. I imagine My inclination is to lave the Floater running all the time, printing nothing, and to leave it saying “wait” for now. That will mean we don’t really want to start the crawl on every attempt to run, but more to the point, we need to convert our other users to the new Provider scheme.

It’s 11:12.

function GameRunner:runBlockingCrawl(aFunction)
    self.cofloater:runCrawl(aFunction, self)
end

This one I’ll just convert blindly, to use an array. I have no idea where we’ll get the array at this moment. Something about unwinding an Encounter, I expect.

function GameRunner:runBlockingCrawl(array)
    self.cofloater:runCrawl(array, self)
end
function Player:addHealthPoints(points)
    local msg = string.format("+%d Health!!", points)
    local f = function()
        coroutine.yield(msg)
    end
    self.runner:runCrawl(f)
    self.healthPoints = math.min(20, self.healthPoints + points)
end

I think this should have been removed and subsumed into another method coming right up, but for now … I have an idea. How about if we have a new method on GameRunner to add to the crawl, assuming that it’s always running along:

function Player:addHealthPoints(points)
    local msg = string.format("+%d Health!!", points)
    self.healthPoints = math.min(20, self.healthPoints + points)
    self.runner:addToCrawl(msg)
end

And while we’re thinking about it.

function GameRunner:addToCrawl(array)
    self.cofloater:addItems(array)
end

And that reminds me that I want my message in an array:

function Player:addHealthPoints(points)
    local msg = string.format("+%d Health!!", points)
    self.healthPoints = math.min(20, self.healthPoints + points)
    self.runner:addToCrawl({msg})
end

Moving right along …

function Player:doCrawl(kind, amount)
    local msg = string.format("+%d "..kind.."!!", amount)
    local f = function()
        coroutine.yield(msg)
    end
    self.runner:runCrawl(f)
end

That follows the pattern above:

function Player:doCrawl(kind, amount)
    local msg = string.format("+%d "..kind.."!!", amount)
    self.runner:addToCrawl({msg})
end

I rather expect now that we’ll get scrolling messages about the gems and chests and whatnot.

However, I forgot to put addItems onto Floater. It’s in Provider:

function Floater:addItems(array)
    self.provider:addItems(array)
end

health

Yes! The attribute messages come out. An encounter, of course, crashes us.

It’s 11:27. About a half hour so far. Well outside the danger zone.

We start Encounter this way:

function Monster:startActionWithPlayer(aPlayer)
    if aPlayer:isDead() then return end
    --Encounter(self,aPlayer):attack()
    self.runner:runBlockingCrawl(createEncounter(self,aPlayer))
end

function Player:startActionWithMonster(aMonster)
    if aMonster:isDead() then return end
    self.runner:runBlockingCrawl(createEncounter(self,aMonster))
end

And …

function createEncounter(player,monster,random)
    f = function()
        attack(player, monster, random or math.random)
    end
    return f
end

The easy fix for this will be to have createEncounter punch the results of the “attack” function into an array, and hand it to the crawl. That’ll be fairly easy, I think.

Hmm … I was going to write code to call coroutine.resume until it stopped. Instead, let’s edit the encounter functions to add to a provided array. That’ll be more invasive, and will require us to succeed or revert. Best commit: using new Provider logic in crawls. Encounter broken.

OK. Now then:

local result = {}

function createEncounter(player,monster,random)
    result = {}
    attack(player,monster, random or math.random)
    Runner:addToCrawl(result)
end

Now let’s redefine yield:

local yield = function(msg)
    table.insert(result, msg)
end

This could possibly work. Let’s see.

attempt to index a nil value
stack traceback:
	[C]: in for iterator 'for iterator'
	Provider:13: in method 'addItems'
	Floater:67: in method 'runCrawl'
	GameRunner:294: in method 'runBlockingCrawl'
	Monster:237: in local 'action'
	TileArbiter:27: in method 'moveTo'
	Tile:82: in method 'attemptedEntranceBy'
	Tile:306: in function <Tile:304>
	(...tail calls...)
	Monster:184: in method 'moveTowardAvatar'
	Monster:104: in method 'chooseMove'
	GameRunner:256: in method 'moveMonsters'
	GameRunner:333: in method 'turnComplete'
	Player:210: in method 'turnComplete'
	Player:127: in method 'keyPress'
	GameRunner:246: in method 'keyPress'
	Main:34: in function 'keyboard'

Ah, not connected quite right.

function Monster:startActionWithPlayer(aPlayer)
    if aPlayer:isDead() then return end
    --Encounter(self,aPlayer):attack()
    self.runner:runBlockingCrawl(createEncounter(self,aPlayer))
end

We’re assuming that we have to call the runner from here, not from the create. So we want the create to just return the array, and we’ll have to change the runBlockingCrawl as well.

function createEncounter(player,monster,random)
    result = {}
    attack(player,monster, random or math.random)
    return result
end

function GameRunner:runBlockingCrawl(array)
    self.cofloater:addItems(array)
end

Once we get this wired up, we’ll refine and clean up. Encounter, as it is, will go away anyway but we want the game to work as is until then.

encounter

And the encounter works as we had hoped. It jams all its text into the crawl array, and it scrolls up until done.

There are anomalies. Note that in the present Encounter we have effects taking place:

...
        defender:displayDamage(false)
        defender:damageFrom(attacker.tile, damage)
...

The first line there would flash the player or monster in yellow or red, indicating that it was hurt, and it would make its “I’m hurt” sound.

With the new scheme, that happens instantly, because we’re unwinding the entire encounter into an array, in a zillisecond, so all the sounds and flashes happen in an instant, before the crawl even starts.

Similarly, the damage effect on the attribute sheets takes place instantly. We’d surely prefer that the status sheets change, and the visual and sound effects, take place as the relevant line appears.

I have a tentative plan for that. Now let’s do one little thing: let’s change the default line in the Provider to a blank line:

function Floater:init(runner, yOffsetStart, lineSize, lineCount)
    self.runner = runner
    self.provider = Provider("")
    self.yOffsetStart = yOffsetStart
    self.lineSize = lineSize
    self.lineCount = lineCount
end

And let’s see what happens:

game plya

Here we see that the crawl appears to come and go as events happen. We know the secret: it’s always running, displaying blank lines. Is that a concern? Not for me. I hope you can deal with it.

This is good enough to commit: Converted Floater to array Provider.

It’s 1201. Less than an hour for the final conversion of the hard bit, the encounter. It’s not perfect: we still have to bring the action and sounds all back in sync, and we’ll have to deal with freezing the player during some intervals. But I have an idea for that. And we should really replace the weird coroutine encounter functions, but that whole construct will be replaced by new Combat anyway,.

Let’s sum up.

Summary

Wow. It seems to me that I dithered for days over how to get to a point where Combat could work. My concern was all tied up with the weird but lovely coroutine-floater relationship.

If there’s a big lesson there, it is that code that is cool should be deeply suspect. On the other hand, it was the only idea I had at the time, and it took just a morning to replace it with something simpler. So we can see this as a perfect example of “technical debt”, the notion that we have an adequate, well-written, working solution, and then one day we have a better idea, and we put it into the system, paying off the debt.

(If you think technical debt is about bad code, you’re mistaken. The technical term for that is “bad code”. Don’t write bad code if you can avoid it. And you can avoid it.)

So another big lesson is: if the code is modular and clean, we can generally improve it or replace it without a lot of trouble.

Our Floater tests are broken, I just noticed. I’ll look at those, next time if I remember.

Overall, a good morning. Looking forward, here’s my rough plan:

Things like Combat will generate messages to the crawl as things are determined to happen. To synchronize things in the game, we’ll have a secret language in the Floater, so that we can send commands along with text, saying “flash the creature” or “apply damage”. So those actions will be deferred until the relevant line gets pulled from the array. We’ll use that facility to lock and unlock the player based on whose “turn” it is.

There’s a bit to discover there, but I think we’ll find that it’s pretty straightforward. The Floater already has the concept of a listener, and we’ll use that to field our secret messages.

I’m pleased, and wondering why it took me a few days to see this approach clearly enough to do it. Sometimes you bite the bear, I guess.

See you next time!


D2.zip