Dungeon 105
I have traps in mind, and smarter monsters. Is that what the day brings? One never knows, do one?
I have some nifty tiles that could be good for traps:
In my imagination, the button might be used for other purposes. It could open a door, perhaps revealing some amazing treasure, or just the path to another part of a dungeon. But it could also be a trap, releasing monsters into the room or causing the spikes to pop up. Or it could even cause the spikes to go down.
What if spikes were extremely dangerous, essentially fatal if we continue to allow the princess to die? So what if there were spikes at the only entrance to some cool place, and you had to find the button that makes them go down, so that you can get there.
I’m also thinking that not all chests should be good. What if some chests release murder hornets or death flies? Maybe there is some way to tell chests apart with reasonable reliability? Or maybe you take your chances.
And I’m thinking about smarter monsters. Perhaps there could be a couple of very dangerous monsters patrolling a doorway, and you need to time your passing through the doorway to when they’re both looking away. Naturally, they move on slightly separate cycles, so the timing is tricky.
I’ve spoken before about monsters that ignore you unless you bother them, and then they attack vigorously. Maybe there are monsters that follow you around but, again, don’t attack uness you start something.
This brings me to another idea that I’m hatching, that I call “set pieces”. The idea is that there are patterns of placement for certain objects and entities. They could even include some kind of narration describing what you’ve found and what is about to happen.
A given set piece could be placed in any room that had enough space for it, and that should be easy enough to figure out. We do even have the room coordinates saved away if we want to use them. A more difficult issue is the question of doors and hallways.
Since we carve halls from room N to room N-1, a given hallway can overlap other rooms. We see some fascinating patterns in the tiny map sometimes.
Since we build the dungeon by blocking in rooms, which can touch each other, and hallways by just marking horizontal and vertical paths as walkable, a given “room” can be made up of more than one instance of Room, and it can have many entrances and exits.
If we have special requirements for a room, such as there being only one way in and out, that’s a tricky question. If we block some of the places where paths cross the room, we could, in principle, make parts of the dungeon inaccessible, and it’s not easy to decide.
We can’t even use doors that can be unlocked or entrances that appear when you stand on a remote button, because there’s no being sure that you can get to the key or the button without passing through the door.
Well, technically, we can be sure. We can certainly search the dungeon to see whether we can get everywhere given that some door is blocked, or that we can get to the key even if the door is closed, but path-searching the space doesn’t seem like much fun. It might be interesting programming, however, so I’m not saying I’d never do it.
I just had a random idea. The tiny map is useful. Perhaps it’s too useful. What if, before you could see the tiny map, you had to find a pen and paper. That might be a fun treat. Maybe the pen is also mightier than the sword. Hmm …
But I digress. Point is, there are lots of fun problems to address, and today I want to start on one of them.
But first, I have errands to run. Take a break, I’ll be back in a bit.
Back with a Plan
OK, I have a plan. Today’s story is:
Place a spike trap in a narrow hallway. The trap should cycle between up and down about every two seconds. If the princess crosses it when it’s up, she takes damage between 5 and 10. If she crosses it when it’s down, she takes damage between 1 and 3.
I see a few parts to this, tasks, if you will:
- Find a place to put the spikes that is in a narrow hallway.
- Cause the spike to change state between up and down every two seconds.
- Apply damage when the princess enters the spike’s tile.
In essence, a spike is a bit like a kind of Loot, albeit a nasty one. Let’s start by looking at that class.
local LootIcons = {Strength=asset.builtin.Planet_Cute.Gem_Blue, Health=asset.builtin.Planet_Cute.Heart, Speed=asset.builtin.Planet_Cute.Star}
function Loot:init(tile, kind, min, max)
self.tile = tile
self.kind = kind
self.icon = self:getIcon(self.kind)
self.min = min
self.max = max
if tile then tile:addDeferredContents(self) end
end
Hm, if max and min were negative … no, it’s more complex than that. We read on …
function Loot:actionWith(aPlayer)
self.tile:removeContents(self)
aPlayer:addPoints(self.kind, math.random(self.min, self.max))
end
function Loot:draw()
pushStyle()
spriteMode(CENTER)
local g = self.tile:graphicCenter()
sprite(self.icon,g.x,g.y+10,35,60)
popStyle()
end
I don’t think we have much to gain from using Loot. Let’s do a new class, Spikes. Should we TDD something? Sure, let’s, just to see what we can manage.
As with other small classes, I’m gong to build Spikes as part of the TestSpikes tab that I’m about to create:
-- TestSpikes
-- RJ 20210225
function testSpikes()
CodeaUnit.detailed = true
_:describe("Spikes", function()
local runner
local room
_:before(function()
end)
_:after(function()
end)
_:test("Spikes", function()
end)
end)
end
We’ll see how far we can get without entangling our Spikes into the overall GameRunner milieu.
Maybe this is a bit much, but I wanted to get a sense of how it might work:
_:test("Spikes", function()
local tile = nil
local spikes = Spikes(tile)
local lo = spikes:damageLo()
local hi = spikes:damageHi()
_:expect(lo).is(1)
_:expect(hi).is(3)
end)
I figure it starts out down.
1: Spikes -- TestSpikes:21: attempt to call a nil value (global 'Spikes')
No surprise here. Let’s type:
Spikes = class()
function Spikes:init(tile)
self.tile = tile
end
function Spikes:damageHi()
return 3
end
function Spikes:damageLo()
return 1
end
I went wild there and saved the tile. There’s just no controlling me, is there?
I expect the test to run, and it does. Extend:
_:test("Spikes", function()
local tile = nil
local spikes = Spikes(tile)
local lo = spikes:damageLo()
local hi = spikes:damageHi()
_:expect(lo).is(1)
_:expect(hi).is(3)
spikes:up()
lo,hi = spikes:damage()
_:expect(lo).is(5)
_:expect(hi).is(10)
end)
I added some new protocol, a damage
function that returns the pair of values. It seemed useful. Code:
function Spikes:init(tile)
self.tile = tile
self.isDown = true
end
function Spikes:damage()
return self:damageLo(),self:damageHi()
end
function Spikes:damageHi()
if self.isDown then return 3 else return 10 end
end
function Spikes:damageLo()
if self.isDown then return 1 else return 5 end
end
function Spikes:up()
self.isDown = false
end
Test runs. Extend once more:
_:test("Spikes", function()
local tile = nil
local spikes = Spikes(tile)
local lo = spikes:damageLo()
local hi = spikes:damageHi()
_:expect(lo).is(1)
_:expect(hi).is(3)
spikes:up()
lo,hi = spikes:damage()
_:expect(lo).is(5)
_:expect(hi).is(10)
spikes:down()
lo,hi = spikes:damage()
_:expect(lo).is(1)
_:expect(hi).is(3)
end)
And code:
function Spikes:down()
self.isDown = true
end
Test is green. Now let’s think about what else this thing needs. It needs to be a contents item of a tile. (I plan to have it just draw itself on top of an ordinary tile, like the WayDown does.) And it needs to draw itself in two ways, depending on whether it’s up or down.
Let’s review WayDown for that.
function WayDown:init(tile,runner)
self.runner = runner
self.tile = tile
self.tile:addDeferredContents(self)
end
function WayDown:actionWith(aPlayer)
self.runner:createNewLevel()
end
function WayDown:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
local c = self.tile:graphicCenter()
sprite(asset.steps_down,c.x,c.y,64,64)
popStyle()
popMatrix()
end
We need some similar code here. The draw, I suggest, looks like this:
function Spikes:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
local c = self.tile:graphicCenter()
sprite(self:sprite(),c.x,c.y,64,64)
popStyle()
popMatrix()
end
We have but to implement sprite
. We can TDD that, so we should.
_:test("Spikes", function()
local tile = nil
local spikes = Spikes(tile)
local lo = spikes:damageLo()
local hi = spikes:damageHi()
_:expect(lo).is(1)
_:expect(hi).is(3)
_:expect(spikes:sprite()).is(downSprite)
spikes:up()
lo,hi = spikes:damage()
_:expect(lo).is(5)
_:expect(hi).is(10)
_:expect(spikes:sprite()).is(upSprite)
spikes:down()
lo,hi = spikes:damage()
_:expect(lo).is(1)
_:expect(hi).is(3)
_:expect(spikes:sprite()).is(downSprite)
end)
Now I’ll need to define the variables downSprite
and upSprite
, and of course implement the function. First I have to move the new tiles into the game.
Digression to Move Tiles
In the Files app, I find the tiles I want, trap_down and trap_up:
I select them and move to Codea’s dropbox, actually a copy:
Then in Codea, I find them in dropbox and move them into the project. The easiest way I know is to click on a sprite command and browse to them. It seems you always have to sync the dropbox first: Codea caches it.
Then you select them and use “Add To” to move them into the project. Then you select one and you get its official name, e.g. “asset.documents.Dropbox.trap_down”.
Now we can complete the test:
_:test("Spikes", function()
local downSprite = asset.documents.Dropbox.trap_down
local upSprite = asset.documents.Dropbox.trap_up
local tile = nil
local spikes = Spikes(tile)
local lo = spikes:damageLo()
local hi = spikes:damageHi()
_:expect(lo).is(1)
_:expect(hi).is(3)
_:expect(spikes:sprite()).is(downSprite)
spikes:up()
lo,hi = spikes:damage()
_:expect(lo).is(5)
_:expect(hi).is(10)
_:expect(spikes:sprite()).is(upSprite)
spikes:down()
lo,hi = spikes:damage()
_:expect(lo).is(1)
_:expect(hi).is(3)
_:expect(spikes:sprite()).is(downSprite)
end)
And that fails as expected:
1: Spikes -- TestSpikes:28: attempt to call a nil value (method 'sprite')
So we implement sprite
:
function Spikes:sprite()
if self.isDown then
return asset.documents.Dropbox.trap_down
else
return asset.documents.Dropbox.trap_up
end
end
I expect this to run. And it does. Let’s review that class, there are things not to like:
Spikes = class()
function Spikes:init(tile)
self.tile = tile
self.isDown = true
end
function Spikes:damage()
return self:damageLo(),self:damageHi()
end
function Spikes:damageHi()
if self.isDown then return 3 else return 10 end
end
function Spikes:damageLo()
if self.isDown then return 1 else return 5 end
end
function Spikes:down()
self.isDown = true
end
function Spikes:draw()
pushMatrix()
pushStyle()
spriteMode(CENTER)
local c = self.tile:graphicCenter()
sprite(self:sprite(),c.x,c.y,64,64)
popStyle()
popMatrix()
end
function Spikes:sprite()
if self.isDown then
return asset.documents.Dropbox.trap_down
else
return asset.documents.Dropbox.trap_up
end
end
function Spikes:up()
self.isDown = false
end
What’s not to like? Those conditionals. As I’ve mentioned, Chet hates if statements. I don’t exactly blame him. Let’s do a thing.
function Spikes:init(tile)
self.tile = tile
self:down()
end
function Spikes:down()
self.state = "down"
end
function Spikes:up()
self.state = "up"
end
Now we don’t have a boolean. Of course we could just do this:
function Spikes:damageHi()
if self.state == "down" then return 3 else return 10 end
end
function Spikes:damageLo()
if self.state == "down" then return 1 else return 5 end
end
function Spikes:sprite()
if self.state == "down" then
return asset.documents.Dropbox.trap_down
else
return asset.documents.Dropbox.trap_up
end
end
That’s of course even more nasty but the tests should, and do, pass. But then:
function Spikes:init(tile)
self.tile = tile
self.damageTable = { down={lo=1,hi=3}, up={lo=5,hi=10}}
self:down()
end
And then:
function Spikes:damageHi()
return self.damageTable[self.state].hi
end
function Spikes:damageLo()
return self.damageTable[self.state].lo
end
We’ve removed two of the three if statements. Tests are green.
One more to go:
function Spikes:init(tile)
self.tile = tile
self.damageTable = { down={lo=1,hi=3}, up={lo=5,hi=10}}
self.assetTable = { down=asset.documents.Dropbox.trap_down, up=asset.documents.Dropbox.trap_up }
self:down()
end
function Spikes:sprite()
return self.assetTable[self.state]
end
Tests are green.
What’s left? Well, we still need to do the up/down timing thing. I think ideally I’d like to use tween.delay, despite the charming and ubiquitous Bruce Onder’s preference for using ElapsedTime. But I’d like them not to start the tween if they’re under test. So let’s provide another parameter. I don’t feel the need to test the value, but let’s allow for it:
_:test("Spikes", function()
local downSprite = asset.documents.Dropbox.trap_down
local upSprite = asset.documents.Dropbox.trap_up
local tile = nil
local spikes = Spikes(tile, fakeTweenDelay)
local lo = spikes:damageLo()
local hi = spikes:damageHi()
That’s the fake function I used in the MusicPlayer thread. We can put it here and make the Spikes refer to it:
function Spikes:init(tile, tweenDelay)
self.delay = tweenDelay or tween.delay
self.tile = tile
self.damageTable = { down={lo=1,hi=3}, up={lo=5,hi=10}}
self.assetTable = { down=asset.documents.Dropbox.trap_down, up=asset.documents.Dropbox.trap_up }
self:down()
self.delay(2, self.toggleUpDown, self)
end
And we need toggleUpDown
:
function Spikes:toggleUpDown()
self.state = ({up="down", down="up"})[self.state]
end
Is that too obscure? I think not in the context of the other tables we’re using. Will it work? That’s a whole ‘nother question. It’s time to find out.
We have special code to put the WayDown into Room 1, as it is not yet placed in an out of the way spot. So let’s see if the spikes can go near there:
function GameRunner:placeWayDown()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
local tile = self:getTile(vec2(rcx-2,rcy-2))
WayDown(tile,self)
end
function GameRunner:placeSpikes()
local r1 = self.rooms[1]
local rcx,rcy = r1:center()
local tile = self:getTile(vec2(rcx-2,rcy+2))
Spikes(tile)
end
I just hacked a similar method for placeSpikes. And I’ll have to call it, and also I think we are not yet adding ourselves as contents. Let’s test that.
_:test("Spikes", function()
local downSprite = asset.documents.Dropbox.trap_down
local upSprite = asset.documents.Dropbox.trap_up
local tile = FakeTile
local spikes = Spikes(tile, fakeTweenDelay)
_:expect(FakeTile.added).is(spikes)
local lo = spikes:damageLo()
I’ll have to enhance FakeTile just a bit:
function FakeTile:addDeferredContents(something)
self.added = something
end
This should fail nicely.
1: Spikes -- Actual: nil, Expected: table: 0x29696d100
Not a great diagnostic. We could put a tostring on Spikes if we wished. Let’s just make it work:
function Spikes:init(tile, tweenDelay)
self.delay = tweenDelay or tween.delay
self.tile = tile
self.tile:addDeferredContents(self)
self.damageTable = { down={lo=1,hi=3}, up={lo=5,hi=10}}
self.assetTable = { down=asset.documents.Dropbox.trap_down, up=asset.documents.Dropbox.trap_up }
self:down()
self.delay(2, self.toggleUpDown, self)
end
I expect the tests to run and a spikes to appear near the player. I immediately find a bug:
function Spikes:toggleUpDown()
self.state = ({up="down", down="up"})[self.state]
end
Tween delays don’t loop.
function Spikes:toggleUpDown()
self.state = ({up="down", down="up"})[self.state]
self.delay(1, self.toggleUpDown, self)
end
Run again. The delay was supposed to be two seconds. Let’s remove the duplication while we’re at it:
function Spikes:init(tile, tweenDelay)
self.delay = tweenDelay or tween.delay
self.tile = tile
self.tile:addDeferredContents(self)
self.damageTable = { down={lo=1,hi=3}, up={lo=5,hi=10}}
self.assetTable = { down=asset.documents.Dropbox.trap_down, up=asset.documents.Dropbox.trap_up }
self:up()
self:toggleUpDown()
end
function Spikes:toggleUpDown()
self.state = ({up="down", down="up"})[self.state]
self.delay(2, self.toggleUpDown, self)
end
So that looks good. Let’s commit: spikes appear near player and go up and down.
Now we’re not yet applying damage. We need to make entries in the TileArbiter tables. The entries look like this:
t[WayDown] = {}
t[WayDown][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithWayDown}
We need:
t[Spikes] = {}
t[Spikes][Player] = {moveTo=TileArbiter.acceptMove, action=Player.startActionWithSpikes}
And player:
function Player:startActionWithSpikes(spikes)
spikes:actionWith(self)
end
Now, for some reason, I want to use CombatRound to deal with this interaction, mostly because it knows how to time things and display things. At first, it goes well:
function Spikes:actionWith(player)
local co = CombatRound(self,player)
co:display("Spikes impale "..player:name().."!")
local damage = math.random(self:damageLo(), self:damageHi())
end
But CombatRound doesn’t quite know how to apply damage, only to roll it. Let’s see how that works:
function CombatRound:rollDamage()
local damage = self.random(1,6)
self.defender:accumulateDamage(damage)
local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
self:append(op)
self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
end
Hm, I thought we were more sophisticated than that. I thought we had different damage for different monsters. I guess not yet. Anyway, let me refactor this a bit:
function CombatRound:rollDamage()
local damage = self.random(1,6)
self:applyDamage(damage)
end
function CombatRound:applyDamage(damage)
self.defender:accumulateDamage(damage)
local op = { op="extern", receiver=self.defender, method="damageFrom", arg1=nil, arg2=damage }
self:append(op)
self:append(self:display(self.defender:name().." takes "..damage.." damage!"))
end
Now I can use that:
function Spikes:actionWith(player)
local co = CombatRound(self,player)
co:display("Spikes impale "..player:name().."!")
local damage = math.random(self:damageLo(), self:damageHi())
co:applyDamage(damage)
end
Now I need to dump this baby into the crawl. Normally, combat goes like this:
function GameRunner:initiateCombatBetween(attacker, defender)
if defender:isDead() then return end
local co = CombatRound(attacker,defender)
self:addToCrawl(co:attack())
end
We can’t really call attack
here, I think. Clearly attack
returns the CombatRound’s collection, so let’s see:
function CombatRound:attack()
if self.attacker:willBeAlive() and self.defender:willBeAlive() then
self:append(self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg))
self:attemptHit()
end
return self.commandList
end
We need a new method to return the command list.
function CombatRound:attack()
if self.attacker:willBeAlive() and self.defender:willBeAlive() then
self:append(self:display(" "))
local msg = string.format("%s attacks %s!", self.attacker:name(), self.defender:name())
self:append(self:display(msg))
self:attemptHit()
end
return self:getCommandList()
end
function CombatRound:getCommandList()
return self.commandList
end
And we need to add it to the crawl. I think we can do that right in our damage thingie:
function Spikes:actionWith(player)
local co = CombatRound(self,player)
co:display("Spikes impale "..player:name().."!")
local damage = math.random(self:damageLo(), self:damageHi())
co:applyDamage(damage)
self.tile.runner:addToCrawl(co:getCommandList())
end
Let’s have a look.
TileArbiter:17: attempt to call a nil value (method 'getTile')
stack traceback:
TileArbiter:17: in field 'moveTo'
TileArbiter:28: in method 'moveTo'
Tile:87: in method 'attemptedEntranceBy'
Tile:318: in function <Tile:316>
(...tail calls...)
Player:172: in method 'moveBy'
Player:128: in method 'executeKey'
Player:166: in method 'keyPress'
GameRunner:257: in method 'keyPress'
Main:34: in function 'keyboard'
We need getTile(). I’ll add that to the test just for drill.
_:test("Spikes", function()
local downSprite = asset.documents.Dropbox.trap_down
local upSprite = asset.documents.Dropbox.trap_up
local tile = FakeTile
local spikes = Spikes(tile, fakeTweenDelay)
_:expect(spikes:getTile()).is(tile)
...
And, of course:
function Spikes:getTile()
return self.tile
end
The damage works. Arguably, if you stand there, it should keep stabbing away at you, but I think we’ll worry about that at a later time.
I’m noticing that monsters are showing up near the player lately. I wonder if that has been broken somehow. I make a sticky note for it.
I think this story is done. Let’s commit: spikes now damage player. Still appear in Room 1.
Let’s sum up.
Summing Up
The story was to place spikes in a narrow hallway, to have them cycle up and down every two seconds, and to have them deal 1-3 points if entered while down, 5-10 points if entered while up. We’ve got them cycling and dealing damage, but they are not yet placed where we’d like.
Probably next time we should deal with placement, since we have both Spikes and WayDown to consider for placement, and we have something going on with monsters apparently coming into Room 1 again.
For now, the game is better, so we can ship it.
I think that the TDD for the Spikes object went quite well. I was gratified to find that I had a useful fake function, fakeTweenDelay
, and a fake object, FakeTile
, that were actually useful in testing. As a Detroit School TDD person, I don’t usually lean toward test doubles, but these have served well in more than one situation now. So that’s interesting.
More interesting, though, is that while I’ve often not TDD’d things like the Spikes class, doing so clearly made things go more smoothly and led to a class that is almost 100 percent tested, and therefore documented by its test. I “should” do that more often.
I have noticed some kinds of duplication, such as the need for getTile
, which is pretty much required for any form of object that can go into tile contents. We don’t have a thing like an interface in Codea, that we could use to guide our implementation. We could use subclassing to ensure that things have those common function elements, but I am reluctant to play the subclassing card owing to trauma in my youth. Well, in my middle age.
I think we could readily build something like interface. Imagine an object that knew the list of required functions in order to be a “tile contents item”. If we could arrange to call that function after compiling a new item, it could easily look into the object’s table and see if all the necessary functions were there. Might be interesting. Worth it? Probably not for this app, but for a larger multi-person effort, it might be. Let’s keep it in mind.
I hacked in a thing to display room number, and took this picture:
We clearly have a ghost in Room 1. I even moved to make sure he wasn’t standing on another number. What’s up with that. I gotta know.
function GameRunner:setupMonsters(n)
self.monsters = Monster:getRandomMonsters(self, 6, self.dungeonLevel)
for i,monster in ipairs(self.monsters) do
monster:startAllTimers()
end
end
function Monster:getRandomMonsters(runner, number, level)
local t = self:getMonstersAtLevel(level)
local result = {}
for i = 1,number do
local mtEntry = t[math.random(#t)]
local tile = runner:randomRoomTile()
local monster = Monster(tile, runner, mtEntry)
table.insert(result, monster)
end
return result
end
function GameRunner:randomRoomTile(roomToAvoid)
return self:getDungeon():randomRoomTile(roomToAvoid)
end
function Dungeon:randomRoomTile(roomToAvoid)
while true do
local pos = vec2(math.random(1, self.tileCountX), math.random(1,self.tileCountY))
local tile = self:getTile(pos)
if tile:getRoomNumber() ~= roomToAvoid and tile:isOpenRoom() then return tile end
end
end
I see no way that can work. We didn’t give it a room number up in the get. The fix is:
function Monster:getRandomMonsters(runner, number, level)
local t = self:getMonstersAtLevel(level)
local result = {}
for i = 1,number do
local mtEntry = t[math.random(#t)]
local tile = runner:randomRoomTile(1)
local monster = Monster(tile, runner, mtEntry)
table.insert(result, monster)
end
return result
end
This is not good, as 1 is a magic number here, but we want the fix. I’l make another note.
Commit: monsters stay out of room 1 again.
I just noticed that there’s no message about stepping on the spikes. There’s supposed to be:
function Spikes:actionWith(player)
local co = CombatRound(self,player)
co:display("Spikes impale "..player:name().."!")
local damage = math.random(self:damageLo(), self:damageHi())
co:applyDamage(damage)
self.tile.runner:addToCrawl(co:getCommandList())
end
What’s up with that, I wonder. Ah. The display
message creates the OP but doesn’t append it. Let’s be for adding a method to do that:
function CombatRound:appendText(aString)
self:append(self:display(aString))
end
And …
function Spikes:actionWith(player)
local co = CombatRound(self,player)
co:appendText("Spikes impale "..player:name().."!")
local damage = math.random(self:damageLo(), self:damageHi())
co:applyDamage(damage)
self.tile.runner:addToCrawl(co:getCommandList())
end
That should do it.
And it does. Commit: spikes impale princess message comes out.
OK, we’re really done now, Things went rather well, but not perfectly. I want to make another note:
- CombatRound appendText vs display is confusing
- Make CombatRound easier to use in e.g. Spikes.
What else have I noticed?
- Things that go to tile contents have a required protocol that is not enforced;
- There’s duplication among things that go into contents
- The Spikes object may be a starting skeleton for other active tile contents.
Overall, a good session. Now can I have a sandwich? Thanks!