Kotlin 177: No Quarter
When it’s GAME OVER and we want to go again … where do we put the quarter? A HARD DECISION: which way would you go?
- Early Warning
- In this article, I see, not entirely clearly, a simple solution. Further thinking lets me see a clever yet still simple solution that follows the style of independent cooperating objects, which I implement rather nicely if I do say so myself. Then the simpler solution becomes more clear in my mind. I implement it, removing the clever solution. Then, perversely, I put the clever solution back.
-
Your mission, I guess, is to observe what happened and think about which end point is the “right” one, the simple clever one or the simpler direct patch one.
-
Here we go …
Right, these games used to cost a quarter per play. It was possible to set the price higher, but I don’t recall that anyone in my community had done that. But when our game here says GAME OVER, all we can do is quit and restart it. That’s no fun. But how are we to implement starting a new game? I can’t wait to find out, because right now I honestly don’t know.
When the game starts, we have some number of ships, and our score is zero. The ship drops in, and a few seconds later, asteroids appear at the perimeter and start drifting in. That happens at startup, like this:
program {
val font = loadFont("data/fonts/default.otf", 640.0)
val controls = Controls()
val game = Game().also { it.createContents(controls) }
keyboard.keyDown.listen {
when (it.name) {
"d" -> {controls.left = true}
"f" -> {controls.right = true}
"j" -> {controls.accelerate = true}
"k" -> {controls.fire = true}
"space" -> {controls.hyperspace = true}
}
}
keyboard.keyUp.listen {
when (it.name) {
"d" -> {controls.left = false}
"f" -> {controls.right = false}
"j" -> {controls.accelerate = false}
"k" -> {controls.fire = false}
"space" -> {
controls.hyperspace = false
}
}
}
The setup is in game.createContents(controls)
:
fun createContents(controls: Controls) {
val ship = newShip(controls)
val scoreKeeper = ScoreKeeper(4)
add(ShipChecker(ship, scoreKeeper))
add(scoreKeeper)
add(WaveMaker())
add(SaucerMaker())
}
fun add(newObject: ISpaceObject) = knownObjects.add(newObject)
We begin some design thinking and idea generation …
One thing that we need to be aware of is that the game doesn’t use the Controls, it just passes them to the Ship.
- Detail:
- The ship holds the controls, which are wired to the keyboard. The
control
message, sent from the Ship to Controls, passes the ship to Controls, so that it can make the ship do things. Hill objects to this “circular” relationship. He could be right; there are other ways to do this. For now, we need to be aware.
We see that the Game adds a ShipChecker, a ScoreKeeper, a WaveMaker, and a SaucerMaker to the mix. It doesn’t add the ship or the asteroids. It does, however, ensure that the ShipChecker has the ship.
Because the game is presently created anew, it doesn’t clear the known objects collection, it creates it when it’s created.
I have a vague idea about how we should do this. Suppose that the Controls knew another keystroke, maybe “q” for quarter. Suppose that when it sees “q”, it adds a Quarter object to the mix. Suppose further that somehow when the Quarter appears, the screen clears and the game restarts. What is “somehow”?
We could do this the easy way. For a change, let’s try that. Suppose that
- Game creates the Quarter and starts the game with nothing in the mix but the Quarter. Or maybe with nothing,
No, that’s no good …
- Game sets up as usual, but also creates a Quarter, which is created with a reference to the game itself;
- Controls is configured to have the Ship, but also the Quarter.
- When the “q” is typed, Controls adds the Quarter to the mix.
- Quarter contains the current setup code, so it can add all the starting things to the mix.
- When Quarter runs, it clears the mix (somehow), then adds the starting seed back in.
- The game restarts.
Sounds settled … but it isn’t …
OK, that’s better. How does it clear the mix? Well, if it has the game, it can just call the game with a new method and tell the game to do it. But if it can do that, why doesn’t it just call the game and tell it to put in the seed as well?
- Added in Post
- Note that I have here the simple patch idea that I revert to at the end … and then revert back. The next few short paragraphs show me moving from the simple idea of patching controls to game, to the more interesting “Quarter object” solution.
Why don’t we just call back to the game when the Q key is typed? I have an answer to that but I don’t entirely believe it. The way the program works is by the cooperation of the individual objects in the mix … with the exception that the Game knows the initial seed. It’d be nice if the Game knew little or nothing about all this, and that everything about the Game could be in the individual objects. Now, obviously the quarter knows a lot in this scheme … but we can imagine another quarter that configures an entirely different game.
But the thing is this: it would be incredibly easy just to set up the quarter to know the game and to tell it “start the game”. Or, for that matter, to have Controls know the game and, if you type “q”, tell the game to put in the seed.
Furthermore … if we wanted different seeds … we could give them to Controls and if you type “s” instead of “q”, it could tell the game to restart with that seed.
I like this seed idea. The seed is, say, an object like Quarter that has just enough intelligence to add all the necessary starting bits when it is executed, and then to remove itself. It just needs a way to tell the game to clear the mix. Maybe there could be a new kind of Transaction entry besides adds
and removes
.
- By this point …
- I’ve moved from a very simple thought, connecting the controls to the game’s startup code, to a “more interesting” notion of a game object that destroys and recreates the game. Which we’ll implement below. So I do it:
Let’s try it.
Can we TDD it? I guess we’d better try.
class QuarterTest {
val quarter = Quarter()
@Test
fun `create it`() {
}
}
I think I’ll create one outside the tests … they’ll all need one. IDEA wants to create the class. IDEA adds in the usual suspect members. We’re down to just three now:
class Quarter: ISpaceObject, InteractingSpaceObject {
override fun update(deltaTime: Double, trans: Transaction) {
TODO("Not yet implemented")
}
override val subscriptions: Subscriptions
get() = TODO("Not yet implemented")
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {
TODO("Not yet implemented")
}
}
Now let’s see what the test wants to do with it.
class QuarterTest {
val quarter = Quarter()
@Test
fun `create it`() {
assertThat(quarter).isNotNull
}
}
So far so good. Now what? Quarter’s job is to clear the mix and then add in the starting stuff, and to remove itself. It can do all that on update … given that we come up with an extension to transaction to clear the mix. That should be easy enough, maybe.
Let’s just check the update method. The first thing I’ll check is that it removes itself.
class QuarterTest {
val quarter = Quarter()
val trans = Transaction()
@Test
fun `create it`() {
assertThat(quarter).isNotNull
}
@Test
fun `removes self on update`() {
quarter.update(0.0, trans)
assertThat(trans.firstRemove()).isEqualTo(quarter)
}
}
This should fail and I’ll let it. I’m trying to build up a rhythm.
An operation is not implemented: Not yet implemented
Right, that’s update.
class Quarter: ISpaceObject, InteractingSpaceObject {
override fun update(deltaTime: Double, trans: Transaction) {
trans.remove(this)
}
This should run. It does. Commit! Initial Quarter removes itself.
IDEA objects to the TODOs that are still in there. I commit anyway, but I’ll fix them next.
override val subscriptions: Subscriptions = Subscriptions()
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {}
Green. Commit: empty subscriptions and callOther.
Shall we do more tiny tests? Let’s do. Got to practice that Many More Much Smaller Steps Thing.
@Test
fun `adds ScoreKeeper`() {
quarter.update(0.0, trans)
assertThat(trans.adds.any { it is ScoreKeeper }).isEqualTo(true)
}
This should check to see if we’ve added any ScoreKeepers. We haven’t, so it should fail. It does.
Game adds a ScoreKeeper with count of 4 (I’m not very good at the game, I need lots of ships). So:
class Quarter: ISpaceObject, InteractingSpaceObject {
override fun update(deltaTime: Double, trans: Transaction) {
trans.remove(this)
val scoreKeeper = ScoreKeeper(4)
trans.add(scoreKeeper)
}
I broke it out as a temp because I’m looking ahead: I know that another object needs to know the ScoreKeeper, because here’s what’s in Game:
fun createContents(controls: Controls) {
val ship = newShip(controls)
val scoreKeeper = ScoreKeeper(4)
add(ShipChecker(ship, scoreKeeper))
add(scoreKeeper)
add(WaveMaker())
add(SaucerMaker())
}
Test should run. Commit: Quarter adds ScoreKeeper.
If I were a fanatic, I’d check that the 4 was sent in, but there’s a limit to my patience. Now WaveMaker, same way.
@Test
fun `adds WaveMaker`() {
quarter.update(0.0, trans)
assertThat(trans.adds.any { it is WaveMaker }).isEqualTo(true)
}
Red.
override fun update(deltaTime: Double, trans: Transaction) {
trans.remove(this)
val scoreKeeper = ScoreKeeper(4)
trans.add(scoreKeeper)
trans.add(WaveMaker())
}
Green. Commit. Quarter adds WaveMaker.
@Test
fun `adds SaucerMaker`() {
quarter.update(0.0, trans)
assertThat(trans.adds.any { it is SaucerMaker }).isEqualTo(true)
}
Red.
override fun update(deltaTime: Double, trans: Transaction) {
trans.remove(this)
val scoreKeeper = ScoreKeeper(4)
trans.add(scoreKeeper)
trans.add(WaveMaker())
trans.add(SaucerMaker())
}
Green. Commit: Quarter adds SaucerMaker.
Now the hard test. We need to add a ShipChecker, which must have a Ship and the ScoreKeeper, and the Ship has to have the official controls. We don’t have access to those … yet.
@Test
fun `adds correctly configured ShipChecker`() {
quarter.update(0.0, trans)
assertThat(trans.adds.any { it is ShipChecker }).isEqualTo(true)
}
This runs red. It should drive out some code.
I start with this:
val shipChecker = ShipChecker()
trans.add(shipChecker)
But I can’t create a ShipChecker with no ship, and it wants the ScoreKeeper too.
val ship = Ship()
val shipChecker = ShipChecker(ship, scoreKeeper)
trans.add(shipChecker)
I can’t create a ship that way. It needs a position and a controls. Position we know.
val shipPosition = U.CENTER_OF_UNIVERSE
val ship = Ship(shipPosition)
val shipChecker = ShipChecker(ship, scoreKeeper)
trans.add(shipChecker)
The Game gets controls from the main program, and we’ll have it pass it to Quarter, so quarter will need a Controls as a creation parameter:
override fun update(deltaTime: Double, trans: Transaction) {
trans.remove(this)
val scoreKeeper = ScoreKeeper(4)
trans.add(scoreKeeper)
trans.add(WaveMaker())
trans.add(SaucerMaker())
val shipPosition = U.CENTER_OF_UNIVERSE
val ship = Ship(shipPosition, controls)
val shipChecker = ShipChecker(ship, scoreKeeper)
trans.add(shipChecker)
}
Now we need to change our test setup … and to check the details, even though we “know” they’re right.
class QuarterTest {
val controls = Controls()
val quarter = Quarter(controls)
val trans = Transaction()
@Test
fun `adds correctly configured ShipChecker`() {
quarter.update(0.0, trans)
assertThat(trans.adds.any { it is ShipChecker }).isEqualTo(true)
val checker = trans.adds.first { it is ShipChecker } as ShipChecker
assertThat(checker.scoreKeeper is ScoreKeeper).isEqualTo(true)
val ship = checker.ship
assertThat(ship.controls).isEqualTo(controls)
}
Green. Commit: Quarter produces correctly configured ShipChecker.
We are left with one concern, which is that the quarter does not yet clear the mix. However, I think that if we were to change the game creation code in game now, to just add a Quarter, the game should start and run correctly.
I have to try it.
class Game ...
fun createContents(controls: Controls) {
add(Quarter(controls))
}
Yes!!! Game runs. It has no restart yet, of course. But commit: Game starts by inserting a Quarter.
Now we need a way for a Transaction to clear the mix. What does Transaction look like?
class Transaction {
val adds = mutableSetOf<ISpaceObject>()
val removes = mutableSetOf<ISpaceObject>()
We could just set a flag. Let’s try that. We need a test.
fun `can clear collection`() {
val coll = SpaceObjectCollection()
val obj = Asteroid(U.CENTER_OF_UNIVERSE)
val trans = Transaction()
trans.add(obj)
trans.applyChanges(coll)
assertThat(coll.size).isEqualTo(1)
val clearTrans = Transaction()
clearTrans.clear()
assertThat(coll.size).isEqualTo(0)
}
Needs clear
. Implement:
class Transaction {
val adds = mutableSetOf<ISpaceObject>()
val removes = mutableSetOf<ISpaceObject>()
var shouldClear = false
fun clear() {
shouldClear = true
}
fun applyChanges(spaceObjectCollection: SpaceObjectCollection) {
if (shouldClear ) spaceObjectCollection.clear()
spaceObjectCollection.removeAndFinalizeAll(removes)
spaceObjectCollection.addAll(adds)
}
class SpaceObjectCollection {
val spaceObjects = mutableListOf<ISpaceObject>()
fun clear() {
spaceObjects.clear()
}
Test should pass. Doesn’t!
expected: 0
but was: 1
Whew! Forgot to apply the changes.
@Test
fun `can clear collection`() {
val coll = SpaceObjectCollection()
val obj = Asteroid(U.CENTER_OF_UNIVERSE)
val trans = Transaction()
trans.add(obj)
trans.applyChanges(coll)
assertThat(coll.size).isEqualTo(1)
val clearTrans = Transaction()
clearTrans.clear()
clearTrans.applyChanges(coll) // < --- had to do this
assertThat(coll.size).isEqualTo(0)
}
Green. Commit: Transaction.clear() will clear all objects when trans is applied, then will do any additional adds. And removes, but they’ll be done already.
Now we want Quarter to do the clearing. We can just check the flag.
@Test
fun `clears all objects`() {
quarter.update(0.0, trans)
assertThat(trans.shouldClear).isEqualTo(true)
}
SHould fail. Does. Fix.
override fun update(deltaTime: Double, trans: Transaction) {
trans.clear()
val scoreKeeper = ScoreKeeper(4)
trans.add(scoreKeeper)
trans.add(WaveMaker())
trans.add(SaucerMaker())
val shipPosition = U.CENTER_OF_UNIVERSE
val ship = Ship(shipPosition, controls)
val shipChecker = ShipChecker(ship, scoreKeeper)
trans.add(shipChecker)
}
I intentionally removed the code that removes the Quarter: it’s no longer needed. Its test will fail and then we’ll remove it. Green. Commit: Quarter clears the mix on update, then adds new game items.
Now we have just one more thing to do. When you type “q”, toss a quarter into the game.
In the main:
keyboard.keyDown.listen {
when (it.name) {
"d" -> {controls.left = true}
"f" -> {controls.right = true}
"j" -> {controls.accelerate = true}
"k" -> {controls.fire = true}
"space" -> {controls.hyperspace = true}
"q" -> {controls.quarter = true}
}
}
In controls:
class Controls {
var accelerate = false
var left = false
var right = false
var fire = false
var hyperspace = false
var quarter = false
fun control(ship: Ship, deltaTime: Double, trans: Transaction) {
if (quarter) {
quarter = false
trans.add(Quarter(this))
return
}
if (hyperspace) {
hyperspace = false
ship.enterHyperspace(trans)
}
turn(ship, deltaTime)
accelerate(ship, deltaTime)
trans.addAll(fire(ship))
}
I think this does the trick. I don’t see how to test with a test but I can run the game.
If I put in a quarter before the game is over, I get a new game immediately. But if I wait until GameOver … nothing happens. Why?
Fatal Flaw: controls talks to ship, but the ship isn’t running, so it doesn’t ask controls to do anything, so controls never get a chance to add the Quarter, so the game doesn’t restart.
Am I daunted? No, but I am ruefully amused that I didn’t see this flaw right away. What are some possible solutions?
- Don’t remove the ship from the mix, just make it go invisible and quiet.
- Put the Controls in the mix so that they’re always there to see the messages. (That might make it possible to maneuver the ship while it’s gone, which would be weird but we could fix it.)
- Connect the q key to some other object, perhaps ScoreKeeper, and arrange to have that object return rather than be recreated.
- Connect the q key back to game, and have Game throw in the quarter.
I think we’ll do that. The “q” can just call game.createContents, which will toss in the quarter. Let’s try that.
That works as intended. Commit: Typing Q inserts a quarter and starts a new game.
So that was nifty, but we need to think a bit about this last step.
- Added in Post:
- At this point I recognize that the simple patch, which I thought might work but didn’t follow through on, would have worked perfectly, in just a few lines. Wow!
Reflection
Is this too cool for school? We have a new object, Quarter. If it’s put in the mix, it clears out whatever is happening and creates a new Asteroids game, by adding in the core checkers and makers that then create the actual game objects. When the game starts up, it just adds the Quarter to the mix. When we want a new game, the q key adds the Quarter to the mix. Nice.
However … once we saw that we could wire the q key to send a message to the game … we could have changed Game’s createGame
method to clear the space objects and then add in the handful of things it did.
So with a two-line change, we could have had this feature, and we would not have added 18 lines of Quarter and almost 50 lines of QuarterTest.
Now we do have this alleged advantage that we could put in a different kind of quarter and start up a different kind of game. But, now that we see that we can wire Controls to other than the ship, we see that we could add a createSpacewar
method to Game, wire the “w” key to that and have the new game. The createSpacewar
method would be just the same as the new kind of Quarter.
- Added in Post
- I decide to remove my clever Quarter object. The simple patch works fine.
Yes. This is too cool for school. As much as I love it, it has to go.
First, we modify Game.createContents
to create the game as before, by copying Quarter’s update code back to game, adjusting for where we are:
fun createContents(controls: Controls) {
knownObjects.clear()
val scoreKeeper = ScoreKeeper(4)
add(scoreKeeper)
add(WaveMaker())
add(SaucerMaker())
val shipPosition = U.CENTER_OF_UNIVERSE
val ship = Ship(shipPosition, controls)
val shipChecker = ShipChecker(ship, scoreKeeper)
add(shipChecker)
}
Everything should work as before. To prove it, remove the QuarterTest and Quarter.
Tests are green. Game works. Should we back out the Transaction clear? No, we’ll leave it.
Commit: Game restarts when you type “q”.
Let’s sum up.
Well, Hell
What was that besides a finger exercise? It was a very good finger exercise. Ten commits in 90 minutes, not bad when you’re writing an article at the same time. And I do like the idea of the game seed, the Quarter, just another SpaceObject that knows how to create a whole game. And shouldn’t game creation be separate from the game loop?
- Added in Post:
- What is this, seller’s remorse? I want my clever object back. So I put it back. Your mission is to decide what you and your team would do if you implemented a simple yet somewhat clever solution and then realized here was an even simpler patch that would do the job.
You know what? I’m going to put it back. I like it, we’re here to learn, and now that it’s done and tested, it’s robust enough to be part of the game.
I’m going to put it back. So there. Git Reset Hard.
- Added in Post:
- So … what would your team do? What would you do? If you were coaching a team and they did this, what would you tell them?
-
Keep the rather elegant but unquestionably more complex Quarter? Or remove it and patch directly from Controls to Game?
Quarter stays. My house, my rules. But …
What about a “real” situation?
Modified in Post: I want to say that if I had thought of the simple connection first, I’d have one it, but a review of the record shows me that I did think of it, and then went on thinking until the Quarter idea was clear enough to appeal to me as somehow “better”. If I had gone ahead with it, I’d have changed a few lines shipped the feature, and had a very short article.
Rationalizing, I continue …
We don’t always think of the best solution. Maybe we never do. We build what we understand, with care, and when it works, we ship it. We work to go as smoothly and rapidly as we safely can. And the build of the Quarter was, in my view, pretty fine. Lots of tiny tests and steps, And you have to admit it works very nicely and simply, in just a few lines. The whole Quarter class is only 18 lines.
What if, in a real situation, we had done this and then realized that we could instead directly tell the game object to clear out and create a new game? Would we remove the Quarter and its tests in favor of the direct solution? I think I know people who would recommend that highly. The code is unquestionably simpler in some ways with the controls telling game to start a new game. It is also coupled a bit oddly, with a tiny control object driving both the ship and the game.
There’s no doubt in my mind that I’m keeping the Quarter because I like it, and it’s hard to argue that it’s harmful at only 18 lines that rather obviously do what they do. I’m going to ask my friends, especially the two who I’m sure will tell me that the direct game connection is the right way, and dropping the Quarter into the game is a crock. I’ll let you know what they think.
Part of me thinks I should probably remove it but it’s so tiny and so nice and so encapsulated … and I love the little furry thing, it followed me home, and I’m going to keep it.
Let me know what you think! See you next time!