← All posts

Game Design with Kotlin DSL

How I used Kotlin DSL to generate and layout game levels for Word Wash. A practical guide to domain-specific languages for game development.

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…

Word Wash Game play

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…

Iterating

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
        }
    }
}

Plan of Action

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()
    }
}

Code

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!

Game

Play it here on Android:

Keep reading

All posts →

Fitting to the Form Factor

Designing Word Wash for mobile. Lessons on fitting game UI, pixel art, and controls to small screens without losing the fun.

  • Game Design
  • Game
  • Pixel Art

Digital Plains - John's New Job

A short sci-fi story set in the Digital Plains universe. John's first day on the job takes a dangerous turn underground.

  • writing
  • Digital Plains