Hi folks, I wanted to talk a little about my experience with coroutine select
in the context of a game engine.
First of all, I’m very happy that it exists in Kotlin! Coroutines are so incredibly useful, and there’s so much yet-unexplored potential for game design purposes, so thank you JetBrains!
The select
part of the library seems experimental and little spoken of, so I wanted to talk about it a bit, and maybe where my needs are differing from what’s provided. Here’s an example script taken directly from a combat tutorial I’m prototyping in my game (making use of a “dialogue DSL”):
// Wait for enemy encounter:
script.waitFor(PlayerScript.State.AGGROED)
wait(1000.milliseconds)
pauseGame = true
defaultAdvance = GuiSpeech.AdvanceMode.AUTO
show {
- "Alright, good work! I'll pause the game so you can plan."
+ "Press the right mouse button or click the right thumbstick to lock on!~"
}
var lockedOn = false
var takenDamage = false
var ranOutOfStamina = false
var startedBleedingOut = false
var timesDead = 0
whileSelect {
if (!lockedOn) script.onState(PlayerScript.State.LOCKED_ON) {
lockedOn = true
soundFacade.playTutorialSuccess()
wait(1000.milliseconds)
show {
- "Great, now your camera will track with the enemy!"
+ "On mouse and keyboard, you can look freely as the camera tracks."
+ "On controller, you can tilt the right thumbstick to adjust your aim.~"
- "Try to lead the bad guy's position as you aim."
+ "The longer you charge your shot, the easier it will be to hit your target!"
+ "Keep an eye on your stamina though...~"
- "Oh! And these guys are fast! Watch out when they disappear and break your lock!~"
}
true
}
if (!takenDamage) script.onEvent(PlayerScript.Event.HIT_BY_PROJECTILE) {
takenDamage = true
wait(500.milliseconds)
show {
- "Ouch! Make sure to move around while you're aiming - the first rule of combat is to survive!"
+ "You'll also lose your charge when you're hit, so watch out!~"
}
true
}
if (!ranOutOfStamina) script.onEvent(PlayerScript.Event.NOT_ENOUGH_STAMINA) {
ranOutOfStamina = true
wait(500.milliseconds)
show {
- "Oh no, you're out of stamina! You can either press E or the Y button to eat and regain it,"
+ "or you can look around for a green halo of light on the ground and blow it up.~"
- "Running away is also an option, but these guys can be hard to shake!"
+ "Plus you're liable to run into more bad guys that way, so be careful!~"
}
true
}
if (!startedBleedingOut) script.onState(PlayerScript.State.BLEEDING_TO_DEATH) {
if (player.vitals.bloodPct > 0.15) {
wait(500.milliseconds)
startedBleedingOut = true
show {
- "You're bleeding out! AAAAHHHHH!"
+ "...okay, it's not the WORST thing that could happen..."
+ "But if you don't take care of it, you're _totally_ gonna die.~"
- "If you go search for one of those _red_ halos of light and blow it up,"
+ "it should stop the bleeding, and recover a bit of your health to boot."
+ "Those bad guys aren't gonna make it easy though, so be evasive!~"
}
}
true
}
script.onState(PlayerScript.State.DEAD) {
timesDead += 1
withContext(GameDispatchers.General) { delay(1000.milliseconds) } // real time
when (timesDead) {
1 -> {
show { - "Ah well, it happens to the best of us! All you can do is get up and try again, eh?" }
script.waitFor(PlayerScript.State.SPAWNED)
wait(500.milliseconds)
pauseGame = false
show { - "Alright, let's find another bad guy and beat 'em up! I'm counting on you!" }
}
2 -> {} //...
}
script.waitFor(PlayerScript.State.AGGROED, true)
wait(1000.milliseconds)
pauseGame = false
show { - "Alright, round ${timesDead + 1}! Here we go!" }
pauseGame = true
true
}
script.onState(PlayerScript.State.AGGROED, false) {
wait(4.seconds) // wait for last enemy to explode
pauseGame = true
show()
- "Great work! You've officially completed the tutorial."
+ "I hope you enjoy your time in the world of Sojourners!~"
false
}
}
To me, this feels like an incredibly natural pattern for a combat tutorial, where event order is out of our control, and yet some degree of structure is required. However, you’ll notice that I check the “do once” conditions before registering any select clause. This is to prevent infinite looping on “state” clauses, which invoke automatically if the state is currently active at selection start.
The “do once” pattern works well, but it isn’t particularly natural, nor is it efficient in terms of allocation - every time an event is selected, every single clause must be reconsidered and reallocated. I’m not sure what a better solution would be, but to me it definitely feels like an area for improvement… maybe a new selectRepeated
function could work, where clauses are persisted across multiple select
invocations and can be added/removed over time? As it stands, the only purpose of code between the clauses is to filter the clauses, but this could be readily encapsulated imo.
The other thing I wanted to talk about was complexity in implementing my own select
clauses - you’ll notice I have onState
and onEvent
methods which are implemented like so:
context(SelectBuilder<R>)
fun <R> onEvent(event: Event, block: suspend (Any?) -> R) {
@OptIn(InternalCoroutinesApi::class)
val clause = makeSelectClause1<Any?>({ getList(event) += it; true }) { getList(event) -= it }
return clause.invoke(block)
}
@InternalCoroutinesApi
inline fun <Q> makeSelectClause1(crossinline register: ((Q) -> Unit) -> Boolean, crossinline deregister: ((Q) -> Unit) -> Unit): SelectClause1<Q> {
return object : SelectClause1<Q> {
override fun <R> registerSelectClause1(select: SelectInstance<R>, block: suspend (Q) -> R) {
if (select.isSelected) return
val handle = { q: Q -> if (select.trySelect()) block.startCoroutine(q, select.completion); Unit }
if (register(handle)) select.disposeOnSelect { deregister(handle) }
}
}
}
Honestly there’s a lot to talk about here, and I’m sure there’s reasons against this, but:
- The pattern of passing a
SelectInstance
that can only be intercepted by implementing aSelectClause1
feels laborious, especially when you can’t often expose the clause externally anyway (see next point.) Why can’tSelectBuilder
expose the instance directly? It doesn’t appear to be getting reused, after all! (See previous point.) - You’ll notice I’ve defined functions that accept parameters and the clause’s lambda, and don’t simply return a clause. This is because Kotlin syntax forbids calling
invoke
on a value returned from a function via lambda syntax - this is interpreted as an extra parameter to the original function instead, even if it doesn’t accept a lambda, resulting in a compile error! The existing syntax of clauses as invokable properties (e.g.onAwait
) seems unnatural to me to start with, but this limitation makes it worse imo. It’s not obvious that you’ll run into this problem defining your own clauses. - There’s enough boilerplate involved that I felt the need to create an inline helper function to define anonymous classes at call sites. The pattern to create a “handle” and dispose it, invoke
block
as a coroutine that chains withselect.completion
, etc is very unintuitive, and I had to scrape it fromkotlinx.coroutine
library code to have any chance of getting it right (and I probably didn’t.) The pattern may be technically necessary and perfect in some sense, but from a user perspective I think this could use a lot of improvement.
I suppose to sum up my concerns, it comes down to unintuitiveness, boilerplate, and multiple unavoidable gotchas. Even with my helper function, clauses can get pretty hard to understand, and I feel the need to define more than a few of them! Conceptually though, I feel like new clauses should be about as hard to add as a new suspend
function that invokes suspendCancellableCoroutine
(or even directly convertible from such definitions.)
Example complex clause:
context(SelectBuilder<R>)
fun <R> onState(state: State, value: Boolean = true, block: suspend () -> R) {
@OptIn(InternalCoroutinesApi::class)
val clause = makeSelectClause0({
if (states[state.ordinal] == value) {
it() // invoke immediately (can infinitely loop!)
false // don't register for select dispose
} else {
getList(state, value) += it
true
}
}) {
getList(state, value) -= it
}
return clause.invoke(block)
}
And for contrast, the equivalent suspend
functions of my clauses:
suspend fun waitFor(event: Event): Any? {
val list = getList(event)
return suspendCancellableCoroutine { cont ->
list += cont
cont.invokeOnCancellation { list -= cont }
}
}
suspend fun waitFor(state: State, value: Boolean = true) {
if (states[state.ordinal] == value) return
val list = getList(state, value)
return suspendCancellableCoroutine { cont ->
list += cont
cont.invokeOnCancellation { list -= cont }
}
}
Thanks for reading, hopefully someone working on the select
library finds this useful! If you have experience working with select
, please feel free to share as well.