Asteroids 36
Some thinking and working on collisions
Naturally, I don’t stop thinking about a program when I’m not coding on it. Thoughts come up, and I ever do a little explicit analysis or design between programming sessions. Today I want to start by talking about some ideas that have come up.
First, I’m feeling more strongly than before that I will benefit from using TDD as I build out the collision logic. It seems a bit tricky and it’s important to get it right.
Second, I’m thinking that the objects will decide what the impact of a collision is, that the Universe
will handle all the creation and destruction, perhaps buffering those actions until safe times during processing, and that when an object does die, we will be sure to send it a message about its impending destruction. This thinking is still tentative. I want to let the tests help us see where behavior belongs.
Third, I did some quick tabular thinking about how the objects that collide interact. I built a table that looks like this:
asteroid | missile | saucer | ship | |
---|---|---|---|---|
asteroid | … | d/d | d/d | d/d |
missile | d/d | … | d/d | d/d |
saucer | d/d | d/d | … | d/d |
ship | d/d | d/d | d/d | … |
d = destroyed |
(The picture I hand drew was easier, but less neat. Took a lot less time than the one above. The things I do for my people.)
The upshot of this is that of these four types, when they collide, both sides are destroyed. Asteroids split when destroyed, unless they are tiny. Missiles, saucers, and ships all are just destroyed. When things strike another object of the same type, nothing happens, although we could imagine that we’d want to be able to shoot down missiles. I don’t think that Asteroids could do that, but I know that Spacewar could.
So there may be a more simple solution than my double-dispatch idea, although it would surely involve checking types in some way. It’s sure tempting, though, to see if this can be modeled such that if a collision occurs, destruction follows, and it’s just that a matching combination is ignored.
However, you can’t ask an object for its type – you just get back table
, although you can ask object:is_a(Ship)
. So checking at the pair level is still fraught. We could imagine embedding type info in each object, but that’s a question we’re not supposed to ask anyway.
So we’ll find out what happens. For this afternoon, I want to start a little TDDing of collisions.
TDDing Collisions
I’ll start from an assumed point: these first tests are assuming that the collision loop has found a pair of objects and determined somehow that they are close enough to collide, so that one of them is told collided
with the other. In general, there will be no way to know in advance which of a pair will be in our left hand when this all starts.
So, a test. I’ll start right off with a relatively simple one, missile hits saucer. At the time our story begins, we have in hand two objects o1 and o2 and we only know that they are not the same object and they are within collision range.
The story begins like this:
_:test("missile hits saucer", function()
local o1 = Missile()
local o2 = Saucer()
o1:collide(o2)
end)
Now I suspect that I can’t even create those two objects without init
parameters, but this test, at least, wants to do that. Once we jump those hurdles, we’ll just tell one of them that he is colliding with the other.
And here’s where it gets tricky! I have in mind a flow like this:
- o1 receives
collide(o2)
- o1 sends
o2:collideWithMissile(self)
- o2, as a saucer, knows that it must be destroyed and the missile must be destroyed. so
missile:destroyed()
andself:split()
The Detroit School microtester, of which I am a founder, generally does not test interactions, but results.1 In this case, the result should be no objects in the universe.
A London School tester would see this as a perfect opportunity for some kind of “test double”, a fake object that can check to be sure that o2
does the right thing. Since part of what o2
does is send back to whomever sent it the collideWithMissile
message, a test double could fairly easily check for that. But that it sends a message to itself is problematical: we can’t use a double for o2
, because we have to test the real o2
code.
Now I did say that we want the universe to handle creation and destruction to the degree possible (and I’m frankly more concerned about destruction than I am about creation, even though both are problems, but for different reasons.)2
We could write our London School test to check for a sequence like this:
function Saucer:collideWithMissile(missile)
U:destroy(missile)
U: destroy(self)
end
And that might be a decent way for things to work. (See how thinking in tests helps us with design?)
The Detroit School microtester is pretty well stuck here, unless they are comfortable just checking the universe to be sure everyone is gone. That’s frankly pretty weak.
The London School microtester might just create a fake Universe and let it track what actually happened. They might also want a fake Missile but probably not because the Saucer wouldn’t run if the Missile didn’t work.
To me, the London school always sends me down this rathole of first making sure that he sends this, then making sure that she sends that, and then that they send this other thing, and the more fake objects I get, the less I’m confident that my real ones work.
This is almost entirely a result of my using fake objects only when I must. I’m sure that an adept user of fake objects doesn’t struggle with them, any more than I usually do with my preferred style.
For me, here, today, I’m struggling with either kind of test. I feel like I could have written this code correctly by now. All this testing nonsense is just confusing me.
It might be the same for you …
If you’re not a frequent user of TDD / microtesting, you might feel all the time as I do right now. That’s OK. If you choose to study and practice either Detroit or London style TDD, you’ll quickly find where you like to use microtests, where you don’t, and where you feel you probably should but don’t know quite how.
Today, right here, I’m on the “don’t know quite how” for this collision logic. I think I know what I want, though I may not know as well as I believe, and I don’t see a good way to test it.
So I know I have a choice before me, between proceeding without a test, and learning something. I’m sure I can build the code just fine without tests, and I’m almost always kind of right when I think that.
Today, I’ll try to learn something to share with you.
Here’s my plan.
Our tests are already saving the old universe and providing a new one, in before
:
_:before(function()
CodeaUnit.oldU = U
U = Universe()
end)
I’ll create a FakeUniverse, make it work well enough to let me create those two objects in it, and then use it to make sure that Saucer does what I intend. This may work out well, or it may be a debacle. Either way it won’t be my first.
_:test("missile hits saucer", function()
U = FakeUniverse()
local o1 = Missile()
local o2 = Saucer()
o1:collide(o2)
end)
This is more than enough to fail. First, I’ll create a simple FakeUniverse class and watch it fail.
FakeUniverse = class()
The first error, no surprise, is:
17: missile hits saucer -- Missile:12: attempt to call a nil value (method 'addMissile')
We build that, and in a flagrant violation of YAGNI, build addSaucer
as well:
FakeUniverse = class()
function FakeUniverse:addMissile(missile)
self.missile = missile
end
function FakeUniverse:addSaucer(saucer)
self,saucer = saucer
end
I have no legitimate reason to be saving those values. Another YAGNI Running the tests again, I see this:
17: missile hits saucer -- Saucer:10: attempt to perform arithmetic on a nil value (field 'currentTime')
Saucers ask too many questions. The creation is this:
function Saucer:init()
function die()
self:die()
end
U:addSaucer(self)
self.pos = vec2(0, math.random(HEIGHT))
self.step = vec2(2,0)
self.fireTime = U.currentTime + 1 -- one second from now
if math.random(2) == 1 then self.step = -self.step end
tween(7, self, {}, tween.easing.linear, die)
end
~~
So our fake universe has to know the time. OK ...
~~~lua
function FakeUniverse:init()
self.currentTime = ElapsedTime
end
OK, now we’re getting somewhere:
17: missile hits saucer -- TestAsteroids:153: attempt to call a nil value (method 'collide')
We need collide on our Missile. The test is beginning to drive behavior. I’ll implement what I expect it to be:
function Missile:collide(anObject)
anObject:collideWithMissile(self)
end
Another test of course demands that method on Saucer:
function Saucer:collideWithMissile(missile)
end
Now if we really want to test drive this baby, at this point we expect the Universe to be told to destroy two objects, the missile and the saucer. Our real universe, we intend at this point, will not be told what is being destroyed: it’ll just manage its tables properly.
So our Fake has to detect the two calls we want and answer whether it’s happy. It should “expect” those two calls and record whether it gets one, both, or neither. For now, I think I’ll do that by saving the items and checking the count. Maybe like this:
function FakeUniverse:init()
self.currentTime = ElapsedTime
self.destroyed = {}
end
function FakeUniverse:destroy(anObject)
self.destroyed[anObject] = anObject
end
function FakeUniverse:destroyedCount()
return self:count(self.destroyed)
end
function FakeUniverse:count(aTable)
local c = 0
for k,v in pairs(aTable) do
c = c + 1
end
return c
end
Now I think I can call the destroyedCount
and expect it to return 2:
_:test("missile hits saucer", function()
U = FakeUniverse()
local o1 = Missile()
local o2 = Saucer()
o1:collide(o2)
_:expect(U:destroyedCount()).is(2)
end)
This test fails: Actual 0 expected 2. We need to implement our Saucer method now:
function Saucer:collideWithMissile(missile)
U:destroy(self)
U:destroy(missile)
end
And the test runs:
17: missile hits saucer -- OK
So now we know with rather good certainty that if we send a Saucer the message collideWithMissile
, it will ask the universe to destroy itself and the missile. If we didn’t have both those lines the test would fail. It would fail if we destroyed ourself twice and not the missile, or the missile twice and not ourself.
This is pretty bullet-proof, if not missile-proof.
To tell the truth, I kind of like it
I generally do tell the truth, and if I didn’t, I’d say that I did. Be that as it may, I rather like this result. It wasn’t too hard, and it gives me real confidence that the Saucer, if hit by a missile, will do the right thing. What if we wanted to check the opposite condition, where the Saucer is the first one addressed? Can we use our FakeUniverse here too?
_:test("saucer hits missile", function()
U = FakeUniverse()
local m = Missile()
local s = Saucer()
s:collide(m)
_:expect(U:destroyedCount()).is(2)
end)
I went for more mnemonic names here, and I changed the other test as well. My first thought was that since the calls start out anonymous, I should use anonymous names. That didn’t help the computer and it certainly didn’t help me.
Running the tests:
18: saucer hits missile -- TestAsteroids:161: attempt to call a nil value (method 'collide')
Saucer doesn’t have collide yet. It needs to send collideWithSaucer
back to whomever sends it. Everyone will do that, each time naming their own type.
function Saucer:collide(anObject)
anObject:collideWithSaucer(self)
end
Test says:
18: saucer hits missile -- Saucer:50: attempt to call a nil value (method 'collideWithSaucer')
Missile doesn’t know that method. Write it naively:
function Missile:collideWithSaucer(saucer)
end
Test fails as expected:
18: saucer hits missile -- Actual: 0, Expected: 2
We implement:
function Missile:collideWithSaucer(saucer)
U:destroy(self)
U:destroy(saucer)
end
18: saucer hits missile -- OK
I honestly do rather like this. For the first time in this Asteroids project, I really feel like my microtests are driving the design and implementation as I like them to. And I’m quite delighted to have introduced a “test double” or “fake object” to help me do it.
This has been a delightful little exercise for me, and I hope, for you.
Commit: “fake universe, saucer/missile collision”. Here’s the Asteroids code and my current version of CodeaUnit: