Asteroids 49
Free ships. Beyond that, maybe some reorganization? No, more like confusion.
Narrator (v.o.): He gets very confused for a while, but it turns out OK. But maybe skim a lot of this …
The game actually plays rather well now. I’m pretty sure that all of you who do not have 12.9 inch iPad Pros, well, this is the final reason you were looking for. I’m still not very good at it, but I think that’s mostly about me.
I discovered one little thing last night. I vaguely remembered something about the saucer missile speed being set to five, and sure enough, the saucer inits shotSpeed
to 5, and Targeter
picks it up and uses it in its calculation, and SaucerMissile
uses it when it fires a targeted missile. The result is that all the missiles from ship and saucer fly normally, but the killer shots come at you like a, well, like a shot.
I may have done that as an experiment, I’m not sure. I remember that if the missile is too slow, the Targeter
can’t get a lock, because a slow missile can’t get to you before it times out, especially if fired backward, where the saucer speed slows it down.
The universe has a constant that we should probably be using:
local MissileSpeed = 2.0
...
self.missileVelocity = vec2(MissileSpeed,0)
The velocity version is used in regular fire, because we pick a direction angle and rotate the velocity to it, as you do. The targeted missile computes a direction vector rather than an angle, so it wants the speed, since speed*direction = velocity.
So I probably just typed in the number and I vaguely recall bumping it up to 5 for better results. Let’s do this. We’ll give Universe
both missile speed and missile velocity, for user convenience and then we’ll use it throughout. We’ll check and see whether the missiles are still accurate enough to be exciting. My guess is that they are.
function Universe:init()
self.processorRatio = 1.0
self.rotationStep = math.rad(1.5) -- degrees
self.missileSpeed = 2.0
self.missileVelocity = vec2(self.missileSpeed,0)
self.frame64 = 0
...
self.attractMode = true
end
We’ll let the saucer keep his shotSpeed variable, but make him fetch it from the universe:
function Saucer:init(optionalPos)
function die()
if self == Instance then
self:dieQuietly()
end
end
Instance = self
U:addObject(self)
if Score:instance():shouldDrawSmallSaucer() then
self.size = 0.5
self.sound = sound(asset.saucerSmallHi, 0.8, 1, 0, true)
else
self.size = 1
self.sound = sound(asset.saucerBigHi, 0.8, 1, 0, true)
end
self.shotSpeed = U.missileSpeed -- <---
self.pos = optionalPos or 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
self:setRandomTurnTime()
tween.delay(7, die)
end
That’s a bit messy, isn’t it? And there are a lot of magic values in there. We’ll see about that later (or never): we’ve got the missile speed thing in the air.
I think those are all the changes I should need.
One test fails. I really like that auto-run. The FakeUniverse doesn’t know missileSpeed
. I’ll add it in. This might happen less often with universal constants broken out, but you expect to have to evolve your fake objects.
Tests run now except for a random one that failed once for some reason. I hate random tests.
I think I’ll patch the accuracy number to perfect to see how the shots look. It seems to still work well enough to kill me. Commit: saucer missile uses universe-provided speed.
Free Ships
The original game gives a free ship every time the score ticks over 10,000 points. That and hyperspace are the only key features we’re missing. Since today is Saturday, I think I’ll take it easy and do the free ship thing.
We probably want that to be done in Score
, since it’s about score and ship count, and Score
handles that. Encapsulation FTW. Here’s Score
, at least the relevant bits:
Score = class()
local Instance = nil
function Score:init(shipCount)
self.shipCount = shipCount or 1
self.gameIsOver = false
self.totalScore = 0
Instance = self
U:addIndestructible(self)
end
function Score:addScore(aNumber)
self.totalScore = self.totalScore + aNumber
end
function Score:spawnShip()
if self.shipCount <= 0 then
self:stopGame()
return false
else
self.shipCount = self.shipCount - 1
Ship()
return true
end
end
How about this? We’ll have a “next rollover point”, and when the score exceeds that value, we’ll add a ship to the ship count, and adjust the rollover upward.
How about a test for that? I bet we can do it.
_:test("Free ship every N points", function()
U = FakeUniverse()
local score = Score(3)
score:addScore(U.freeShipPoints)
_:expect(score.shipCount).is(4)
score:addScore(U.freeShipPoints/2)
_:expect(score.shipCount).is(4)
score:addScore(U.freeShipPoints/2)
_:expect(score.shipCount).is(5)
end)
See? I knew we could. Doesn’t run, of course. I put the freeShipPoints
in both fake and real universe, lest I forget. Now the logic in Score
. With initializing the new value in init
, I think this should do the job:
function Score:addScore(aNumber)
self.totalScore = self.totalScore + aNumber
if self.totalScore > self.nextFreeShip then
self.shipCount = self.shipCount + 1
self.nextFreeShip = self.nextFreeShip + U.freeShipPoints
end
end
The test will tell. Ha! Doesn’t work! Who forgot to do greater than or equal? I chose to put the test values right on the money to handle the edge case. Then I failed to implement the edge case correctly. That worked so smoothly I could probably convince you that I planned the error to teach a lesson. I didn’t, though. I just made the mistake I had anticipated, by mistake.
function Score:addScore(aNumber)
self.totalScore = self.totalScore + aNumber
if self.totalScore >= self.nextFreeShip then --<---
self.shipCount = self.shipCount + 1
self.nextFreeShip = self.nextFreeShip + U.freeShipPoints
end
end
That works. We could check 9999 and such, but I’m convinced it works. And the random test for small saucer accuracy is failing. It gets zero shots on target instead of the expected 200:
_:test("Small Saucer shoots at 4/20 accuracy", function()
U = FakeUniverse()
local score = Score(3)
score:addScore(3000)
local ship = Ship(vec2(400,800))
local saucer = Saucer(vec2(500,800))
local count = 0
for i = 1,1000 do
local m = SaucerMissile:fromSaucer(saucer)
if m.step.y == 0 then
count = count + 1
end
end
saucer:dieQuietly()
_:expect(count).is(200,50)
end)
At first, I think this means that our missile velocity is too slow, since we did bump it down. But that doesn’t make sense, since neither ship nor saucer is moving in this scenario. Just to be sure, I’m going to patch the speed back up to 5. Super, the test still fails. Did I mess it up when I adjusted the target ratio for testing?
That looks OK:
function SaucerMissile:fromSaucer(saucer)
if not Ship:instance() or math.random() > saucer:accuracyFraction() then
return SaucerMissile:randomFromSaucer(saucer)
end
local targ = Targeter(saucer, Ship:instance())
local dir = targ:fireDirection()
bulletStep = dir*saucer.shotSpeed + saucer.step
return SaucerMissile(saucer.pos, bulletStep)
end
function Saucer:accuracyFraction()
return self.size == 1 and 1/20 or 4/20
end
Are we ever getting to the targeter? Targeter can fail and if it does returns a random missile. So that could be happening, but if so, why?
I added this line to the test, in case the init was failing in our fake universe:
_:expect(saucer.shotSpeed).is(2)
But that works. Still, it has to have to do with the shot speed change somehow. Here’s the targeting:
function Targeter:timeToTarget()
local a = self.deltaVee:dot(self.deltaVee) - self.shotSpeed*self.shotSpeed
local b = 2*self.deltaVee:dot(self.deltaPos)
local c = self.deltaPos:dot(self.deltaPos)
local desc = b*b - 4*a*c
if desc > 0 then
return 2*c/(math.sqrt(desc) - b)
else
return -1
end
end
function Targeter:fireDirection()
local dt = self:timeToTarget()
if dt < 0 then return vec2(1,0):rotate(2*math.pi*math.random()) end
local trueAimPoint = self.target.pos + self.target.step*dt
local relativeAimPoint = trueAimPoint - self.saucer.pos
local offsetAimPoint = relativeAimPoint - dt*self.saucer.step
local dir = offsetAimPoint:normalize()
return dir
end
timeToTarget
can return -1 if it can’t reach the target, and then fireDirection
returns a random shot. (At the wrong speed, I note.)
I’m going to toss an assert into the fail branch and see if that’s what’s happening.
That didn’t hit. What did happen, though, was that the test passed, then failed a few times, then passed again. It’s hard to imagine 1000 random numbers never hitting our threshold but that’s what it looks like.
In Saucer, we have this:
function SaucerMissile:fromSaucer(saucer)
if not Ship:instance() or math.random() > saucer:accuracyFraction() then
return SaucerMissile:randomFromSaucer(saucer)
end
local targ = Targeter(saucer, Ship:instance())
local dir = targ:fireDirection()
bulletStep = dir*saucer.shotSpeed + saucer.step
return SaucerMissile(saucer.pos, bulletStep)
end
This would never target if there were no ship instance. I don’t see quite how that could happen, but I’m going to assert on that:
function SaucerMissile:fromSaucer(saucer)
assert(not Ship:instance(), "no ship")
if not Ship:instance() or math.random() > saucer:accuracyFraction() then
return SaucerMissile:randomFromSaucer(saucer)
end
local targ = Targeter(saucer, Ship:instance())
local dir = targ:fireDirection()
bulletStep = dir*saucer.shotSpeed + saucer.step
return SaucerMissile(saucer.pos, bulletStep)
end
Sure enough, that asserts. Something in our test isn’t set up right.
After some probing and deliberation, which I’ll spare you, and even a test to be sure that random numbers are random, I’ve figured out the issue. The saucer initializes itself with a velocity of either (2,0) or (-2,0), randomly. If it happens to come up flying away from the ship, its velocity cancels out the velocity of the missile and the missile cannot reach its target, so the targeter returns a random shot. When I fiddled the shot speed, I’m not sure why it didn’t take: I’d have expected that to fix the problem.
Setting the saucer velocity to zero fixes it. Now I want to explore why the higher shot speed didn’t. Well, this time setting shot speed to five does fix the problem. I guess we do need a missile speed faster than the saucer speed. Probably doesn’t have to be much faster in order to pass the test. I’ll try setting it to 2.1.
Further messing about leaves me with more information and less understanding. It turns out that that quadratic that I lifted from gamasutra is sometimes returning infinite time to target, because of a zero denominator in the final divide. And it all only happens if the saucer is receding from the ship. I can make the test run by freezing the saucer. Or I could ignore the test, which would at least serve as a reminder that something is up.
What is odd is that I don’t believe it failed before. I’m going to check this in and then check out a previous version and see what I can figure out. Commit: free ship at 10,000, saucer speed change, test fails randomly.
As I suspected and feared, this morning’s starting point never fails that test. What about the first checkin, with the speed change?
OK, that has the intermittent failure as well. Plus the other problem with Fake Universe that I fixed and immediately forgot what it was.
So I think the first thing will be to see what Working Copy can help with, looking at the diffs between starting and that first commit.
Reviewing the diffs, I see nothing. “The changes I made couldn’t have affected that”. Yeah, but they did.
I think the right thing to do is to go back to yesterday’s commit and do the scoring change again, leaving the speed issue alone. It should take no time and maybe I can learn Working Copy’s cherry pick.
Meh. The best Working Copy feature I could find was Undo, which removed the commits, but has left me with all of today’s changes ready to commit. Or at least some of them. I’m starting over so I’ll revert the local changes out.
Now to run the tests a few dozen times. That works. Now I have a convenient copy of the free ship test here in this article.
_:test("Free ship every N points", function()
U = FakeUniverse()
local score = Score(3)
score:addScore(U.freeShipPoints)
_:expect(score.shipCount).is(4)
score:addScore(U.freeShipPoints/2)
_:expect(score.shipCount).is(4)
score:addScore(U.freeShipPoints/2)
_:expect(score.shipCount).is(5)
end)
Now to just tick through and do it again. And then commit.
function Score:addScore(aNumber)
self.totalScore = self.totalScore + aNumber
if self.totalScore >= self.nextFreeShip then
self.shipCount = self.shipCount + 1
self.nextFreeShip = self.nextFreeShip + U.freeShipPoints
end
end
And the init of course, and the test runs again. Just the work of moments. Reverting is like that. Should do it more often.
Commit: free ship every 10000.
Back to the missile?
Shall we go after the missile issue again? We still don’t know why it failed when we went to speed 2 and then stayed failed thereafter. But we do know that speed 2 is too slow, and will indeed result in an infinite time to target, which is not good, especially since the code we stole doesn’t seem to deal with that possibility.
And we do know that we need to redo targeting anyway. What’s a boy to do? Let’s set the speed to a bit over 2, without the extra fiddling with the velocity vectors, and see what happens.
Saucer was ignoring the universe and has
self.shotSpeed = 5
And it uses that value through out the targeting phase (though not in the random missile phase). Let’s set it to 2 and see what that does.
That’s sufficient to make the test fail randomly, about half the time, which I’d expect. Let’s try 3. Works fine, but if the ship were going away, again we’d see fails. I think the reason I picked 5 was because it was greater than the maximum receding speed of the two vehicles.
I think I’ll play the game at 3 and see what happens. It seems OK. The saucer is still accurate when it wants to be, but if you’re moving you can evade fairly well. Except for the asteroids in the way.
So I’m going to commit that with just the speed changed to 3: “saucer.shotSpeed = 3 (applies to targeted only)”.
I don’t see the upside to doing the “enhancement” of making a shot velocity and using it from the saucer: that was where we got into trouble, and it didn’t really add anything useful, especially with targeting likely to change.
I did notice that the ship can run into its own missiles and destroy itself. That’s not a surprise in the sense that we programmed it that way, but it’s not a thing I had done before. We could change the spec, of course.
For this morning, I think we’re well past done. Let’s sum up.
Summing Up
First of all, commit only single conceptual units, not two or more at once. Had I not had the scoring and the saucer together, reverting would have seemed much easier.
Second, don’t hesitate to revert. If you’re working in small cohesive change sets, it’s quick and easy to revert and do over. It’s always easier and usually better the second time anyway.
Third, the tests are really helpful. Even the random one did identify an issue or two.
Fourth, I hate random tests, but I still don’t see a good way to test whether the saucer fires targeted missiles four out of twenty times.
Fifth, I really didn’t expect the morning to go as it did. I figured we’d quickly fix that “5” bug, and then clean up some timers or constants or something easy. Sometimes life surprises you. It’s possible that I should have noticed sooner that I was in trouble and reverted. Again, that would have felt more reasonable had I not had two sets of changes mixed together.
Summing up the summing up, though, this is a pretty realistic version of what often happens. Even with good tests and decent skill, sometimes we take too big a bite, or make some simple mistake, and things go wrong. Sometimes our ideas about what to do lead us astray and we go down a rathole for a while. The thing is to notice and climb back out as soon as possible.
And we did get a feature, free ships (which I’ll never see, I reckon). And we learned or relearned a bit about that targeter.
You know what I regret most? Adopting that targeting code without fully understanding it. It seems to work, but it’s arcane and I saw it returning infs and nans while testing it. Those seem to flow through the code anyway, but they can’t be right.
Targeting is a critical feature of the code, and it’s tricky however you do it, and we should understand it. This just redoubles my desire to understand what Asteroids did, and to do one either that way or some other way.
Speaking of that, I’ll share my current thinking.
Suppose we divide the circle around the saucer into a bunch of equal sectors, maybe as many as 30 or so, every few degrees. Suppose when it is time to fire, we determine what sector the ship is in, and the time a missile would take to hit it. Then we use the ship’s velocity to determine what sector it will be in about that long from now. We fire into that sector. Maybe we even iterate twice if the second distance is a lot larger than the first.
Seems it might work pretty well but not with that machine-like precision that we’re getting now.
So I’ll perhaps play with that one of these days.
See you next time!