Robot World 000 - Something Actually Different
Tuesday night’s Friday Night Coding Zoom has hooked me. This is the beginning of the result of that hook.
GeePaw Hill is working with We Think Code, a South African organization that is helping would-be programmers in that country to learn the trade. A worthy effort, certainly. One of the applications they’re using as a base for learning is a game called “Robot World”.
Hill has written at least four articles about Robot World, and I have read none of them. I have, however, talked with him and others as we looked at some of the reference code for the game, so I’ve picked up a little bit about what the game is. I do not plan to look further into what he has done, at least until I am well and truly in trouble over here chez Ron.
Over here chez Ron, we’re going to program something in Codea Lua, based on a very limited understanding of what the game is supposed to be. Over time, I’ll ask Hill to reveal requirements or raise issues or whatever, guiding what I do. But by and large, my plan here is to invent something inspired by what little I know.
What little I know is that the SA Robot World is a client-server application, with the clients being individual user-controlled robots, and the server running the world, keeping track of where everything in the world is. Robots can only do whatever they can do by asking the server to do it for them, be that to move, to look around, whatever. I suppose that the robots will have weapons and fight with each other, though these days I am less inclined toward that kind of behavior than I might have been in the past. We’ll see where we go.
In these articles, I will include the tags “Codea” and “robot”, and try to limit the other tags to specific articles where that topic is actually prominent. The idea will be to make categories and tags more useful on my site, which is becoming as much a jungle as the works of Vivaldi.
The topics on this first article tell us the areas I expect to touch upon during this exercise.
Let’s do just a bit of thinking about the spec and then get started.
Robot World 0.1 Spec
The real app is client-server. We’ll build it so that it could be client-server, but as it is being built on my iPad, I doubt that we’ll ever get to a real client-server situation. One never knows.
There is a world. It is a rectangular grid. It can contain Robots, Walls, Pits, and Mines. Maybe other things to be invented or discovered.
The human players can have a robot. The Robot will be able to move, to look around, and probably fire weapons. Whatever it does is mediated by the world, which will decide whether the robot can do that thing, and what the results are.
I imagine that the game is not turn-based, so that a player with faster fingers and connection can take multiple actions while another slower player muddles about.
I know that Hill wants to give the students an important distinction between the “Shipping App”, the game, and the “Making App”, the toolset that the developers use to build the Shipping App. I will try to create that distinction as well, and I expect to stumble a lot during the process.
And, of course, we’ll be using TDD as much as we can, refactor as needed, and in general work in that iterative incremental style that is the best thing we know to do, right now. And, as usual, I’ll be pushing the limits of that style, making decisions early, before I know much about the requirements, design, or code, so that we can see just how incremental and iterative we can be before we crash and burn like a destroyed robot on the plains of … ominous music … ROBOT WORLD!!!!
Let’s Get Going!
I’ll create a Codea project, with CodeaUnit built in, call it RobotWorld, and put it into WorkingCopy. Commit four files: Initial Commit. The files are info.plist, README, Main.lua, and Tests.lua.
The tests are in the initial form. I’ll run them once to let them fail.
Feature: YOUR_TEST_NAME_HERE
1: HOOKUP testing fooness --
Actual: Foo,
Expected: Bar
2: Nothing -- Ignored
3: Floating point epsilon -- OK
1 Passed, 1 Ignored, 1 Failed
Just right. Now I’ll rename my tests, remove all the hookup ones and … oh, I guess we’ll have to write a new test for something. I guess we need to create the world before we create robots, though I’m not entirely sure why. It just seems right.
I rename the tab TestWorld, remove the starter tests and add our first attempt at a test:
_:test("Create Default World", function()
local world = World()
end)
Now, I happen to know that the reference model that Hill is working with provides a very configurable world. That seems premature here, since right now we don’t have any world at all. And this test will fail because we don’t even have a World class. Certainly not a world-class World class. (Sorry, I have to entertain myself, the cat is out on the deck.)
1: Create Default World -- TestWorld:14: attempt to call a nil value (global 'World')
OK, I’ll really try to do the minimum work here, to get in the rhythm of ideal TDD and “Many More Much Smaller Steps”, but you can expect me to dash off any minute and type in a bunch of stuff. I’m only human and my mind is full of code.
For now, just this:
World = class()
The test will run, I fondly hope. It does. Shall we commit? Sure, in for a penny … commit: Initial World class.
Now what? How about this idea: we’re done with World for now, because until we have a Robot to ask questions or give commands, we have no need for more capability in World. That’s it, just the class definition. No member variables, nothing. (Of course we have ideas for what will be in there, but no need, no code.)
So now let’s work on Robot, similarly. But first, I make a new tab, TestStarter, containing an essentially empty test frame. I should probably have copied the provided tab rather than editing it for World. Anyway it looks like this:
-- TestStarter
-- RJ 20220608
function test_RENAME()
_:describe("RENAME", function()
_:before(function()
end)
_:after(function()
end)
_:test("RENAME", function()
_:expect(2).is(2)
end)
end)
end
Now I’ll make a TestRobot tab out of that.
function test_RENAME()
_:describe("Robot", function()
_:before(function()
end)
_:after(function()
end)
_:test("Robot", function()
local robot = Robot()
end)
end)
end
That will drive out a trivial class with this error:
1: Robot -- TestRobot:15: attempt to call a nil value (global 'Robot')
We write:
Robot = class()
Tests are green but are producing more output than I like, so I put this line into each describe
function, and into Main:setup, to keep the console clear. Errors will show up but not the general summary info nor all the OKs.
CodeaUnit_Detailed = false
Commit: Initial Robot, TestStarter tab
I make a card (yellow sticky note) saying “Mod CodeaUnit2 to make more clear to duplicate test tab, not edit in place. CodeaUnit is part of my Making App, and I want to give it an improvement. Oh, hell, now that I’ve distracted myself by making the card let’s do the change.
OK, done, just changed the comments and made the names easier to edit. Nothing to see here. Back to RobotWorld.
That quick update took less than five minutes. No point deferring it to the indefinite future. Shoes for the shoemaker’s kids, yay.
What Do Robots Want?
Let’s think a bit about how this might work.
We can figure that the World has some size in x and y number of tiles or coordinates or whatever they are. At each coordinate there can be empty space or something else, where that something else could be another Robot, or a Wall, or a Pit, or a Mine. I capitalize these as candidate class names or the like.
We can figure that each player Robot has a view of the world on their screen. But they don’t get that view automatically. One of their possible actions is to do a scan, which will return info about whatever the scan detects. Presumably the scan will interrogate the World, and the World will return some kind of list of coordinates and things detected at that coordinate.
I don’t know what Hill’s game is supposed to do, but my game is going to display my Robot at some point on my screen, surrounded by squares representing the grid, and showing the objects that I’ve seen in my scans. (Most things will stay where we initially scanned them, but the Robots we scan might move. We’ll want to deal with that.)
Looking forward just a bit, since our scan is based wherever we are, let’s assume that scan info comes back with relative coordinates, and that we’ll have to adjust accordingly as we move. I’m not sure just what that will entail.
I’d like to get to display soon, but perhaps not yet. Let’s do a bit of work on scanning and what the Robot knows. Let’s assume that the Robot does not know World coordinates, only local ones. So our Robot will start at local coordinates 0,0, and will work from there, and scans will return coordinates relative to 0,0. (The World will know the World coordinates of everything, of course. (At least I think it’s “of course”.)
Now from the client-server viewpoint, there will be some textish format of messages. But Robots and Worlds don’t want to know about that. They want to communicate on their own terms.
OK, I have a test in mind.
Robot First Test
I plan to work out what the Robot might have as its internal view of the World, and to move it, and make sure that the view updates as needed.
To do that, I’ll need a Robot and, for now, a pretty raw list of information. But I do want to get to objects very quickly, even if they are wrong. So let’s imagine
- GridCell
- This object has a Coordinate, and an Item.
- Coordinate
- This is a pair of integers, probably, representing the offsets in x and y of the GridCell in question, relative to the Robot.
- Item
- This will ultimately be a Wall, Robot, Pit, Mine, whatever we can identify from the scan. (We might not be able to see pits, for example, and we surely can’t see past Walls.)
Let me try a test. I sketch it this way:
_:test("Robot updates knowledge on move", function()
local robot = Robot()
setUpKnowledge()
robot:move(1,0)
checkKnowledge()
end)
I don’t know what set up and check knowledge is … but having written it that way, it comes to me that a Robot has knowledge. Knowledge is a thing, a collection of … what is a bit of knowledge called … I don’t know, for now, GridCells.
I think I’ve discovered a new class. As such, it needs tests. I make a new test tab.
Now we need to at least be aware that I’m designing an object without anyone needing it. It would be possible, instead, to give the Robot an array of GridCells (or raw tables) and then evolve to discover that we need an object and so on. On another day, I might do that. Today, I wrote “Knowledge” and it made sense to me. I expect we’ll wire it in soon enough.
_:test("Knowledge", function()
local knowledge = Knowledge()
knowledge:addFact(GridCell(1,1,"A"))
knowledge:addFact(GridCell(1,3,"B"))
knowledge:addFact(GridCell(5,1,"C"))
knowledge:addFact(GridCell(4,4,"D"))
_:expect(knowledge:factCount()).is(4)
knowledge:adjust(2,2)
_:expect(knowledge:factAt(-1,-1)).is("A")
_:expect(knowledge:factAt(-1,1)).is("B")
_:expect(knowledge:factAt(3,-1)).is("C")
_:expect(knowledge:factAt(2,2)).is("D")
end)
Bits of knowledge are Facts, obviously. (Some are speculation, hmm …) We set up some facts in our Knowledge instance. We then move 2 cells right and 2 up in x and y, and expect that the facts are adjusted accordingly. (When our X increases, facts’ X’s relative to ours decrease, and so on.)
This is a pretty big test to have written all at once but we’ll work through it bit by bit.
1: Knowledge -- TestKnowledge:16: attempt to call a nil value (global 'Knowledge')
I forgot to ignore my Robot sketch test. And I notice something weird, which is that the Robot test is running twice, displaying its summary lines twice:
I’m going to let that ride until I have this test working.
1: Knowledge -- TestKnowledge:16: attempt to call a nil value (global 'Knowledge')
Create class:
Knowledge = class()
1: Knowledge -- TestKnowledge:17: attempt to call a nil value (global 'GridCell')
Create that class:
1: Knowledge -- TestKnowledge:17: attempt to call a nil value (method 'addFact')
function Knowledge:addFact(aFact)
table.insert(self.facts, aFact)
end
This won’t work because there are no facts:
1: Knowledge -- TestKnowledge:35: bad argument #1 to 'insert' (table expected, got nil)
function Knowledge:init()
self.facts = {}
end
1: Knowledge -- TestKnowledge:21: attempt to call a nil value (method 'factCount')
function Knowledge:factCount()
return #self.facts
end
1: Knowledge -- TestKnowledge:22: attempt to call a nil value (method 'adjust')
I’ll just add a null method for now …
function Knowledge:adjust(x,y)
end
1: Knowledge -- TestKnowledge:23: attempt to call a nil value (method 'factAt')
And that can be null as well, then we’ll get to work.
function Knowledge:factAt(x,y)
end
1: Knowledge --
Actual: nil,
Expected: A
1: Knowledge --
Actual: nil,
Expected: B
1: Knowledge --
Actual: nil,
Expected: C
1: Knowledge --
Actual: nil,
Expected: D
Well. Clearly my test isn’t fine-grained enough. Let me check some values before adjusting. I split into two tests:
_:test("Knowledge retained", function()
local knowledge = Knowledge()
knowledge:addFact(GridCell(1,1,"A"))
knowledge:addFact(GridCell(1,3,"B"))
knowledge:addFact(GridCell(5,1,"C"))
knowledge:addFact(GridCell(4,4,"D"))
_:expect(knowledge:factCount()).is(4)
_:expect(knowledge:factAt(1,1)).is("A")
_:expect(knowledge:factAt(1,3)).is("B")
_:expect(knowledge:factAt(5,1)).is("C")
_:expect(knowledge:factAt(4,4)).is("D")
end)
_:test("Knowledge adjusted", function()
local knowledge = Knowledge()
knowledge:addFact(GridCell(1,1,"A"))
knowledge:addFact(GridCell(1,3,"B"))
knowledge:addFact(GridCell(5,1,"C"))
knowledge:addFact(GridCell(4,4,"D"))
_:expect(knowledge:factCount()).is(4)
knowledge:adjust(2,2)
_:expect(knowledge:factAt(-1,-1)).is("A")
_:expect(knowledge:factAt(-1,1)).is("B")
_:expect(knowledge:factAt(3,-1)).is("C")
_:expect(knowledge:factAt(2,2)).is("D")
end)
Now I really ought to ignore the second one but this will just take a moment.
1: Knowledge retained --
Actual: nil,
Expected: A
And so on. So …
function Knowledge:factAt(x,y)
for i,fact in ipairs(self.facts) do
if fact.x == x and fact.y==y then return fact.content end
end
return nil
end
This will fail still returning nil, because our GridCells don’t have any values yet.
Yes. Enhance GridCell (which I am already not loving).
function GridCell:init(x,y,content)
self.x = x
self.y = y
self.content = content
end
Now I think this might actually work on the first test. In fact it does.
The second test doesn’t. We have to do adjust. But wait. A wild thought has appeared!
Wild Thought
I was planning to go through and update all the grid cells to have the adjusted x,y coordinates. But what if our Knowledge had a coordinate saved, 0,0, and applied that coordinate in the search for factAt. And then suppose when we moved, we just updated that saved coordinate. Might that not work?
I’m slightly concerned about subsequent facts, but I think they’ll sort out just fine as well. We’ll try it.
function Knowledge:init()
self.x = 0
self.y = 0
self.facts = {}
end
function Knowledge:adjust(x,y)
self.x = self.x + x
self.y = self.y + y
end
function Knowledge:addFact(aFact)
table.insert(self.facts, aFact)
end
function Knowledge:factAt(x,y)
for i,fact in ipairs(self.facts) do
if fact.x - self.x == x and fact.y - self.y ==y then return fact.content end
end
return nil
end
We bump up our x and y on adjust, and subtract the current x and y in fact checking. The tests all run.
Commit: Knowledge can be retained and adjusted for movement.
This is nice. Now let’s see if we can see what’s up with Feature: Robot showing up twice in the output. Hm, it had to do with not renaming one of the test frames. Now I get what I expect:
I could change the starter test not to show up, but why not leave it? It might remind me that I have it.
Now let’s go back to our Robot test and see what we think, now that we have Knowledge.
_:test("Robot Can be created", function()
local robot = Robot()
end)
_:test("Robot updates knowledge on move", function()
local robot = Robot()
setUpKnowledge()
robot:move(1,0)
checkKnowledge()
end)
We know that Knowledge works, so what is there to test here, and what can we say about how it’s done?
Well, when the Robot moves, it needs to adjust its knowledge. And presumably, when it does a scan, it’ll get some knowledge from the world …
So when a Robot is created, it will have to have a World to talk with.
Let’s expand this test a bit:
_:test("Robot updates knowledge on move", function()
local world = World:test1()
local robot = Robot(world)
robot:scan()
_:expect(robot:factAt(5,6)).is("fact")
robot:move(1,1)
_:expect(robot:factAt(4,5)).is("fact")
_:expect(robot:factAt(5,6)).is(nil)
end)
I’m imagining a World factory method that puts a fact at 5,6. I’m imagining that robot:scan
will issue a scan message to world. And I’m imagining that Robot implements factAt
, which it will obviously defer to its Knowledge. So this will fail looking for test1
, I reckon.
2: Robot updates knowledge on move -- TestRobot:20: attempt to call a nil value (method 'test1')
function World:test1()
return World()
end
We’ll fail on scan
.
2: Robot updates knowledge on move -- TestRobot:22: attempt to call a nil value (method 'scan')
Implement:
function Robot:init(aWorld)
self.world = aWorld
end
function Robot:scan()
local scanResult = self.world:scan()
end
This will fail for want of World knowing scan.
2: Robot updates knowledge on move -- TestRobot:40: attempt to call a nil value (method 'scan')
What shall we have come back from scan? Let’s have it be a Knowledge.
Oh, wait. I remember a test we didn’t do on Knowledge. Let’s ignore this one again and pause to think about and do the Knowledge one.
I mentioned that I thought the current Knowledge could deal with multiple moves, but I think we’d better test that before we start putting a lot of weight on Knowledge.
_:test("Knowledge adjusted twice", function()
local knowledge = Knowledge()
knowledge:addFact(GridCell(1,1,"A"))
knowledge:adjust(2,2)
_:expect(knowledge:factAt(-1,-1)).is("A")
knowledge:addFact(GridCell(1,1,"B"))
knowledge:adjust(1,1)
_:expect(knowledge:factAt(0,0)).is("B")
_:expect(knowledge:factAt(-2,-2)).is("A")
end)
I think we have some issues here, not least that we’re not dealing with duplicated coordinates in Knowledge and maybe we need to. But depending on how we store things, this isn’t likely to work. I’m not sure. Run it.
3: Knowledge adjusted twice --
Actual: nil,
Expected: B
Interesting. We found nothing at 0,0. Let me look at the code, I wrote it literally minutes ago and I don’t recall just how it works.
function Knowledge:init()
self.x = 0
self.y = 0
self.facts = {}
end
function Knowledge:adjust(x,y)
self.x = self.x + x
self.y = self.y + y
end
function Knowledge:addFact(aFact)
table.insert(self.facts, aFact)
end
function Knowledge:factAt(x,y)
for i,fact in ipairs(self.facts) do
if fact.x - self.x == x and fact.y - self.y ==y then return fact.content end
end
return nil
end
Right. We will store a new 1,1,B in addFact
, and our x and y when we look for it will be 3,3, so we’ll be looking for 1-3, 1-3. This is hard to think about.
Clearly I can do this by adjusting everything when we move, I say “clearly”, but I don’t mean it. I think we need to store things in adjusted coordinates, not just accepting whatever we’re given. That way everything will be stored relative to our original location. So …
function Knowledge:addFact(aFact)
table.insert(self.facts, aFact:adjust(self.x, self.y))
end
And … do we add or subtract? We add, because we’re subtracting on the fetch.
function GridCell:adjust(x,y)
return GridCell(self.x+x, self.y+y, self.content)
end
This might work. It does. All the tests run except our ignored one. Back to that:
_:test("Robot updates knowledge on move", function()
local world = World:test1()
local robot = Robot(world)
robot:scan()
_:expect(robot:factAt(5,6)).is("fact")
robot:move(1,1)
_:expect(robot:factAt(4,5)).is("fact")
_:expect(robot:factAt(5,6)).is(nil)
end)
Run it.
2: Robot updates knowledge on move -- TestRobot:39: attempt to call a nil value (method 'scan')
The test politely reminds us where we were. World needs scan
. It will ask the world for a scan. *We’ll need to decide how to deal with the World and Robot coordinates staying in sync, but for now we can ignore that.
function World:scan()
local knowledge = Knowledge()
knowledge:addFact(GridCell(5,6,"fact"))
return knowledge
end
I expect this test to fail on robot:move
. Oops no:
2: Robot updates knowledge on move -- TestRobot:23: attempt to call a nil value (method 'factAt')
function Robot:factAt(x,y)
return self.knowledge:factAt(x,y)
end
Of course we don’t have any knowledge yet, so I realize I meant to say this:
function Robot:scan()
self.knowledge = self.world:scan()
end
Test.
2: Robot updates knowledge on move -- TestRobot:24: attempt to call a nil value (method 'move')
Ah. OK.
I’m moving too fast. I can feel it. I was doing fine, but now I’m leaning forward, starting to windmill my arms, about to fall on my face. Got to get this loop closed but I need to slow down.
function Robot:move(x,y)
self.knowledge:adjust(x,y)
end
Test. And it runs. Commit: Robot can scan and move, updating knowledge correctly.
Perfect time to reflect and perhaps even sum up for the morning, since it is now 1248. (We started late, first commit was 1014.
Reflection
My speedy rhythm was fine for a while, but then suddenly I was going too fast. Fortunately I was just a few lines away from green or I’d have fallen on my nose. I find it valuagle to try to be aware of feelings like that, because if they go on for any length of time, things often start to go wrong. Just a quick thought break is usually enough to sort me back out. Usually.
What have we here? I’m rather pleased, with one exception. Or two. We’ll see.
We’ve got tests for everything the program does. Let’s try to keep that happening. What we perhaps do not have is tests that stress what we have, but it’s really early days for that. I am far more inclined to test the happy path and make the happy path always work, than to test things that break. I never know what to do, and no one wants an exception.
We have a World object that has no real capability yet, just a scan
method that returns a particular result for testing. We have a test1
factory method on World but it just returns a vanilla world at this point.
We have a Robot who has knowledge. It knows a move command, which it uses to adjust its knowledge, since its knowledge is all relative to its own starting position.
We have a Knowledge class that holds GridCells, called “facts”, at relative coordinates to the robot’s starting position. The Knowledge knows what the Robot’s moves have been, so when it is asked for a fact, the coordinates you give it are relative, not absolute.
This seems to be working and we definitely have tests, but I freely grant that I have difficulty thinking about the adjustments on the way in and way out.
I am not fond of the GridCell, and I’m sure I can write a Knowledge test that will break things, if I store two things at the same location. In fact, let’s do that as a placeholder for next time. As soon as I even write the test name, I realize we have no way to express this notion:
_:test("Knowledge can have two things at same place", function()
local knowledge = Knowledge()
knowledge:addFact(GridCell(1,1,"A"))
knowledge:addFact(GridCell(1,1,"B"))
_:expect(knowledge:factAt(1,1)).is("A")
_:expect(knowledge:factAt(1,1)).is("B")
end)
This will fail and leave us something to do tomorrow.
4: Knowledge can have two things at same place --
Actual: A,
Expected: B
Now I’ll ignore this test so that I can commit, even though we probably aren’t going to ship today. Commit: One knowledge test ignored as marker for work needed.
But Client - Server?!?!
I’m completely ignoring the client-server issue. How will I deal with it when the time comes? I don’t exactly know, but my guess is that I’ll create some adapters that wrap the objects on each ends, and translate text messages back and forth between them. I’ll probably wind up waving my hands at the whole problem, although I do know that there is a sockets library in Lua. So maybe there’s something I could do. Anyway, worst case, if we go through a text translation, the client server separation can be “left to the reader”. (That sound you hear is either rueful laughter or evil laughter, I’m not sure which.)
Looking Forward
I’m not saying we will do this, but right now we could create an on-screen display of what the Robot knows, since it has relative offsets of things, so it could display itself in the middle of the screen with things arrayed around it at suitable intervals.
The main thing that I’d like is to feel more confident in the perhaps-too-clever way I’m handling knowledge offsets. It’s passing my tests but I can’t visualize what it’s doing, and the code, while it seems to work, isn’t expressing my ideas, such as they are. So that needs work.
But not bad for a few morning hours.
And it’s new! It’s not a dungeon! Unless there are robots in the dungeon …
See you next time!