How Much of a Language?
Making a dungeon seems generally to require looping and even conditionals. Do we want special support for those?
Back last century, I had occasion to work on a product where one team, mine, was working on what amounted to a Domain Specific Language, and another team, headed by Charles, was using the DSL to build the product. It did not end well. Some of the mistakes that I now recognize include:
- My team did not build any product components using our tools. We should have: we’d have learned a lot.
- The product team could have made better progress had they used a mix of the DSL and Smalltalk, the underlying language. They could have started sooner and progressed farther.
- We did not have or follow the kinds of practices that became known as Extreme Programming shortly thereafter. We’d probably have delivered value sooner and, worst case, it would have become clear that we were in trouble sooner, saving a lot of money and tears.
Everyone did the best they knew how to do: it just wasn’t good enough. One one level, I don’t feel that I, or anyone, was “to blame”, but on another level, I feel that I should have known, should have done better.
That was then, this is now. Last night, dozing off, I was imagining combining my Python Forth implementation with the dungeon program, to build a simple DSL, and then my thoughts drifted toward the lessons learned from the effort described above. And in that light, here is my current thinking on how to make dungeon creation easier, as if we had a team of dungeon makers who were cranking them out for our infinite dungeon game.
Parallel Improvement
I struggled to come up with that heading, so don’t write it down as the new official development method: it’s just what I’m going to try to do. As we build this little game, or partial game, or whatever it is, we’ll have a series of “features” that we want, which right now are different kinds of map layouts, and which could later include other game-like features, depending only on what seems interesting to me. Who knows, in a month, we might be working on some entirely different kind of project.
The “parallel improvement” notion means to me that we’ll take some part of the problem, such as making dungeons containing rooms, which is going on right now. We’ll write Python code to build the dungeons, working to give them the various styles, designs, features that we want.
We’ll improve the code with classes, objects, functions, schemes, whatever tricks of the trade we know, to keep the code alive. We’ll keep building dungeons, and we’ll work to make that easier, because we’ll have experience doing it. For a long time, perhaps for the duration of the project, we’ll be writing Python, but we’ll be taking advantage of ideas for making that Python more data-driven, easier to write, more and more like a Dungeon Specific Language (see what I did there?).
We’ll try to simplify the work it takes to build a dungeon, while also working to make more and more dungeon things exist at all. In essence, we’ll have the Dungeon Building Team working side by side with the Dungeon Systems Team. This won’t be terribly difficult, since both teams consist entirely of me, but it does mean that I won’t be allowed to spend too much time struggling to build dungeons, which will not be much of a risk, nor to spend too much time inventing really cool ways to build dungeons which I’m sure we can have working in just another month or so, which is a risk, because I do love to dive into new ideas.
Will we ever replace Python with a real DSL that supports looping and conditionals? I’m not going to say yes or no here: I am open to the idea but also to showing the Dungeon Building Team how to write or use boiler plate Python to get their work done.
Why am I doing this? As always, I’m doing it to see what happens, to enjoy the trip, and to write about it. Let’s get to it.
Next Phase
We have a specific set of settings for cave-like rooms that seems to build interesting dungeons. We have some specialized room shapes that can be created, the round and diamond rooms. We could easily provide rectangular rooms, triangle rooms, maybe other algorithmic shapes. We may do some of those or not, depending on what we prioritize.
We have a scheme that produces fully-connected rooms, essentially by adding squiggly rooms until everything connects. I think we should prioritize a more thoughtful layout capability, allowing us to create a dungeon of specific rooms with specific paths between them. That will require us to work out some kind of better paths than we have now.
While our focus will remain on largely random dungeons with largely random paths, we’ll have a need for certain kinds of layouts, certain combinations of rooms.
With all that in mind, I think what we’ll do next is something like this:
- Provide a spec for a dungeon that amounts to a list of dictionaries, where each dictionary represents a room, including a type key plus all the key-value pairs required for that type.
- Build up a few lists of that kind, to get dungeons with properties we want, to learn what else we need.
- In particular, after all the rooms are in, we’ll need a few ways to ensure that everything connects.
We’ll try to work in the way that a clever dungeon builder and a clever dungeon systems person would work together, trying things, learning things, shaping things better than either might do alone.
That’s a lot to fit into one person’s head. We’ll see what happens.
Next Step
Let’s build the list of dictionaries idea. We’ll start with the dictionary part.
And we’ll try to write some tests. I think I’ll start a new test file for this.
class TestDungeonMaking:
def test_hookup(self):
assert False
Red. Perfect. Change to True, run green. commit new test file. Now this test:
def test_diamond_dictionary(self):
space = CellSpace(64, 56)
cell = space.at(30, 30)
diamond = {"shape":'diamond',
"number_of_cells":13,
"origin":cell}
maker = RoomMaker()
room = maker.build_one(diamond)
assert len(room.cells) == 13
We can’t call the kind of room “type” as that is kind of a Python magic word. I chose “shape”. Test fails for want of build_one, so:
class RoomMaker:
def build_one(self, args):
shape = args.get('shape')
if shape == 'diamond':
return self.diamond(**args)
else:
return Room([], shape.get('origin'))
def diamond(self, *, number_of_cells: int, origin: Cell, name='diamond', **kwargs):
# layer sizes: 1 4 8 12 16 20
# full sizes: 1 5 13 25 41 61
cells = DiamondCellCollector().build(number_of_cells=number_of_cells, origin=origin)
return Room(cells, origin, name)
Test runs. Note that we needed to add **kwargs to the argument list of diamond, because the keywords it receives will include shape, which it doesn’t know. The **kwargs just accumulates any extra parameters in a dictionary.
I just wrote the simplest code I could to make it go. I think we can be a bit more clever using Python getattr, like this:
def build_one(self, args):
shape = args.get('shape')
method = getattr(self, shape)
return method(**args)
Still green. We look up the method and call it. We’ll improve that in a moment, but first commit: room dictionary diamond works.
Now I think that the other methods will also work. What will not work is calling it with an unknown shape. We’ll get there but first let’s test at least one other room type.
def test_another_room_shape(self):
space = CellSpace(64, 56)
cell = space.at(30, 30)
cave = {"shape":'cave',
"number_of_cells":100,
"origin":cell}
maker = RoomMaker()
room = maker.build_one(cave)
assert len(room.cells) == 100
That nearly worked. I forgot to add **kwargs. Now I’ve added it to all four of the room-making methods, so I am sure they’ll all work. However, given that I’ve already forgotten to do it, I think we’d do well to remove the shape key from the dictionary, so that a later forgetting won’t cause trouble. I was planning not to test the other two types, experimental and round, but now I think I’d better do that.
def test_round_without_kwargs(self):
space = CellSpace(64, 56)
cell = space.at(30, 30)
round = {"shape":'round',
"radius": 2,
"origin": cell}
maker = RoomMaker()
room = maker.build_one(round)
assert len(room.cells) == 13
def build_one(self, args):
shape = args.get('shape')
args.pop('shape')
method = getattr(self, shape)
return method(**args)
We have at least two more cases to worry about. What if they ask for a shape we don’t know, and what if they don’t provide a shape at all?
def test_unknown_shape(self):
maker = RoomMaker()
room = maker.build_one({'shape':'garble'})
assert len(room.cells) == 0
assert room.name == 'unknown shape "garble"'
def build_one(self, args):
shape = args.get('shape')
args.pop('shape')
method = getattr(self, shape, None)
if method is None:
return Room([], None, f'unknown shape "{shape}"')
return method(**args)
Green, commit handling unknown shape by returning empty error room.
def test_no_shape(self):
maker = RoomMaker()
room = maker.build_one({})
assert len(room.cells) == 0
assert room.name == 'build_one called with no shape'
def build_one(self, args):
shape = args.get('shape')
if shape is None:
return Room([], None, 'build_one called with no shape')
args.pop('shape')
method = getattr(self, shape, None)
if method is None:
return Room([], None, f'unknown shape "{shape}"')
return method(**args)
Green. Commit handling no shape provided to build_one.
I like to stop on a win. Let’s reflect and sum up.
Reflection
We can now create a room from a small data structure, a dictionary, with keywords as needed. It should be a matter of moments to build something that will take a list of those dictionaries and return a list of rooms, or a method on Dungeon that does that, populating itself.
The scheme is simple, the shapes name a method and we return an empty room with a name reflecting the error if we get a bad shape or no shape at all. Pretty solid for right out of the box, if I do say so myself.
Creating the dictionary is easy enough but prone to typographic errors. We might want to work on that after some experience with it. Probably next time we’ll implement the list capability and build up a few room definitions. We’ll surely discover some new needs and get some ideas for convenience.
As things stand, we’d still have to write some conditional or looping Python to get what we want. I see at least these concerns:
Building a dungeon …
- with a random number of rooms;
- with random room types;
- with random room sizes;
- that is fully connected.
There will be more, certainly. Lots more.
Summary
A little bit of code shows promise, possibly making dungeon creation a bit less Python-like, and a bit simpler. We’ll learn more as we try it but for a hour or so of work we have something interesting and seemingly useful.
And we have a couple of new techniques under our belt, with the get/getattr stuff. I have a very tiny voice saying that we can package that up a bit more nicely, but it’s all in one method now, so one can hardly complain about it. We’ll see.
Inch by inch. That’s how we do it. See you next time!