Space Invaders 40
Let’s continue the bomb dropping logic. Our current design can’t do what’s required.
Since I don’t really understand the assembly language for the 8080 any more, I haven’t been able to decode quite how the logic works for bomb dropping in the original game. Even using the comments provided by Computer Archaeology and observing the game, I can’t see what the pattern of fire is.
One game I watch frequently fires rolling, plunger, squiggle, squiggle, rolling, plunger, plunger, squiggle, squiggle, rolling, plunger, rolling, plunger, squiggle, rolling, …
I see no useful pattern there, nor do I see anything that looks like an explicit random choice of what to do in the original code. It seems to just blithely process objects in the order rolling, plunger, squiggle.
But it doesn’t fire in cyclic order. Somehow the shot types are pretty random. Each shot does have a timer associated with it, that counts down the time before its task is executed. But that seems to be more about processing the moves than the firing.
I suspect the “firing” might be quite subtle. Since each bomb has its own dedicated task structure, firing might be accomplished by “just” setting a position and a flag of some kind to set it running. Without the flag, it’d be idle.
I just found a comment that may be enlightening, The rolling shot (the targeted one) skips its first attempt to fire on being reinitialized. that could account, perhaps, for the lower frequency of rolling shots in the sequence above. It would not explain the choice of duplicate shots.
There’s this comment:
; The alien "fire rate" is based on the number of steps the other two shots on the screen
; have made. The smallest number-of-steps is compared to the reload-rate. If it is too
; soon then no shot is made. The reload-rate is based on the player's score. The MSB
; is looked up in a table to get the reload-rate. The smaller the rate the faster the
; aliens fire. Setting rate this way keeps shots from walking on each other.
Maybe this means something like this happens. In every cycle we check to see if we want to fire a missile of each type. If it’s flying, we don’t fire a new one, because we only have one of each type. If it’s not firing, we check the other two shots’ number of steps. If that’s too small, we don’t fire. We will, however, go on to the other one.
So is it possible that we could immediately fire the other one, even if it was the last one fired? It seems we could, because we only consider the steps of the other two. It’s very weird. I’m going to have to draw up a table to think about that.
Does this seem like a weird thing to be writing?
It does to me. I hate that I don’t know what’s even needed here, and I hate telling you that I don’t know.
The thing is, if you’re like me, you spend a lot of your time not knowing quite what’s needed, either because the requirements aren’t clear, or because how to implement them isn’t clear. So I choose to share my confusion with you.
It’s hell to write. I hope it’s not that difficult to read. You can probably skip forward to the good bits. I hope that’s not someone else’s blog.
Time for a break. It’s early AM, and I don’t have my chai yet.
10 AM and all’s, well, …
Car behaved fine on the way to the *$ and back. Intermittent glitch? Lying in wait to attack when I least expect it? I guess there’s no way to be sure.
So far, there’s also no way to be sure how this 8080 code actually works. I think I have a very rough plan that would look similar to what it does, and it goes like this:
- Keep a timer that separates shots by a minimum interval
- Shorten the timer similarly to the way the original game does, based on current score
- When the timer has expired, choose a shot type randomly
- When the time is right, choose only between rolling and squiggle
- Follow the original game’s column order, because we can.
I think this should be easy enough to do. I’ve been wrong before, but it seems doable enough. We’ll see, won’t we?
The original game makes firing decisions at the top level, because it has a top level loop that drives all objects. We presently make the firing decision in the Army:
function Army:update()
updateGunner()
self:updateBombCycle() -- <---
self:possiblyDropBomb() -- <---
local continue = true
while(continue) do
continue = self:nextInvader():update(self.motion, self)
end
self.rollingBomb:update(self)
self.plungerBomb:update(self)
self.squiggleBomb:update(self)
end
function Army:updateBombCycle(anAmount)
self.bombCycle = self.bombCycle + (anAmount or 1)
end
function Army:possiblyDropBomb()
if not self:canDropBomb() then return end
if not self.rollingBomb.alive then
self:dropBomb(self.rollingBomb)
elseif not self.plungerBomb.alive then
self:dropBomb(self.plungerBomb)
elseif not self.squiggleBomb.alive then
self:dropBomb(self.squiggleBomb)
end
end
function Army:canDropBomb()
return self.bombCycle > self.bombDropCycleLimit
end
This is factored down to some pretty small methods, smaller than I’ve usually wound up with in these articles. Tiny methods are quite typical of Smalltalk, and when I found myself using them a few days back, I started pushing a bit more in that direction. One advantage they have is that the calls say what’s intended in a way that the single line of code does not. I like them: YMMV.
Looking at that code, it seems that the random selection will go where that if nest goes. That should be broken out, by the way, using the Composed Method pattern. We’ll be replacing that code, with this:
function Army:possiblyDropBomb()
if self:canDropBomb() then
self:dropRandomBomb()
end
end
Super! We’re practically done. Just need that one method dropRandomBomb
. The old code wouldn’t let us drop a bomb that’s alive and otherwise dropped the first one available. We still want not to have two of the same type on the screen at once.
I’d really like to select from a table to decide what to do but let’s do this first:
function Army:dropRandomBomb()
local bombType = math.random(3)
if bombType == 1 then
self:dropTargetedBomb(self.rollingBomb)
elseif bombType == 2 then
self:dropBomb(self.plungerBomb)
else
self:dropBomb(self.squiggleBomb)
end
end
Basically a case statement. It’s really clear how to turn that into a table lookup if we drop the “targeted”. Let’s assume we’ll deal with that further down. (I’m already determined to check the alive
flag further down.)
So first this:
function Army:dropRandomBomb()
local bombType = math.random(3)
if bombType == 1 then
self:dropBomb(self.rollingBomb)
elseif bombType == 2 then
self:dropBomb(self.plungerBomb)
else
self:dropBomb(self.squiggleBomb)
end
end
And then this:
function Army:dropRandomBomb()
local bombs = {self.rollingBomb, self.plungerBomb, self.squiggleBomb)
local bombType = math.random(3)
self:dropBomb(bombs[bombType]
end
Strictly speaking, that was a refactoring done in the middle of an implementation, but they’re more guidelines than actual rules. If I were more clever, I might have written that the first time, but I was juggling the notion of the alive flag and the targeting, and it didn’t leave room for the table solution until I could see the non-table code. Now for the drop. Right now, it looks like this:
function Army:dropBomb(aBomb)
self.bombCycle = 0
aBomb.alive = true
local col = aBomb:nextColumn()
local leader = self.invaders[11]
local leaderPos = leader.pos
local leaderX = leaderPos.x
local bombX = 8 + leaderX + (col-1)*16
aBomb.pos = vec2(bombX, leaderPos.y - 16)
end
That’s just fine except that we don’t drop a bomb when it’s already alive, so:
function Army:dropBomb(aBomb)
if aBomb.alive then return end
self.bombCycle = 0
aBomb.alive = true
local col = aBomb:nextColumn()
local leader = self.invaders[11]
local leaderPos = leader.pos
local leaderX = leaderPos.x
local bombX = 8 + leaderX + (col-1)*16
aBomb.pos = vec2(bombX, leaderPos.y - 16)
end
I think this is good to run and watch. Well except for all those syntax errors. This is better:
function Army:dropRandomBomb()
local bombs = {self.rollingBomb, self.plungerBomb, self.squiggleBomb}
local bombType = math.random(3)
self:dropBomb(bombs[bombType])
end
Everything seemed to be going so nicely but then suddenly:
Army:73: attempt to perform arithmetic on a nil value (local 'col')
stack traceback:
Army:73: in method 'dropBomb'
Army:54: in method 'dropRandomBomb'
Army:47: in method 'possiblyDropBomb'
Army:84: in method 'update'
GameRunner:42: in method 'update60ths'
GameRunner:31: in method 'update'
Main:28: in function 'draw'
The code is this:
function Army:dropBomb(aBomb)
if aBomb.alive then return end
self.bombCycle = 0
aBomb.alive = true
local col = aBomb:nextColumn()
local leader = self.invaders[11]
local leaderPos = leader.pos
local leaderX = leaderPos.x
local bombX = 8 + leaderX + (col-1)*16 -- <--- line 73
aBomb.pos = vec2(bombX, leaderPos.y - 16)
end
So nextColumn
has returned a nil. I thought we had tested that. Anyway the code is:
function Bomb:nextColumn()
local next = self.columnTable[self.columnOffset + self.columnIndex+1]
self.columnIndex = (self.columnIndex+1)%16
return next
end
Lua will return a nil if you index outside an array. So my guess is that we have indexed outside the column table, and the contender for that is the squiggle bomb, which starts with an offset of six in the column table. I’m pretty sure I didn’t check all the returns for the squiggle in my test. Let’s do that:
_:test("squiggle bombs select correct columns", function()
local theArmy = Army()
local squiggleBomb = theArmy.squiggleBomb
_:expect(squiggleBomb:nextColumn()).is(0x0b)
_:expect(squiggleBomb:nextColumn()).is(0x01)
_:expect(squiggleBomb:nextColumn()).is(0x06)
_:expect(squiggleBomb:nextColumn()).is(0x03)
end)
We were doing so well. I’ll begin by just looping some more calls, asserting non-nil:
_:test("squiggle bombs select correct columns", function()
local theArmy = Army()
local squiggleBomb = theArmy.squiggleBomb
_:expect(squiggleBomb:nextColumn()).is(0x0b)
_:expect(squiggleBomb:nextColumn()).is(0x01)
_:expect(squiggleBomb:nextColumn()).is(0x06)
_:expect(squiggleBomb:nextColumn()).is(0x03)
for i = 1,16 do
local col = squiggleBomb:nextColumn()
_:expect(col).isnt(nil)
end
Indeed, it fails. A quick walk down the hall tells me what it probably is:
function Bomb:nextColumn()
local next = self.columnTable[self.columnOffset + self.columnIndex+1]
self.columnIndex = (self.columnIndex+1)%16
return next
end
It seems to me that columnOffset + columnnIndex + 1 might exceed the table size. Our column offset is 6. The column index can be as high as 15, and we add 1, so we could get 16+6 or 22. How big is that table?
I have 21 elements. That seems unlikely. What about the original? Yes, it’s 21. Perhaps our starting offset is one too large? Our tables are origin 1. The code (comments) say that the first column for the squiggle shot is 0x0B. Our test checks and the first return is in fact 0x0B.
I think I know what I have to do. Remove the +1 from the subscript, and set the offsets of the other two bomb types to 1, folding the 1 origin into the offset. As I think about this, I think it’s still not right. But I’ll try it.
So that doesn’t work. The code comments say that the one type uses bytes 00-0F of the table, and the other byte 06-14. But 0F-00 is 0F and 14-06 is 0E. The second guy cycles differently!
Wow. Why would it do that? If the cycle lengths are relatively prime, then the pattern will appear a bit more random: otherwise if the one bomb fell from a given column, you could more easily know where the next one was coming from.
OK, weird. We signed up for weird when we decided to copy this program’s behavior wherever it led. We need to tell the bombs the range of indices to allow:
self.rollingBomb = Bomb(vec2(0,0), bombTypes[1], bombExplosion, 1,15)
self.plungerBomb = Bomb(vec2(0,0), bombTypes[2], bombExplosion, 1,15)
self.squiggleBomb = Bomb(vec2(0,0), bombTypes[3], bombExplosion, 6,20)
And
function Bomb:init(pos, shapes, explosion, columnStart, columnEnd)
self.columnStart = columnStart
self.columnEnd = columnEnd
self.columnIndex = columnStart
function Bomb:nextColumn()
local next = self.columnTable[self.columnIndex]
self.columnIndex = self.columnIndex+1
if self.columnIndex > self.columnEnd then
self.columnIndex = self.columnStart
end
return next
end
I have high hopes for this change.
As it turns out, Michael Henos was right when he said “Hope is not a strategy”. My squiggle tests fail, since I need to start my table entry one higher (one origin tables, don’t you just love it), and I think we change the end as well:
self.squiggleBomb = Bomb(vec2(0,0), bombTypes[3], bombExplosion, 7,21)
Now I have actual confidence. Will it be dashed? Let’s see.
Dashed. Plunger bombs need to be 1-16 not 1-15:
self.rollingBomb = Bomb(vec2(0,0), bombTypes[1], bombExplosion, 1,16)
self.plungerBomb = Bomb(vec2(0,0), bombTypes[2], bombExplosion, 1,16)
self.squiggleBomb = Bomb(vec2(0,0), bombTypes[3], bombExplosion, 7,21)
There, that wasn’t so hard now was it? Test all run except for the ignored one, and we’ll commit: random bombs from correct columns.
Now What?
That was easy yet odd, because of the 16-15 thing and the 0-1 origin thing.
One thing that bothered me yesterday was the strange order of the invaders in the table. I think I’d like them ordered with the left-most bottom-most alien either first or last, not 11th. I mean, really. Let’s see if we can clear that up a bit.
local vaders = {
{vader31,vader32},
{vader21,vader22}, {vader21,vader22},
{vader11,vader12}, {vader11,vader12}
}
local scores = {30,20,20,10,10}
self.invaders = {}
for row = 1,5 do
for col = 1,11 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row], scores[row], army))
end
end
As we go now, we’re adding them at the front of the table, starting with the top row. So the bottom row is added last. But the one added last will have row = 5, col = 11. That’s not so good. Let’s add them in reverse column order.
for row = 1,5 do
for col = 11,1,-1 do
local p = vec2(col*16, 185-row*16)
table.insert(self.invaders, 1, Invader(p,vaders[row], scores[row], army))
end
end
And before I forget, change that leader stuff:
function Army:dropBomb(aBomb)
if aBomb.alive then return end
self.bombCycle = 0
aBomb.alive = true
local col = aBomb:nextColumn()
local leader = self.invaders[1] -- <--
local leaderPos = leader.pos
local leaderX = leaderPos.x
local bombX = 8 + leaderX + (col-1)*16
aBomb.pos = vec2(bombX, leaderPos.y - 16)
end
That works fine. Commit: invaders created in 55-1 order.
Now what about that ignored test:
_:ignore("drop three and only three bombs at a time", function()
local theArmy = Army()
_:expect(theArmy.rollingBomb.alive).is(false)
_:expect(theArmy.plungerBomb.alive).is(false)
_:expect(theArmy.squiggleBomb.alive).is(false)
theArmy:possiblyDropBomb()
_:expect(theArmy.rollingBomb.alive).is(true)
_:expect(theArmy.plungerBomb.alive).is(false)
_:expect(theArmy.squiggleBomb.alive).is(false)
theArmy:possiblyDropBomb()
_:expect(theArmy.rollingBomb.alive).is(true)
_:expect(theArmy.plungerBomb.alive).is(true)
_:expect(theArmy.squiggleBomb.alive).is(false)
theArmy:possiblyDropBomb()
_:expect(theArmy.squiggleBomb.alive).is(true)
_:expect(theArmy.rollingBomb.alive).is(true)
_:expect(theArmy.plungerBomb.alive).is(true)
end)
I think that test has served its purpose, which was to drive out the behavior we had before what we put in today. I declare that we thank it for its service and throw it away.
Tests are green, of course. Commit: removed obsolete test.
Today’s late start has brought me up to lunch time, with at least our nominal two hours of work in. We’ll wrap this after summing up.
Summing Up
We have a new version of the game that processes bombs where we want them, in the Army, limits total bombs to three, uses the same bombs over and over, and drops two types from the right columns. The targeted type is not yet done.
We’re selecting which one to drop randomly. That does not seem to be what the original game is doing, but I’ve not been able to figure out just how it works. Random dropping looks decent to me.
We need to increase the drop frequency as the score increases. That’s done by a table lookup in the original game, and we should be able to duplicate that readily.
We need to stop bombs falling when there’s no player, and I think there may be supposed to be a short delay after the player appears. We’ll have to look into that last bit.
I noticed that in the original, the player seems always to start at the far left. We might want to do that.
And there’s the saucer yet to do.
But today’s work went well. When the squiggle bomb crashed, we quickly got a test to demonstrate the problem and finally worked out that it was supposed to use a shorter repeat cycle than the other bomb. Weird but true.
Then I got nearly every ending value wrong, doing the zero-one origin thing. That’s clearly a standard bug in my brain, and other than “be more careful” I don’t have a fix for it. I’ll mull it over: there might be something smart to do that I’ve not thought of.
All in all, a decent morning. See you next time!