Dungeon 79
Let’s talk about some design aspects, starting with the new DeferredTable. Then we’ll follow our nose.
Does it seem to you that I have no plan? Or does it seem that I have plans that change immediately? Well, the answer is yes. I have things I want to do, and I prioritize them in some way, but also when my understanding of the needs changes, I change course. It’s a bit like walking through the woods. You may be heading east, but that cliff suggests that bearing south a bit might be wise.
Today, I plan to talk about two aspects of the program:
- The new DeferredTable: why should we do a thing like that, if we ever should?
- The curious aspect of the Rooms in the game.
Let’s get started before the plan changes.
Narrator (v.o.): It changed. We find a paradox: our simple Tile objects seem to be more useful than more powerful Room and Hallway objects would be.
DeferredTable
On Twitter, Matteo Vaccari (@xpmatteo) asked:
How about replacing that deferred table stuff with two explicit separate collections, say base and buffer, and then instead of calling update() on the DT you simply merge the buffer into the base collection? Just wondering.
Some of my replies, slightly edited, went like this:
That’s what all three solutions do. The questions are where do you put the buffers, and how to control the updates.
The base issue, I’d say, is that we started with those two collections in Tile class, and managed them that way, in a dozen lines of a very large 350 line class. (Too large (even) w/o this concern.)
So those bits of logic don’t belong there, though what they accomplished, that is, maintaining Tile contents, does belong there. So the details of deferral need to reside elsewhere. I chose the notion of a deferred table - perhaps buffered would be a better word - and tried two ways of building the concept.
The first way, the tricksy one, is too cool for school, and incomplete anyway. The second is a nicely encapsulated class embodying this buffered notion, which is necessary to permit adding things while iterating.
There are surely other ways, but just now I don’t have a more promising idea. If one came along, I’d probably try it and write about it.
Thanks!
That almost says it all, but I have a bit to add.
There are two key notions in software design that apply here: cohesion, and coupling. Cohesion is the notion that functionally-related components of the system are close together, and coupling is the notion that objects should have limited interdependencies.
A related notion you may have heard of is the “Single Responsibility Principle”, introduced by Robert C. Martin. The core of the idea is that a class should be in charge of one thing.
All that by the way, my preferred rules for good design are Kent Beck’s “Rules of Simple Design”. My preferred phrasing of those is that the program must
- Run all the tests correctly.
- Contain no duplication.
- Express all the programmer’s ideas.
- Minimize the number of entities.
These are in priority order. Numbers 2 and 3 are often expressed in the reverse order, and they are surely very close in importance. I prefer the ordering above, because the presence of duplication in the code is always a hint that there’s something going on that is not well expressed.
So why did I want to extract DeferredTable from Tile? It has to do with all those ideas above. The first way I’d put it, if I were looking at it head on, would be something like:
What are these two collections? What are they doing? Why is one being used and then being copied into the other?
The code does not express the reason for those being there particularly well. Possibly not at all.
I’d go on to ask:
What is that doing here? This object is about being a tile in the game, handling the relationships between its contents and other game entities. It shouldn’t be thinking about nitty-gritty things like these two collections. It should be getting some help.
Now it turns out that Tile is one of the biggest classes in the system. In fact, after a quick check, it’s the largest, at about 350 lines. GameRunner is about 300, as is Monster. Player is 250.
I can tell you right offhand that GameRunner includes at least two responsibilities. It creates the level, and then it operates the game, calling draw
, dealing with touch, and serving as a center of communication. I have no doubt that it could be split, and it probably should be. I am also pretty sure that it includes some test-oriented code that could just be removed. GameRunner could surely be split down to two or more much smaller classes.
Monster class includes a large table of monster properties that should reasonably be stored in a file or a MonsterFactory. And Monsters have behavior. They move randomly or with purpose. So I think the class has a legitimate reason to be large, but I’m sure we could break out some classes for behavior or other aspects.
What about Tile? DeferredTable may not belong in there but the reduction in Tile code was a few lines at most. Are there other things to consider?
There’s a mass of code that defines the floor tiles. And it’s full of commented-out lines that I saved for some reason. All that could be deferred off to a flooring class of some kind.
There’s code for creating walls. We’ll talk about that below, but there’s probably some other object waiting to be born there.
There’s illumination logic that determines which tiles are visible from where the player is standing. That has a helper class, Bresenham, but there may be more code that should be moved.
And then there’s the logic determining whether an entity can come in and whether there needs to be a scuffle.
Suffice to say, there’s prospect for making these classes much more cohesive, and probably without increasing coupling much at all.
Yeah, but what about DeferredTable?
Looking at the big picture, DeferredTable is small beans. However, the original implementation, woven into Tile, caused a problem, one that took literally hours to find. It was hard to find, not just because I’m not very bright, but also because the problem was woven into two member variables and four methods, spread around in the biggest source file in the system.
Once I found it, I realized I’d had the same basic issue of deferring additions several times in the past, so it made sense to look for a solution.
Is it better as a separate class? I’m certain of it. Its interesting behavior is isolated, and when you define something as a DeferredTable, everyone will know what to expect.
Did it pay off right now in this program? Almost certainly not. Tests aside, there’s only one reference to DeferredTable in the whole system. (There are some other collections that are probably candidates, by the way.) Be that as it may, DT is not bearing much weight and we won’t go lots faster in future development because of it.
Should I have done it? I don’t know. I got a few articles out of it, I learned a lot about the innards of Lua, and I got a nice little class out of the deal.
I’m glad I paid the price. On a real project? I suspect it’d still be worth it, but you get to decide what you’d do.
But now …
The Tile Design
The Tile object could be better: we’ve discussed that. But I want to talk a bit about what the Tile approach has done to the design of this program compared to alternatives.
I’ll compare with Bryan’s program, which inspired me to do this one when he showed it to us at an early Zoom Ensemble meeting. Brian’s program follows a well-known approach to dungeon making, which is to draw a bunch of rectangular rooms, spread them apart until they don’t overlap, then connect them with hallways.
That program thinks in terms of rooms and hallways. It has logic in it to decide whether it can just put in a doorway, whether two rooms can be connected by straight hallway, and so on. Its fundamental notions are rooms and halls.
I started out that way. I wrote 22 or 23 articles about using that approach. Then, based on something I read somewhere, I went to the current approach, which goes something like this when it creates a level:
- Create a random rectangle inside the space of the whole game (85x64 tiles).
- Check to be sure no room overlaps this rectangle. If not, change all those tiles to TileRoom.
- Repeat until you’ve placed enough rooms.
- Connect the rooms, #1 to #2, #2 to #3, by moving first horizontally and then vertically, or vice versa, making the tiles along those two lines into TileRoom. (Because the rooms are placed randomly, those two “lines” can intersect any number of other rooms. We don’t even look.)
- Henceforth, ignore rooms and work with Tiles.
It’s in that last line that the magic happens. The game doesn’t know about rooms. Everything is about the tile you’re on and the tile you’re moving to.
Perhaps the most interesting aspect of this is the walls. The game displays walls around the rooms and hallways. But it doesn’t know where the rooms and hallways even are!
How does it work? The game scans all the tiles, which are now either TileEdge or TileRoom. When it inspects a tile, it checks to see what pattern of Edge, Room, or Wall surrounds it. There are 256 cases. It looks up its pattern in a big table and sets the tile to a Wall with the given sprite. That process starts in Dungeon 72.
Most everything works like that. Loot and monsters are placed on floor tiles, subject to some constraints: A Loot or chest is never placed against a wall. This, I believed at the time, was an easy way to ensure that they could never block a hallway … because the game doesn’t know where the hallways are!
What is amazing is how much simpler this approach made the game. Most of the time there are just tiles, and the player, the monsters, and the game, just wander around on the tiles, reacting to each other and to the tile properties. No one walks inside walls, for example.
A bit of a paradox.
To me this is a bit surprising, a bit of a paradox. We usually think that in order to have a sophisticated program we need powerful abstractions. We would expect to need ideas like rooms and hallways, and perhaps even more powerful notions. Instead, the game works well, and is easier to program, because it’s running against a very simple notion: the Tile.
What is almost obvious however, is that the Tile is probably necessary, no matter what abstraction one has above, in the sense that when it comes down to it, we need to know whether you’ve stepped into a trap, onto a monster, or on top of a power gem. And, probably, we want to have a tiled flooring, although there’s no requirement that it be done that way. But in most such games, the tile is almost inevitable. Ours is perhaps a bit stronger than some, and it’s certainly isolated out to where it can contain information and behavior.
I’m not sure what to conclude about this tile focus. Having worked for 20 articles in a mode of having rooms and halls, I’m dead certain that this is simpler and easier to program. And so far, the program seems to be able to do anything you’d want, all without those abstractions.
Of course, I’ve not thrown the rooms away. The game still has a collection of them if we ever wanted them. However … I think that if I ever need them, the current ones may not serve. I think I’m keeping them just because there’s no point throwing them away. Nostalgia.
Let’s do some code. I have a weird idea:
A Round Room
My rooms all start out as rectangles. I did a little experiment where I let them overlap, which was interesting, in that it made some rooms that looked like two different sized blocks overlapping. As you’d expect.
It occurred to me this morning that I could make a round room. I suspect I’d have to do something a bit different in this code:
function GameRunner:hasRoomFor(aRoom)
for i,room in ipairs(self.rooms) do
if room:intersects(aRoom) then return false end
end
return true
end
This logic does use the Room idea, during Room creation, to check whether the rooms overlap. We could, however, readily change that. Instead of checking all the rooms for intersecting, we could look at all the tiles under the room we’re drawing, and ensure that they are all of type TileEdge. If they’re not, no room for the Room.
If we used that logic, we could have rooms of any shape.
Let’s make that change. I don’t think there are tests for hasRoomFor
. I’ll check. Nope, there’s just one call to that method.
Let’s forward this message to Room:
function GameRunner:hasRoomFor(aRoom)
return aRoom:isAllowed()
end
Now in Room:
function Room:isAllowed()
for x = self.x1,self.x2 do
for y = self.y1,self.y2 do
local t = self.runner:getTile(vec2(x,y))
if not t:isEdge() then return false end
end
end
return true
end
We just check all the tiles in the room to be sure they are edge tiles. I think that should just work, but we’ll see why it doesn’t.
Yes. Works fine. Tests all good. Commit: Rooms are allocated by checking tiles, not intersection.
Now can we remove the intersect method? It’s not called. Yes. Commit: remove unused intersects
.
Now, for fun, let’s do a round room. I think there’s a spot for it. I have a thing I used for testing in GameRunner:
function GameRunner:createRandomRooms(count)
local r
self.rooms = {}
while count > 0 do
count = count -1
local timeout = 100
local placed = false
while not placed do
timeout = timeout - 1
if false and #self.rooms == 0 then
r = Room(1,1,10,10,self)
else
r = Room:random(self.tileCountX,self.tileCountY, 4,13, self)
end
if self:hasRoomFor(r) then
placed = true
r:paint()
table.insert(self.rooms,r)
elseif timeout <= 0 then
placed = true
end
end
end
end
Optionally I have it create a special room. The one I chose was in the lower left corner. I don’t even remember why.
Now I cannot create a Room item for a round one, but I can certainly just draw one. Let’s put it near the middle:
My biggest problem is how to draw a disc into the tiles. A circle would be easy. A disc has to be filled in. Oh, and I have a fun idea for the end. Let’s just go for it:
function GameRunner:createRandomRooms(count)
local r
self.rooms = {}
while count > 0 do
count = count -1
local timeout = 100
local placed = false
while not placed do
timeout = timeout - 1
if #self.rooms == 0 then
self:createInitialRoom()
end
r = Room:random(self.tileCountX,self.tileCountY, 4,13, self)
if self:hasRoomFor(r) then
placed = true
r:paint()
table.insert(self.rooms,r)
elseif timeout <= 0 then
placed = true
end
end
end
end
Not quite done yet … what about this for a start?
function GameRunner:createInitialRoom()
local center = vec2(40,32) -- near center
local radius = 5
local radSq = radius*radius
for x = -radius,radius do
for y = -radius,radius do
local sq = x*x+y*y
if sq <= radSq then
self:setTile(Tile:room(center+vec2(x,y)))
end
end
end
end
Let’s run it, then explain it.
Sweet! The room clearly starts out as round as it can, given the small radius vs the tile size. Then hallways give it an even more interesting shape.
This is a fun outcome from the fact of the game being tile focused. And I have another sort of random thing to try.
Could we put a “hole” in the room, to make it sort of a donut? Not quite, given how the hallways are drawn, but we might get close. Let’s try this:
function GameRunner:createInitialRoom()
local center = vec2(40,32) -- near center
local radius = 5
local radSq = radius*radius
for x = -radius,radius do
for y = -radius,radius do
local sq = x*x+y*y
if sq <= radSq and sq > 4 then
self:setTile(Tile:room(center.x+x, center.y+y, self))
end
end
end
end
That will leave a hole. And it’ll also make me recognize a bug.
There’s the room. The hole shows nicely on the map. It does look kind of odd to walk around, since we can’t see through the walls to take it all in.
The bug is that since this room is not recorded in the Rooms table, it’s not guaranteed to be connected to any other rooms. In practice, it usually will be, but we’d need to do something to make sure. It’d be easy enough to add a dummy room to the table to connect to. But if we did, it might mess up the hole, and we wouldn’t want that. I suppose we could offset it from the hole.
Netting it all out, though, while it’s a cool fact that we can make a round room, or any shape of room, I don’t see much advantage to it. I think I’ll set the round room not to happen but leave in the code for future reference. A wiser person than I would probably save it in git, but I’m old-fashioned.
function GameRunner:createRandomRooms(count)
local r
self.rooms = {}
while count > 0 do
count = count -1
local timeout = 100
local placed = false
while not placed do
timeout = timeout - 1
if false and #self.rooms == 0 then
self:createInitialRoom()
-- room may not connect, be careful.
end
r = Room:random(self.tileCountX,self.tileCountY, 4,13, self)
if self:hasRoomFor(r) then
placed = true
r:paint()
table.insert(self.rooms,r)
elseif timeout <= 0 then
placed = true
end
end
end
end
OK. Commit: round room implemented and turned off.
Let’s Wrap This Baby Up
Mostly just some discourse today, but with some focus on two important issues, reasons for changing code, and the effects of cooperating small objects.
I think the experiment with the round room is “significant” in that it shows how flexible this design is. The game never realized there was a round room in it, it just dealt with the walls being wherever they were. I’m rather confident that any reasonable shape would work, if we had need of it.
However, there are issues with which this scheme may struggle. Here are two that come to mind:
First, is there any way we could have rooms with doors? We don’t even really know where the rooms are. Maybe as we draw hallways, we can detect transitions from room to edge and place doorways.
Second, how can we best deal with the notion of a “boss” that you have to conquer or get past in order to progress to a new level or get a special prize? There’s nothing right now that prevents the game from placing any monster or treasure in the same room, even on the same tile, as the player. What we’d want, I imagine, is for there to be a long slog through dank, dark hallways, facing terrifying monsters, until finally, tired and bedraggled, you encounter the most horrible most deadly most hideously nasty monster ever seen …
Excuse me. Anyway, we might want to place items in locations that are more goal-oriented, and less random.
Hm.
Based on what we just did, an idea comes to mind.
What if, at the start of creating a level, we reserved some large rectangle of tiles, maybe off to the side. Then we draw the dungeon in the rest of the area. Then we create a more “designed” pattern of tiles in the reserved rectangle, and then make a single connection from our rectangle out to one of the rooms in the unreserved area.
We could then pick a random position for the player, randomly preferring positions off to the other side. That should be easy enough as well.
Yeah. I bet we can make that work. The game still won’t know the dungeon design. It’ll just know some tricks to get a design that we like.
See what I mean? The tiles don’t make things hard. If anything they drive us to simple geometric solutions that are easy to express.
It’s rather surprising. I’d really think that more powerful objects might be better. It seems not to be the case in this instance.
It makes you think. Which, I guess, is the point.
See you next time!