Kotlin 169: Out of the Blue
Where do ideas come from? I don’t know. Where do they go? Into the code, of course.
In the interval between first sort of waking up and finally deciding I might as well roll out of bed, I often think about programming. (Yes, I know there are more interesting things, but I’m old. Very old.) Often, as I drift near sleep, an idea seems to drift into my head. This morning’s idea, given a few more minutes’ thought as I made food for the cat and chai for the Mister, is called TellMeWhen
.
Suppose we had a TellMeWhen class, initialized with a time interval in seconds, a transaction, and a block of code. And suppose that, when the interval has elapsed, the block of code will be called, once, and passed a transaction to use as it may wish.
I believe I thought of this notion in the context of the GAME OVER implemented last night. GAME OVER pops up the instant that the ScoreKeeper is asked takeShip
and does not have one available. I was thinking that it might be polite or otherwise desirable for there to be a short delay before the GAME OVER comes up. I don’t really plan to implement that: I was just thinking about it.
And that got me thinking that it would be “nice” if there was an object in the system that could send a message to ScoreKeeper saying “the polite delay before shouting GAME OVER has elapsed”, or some similar thing.
And we do have a number of delays that are used in the system. ShipMaker waits a while before making a new ship, if the ship has been destroyed by a collision (but not if it has gone to hyperspace: hyperspace emergence is immediate). Missiles time out after 3 seconds. The Saucer runs every seven seconds.
Maybe a TellMeWhen object would be useful. I propose to find out, by implementing it. Here’s my cunning plan …
Cunning Plan
As I sketched above, the TellMeWhen is called with an interval, a transaction, and a block, something like this:
TellMeWhen(7.0, trans) { transaction -> transaction.add(saucer) }
Seven seconds later, the block runs, passed a live transaction to which it adds the saucer, and the TellMeWhen expires.
The trans
argument to the TellMeWhen is not the transaction that you’ll receive later on. It needs to be a live transaction when you create the TellMeWhen, because it needs to add itself to the mix so that it can do its work.
Let’s do an experiment, plugging TellMeWhen into SaucerMaker and using it to get a saucer made. Do we have any SaucerMaker tests? In fact we do:
fun `notices whether saucer present`() {
fun `makes saucer after seven seconds`() {
fun `a further seven seconds required for next saucer`() {
Those would almost suffice for our purposes, since if this doesn’t work, those tests won’t work, but it’s not that simple, because we’re going to change SaucerMaker so that it doesn’t do all that timekeeping.
We will need to revamp those tests, and I’m not quite sure how to do it. Let’s start with some tests for the TMW itself.
TellMeWhenTest {
var done = false
@Test
fun `triggers after n seconds`() {
val trans = Transaction()
TellMeWhen(2.0, trans) { _ -> done = true}
}
}
IDEA wants to help build the class. OK, I’m easy. We settle for this:
class TellMeWhen(
delay: Double,
trans: Transaction,
action: (Transaction) -> Unit
) : ISpaceObject, InteractingSpaceObject {
}
I believe that that last bit is right, that action
is a function taking a Transaction and returning nothing.
The class needs to implement the usual suspects per its interfaces. IDEA will help again. It gets update
, subscriptions
, and callOther
. They all contain a fatal TODO, soon to be encountered. But I think our test will pass now. Yes. That’s because it doesn’t do anything yet.
What it should do is tick the TMW’s timer, note that done
is still false, then tick it past 2 seconds and note that done
goes true. There should be some transaction action as well.
Elaborate the test:
class TellMeWhenTest {
var done = false
@Test
fun `triggers after n seconds`() {
val trans = Transaction()
TellMeWhen(2.0, trans) { _ -> done = true}
val tmw = trans.firstAdd()
val newTrans = Transaction()
tmw.update(1.1, newTrans)
assertThat(done).isEqualTo(false)
assertThat(newTrans.adds).isEmpty()
assertThat(newTrans.removes).isEmpty()
tmw.update(1.1, newTrans)
assertThat(done).isEqualTo(true)
assertThat(newTrans.adds).isEmpty()
assertThat(newTrans.removes).contains(tmw)
}
}
After a second (I use values larger than 1 so that I don’t have to worry about any rounding), nothing happens, but after two seconds, done should be set true and the tmw should remove itself.
This test fails on the TODO. Let’s code.
class TellMeWhen(
private val delay: Double,
initialTransaction: Transaction,
private val action: (Transaction) -> Unit
) : ISpaceObject, InteractingSpaceObject {
var elapsedTime = 0.0
init {
elapsedTime = 0.0
initialTransaction.add(this)
}
override fun update(deltaTime: Double, trans: Transaction) {
elapsedTime += deltaTime
if (elapsedTime > delay ) {
action(trans)
trans.remove(this)
}
}
override val subscriptions: Subscriptions = Subscriptions()
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {}
}
Not much to it. It doesn’t interact in any way. It just ticks its timer and when the time is up, it executes its action and removes itself.
And the test is green. Commit: TellmeWhen object implemented.
Unfortunately, using TMW in the SaucerMaker is going to mean changing the tests. Let’s think about SaucerMaker and decide what we can ask of it. We’ll probably want to review how it works now, but first let’s think abstractly.
SaucerMaker is supposed to make a saucer seven seconds after it notices that the saucer is missing. Given that TellMeWhen exists, we can imagine that the SaucerMaker will notice that the saucer is missing and immediately create a TellMeWhen. But what should the action passed to. It could be
{ trans -> trans.add(saucer)}
And that would work just fine. However, SaucerMaker then has to stop looking for the saucer, because otherwise, it will detect the saucer missing again, create another TellMeWhen and next thing you know we have 420 saucers flying around. That would be amusing, but bad.
OK, we’ve got this. Let me code it. I’m not sure how to test it, but I think this works. All the action is here:
override val subscriptions: Subscriptions = Subscriptions(
beforeInteractions = { sawSaucer = false },
interactWithSaucer = { _, _ ->
timeSinceLastSaucer = 0.0
sawSaucer = true
},
afterInteractions = { trans ->
if (! sawSaucer ) {
trans.remove(this)
TellMeWhen(7.0, trans) {
it.add(saucer)
it.add(this)
}
}
}
)
We assume no saucer in before
; we notice saucer in interact
. In after
, if we have seen no saucer, we create a TellmeWhen, seven seconds out, and remove ourselves, the SaucerMaker, from the mix. Seven seconds later, we add the saucer, and ourselves, back into the mix.
Sweet. How can we test it, however? Our current SaucerMaker tests will fail, I’m sure, but the in-game test shows that this is working.
- Aside
- Not as “sweet” as it might be. Ron is too enamored of these objects leaping in, doing something, then destroying themselves only to be revived. There’s a better way. Like a man in a dark room, Ron fumbles around until he finds it1.
We’ll have to adjust our test of the SaucerMaker’s transactions. Let’s try.
@Test
fun `makes saucer after seven seconds`() {
val saucer = Saucer()
val maker = SaucerMaker(saucer)
val trans = Transaction()
maker.update(0.01, trans)
maker.subscriptions.beforeInteractions()
// no saucer for you
maker.subscriptions.afterInteractions(trans)
val tmw = trans.firstAdd() as TellMeWhen
val newTrans = Transaction()
tmw.update(7.1, newTrans)
assertThat(newTrans.adds).contains(saucer)
assertThat(newTrans.adds).contains(maker)
}
I think this will pass. Note the as TellMeWhen
, which will make the test fail if we don’t get a tNW back. There’s probably another way to do this, but I don’t know it. If you do, please tell me.
It does pass. Now the other failing test needs to be dealt with as well. I’m torn. The failing test is called
fun `a further seven seconds required for next saucer`() {
And it tests that we do not get another saucer right after the first one is created.
I’m going to remove this test and then write a story for a new kind of testing that we might need. Commit: SaucerMaker tests green. test for duplicate saucers removed.
Need Better Testing
Or different testing. The game works by executing a cycle of operations on all the objects “in the mix”. During some of those operations, objects are removed from the mix, or added to it. The effect for the user is that asteroids split, ships explode, saucers appear and disappear and so on.
Our testing mostly relies on checking that a series of adds and removes has been executed in the right order, by opening up and inspecting transactions. This is tedious, and the tests are not clear.
It might be better — and I’m not sure of this — it might be better if we could put some objects into a “test mix”, run a few cycles on them, or specific sub-cycle operations, and then check to see whether we have the right mix.
Let’s try to write a test that works like that, just to see how it would feel, and to imagine whether it would make things better. Let’s do the test that checks to see if we replace the saucer after seven seconds. It might look like this:
@Test
fun `game-centric saucer appears after seven seconds`() {
val mix = SpaceObjectCollection()
val saucer = Saucer()
val maker = SaucerMaker(saucer)
mix.add(maker)
val game = Game(mix) // makes game without the standard init
game.cycle(0.1) // seconds only, no draw
assertThat(mix.size).isEqualTo(1)
assertThat(mix.hasInstance(TellMeWhen::class)).isEqualTo(true)
game.cycle(7.1)
assertThat(mix.size).isEqualTo(2)
assertThat(mix.has(saucer)).isEqualTo(true)
assertThat(mix.has(maker)).isEqualTo(true)
}
Let’s try to make that work. Let’s begin by changing the calling sequence to allow us to provide a mix. That’s trivial:
class Game(val knownObjects:SpaceObjectCollection = SpaceObjectCollection()) {
Now I’d like to invert the calling sequence on cycle, and allow drawer to be null.
fun cycle(elapsedSeconds: Double, drawer: Drawer? = null) {
val deltaTime = elapsedSeconds - lastTime
lastTime = elapsedSeconds
tick(deltaTime)
beginInteractions()
processInteractions()
finishInteractions()
drawer?.let {draw(drawer)}
}
I think those question marks do the job. drawer
might be a Drawer, but might be null. If it isn’t null, do the draw function otherwise don’t.
Now SpaceObjectCollection (whose name I do not love now that I have to type it a lot) needs has
and hasInstance
. The words used in Kotlin are contains
, so
fun contains(obj:ISpaceObject): Boolean {
return spaceObjects.contains(obj)
}
Since we won’t know the specifics of new instances, I’d like to check the mix to see if it contains an instance of a given class. I can’t figure out how to defer the instance checking inside, so my test looks like this:
@Test
fun `game-centric saucer appears after seven seconds`() {
// cycle receives ELAPSED TIME!
val mix = SpaceObjectCollection()
val saucer = Saucer()
val maker = SaucerMaker(saucer)
mix.add(maker)
val game = Game(mix) // makes game without the standard init
game.cycle(0.1) // ELAPSED seconds only
assertThat(mix.size).isEqualTo(1)
// want to say
// assertThat(mix.hasInstance(TellMeWhen) but can't figure out syntax
val some = mix.spaceObjects.filterIsInstance<TellMeWhen>()
assertThat(some.size).isGreaterThan(0)
game.cycle(7.2) //ELAPSED
assertThat(mix.contains(saucer)).describedAs("saucer missing").isEqualTo(true)
assertThat(mix.contains(maker)).describedAs("maker missing").isEqualTo(true)
assertThat(mix.size).isEqualTo(2)
}
Kotlin does have any
and all
predicates, so can say this:
fun `game-centric saucer appears after seven seconds`() {
// cycle receives ELAPSED TIME!
val mix = SpaceObjectCollection()
val saucer = Saucer()
val maker = SaucerMaker(saucer)
mix.add(maker)
val game = Game(mix) // makes game without the standard init
game.cycle(0.1) // ELAPSED seconds only
assertThat(mix.size).isEqualTo(1)
// want to say
// assertThat(mix.hasInstance(TellMeWhen) but can't figure out syntax
val some = mix.spaceObjects.filterIsInstance<TellMeWhen>()
assertThat(some.size).isGreaterThan(0)
val hasOne = mix.any { it is TellMeWhen }
assertThat(hasOne).isEqualTo(true)
game.cycle(7.2) //ELAPSED
assertThat(mix.contains(saucer)).describedAs("saucer missing").isEqualTo(true)
assertThat(mix.contains(maker)).describedAs("maker missing").isEqualTo(true)
assertThat(mix.size).isEqualTo(2)
}
I add this:
fun any(predicate: (ISpaceObject)-> Boolean): Boolean {
return spaceObjects.any(predicate)
}
Test is green. Remove some mess:
@Test
fun `game-centric saucer appears after seven seconds`() {
// cycle receives ELAPSED TIME!
val mix = SpaceObjectCollection()
val saucer = Saucer()
val maker = SaucerMaker(saucer)
mix.add(maker)
val game = Game(mix) // makes game without the standard init
game.cycle(0.1) // ELAPSED seconds only
assertThat(mix.size).isEqualTo(1)
assertThat(mix.any { it is TellMeWhen }).isEqualTo(true)
game.cycle(7.2) //ELAPSED
assertThat(mix.contains(saucer)).describedAs("saucer missing").isEqualTo(true)
assertThat(mix.contains(maker)).describedAs("maker missing").isEqualTo(true)
assertThat(mix.size).isEqualTo(2)
}
That’s nearly good, I think. I’ll continue to try to figure out how to pass class names around to make that a bit more clear. Commit: Support for game-centric mix-based testing.
Let’s sum up. I’m interested to learn what I think about this new object.
Summary
Perhaps the best part of all this is the invention of a style of test that checks the mix directly. Such a test at least expresses something essential. A more compact format for that expression might make it even better. I’ll try to work on that as time passes.
But the point of the morning is TellMeWhen
.
The TellMeWhen is basically a time-delay, or a tween.delay
such as exists in Codea Lua. It waits an interval and then executes a function. Because we manage the universe via transactions, the function executed has a transaction as its parameter. The function is essentially a closure in whatever instance created the TellMeWhen, so its code can basically do anything that the original object could do, including calling methods on it. So in our sole use of the object so far, we add the original caller (a SaucerMaker) and the saucer, which the maker has as a member.
afterInteractions = { trans ->
if (! sawSaucer ) {
trans.remove(this) // stop looking
TellMeWhen(7.0, trans) {
it.add(saucer)
it.add(this) // start looking again
}
}
}
The it
in the block is a transaction (and IDEA tells you so, though it doesn’t show up in the copied code.) Well, actually, you can make it do that:
afterInteractions = { trans ->
if (! sawSaucer ) {
trans.remove(this) // stop looking
TellMeWhen(7.0, trans) { it: Transaction ->
it.add(saucer)
it.add(this) // start looking again
}
}
}
The code for SaucerMaker is, however, longer than it was. The code above was initially just this:
afterInteractions = { trans -> if (timeSinceLastSaucer > 7.0) trans.add(saucer) }
And we have changed the general operation of SaucerMaker2 to be a bit more tricky … it used to just notice that the saucer was gone, and how long it had been gone, and if it had been gone long enough, it added the saucer back. Now what happens is that it notices the saucer is gone, creates A TellMeWhen, and removes itself from the mix. When the TellMeWhen triggers, it calls back, causing the SaucerMaker to add the saucer … and itself … and the TellMeWhen removes itself. So we do four transactions now instead of just one.
But overall, SaucerMaker is, I think, simpler:
class SaucerMaker(saucer: Saucer = Saucer()): InteractingSpaceObject, ISpaceObject {
var sawSaucer: Boolean = false
override fun update(deltaTime: Double, trans: Transaction) {}
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {}
override val subscriptions: Subscriptions = Subscriptions(
beforeInteractions = { sawSaucer = false },
interactWithSaucer = { _, _ -> sawSaucer = true },
afterInteractions = { trans ->
if (! sawSaucer ) {
trans.remove(this) // stop looking
TellMeWhen(7.0, trans) { it: Transaction ->
it.add(saucer)
it.add(this) // start looking again
}
}
}
)
}
- Brief Q&A
-
Simple enough? I think not.Could it be simpler? Absolutely. Is it better now than it was before? Perhaps. Will we do better below? Yes.
I honestly have my doubts. I think to a new programmer on the team, the way these little objects interact through the mix will be confusing. I also think they’ll quickly get the hang of it.
Is this a better way to build a game like this? Again, I have my doubts. It’s interesting. It’s surprisingly powerful. The individual objects are structured very differently from more conventional approaches, and are often very simple. But they interact “at a distance” and what happens is the result of two or three different objects cooperating. If any one of them were to be absent or not do its job, the game would break.
Whatcha Gonna Do?
What I’m gonna do is keep pushing the idea. I want to see what it can boil down to. Let’s refactor a bit:
class SaucerMaker(saucer: Saucer = Saucer()): InteractingSpaceObject, ISpaceObject {
var saucerMissing: Boolean = true
override fun update(deltaTime: Double, trans: Transaction) {}
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {}
override val subscriptions: Subscriptions = Subscriptions(
beforeInteractions = { saucerMissing = true },
interactWithSaucer = { _, _ -> saucerMissing = false },
afterInteractions = { trans -> replaceMissingSaucer(trans, saucer) }
)
private fun replaceMissingSaucer(trans: Transaction, saucer: Saucer) {
if (saucerMissing) replaceSaucerInSevenSeconds(trans, saucer)
}
private fun replaceSaucerInSevenSeconds(trans: Transaction, saucer: Saucer) {
trans.remove(this)
TellMeWhen(7.0, trans) {
it.add(saucer)
it.add(this)
}
}
}
Better? Worse? It tells the story better. I think the weirdest bit is the trans.remove(this)
, which is just our way of making the SaucerMaker not worry for a while.
I have an idea for another way to make it work. Let’s try this:
class SaucerMaker(saucer: Saucer = Saucer()): InteractingSpaceObject, ISpaceObject {
var saucerMissing: Boolean = true
var readyToMake = true
override fun update(deltaTime: Double, trans: Transaction) {}
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {}
override val subscriptions: Subscriptions = Subscriptions(
beforeInteractions = { saucerMissing = true },
interactWithSaucer = { _, _ -> saucerMissing = false },
afterInteractions = { trans ->
if ( saucerMissing && readyToMake ) {
readyToMake = false
TellMeWhen(7.0, trans) {
it.add(saucer)
readyToMake = true
}
}
}
)
}
Now we’re not destroying the maker, we’re just setting it to unready and back to ready. I think I like that well enough to keep it. Must make the test work with this new scheme.
@Test
fun `game-centric saucer appears after seven seconds`() {
// cycle receives ELAPSED TIME!
val mix = SpaceObjectCollection()
val saucer = Saucer()
val maker = SaucerMaker(saucer)
mix.add(maker)
val game = Game(mix) // makes game without the standard init
game.cycle(0.1) // ELAPSED seconds
assertThat(mix.size).isEqualTo(2) // <--- changed and line below is new
assertThat(mix.contains(maker)).describedAs("maker sticks around").isEqualTo(true)
assertThat(mix.any { it is TellMeWhen }).isEqualTo(true)
game.cycle(7.2) //ELAPSED seconds
assertThat(mix.contains(saucer)).describedAs("saucer missing").isEqualTo(true)
assertThat(mix.contains(maker)).describedAs("maker missing").isEqualTo(true)
assertThat(mix.size).isEqualTo(2)
}
Ah. I think I like that and now the spooky removal and insertion isn’t there any more. We just notice things, and set up a TellMeWhen to fix them later.
Would this renaming help?
class SaucerMaker(saucer: Saucer = Saucer()): InteractingSpaceObject, ISpaceObject {
var saucerMissing: Boolean = true
var makingSaucer = false
override fun update(deltaTime: Double, trans: Transaction) {}
override fun callOther(other: InteractingSpaceObject, trans: Transaction) {}
override val subscriptions: Subscriptions = Subscriptions(
beforeInteractions = { saucerMissing = true },
interactWithSaucer = { _, _ -> saucerMissing = false },
afterInteractions = { trans ->
if ( saucerMissing && !makingSaucer ) {
makingSaucer = true
TellMeWhen(7.0, trans) {
it.add(saucer)
makingSaucer = false
}
}
}
)
}
I like the makingSaucer
flag better than the readyToMake
. And this code seems fairly simple to me.
We’ll keep this idea for now and try it in a few other places If we can use it to simplify code or remove whole objects, that will be a good thing.
Did you get this far? If so, let me know what you think about all this. See you next time!