While working on my new game Word Wash, I ported most the code from Java to Kotlin. As part of the development I took a stab at Kotlin DSL and found it brings a new flexibility to my programming and game development flows that I haven't experienced before. Allowing for an iterative way to both generate and manually layout levels, while also allowing for me to not paint myself in a corner as I decide on API contracts. Making it easier to come back later and retrofit new approaches and designs.
Let me take you through an example of it by showing how I used it to generate a RaceTrack design for the level. Looking at a sample of level 1 game play here, you can see the racetrack at the bottom of the screen...
Which is a much longer track than displayed, so I wanted the ability to easily review the full output of my generated designs. Opting for a long strip of ASCII art (with emoji track objects) allowed me to to review the level I am working on, wrapped here to show you the track without scrolling...
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ๐ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - โฝ ๐ข ๐ข ๐ข ๐ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ๐ข - ๐ข - ๐ข - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ๐ข - ๐ข - ๐ข - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Now that I have defined what I am solving for let us turn to Kotlin DSL. It can be a bit painful to wrap your head around even if coming from a functional background. So like the excellent type safe builder tutorial let me walk you through it bottom up, as it should help give a better idea of how to grok it.
@Serializable
sealed class RaceTrackType {
abstract val symbol: String
@Serializable
sealed class AiCar(override val symbol: String) : RaceTrackType() {
@Serializable
object Any : AiCar(symbol = "\uD83D\uDE97 ")
@Serializable
object Ambulance : AiCar(symbol = "A ")
}
@Serializable
sealed class RoadHazardType(override val symbol: String) : RaceTrackType() {
@Serializable
object TrafficBarrel : RoadHazardType(symbol = "\uD83D\uDEE2 ")
@Serializable
object Dirt : RoadHazardType(symbol = "# ")
}
@Serializable
sealed class RoadCarPartType(override val symbol: String) : RaceTrackType() {
@Serializable
object Gas : RoadCarPartType(symbol = "โฝ ")
}
}
Starting with the models for the race track objects, this gives me, at a practical level, an easy way to add new objects as I think of them. You can see me doing that with the Ambulance
class under AiCar
which isn't in the game yet. It also gives me a type safe way to use this in my actual game along with anything else that comes with that (kotlinx.serialization you can see me adding here with the @Serializable).
Now let's dive into the DSL...
fun rackTrackDesign(name: String = "DSL Generated Design", init: RackTrackDesign.() -> Unit) =
RackTrackDesign(name = name).apply { init() }
So it's a function and we are gonna call it by doing something like this when generating our Racetrack...
rackTrackDesign(name = "Level 1") {}
Which for my approach basically means "generate and fit everything, no manual intervention", but what if I want to massage portions of the track or guarantee certain patterns? One of the ways I found that worked best was to simply start planning out your desired API contract for the DSL and then build upon on it...
So let's say I want to have x blank track segments at the beginning of most levels...
rackTrackDesign(name = "Level 1") {
segments(length = 5) {}
}
We get our x blank tracks per level, but wait why do we got {}
? Well actually you can omit them, but here I use them as an indicator to remind me there is more underneath. In the case of my designs currently there are Lane
and inside each of them are Spot
objects. With each of those new inner layers comes the possibility for me to add a manual exception or pattern to the design. Let's say you want to guarantee within the next ten sections some traffic barrels show up...
segments(length = 10) {
random {
val lane = lanes.random()
lane.apply {
spots[2] = RaceTrackType.RoadHazardType.TrafficBarrel
spots[3] = RaceTrackType.RoadHazardType.TrafficBarrel
spots[4] = RaceTrackType.RoadHazardType.TrafficBarrel
}
}
}
So the designs start to define themselves as I am thinking about them and before I knew it, I get BDD in tandem! Defining the segments, lanes, and spots to see the full picture...
@Serializable
@RaceTrackDsl
data class Segment(
var lanes: Array<Lane> = arrayOf(Lane(), Lane()),
var trackType: TrackType = TrackType.Basic,
)
@Serializable
@RaceTrackDsl
data class Lane(
val spots: Array<RaceTrackType?> = arrayOfNulls<RaceTrackType?>(6),
val width: Int = 6
)
@RaceTrackDsl
data class Segments(var length: Int = 1,
var segmentTemplate: Segment? = null,
var childSegments: MutableList<Segment> = mutableListOf()) {
fun random(init: Segment.() -> Unit): Segment {
val segment = childSegments.random()
segment.init()
return segment
}
}
Finally here is the RaceTrack Design class that also adds rendering it to the terminal...
@Serializable
@RaceTrackDsl
data class RackTrackDesign(
var name: String? = null,
var childSegments: MutableList<Segment> = mutableListOf(),
var numberLanes: Int = 2,
) {
fun finalSegment(init: Segment.() -> Unit) = Segment(trackType = TrackType.Finish).also {
childSegments.add(it)
it.init()
}
fun segments(length: Int, init: Segments.() -> Unit) = Segments(length = length).also {
(0 until it.length).forEach { _ ->
val new = it.segmentTemplate?.copy() ?: Segment()
childSegments.add(new)
it.childSegments.add(new)
}
it.init()
}
override fun toString(): String {
val track = childSegments.joinToString(separator = "") { "------------" }
val stringBuilder = StringBuilder()
stringBuilder.append("$name : \n")
stringBuilder.append("$track \n")
(0 until numberLanes).map { i ->
val hazards = childSegments.joinToString(separator = "") {
val lane = it.lanes[i]
lane.spots.joinToString(separator = "") { type ->
when (type) {
is RaceTrackType -> type.symbol
else -> "- "
}
}
}
stringBuilder.append(hazards)
stringBuilder.append("\n")
}
stringBuilder.append("$track \n")
return stringBuilder.toString()
}
}
Want to try it yourself? Check it here.
I tried to keep the dependencies as small as possible (just delete @Serializable annotations if don't want to get kotlinx serialization installed, but left for the full example). Also I skipped over my fitting algorithms, because they are very basic at this point and don't use them in the game yet for the most part, but try it out still and would love to know from you how to do better!
Play it here on Android: