Appealing Idea
Hello, loves!
The recent refactoring around Layout and Cell gave me a very appealing idea. Let’s discuss and quite likely try it. “What kind of person wears Louboutins into a dungeon anyway?”
In the Procreate app on my iPad, I have, amid all my attempts at drawing, a multi-page file of diagrams and scribbles about the dungeon program. Last night I was thinking, and I scrawled out a couple of pages:


In the unlikely event that those entirely professional and crisp pictures don’t tell the complete story with unparalleled clarity, here’s the gist:
In my past thinking, I’ve envisioned the Cells as a 2D array, with each cell directly connected to its neighbors. The memory structure is not linked, but the code that supports the layout and cells is based on the notion that given a cell you can get to its neighboring cells easily.
The current way …
As we’ve seen in the past couple of days, the upshot of that is that when Dot tries to move to an adjacent cell, we execute some formerly quite complex code, replaced by some only kind of complex code yesterday, asking questions about the cells to decide if Dot can move:
- Is there even a cell in that direction? Sorry, no move for you.
- Is that cell in the same room? OK, go ahead.
- Is there a passage between them? OK, go ahead.
- Sorry, no move for you.
And after that, if Dot gets to move, she interacts with all the contents of the new room.
So that’s a lot and it is quite procedural.
The new idea …
I drew “connections” between the cells. I’m not sure why. I originally drew cells directly wired together and then somehow, drawing the wires made me think they were connections and I drew the picture again with connections.
And that’s the idea:
Given two cells
aandb, between which Dot wants to move, request a Connectiona.connect_to(b), or possiblya.connect_toward(offset), or possiblya.connect(NORTH), whatever turns out to be convenient to use. The method will return a concrete instance of a abstract class Connection, such as OpenConnection, NoConnection or MaybeConnection.
The connection will have a simple protocol that we’ll have to figure out, probably either performing the move or returning permission to do it, plus an interact method that either will or will not run the cell’s interactions. that method will leave open the possibility of running the interactions again even if Dot does not move.
That might be useful, for example, if Dot has stepped into a puddle and her Louboutins are taking 1 point of damage per turn in the puddle. If she fails to step out, one more hit on the expensive footwear what kind of person wears Louboutins into a dungeon anyway?
Perhaps I am getting ahead of myself although I’m pretty sure we’re going to implement that.
Anyway … that’s the idea, and now let’s get started working on it.
Vague but Sincere Planning
This new thing will be used by the motion logic, which starts in the DungeonView, but defers the action to Dungeon, which is where the knowledge of all things resides:
class Dungeon:
def move_player_north(self):
self.attempt_move_to_offset(self.player_cell, (0, 1))
def move_player_east(self):
self.attempt_move_to_offset(self.player_cell, (1, 0))
def move_player_south(self):
self.attempt_move_to_offset(self.player_cell, (0, -1))
def move_player_west(self):
self.attempt_move_to_offset(self.player_cell, (-1, 0))
def attempt_move_to_offset(self, cell, offset):
new_cell = self.layout.cell_we_can_move_to(cell, offset)
if new_cell != cell:
self.place_player_at(new_cell)
def place_player_at(self, cell):
interactor = Interactor(self, cell)
if all(interactor.execute(item) for item in list(self.contents_at(cell))):
self.player_cell = cell
A slightly deeper than careless look at this points out a mistake in my thinking above: Dot cannot move into a cell unless the interactions all allow it. So the interaction needs to be triggered by the Connection and all the caller needs to do is to place the player where it’s told. Unless the Connection handles that as well. We’ll know more when we have one.
Also, a subprocess running in my brain is thinking that yes, we should probably always run the interactions in the result cell, whether Dot moves or not. We’ll see.
The work above calls down to layout:
class DungeonLayout:
def cell_we_can_move_to(self, cell, offset):
new_cell = cell.offset_by(offset)
if self.are_in_same_room(cell, new_cell):
return new_cell
elif self.has_passage(cell, new_cell):
return new_cell
else:
return cell
def are_in_same_room(self, cell, new_cell):
return cell and new_cell and cell.room == new_cell.room
def has_passage(self, cell, neighbor):
if self.passages.get((cell, neighbor)):
return True
else:
return False
I reckon that passages will currently return an OpenConnection, as will moves within the room. Trying to walk into another room through wall will return a NoConnection, as will trying to walk off world.
Although I often start building some new idea by working first on the new object, I generally get better results when I begin by trying to become clear about how the object will be used. In that light, I think we’re probably wanting to replace this code:
class Dungeon:
def move_player_north(self):
self.attempt_move_to_offset(self.player_cell, (0, 1))
def move_player_east(self):
self.attempt_move_to_offset(self.player_cell, (1, 0))
def move_player_south(self):
self.attempt_move_to_offset(self.player_cell, (0, -1))
def move_player_west(self):
self.attempt_move_to_offset(self.player_cell, (-1, 0))
def attempt_move_to_offset(self, cell, offset):
new_cell = self.layout.cell_we_can_move_to(cell, offset)
if new_cell != cell:
self.place_player_at(new_cell)
def place_player_at(self, cell):
interactor = Interactor(self, cell)
if all(interactor.execute(item) for item in list(self.contents_at(cell))):
self.player_cell = cell
Why not have the View just call self.dungeon.attempt_move('NORTH'), or perhaps some kind of enum NORTH? So we want a method something like this:
class Dungeon:
def attempt_move(direction):
connection = self.player_cell.connection_to(direction)
self.player_cell = connection.move()
Hm. Maybe this:
class Dungeon:
def attempt_move(direction):
self.player_cell = self.player_cell.attempt_move(direction)
I’m not entirely comfortable with the name cell.attempt_move since the cell isn’t moving, but we’ll see.
Trying the Plan
I want to write some code, this is more speculation than is ideal. But it has led us to a good place, I think. Let’s try some tests. We’ll do them in the cell tests, I think.
def test_simple_attempt_move(self):
layout = DungeonLayout(5, 5)
room = Room([Cell(0,0), Cell(1,0)], '')
layout.add_room(room)
layout.player_cell = Cell(0,0)
moved = Cell(0,0).attempt_move(Cell(1,0))
assert moved is Cell(1,0)
This fails for lack of the attempt_move method.
class Cell:
def attempt_move(self, cell):
return cell
Test goes green. Shall we commit. Not yet but we’ll need to decide soon. I think we’ll decide yes.
Now let’s write the attempt_move we had in mind:
class Cell:
def attempt_move(self, cell):
connection = self.get_connection(cell)
return connection.move()
def get_connection(self, cell):
return OpenConnection(self, cell)
class OpenConnection:
def __init__(self, origin, target):
self.origin = origin
self.target = target
def move(self):
return self.target
Green. This is fun. I think we’ll begin to commit: working on Connection.
Let’s write another test.
def test_two_rooms_no_passage(self):
layout = DungeonLayout(5, 5)
r1 = Room([Cell(0,0)], '1')
layout.add_room(r1)
r2 = Room([Cell(1,0)], '2')
layout.add_room(r2)
layout.player_cell = Cell(0,0)
moved = Cell(0,0).attempt_move(Cell(1,0))
assert moved is Cell(0,0)
Here we have two rooms so we shouldn’t get between them. We need to start adding logic to get_connection, and, of course, at least one more Connection class.
def get_connection(self, cell):
if self.room == cell.room:
return OpenConnection(self, cell)
else:
return NoConnection(self, cell)
class NoConnection:
def __init__(self, origin, target):
self.origin = origin
self.target = target
def move(self):
return self.origin
Green. Commit.
Reflection
Can you feel how easy and smooth this is? I’m feeling good about this. Let’s do a passage.
def test_two_rooms_with_passage(self):
layout = DungeonLayout(5, 5)
origin = Cell(0, 0)
r1 = Room([origin], '1')
layout.add_room(r1)
target = Cell(1, 0)
r2 = Room([target], '2')
layout.add_room(r2)
layout.add_passage((origin, target))
layout.player_cell = origin
moved = origin.attempt_move(target)
assert moved is target
We need to do a bit more work now:
def get_connection(self, cell):
if self.room == cell.room:
return OpenConnection(self, cell)
elif self.has_passage(cell):
return OpenConnection(self, cell)
else:
return NoConnection(self, cell)
def has_passage(self, cell):
return self.layout.has_passage(self, cell)
And we’re green. Commit.
Let’s reflect a moment. I’m starting to feel like rushing.
Reflection
Sometimes things get going so well that I can’t wait to do the next thing, and I wind up rushing, and that usually leads to trouble. So it’s good to kind of sit back a moment. So, how are things going? Fine here, how about you? All OK, I hope? Smoke ‘em if ya got ‘em. OK, back to it.
We aren’t dealing with the interactions at all yet. That will come after we’re done with get_connection. What are the other options there? There is the case of trying to move off-world, where presently you don’t get a cell at all. Let’s write that test to see what happens.
def test_cannot_go_off_world(self):
layout = DungeonLayout(5, 5)
origin = Cell(0, 0)
r1 = Room([origin], '1')
layout.add_room(r1)
target = Cell(-1, 0)
assert target is None
layout.player_cell = origin
moved = origin.attempt_move(target)
assert moved is origin
The target cell does not exist. That’s our current convention and let’s cater to it, but I think we’d really like to have an object that we can talk to, Null Object or Missing Object style. We’ll see. For now:
def get_connection(self, cell):
if not cell:
return NoConnection(self, cell)
elif self.room == cell.room:
return OpenConnection(self, cell)
elif self.has_passage(cell):
return OpenConnection(self, cell)
else:
return NoConnection(self, cell)
Commit this.
I wonder if we could put this into play. That would be nice. In Dungeon:
class Dungeon:
def attempt_move_to_offset(self, cell, offset):
new_cell = self.layout.cell_we_can_move_to(cell, offset)
if new_cell != cell:
self.place_player_at(new_cell)
I tried to replace that with the equivalent connection thing and two tests failed. We are missing a test for our connections.
def test_cannot_move_to_available(self):
layout = DungeonLayout(5, 5)
origin = Cell(0, 0)
r1 = Room([origin], '1')
layout.add_room(r1)
target = Cell(1, 0)
layout.player_cell = origin
moved = origin.attempt_move(target)
assert moved is origin
I expected that to fail and it does not. I agree, it shouldn’t fail because an available cell’s room is not a room. So why did the other tests fail?
I must have typed something wrong because when I did it this time, we’re green, which frankly I expected all along:
class Dungeon:
def attempt_move_to_offset(self, cell, offset):
proposed = cell.offset_by(offset)
new_cell = cell.get_connection(proposed).move()
if new_cell != cell:
self.place_player_at(proposed)
What if we had a get_offset_connection on Cell? Wouldn’t that be useful?
class Dungeon:
def attempt_move_to_offset(self, cell, offset):
new_cell = cell.get_connection_offset(offset).move()
if new_cell != cell:
self.place_player_at(new_cell)
class Cell:
def get_connection_offset(self, offset):
return self.get_connection(self.offset_by(offset))
Commit: Connection used in Dungeon.
It’s just about two hours, let’s sum up.
Summary
Obviously, I think this is good enough to use, because we’re using it. It’s not complete: we need to fold the interactions into it, supposing that when we look at that we find it useful.
And I like it. Let me see if I can explain why …
I think we’ll probably have some code savings here and there, and that’ll be good. But what I like is that our little objects, OpenConnection and NoConnection, convert what was open procedural code spanning a couple of classes into a tiny object with no conditional behavior at all (yet, and with luck, ever). There is one somewhat procedural bit in the get_connection method but even that is more clear than what we had:
class Cell:
def get_connection(self, cell):
if not cell:
return NoConnection(self, cell)
elif self.room == cell.room:
return OpenConnection(self, cell)
elif self.has_passage(cell):
return OpenConnection(self, cell)
else:
return NoConnection(self, cell)
I like that the addition of interesting behavior will most likely come down to additional kinds of connections. I think what we may want is a get_passage_connection method that will return NoConnection, OpenConnection, DoorConnection, HunkaBurninLoveDoorConnection, whatever kind of smart connection we may want between rooms.
There could be a passage-style connection that Dot can only pass through if she’s wearing the FeatheredCapOfSourOne. Whatever we want, it seems, can likely be built into smarter and smarter connections.
We have identified one part of moving between cells, the choice of the cell to wind up in. We already know there is another part, the interactions, and we have an Interactor object that we’ll use or integrate. And all of those parts are represented by objects rather than procedural code.
In fairness to any naysayers and head-scratchers, we have not reaped many benefits yet. I would argue that the code is clearly a bit simpler, but so far this doesn’t look like a great revelation. But we’ve taken the first step in a direction that feels quite good to me.
And that is the way. See you next time!