Dungeon 201
In which our intrepid author muses at length and decides to keep on keeping on. And to do some random stuff, no doubt.
Two hundred articles and counting. And if we view this thing as a game, there are still many things, large and small, that we could do. Here are some that come to mind:
- Semi-rational Room Layout
- Room layout is entirely random, with hallways carved between room N+1 and room N. This results in interesting shapes, but it means that there’s not an easy way to choose remote rooms for boss encounters or daunting quests from one end to the other.
-
In particular, the WayDown to the next level is physically as far as possible from room 1, where the player starts, but it could still be room 2, with a path directly there. For any level that has a kind of enduring purpose, the random room layout makes things a bit difficult.
-
There are of course many ways to fix this. Perhaps we could allocate part of the dungeon space to beginning and ending rooms, randomly allocate the rest, then connect the begin / end bits in. Perhaps we could devise random-ish yet sequential layouts with long single paths. Perhaps we could analyze the dungeon somehow, adjusting it or just rolling again if we don’t like it. We could even go to a new level creation method with more intelligence.
- Room Provisioning
- Provisioning is done after the entire dungeon is carved. The game setup presently provisions randomly, into Tiles rather than Rooms or other structures. The game then adds in the desired number of contents items, monsters, chests, loot, and so on. It does so by randomly selecting a tile for each item to be inserted, subject only to some simple constraints that ensure that no hallways are blocked.
-
Provisioning one room at a time might let us create rooms that make a kind of sense, perhaps even something like placing valuable treasures and guardian monsters together. At present, only the WayDown has special guardians, who are placed at the time the WayDown room is selected.
-
Room-oriented provisioning could help with puzzles. Because the game is so focused on the notion of the Tile, I’ve been thinking in terms of finding a large enough expanse of tiles to lay down a puzzle. That’s not good thinking: just because there’s not much more to a Room than a technique for laying out a rectangle of tiles, that doesn’t mean we couldn’t use that construct for more purpose.
- Digression on Tiles
- I think there is a neat kind of simplicity to the game’s focus on Tiles. Everything in the dungeon is on a Tile. Motion is from Tile to Tile. Encounters with creatures or objects take place Tile to Tile. The running of the game doesn’t need to know about structures like rooms at all. It’s all Tiles, and it’s really rather nice.
-
One issue with a low-level elemental structure like that could be that some things would be hard to do because you really need a high-level structure to solve certain issues. So far that has mostly not been the case, and that makes me fairly satisfied with that design aspect.
-
Nonetheless, there are reasons why other structures might give us abilities we do not now have. So far, we’ve not really encountered many such issues. The PathFinder is one example. It finds its way from any tile to the WayDown, having used a simple Dijkstra / A-Star kind of mapping of the dungeon.
- Better Art
- The game’s art is certainly nothing to be proud of. The tiles are pretty nice, and the creatures are random flat sprites that I downloaded from the Internet. A 2.5D layout might look better. However, with one exception, I feel there’s not much to do in the art area that advances my interests, which are the programming, not finding and splashing out different sprites.
-
In a “2.5D” dungeon, the north walls, and sometimes the south walls, are given a sort of 3D look, while the rest of the walls are drawn with a top-down view, like this:
-
In our game, walls are drawn after the entire dungeon is carved, and there’s actually a fair bit of logic to selecting the wall tiles now:
-
You can see a little 3D effect in the south, but the rest is pretty flat. I do have some north wall tiles, and it would be possible to enhance the game to draw them. Mostly, however, I think it would be tedious and not very educational. Which brings up the next topic.
- Learning and Discovery
- I write these little programs to learn and discover things about programming, and to share those learnings and discoveries with readers. Both of you! For me, I’m interested in seeing how far we can go with incremental design and development, given that we keep our code reasonably habitable and that we have a robust collection of tests. I enjoy encountering things that are hard to test, or that I don’t want to test, and then discovering what happens to me when I don’t do the things I have learned to feel are best.
-
That’s right, I don’t always do what I have learned is best. I don’t exercise enough, I eat too much questionable food, I don’t always write my tests before I write my code, and I don’t always refactor code that needs it. What is interesting about those observations are that I am not as strong as I could be, weigh more than I might, and encounter defects in my code that are hard to find and could have been prevented or discovered sooner.
-
To me, that’s a big part of what’s valuable and interesting in what I do here: it shows me, directly, what happens when I do the things I do, well or poorly, good or bad. And, maybe it shows you, and maybe it gives you ideas for things to do, or not to do.
-
Another kind of learning comes mostly at the beginning of a project, the creation or discovery of the basic design, and the learning of specific new techniques that apply to that particular project. The gravity of Spacewar and the drawing of the ships; the breaking up of the Asteroids; the motion of the Space Invaders and the destruction of the things they shoot. There are always new things like that in a new project.
-
Of course, there are new things right here as well. We have some big ideas that may not fit in well with our current design. Quests involve a long-running series of activities aimed at some goal. Puzzles are somewhat similar, involving objects that interact in ways that are not obvious and that have to be manipulated to solve the puzzle. Non-Player Characters could be created to make the game more interesting and to provide important information. And so on.
-
This program, despite over 200 articles, is not mined out. Or dungeoned out.
- Avoiding Larger Topics
- I’m not sure if I’ve been using these little programs to avoid larger topics or not. Certainly I write fewer articles about the more broad, human aspects of life and software development. Probably part of that is pure denial: in “these trying times”, this work takes me to a simpler universe, where it’s my mind and the help of my friends that gives me results that are more or less what I set out to do.
-
Looking at the world around us, or even the world of programmers Under the Thumb of Scrum, is much more daunting. I can release a new feature any day I want to with this program. I can take a week off to refactor any time I want. Those larger problems seem intractable, and the work of a quarter century hasn’t made the world much better than it was. Maybe it’s net better. Maybe it’s not better.
-
I could write more about those things. I know some stuff, and I’d like to learn more. And yet … in here, in this program, I can make a difference. And maybe, for a few readers, even outside it, using it as an example for ways of dealing with larger issues.
- Error Avoidance
- Here’s a challenging topic for projects like this one, with this toolset: how can we arrange things to prevent errors, or at least detect them sooner? For the past few days, the PathFinder has been broken, because it can be created without a target to find, and given the target later. I’m not sure why I did that: maybe I was thinking it might be useful to use it dynamically. Be that as it may, the only tangible result so far was that it didn’t have a valid map and the PathFinder crashed the program.
-
I could fix that particular concern, perhaps by checking to be sure that the PathFinder is always created with legal inputs. If it isn’t, though, what should we do? If we throw a fatal error, the crash could still occur, just with a better message. Maybe we should just detect the situation and have map users visibly become confused, and send me an email saying that it happened.
-
The general issue of avoiding and dealing with errors is always interesting and usually difficult. Are there general lessons to draw out about handling errors? Are there tools and techniques for Codea? Are there ideas from Codea that are of general value? Almost certainly. We can explore and discover those ideas in this program as well as any other, and perhaps more so, because it is more complex than earlier exercises in Codea.
- Export and Xcode
- Codea has the ability to export your program to Xcode, so that you can use it to build a real iOS app. The enjoyable game CargoBot on the App Store is written in Codea.
-
I could certainly undertake the effort to learn how to make Codea apps that way. And there would be learning, possibly painful learning. Someday, I “should” probably do that. I don’t have to do things for “should”, so I’m going to wait until I feel like it, or until someone volunteers to pair with me on it.
I’m Glad We Had This Little Chat
I’ve convinced myself that despite the ludicrous notion of over 200 articles about a single program, there’s still fun and discovery to be had here.
Deal with it, or write and tell me why you’d rather not.
Now let’s do something useful.
Beef Up PathFinder
Because we shipped a defect involving creation of a Map that couldn’t be used, let’s try to learn a lesson, improve our testing, and create a softer failure mode for Map.
One issue is that the Map can be created without a target, target to be provided later:
function Map:init(dungeon, tile)
self.dungeon = dungeon
self:initCells()
self.visited = false
if tile then self:target(tile) end
end
function Map:target(tile)
self:targetXY(tile:pos():unpack())
self:run()
end
It’s that latter code that causes the Map to link together its cells. What it’s doing is starting from the target, check each cell adjacent to all the currently visited cells, and linking them to whichever cell “found” them. This means that cells right around the target link to the target, cells right around them link to the generation one cells, gen three to gen two, and so on.
You might ask yourself what Map.visited
is about. I’m asking that as well. I think it is unused. I see no references to it. No idea why I put it in there. Shall we remove it? Not now, we are looking for larger fish.
The tests presently rely on the ability to create a Map without initializing it:
_:test("Initial Map", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local map = Map(dungeon)
_:expect(map:cell(1,1).previous).is(nil)
_:expect(map:cell(50,50).previous).is(nil)
end)
_:test("One Frontier Step", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local map = Map(dungeon)
map:targetXY(10,10)
_:expect(map:queue()[1]).is(map:cell(10,10))
map:step()
_:expect(#map:queue()).is(4)
local queue = map:queue()
_:expect(queue).has(map:cell(9,10))
_:expect(queue).has(map:cell(10,9))
_:expect(queue).has(map:cell(11,10))
_:expect(queue).has(map:cell(10,11))
_:expect(map:cell(9,10).parent).is(map:cell(10,10))
_:expect(map:cell(10,11).distance).is(1)
end)
What we see here is a rationale for the ability to create the Map without giving it a target: that first test wants to check that no tiles are linked at the beginning. Not a very strong test, but it does tell part of the story.
The second test is similar. It sets the target directly, and then executes just one step of the algorithm, and checks the cells around the target to see that they are correctly linked and enqueued.
I’d like to do this to Map:
function Map:init(dungeon, tile)
self.dungeon = dungeon
self:initCells()
self.visited = false
self:target(tile)
end
That will at least blow up immediately if no tile was provided. It will also break the Map tests. I’m trying to think of how to do this.
How about this: we’ll create our test maps legitimately, giving them a target, and then we’ll call the necessary functions to init the tiles, provide a new target, and so on.
This test is failing:
_:test("Initial Map", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local map = Map(dungeon)
_:expect(map:cell(1,1).previous).is(nil)
_:expect(map:cell(50,50).previous).is(nil)
end)
3: Initial Map -- PathFinder:210: attempt to index a nil value (local 'tile')
We’re now requiring a legal tile. Let’s give it one and let it break.
_:test("Initial Map", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local targ = dungeon:getTile(vec2(10,10))
local map = Map(dungeon, targ)
_:expect(map:cell(1,1).previous).is(nil)
_:expect(map:cell(50,50).previous).is(nil)
end)
It doesn’t break. At first I wonder why. Upon exploration, I realize that the map cells don’t even include a previous
member variable: that test will always pass if a cell exists at all.
We may want to remove this test. Let’s do the next one first, decide later.
_:test("One Frontier Step", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local map = Map(dungeon)
map:targetXY(10,10)
_:expect(map:queue()[1]).is(map:cell(10,10))
map:step()
_:expect(#map:queue()).is(4)
local queue = map:queue()
_:expect(queue).has(map:cell(9,10))
_:expect(queue).has(map:cell(10,9))
_:expect(queue).has(map:cell(11,10))
_:expect(queue).has(map:cell(10,11))
_:expect(map:cell(9,10).parent).is(map:cell(10,10))
_:expect(map:cell(10,11).distance).is(1)
end)
Here, we really didn’t want the Map to automatically run. I think, however, that providing a target and then re-initializing the cells will make the test pass. Let’s try that.
_:test("One Frontier Step", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local targ = dungeon:getTile(vec2(10,10))
local map = Map(dungeon, targ)
map:initCells()
map:targetXY(10,10)
_:expect(map:queue()[1]).is(map:cell(10,10))
map:step()
_:expect(#map:queue()).is(4)
local queue = map:queue()
_:expect(queue).has(map:cell(9,10))
_:expect(queue).has(map:cell(10,9))
_:expect(queue).has(map:cell(11,10))
_:expect(queue).has(map:cell(10,11))
_:expect(map:cell(9,10).parent).is(map:cell(10,10))
_:expect(map:cell(10,11).distance).is(1)
end)
That works. It’s irritating to know that the Map actually ran a full cycle before we re-initialized. Let’s provide a third parameter to Map, used only by testing, to skip the targeting and running.
_:test("One Frontier Step", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local targ = dungeon:getTile(vec2(10,10))
local testing = true
local map = Map(dungeon, targ, testing)
map:initCells()
map:targetXY(10,10)
_:expect(map:queue()[1]).is(map:cell(10,10))
map:step()
_:expect(#map:queue()).is(4)
local queue = map:queue()
_:expect(queue).has(map:cell(9,10))
_:expect(queue).has(map:cell(10,9))
_:expect(queue).has(map:cell(11,10))
_:expect(queue).has(map:cell(10,11))
_:expect(map:cell(9,10).parent).is(map:cell(10,10))
_:expect(map:cell(10,11).distance).is(1)
end)
I used Explaining Variable Name there to make it more clear what I was doing.
In Map:
function Map:init(dungeon, tile, testing)
self.dungeon = dungeon
self:initCells()
self.visited = false
if not testing then
self:target(tile)
end
end
I’ll put that into all the tests as we go along here. Anyway, One Frontier Step now runs. Next:
_:test("A few steps", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local map = Map(dungeon)
map:targetXY(10,10)
map:step()
_:expect(#map:queue()).is(4)
map:step()
_:expect(#map:queue()).is(6)
end)
Same drill:
_:test("A few steps", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local targ = dungeon:getTile(vec2(10,10))
local testing = true
local map = Map(dungeon, targ, testing)
map:targetXY(10,10)
map:step()
_:expect(#map:queue()).is(4)
map:step()
_:expect(#map:queue()).is(6)
end)
Test runs. Next:
_:test("Full Loop", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local map = Map(dungeon)
map:targetXY(10,10)
map:run()
_:expect(map:cell(8,10).distance).is(2)
_:expect(map:cell(50,50).distance).is(80)
_:expect(map:cell(1,1).distance).is(18)
for x = 1,50 do
for y = 1,50 do
local d = map:cell(x,y).distance
local e = math.abs(x-10) + math.abs(y-10)
_:expect(d).is(e)
end
end
end)
This one is assuming that run is not automatic. We’ll let it continue to think that:
_:test("Full Loop", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local targ = dungeon:getTile(vec2(10,10))
local testing = true
local map = Map(dungeon, targ, testing)
map:targetXY(10,10)
map:run()
_:expect(map:cell(8,10).distance).is(2)
_:expect(map:cell(50,50).distance).is(80)
_:expect(map:cell(1,1).distance).is(18)
for x = 1,50 do
for y = 1,50 do
local d = map:cell(x,y).distance
local e = math.abs(x-10) + math.abs(y-10)
_:expect(d).is(e)
end
end
end)
Test runs. Next, same drill:
_:test("Obstacle", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(10,10)
local dungeon = dungeonClass(tiles)
local targ = dungeon:getTile(vec2(10,10))
local testing = true
local map = Map(dungeon, targ, testing)
for y = 1,9 do
map:cell(5,y):setInaccessible()
end
local start = vec2(1,1)
local mid = vec2(5,10)
local stop = vec2(10,1)
map:targetXY(1,1)
map:run()
local md = manhattan(start,mid) + manhattan(mid,stop)
_:expect(map:cell(stop.x,stop.y).distance).is(md)
local path = map:cell(stop.x,stop.y):path()
_:expect(#path).is(28)
local prev = path[1]
for i,c in ipairs(path) do
if i > 1 then
_:expect(c:manhattan(prev)).is(1)
prev = c
end
end
end)
Just the standard changes in the initial creation.
One more:
_:test("target accepts tile", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local map = Map(dungeon)
local t = FakeMapTile(10,10)
map:target(t)
map:run()
t = FakeMapTile(11,10)
local g = map:nextAfter(t)
local pos = g:pos()
_:expect(pos).is(vec2(10,10))
end)
Same thing:
_:test("target accepts tile", function()
local dungeonClass = FakeMapDungeon
local tiles = FakeMapTile:create(50,50)
local dungeon = dungeonClass(tiles)
local targ = dungeon:getTile(vec2(10,10))
local testing = true
local map = Map(dungeon, targ, testing)
local t = FakeMapTile(10,10)
map:target(t)
map:run()
t = FakeMapTile(11,10)
local g = map:nextAfter(t)
local pos = g:pos()
_:expect(pos).is(vec2(10,10))
end)
Tests all run.
Let’s commit: Map object now requires a Tile on creation.
However, I’m still concerned about that PathFinder crash. Let’s try to reproduce it and see what we can do.
function GameRunner:mapToWayDown()
return Map(self:getDungeon(), self.wayDown:getTile())
end
That’s where the bug was, used to say self.wayDown.tile
. Let’s break this code:
function GameRunner:mapToWayDown()
local map = Map(self:getDungeon(), self.wayDown:getTile())
map:initCells()
return map
end
Then let’s find a PathFinder and bring down the world.
Here’s the error:
Monster:65: attempt to index a nil value (local 'tile')
stack traceback:
Monster:65: in field 'init'
... false
end
setmetatable(c, mt)
return c
end:24: in global 'Monster'
Monster:45: in method 'getPathMonster'
Monsters:101: in method 'createPathMonster'
GameRunner:263: in method 'createPathMonster'
Player:254: in field '?'
Inventory:268: in method 'touched'
Inventory:206: in method 'touched'
GameRunner:557: in method 'touched'
Main:97: in function 'touched'
It’s not obvious, but this actually comes from spawnPathfinder
in Player:
function Player:spawnPathfinder()
local map = self.runner:mapToWayDown()
local myTile = self:getTile()
local monsterTile = map:nextAfter(myTile)
self.runner:createPathMonster(map, monsterTile)
end
In that method, we init the PathMonster on the tile nextAfter
the player’s tile, so the monster appears right beside the player. It’s nextAfter
that went wrong:
function Map:nextAfter(tile)
local cell = self:getCellFromTile(tile)
local prev = cell.parent
if not prev then return nil end
return self.dungeon:getTile(vec2(prev.x,prev.y))
end
If the map doesn’t show the monster where to go (prev
) then we return nil. Let’s instead return the input tile. This will cause the monster to rez on top of us and to stay there, but it shouldn’t explode.
That works as intended.
Put GameRunner back as it was.
function GameRunner:mapToWayDown()
return Map(self:getDungeon(), self.wayDown:getTile())
end
Commit: Bulletproof Pathfinder to stand still if path is broken.
I think I’d like the path monster to announce itself upon rezzing. We could associate that with the Magic Jar, I think:
InventoryItem{ icon="blue_jar", name="Magic Jar", object=self.player, method="spawnPathfinder" },
We now support more descriptive information:
function InventoryItem:init(aTable)
self.icon = aTable.icon
self.name = aTable.name or self.icon
self.object = aTable.object or self
self.message = aTable.method or "print"
self.description = aTable.description or self.name
self.attribute = aTable.attribute
self.value = aTable.value
end
function InventoryItem:informObjectRemoved()
self:informObject("You have used a "..self.description.."!")
end
We’re stuck with the “You have used” bit, and changing that is a bit more than I want to do right now. But we can make the description something like this:
function GameRunner:createDecor(n)
local sourceItems = {
InventoryItem{ icon="red_vase", name="Poison Antidote", object=self.player, method="curePoison" },
InventoryItem{ icon="blue_jar", name="Magic Jar", object=self.player, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature" },
InventoryItem{object=self.player},
InventoryItem{object=self.player},
InventoryItem{object=self.player},
}
Let’s see how that looks.
The message comes out as intended, but it also comes out when you acquire the object. This leads me to want something more robust in the messaging. First let’s commit: pathfinder jar has message about pathfinding cloud creature. Then let’s create a new story.
Wait. In testing, I found a Magic Jar just lying about, not in Decor, and it doesn’t have the same message. We have some kind of weird duplication going on here.
function Loot:addPathfinderToInventory(aPlayer)
local item = InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder"}
Inventory:add(item)
end
I can’t say that I love this, but for now, I’ll continue the duplication.
function Loot:addPathfinderToInventory(aPlayer)
local item = InventoryItem{icon="blue_jar", name="Magic Jar", object=aPlayer, method="spawnPathfinder", description="Magic Jar to create a Pathfinding Cloud Creature"}
Inventory:add(item)
end
I expect that we’ll converge Loot and Decor at some future point. Make a card for it.
Now then, the story we were going to write.
Advanced Descriptions
As Senior Dungeon Designer,
I want advanced descriptions
So that
No! That’s not a story. This is a story:
- Conversation (with the the whole team (me))
- Right now, an Inventory item has a default description, its name, like “Magic Jar”, and one detailed description, which can be any text. When you get an item, you see the message “You received” and either the description or the name. When you use the item, you see “You used” and again the same description or name.
-
We’d like to have the messages not all say “you received” and “you used”, and we’d like to have different messages for receiving and using. That would let us do things like “You found a Magic Creature-Rezzing Jar”, and “At a touch of your Magic Jar, a Pathfinding Cloud Creature appears! Where will it lead you?”
-
There could even be a different message when you query the object before picking it up. And, maybe someday, we’ll want the ability to touch an inventory item to find out what it is, without using it. That remains to be seen.
-
OK. Currently we have one description field. We could have two or more and use different ones in different situations. Wouldn’t we want them to default down somehow, so that you could provide a very generic one, a somewhat detailed one, or two or more very special ones?
-
Yes, that makes sense. We can already see three such messages. There could be more.
-
What about those lead-in bits, “You received” and such? Should we just say that if you provide a detailed message, it will include that, and we only use the generic lead-ins for objects with no description other than their name?
-
That sounds sensible, let’s try it that way.
-
What about powerups? They currently say something like “You have used a Potent Potion of Health”, and then you get a +5 Health in the Crawl. That last part is issued elsewhere. Can we just let that be for now, until we have more experience with the multiple descriptions?
-
Yes. Makes sense. What’s the story?
- Card
- Provide ability to override standard “You received” and “You used” messages with custom messages for each case, for all Inventory Items whether free-standing Loot or inside Decor or however discovered.
- Confirmation
- How will we know this works?
-
We’ll write some TDD tests for it, and we’ll demonstrate it. No special confirmation planned.
That’s how you do that. Conversation to build understanding, Card to summarize, Confirmation to decide how you’ll test it. Card, Conversation, Confirmation.
I think we’re good for today. It’s the weekend, after all. Let’s sum up.
Summary
It’s valuable, often, to step back and take an overall look at what we’re building, or what we’re doing with our lives. Today, I decided to continue with the present series, though I’m gong to raise my attention to larger issues and maybe write some articles about those as well.
It’s valuable, when something goes wrong, to apply some attention to making sure the problem can’t occur again. In today’s case, the issue was due to providing a protocol that wasn’t safe, and that could leave the object in a bad state, combined with a crash if it was in that state.
We could have considered things fixed by just providing the right input when using it. We could have considered them extra fixed by making the change that deals with the path not returning a tile, which could, I suppose, happen some other way. We chose also to make the object require a complete setup, and then modified our tests to do that.
We don’t really expect to have to modify tests often. It’s more common to add to them. This time it happened because we were changing the fundamental operation of the Map.
It’s valuable, when you’re working in some area, to look for quick wins related to that area. In our case, we chose to improve the messaging from use of the Path Monster.
But wait! Those descriptions were a feature! Devs can’t just go putting in unwanted features, can they?
No, but it wasn’t an unwanted feature. I happen to have intimate knowledge of what my “product owner” wants, but any decent team should be no more than a quick conversation away from product understanding. “Hey, Jack, I’m working on that path finding problem. Would you like to change the description of the Magic Jar to make it more interesting?”
Jack walks over and working with her, you quickly improve the system and ship it.
A good morning. See you next time!