Creating a retro-style game with Jetpack Compose: level completed
Tags: kotlin, jetpackcompose, gamedev
Published: 2021-07-08 17:03:34

Welcome to the final part of *Creating a retro-style game with Jetpack Compose*. In this instalment we will wrap things up by learning how to reset the game, complete a level, and to create enemies that chase our hero.

### Resetting and restarting the game

A lot of the game mechanics in *Compose Dash* relies on state. As this is an intentionally incomplete prototype, I have not included a view model (which would usually store the *domain data*). This is an omission you should certainly not copy. But it makes my code smaller, therefore easier to follow. So, what domain data is there? The aim of the game is to complete a level by collecting all gems, and not being hit by falling gems or rocks. So we have:

- the number of gems to be collected
- the number of gems that already have been collected
- the number of lives still available
- the level description

The total amount of lives after a reset is a constant, so I do not consider it domain data. Now, please take a look at the following code fragment.

```kotlin
fun ComposeDash() {
  key = remember { mutableStateOf(0L) }
  levelData = remember(key.value) {
    createLevelData()
  }
  enemies = remember(key.value) { createEnemies(levelData) }
  val gemsTotal = remember(key.value) { Collections.frequency(levelData, CHAR_GEM) }
  val gemsCollected = remember(key.value) { mutableStateOf(0) }
  // Must be reset explicitly
  val lastLives = remember { mutableStateOf(NUMBER_OF_LIVES) }
  lives = remember { mutableStateOf(NUMBER_OF_LIVES) }
  Box {
    LazyVerticalGrid(
```

Some state variables have become global, here is how they look:

```kotlin
lateinit var enemies: SnapshotStateList<Enemy>
lateinit var levelData: SnapshotStateList<Char>
lateinit var lives: MutableState<Int>
lateinit var key: MutableState<Long>
```

You will see later why I have done this. `ComposeDash()` is the relevant root composable for the game play, so I `remember` all game-relevant states here. Please recall that whenever a remembered state changes, a recomposition will take place. The initial value is usually computed once. Both `lastLives` and `lives` are set to `NUMBER_OF_LIVES`. If I change the value, for example like in the following code snippet, there is no way for `ComposeDash()` to know what the initial value has been.

```kotlin
private suspend fun freeFall(
  levelData: SnapshotStateList<Char>,
  current: Int,
  what: Char,
  lives: MutableState<Int>
) {
  lifecycleScope.launch {
    delay(800)
    if (levelData[current] == what) {
      freeFall(levelData, current - COLUMNS, what, lives)
      val x = current % COLUMNS
      var y = current / COLUMNS + 1
      var pos = current
      var playerHit = false
      while (y < ROWS) {
        val newPos = y * COLUMNS + x
        when (levelData[newPos]) {
          CHAR_BRICK, CHAR_ROCK, CHAR_GEM -> {
            break
          }
          CHAR_PLAYER -> {
            if (!playerHit) {
              playerHit = true
              lives.value -= 1
            }
          }
        }
        levelData[pos] = CHAR_BLANK
        levelData[newPos] = what
        y += 1
        pos = newPos
        delay(200)
      }
    }
  }
}
```

If I need to set `lives` to its initial value, I must know what this has been. I'll show you shortly when and where this is done. But first, let's take a closer look to some other states.

```kotlin
key = remember { mutableStateOf(0L) }
levelData = remember(key.value) {
  createLevelData()
}
```

`levelData` holds the level data and reflects all changes since the initial creation. For example,

- if gems have already been collected, they are no longer there
- if rocks or gems have been falling down, they are at new locations
- if the player has been moved, it is at a new location

Please recall that this is precisely why I use `SnapshotStateList<Char>` to hold the level data. I want any change to cause a recomposition. 

But why do I pass a key to `remember`? There are situations in the game when you want to reset (almost) all states to their initial values. This, for example, is the case if the user has made a bad move, thus the player is hit by a rock or gem. The number of lives is decremented, but the number of collected gems must be set to zero, and the level data must be brought to its initial state, too. While you could do this in a function, there is a much more convenient way: if you pass a key to `remember` the value is recalculated when that key changes. Take a look:

```kotlin
@Composable
fun NextTry(
  key: MutableState<Long>,
  lives: MutableState<Int>,
  lastLives: MutableState<Int>
) {
  val canTryAgain = lives.value > 0
  Box(
    modifier = Modifier
      .fillMaxSize()
      .background(color = Color(0xa0000000))
      .clickable {
        if (canTryAgain)
          lastLives.value = lives.value
        else {
          lives.value = NUMBER_OF_LIVES
          lastLives.value = NUMBER_OF_LIVES
        }
        key.value += 1
      }
  ) {
    Text(
      "I am sorry!\n${if (canTryAgain) "Try again" else "You lost"}",
      style = TextStyle(fontSize = 48.sp, textAlign = TextAlign.Center),
      color = Color.White,
      modifier = Modifier
        .align(Alignment.Center)
    )
  }
}
```

So, a simple `key.value += 1` leads to all remembered values that are bound to `key` will have their values recomputed. Please note that using a true counter here is not necessary, even a `Boolean` would have sufficed. I just wanted to show that you can even measure the number of changes, if you need to. In the code snippet above you can also see why `lives` and `lastLives` have no key. The logic when to set their values to the initial one just differs. Here's another example:

```kotlin
@Composable
fun RestartButton(
  key: MutableState<Long>, scope: BoxScope,
  lives: MutableState<Int>,
  lastLives: MutableState<Int>
) {
  scope.run {
    Text(
      POWER.unicodeToString(),
      style = TextStyle(fontSize = 32.sp),
      color = Color.White,
      modifier = Modifier
        .align(Alignment.BottomStart)
        .clickable {
          lives.value = NUMBER_OF_LIVES
          lastLives.value = NUMBER_OF_LIVES
          key.value += 1
        }
    )
  }
}
```

We have now explored almost all relevant parts of *Compose Dash*. To conclude this series, I would like to share one more thing: how to create enemies that make life of the player a little harder.

### Enemies

First, I slightly altered the level map.

```kotlin
val level = """
    ########################################
    #...............................X......#
    #.......OO.......OOOOOO................#
    #.......OO........OOOOOO...............#
    #.......XXXX!.......!X.................#
    #......................................#
    #.........................##############
    #.........OO...........................#
    #.........XXX..........................#
    ##################.....................#
    #......................XXXXXX..........#
    #       OOOOOOO........................#
    #      !.X......................@......#
    ########################################
    """.trimIndent()
```

and

```kotlin
const val SPIDER = 0x1F577
const val CHAR_SPIDER = '!'
```

so `!` becomes 🕷

Now, quite a few lines of code. Please recall that I defined `enemies` likes this: `lateinit var enemies: SnapshotStateList<Enemy>`. Each spider is represented by an instance of a data class which holds its current location (`index`) and the direction it is traveling. `moveEnemies()` take care of the movement.

```kotlin
data class Enemy(var index: Int) {
  var dirX = 0
  var dirY = 0
}

suspend fun moveEnemies() {
  delay(200)
  var playerHit = false
  if (::enemies.isInitialized) {
    val indexPlayer = levelData.indexOf(CHAR_PLAYER)
    val colPlayer = indexPlayer % COLUMNS
    val rowPlayer = indexPlayer / COLUMNS
    enemies.forEach {
      if (!playerHit) {
        val current = it.index
        val row = current / COLUMNS
        val col = current % COLUMNS
        var newPos = current
        if (col != colPlayer) {
          if (it.dirX == 0)
            it.dirX = if (col >= colPlayer) -1 else 1
          newPos += it.dirX
          val newCol = newPos % COLUMNS
          if (newCol < 0 || newCol >= COLUMNS || levelData[newPos] != CHAR_BLANK) {
            if (isPlayer(levelData, newPos)) {
              playerHit = true
            }
            newPos = current
            it.dirX = -it.dirX
          }
        }
        if (row != rowPlayer) {
          val temp = newPos
          if (it.dirY == 0)
            it.dirY = if (row >= rowPlayer) -COLUMNS else COLUMNS
          newPos += it.dirY
          val newRow = newPos / COLUMNS
          if (newRow < 0 || newRow >= ROWS || levelData[newPos] != CHAR_BLANK) {
            if (isPlayer(levelData, newPos)) {
              playerHit = true
            }
            newPos = temp
            it.dirY = 0
          }
        }
        if (newPos != it.index) {
          levelData[newPos] = CHAR_SPIDER
          levelData[it.index] = CHAR_BLANK
          it.index = newPos
        }
      }
    }
  }
  if (playerHit) {
    lives.value -= 1
    key.value += 1
  }
}

fun isPlayer(levelData: SnapshotStateList<Char>, index: Int) = levelData[index] == CHAR_PLAYER
```

The movement of the spider is pretty simplistic. I take a look at where the player is and let it move in that direction until an obstacle is met. If so, the direction is reversed.

Here is how I populate the list. This is done in `ComposeDash()` (after `levelData` has been filled).

```kotlin
enemies = remember(key.value) { createEnemies(levelData) }
```

Finally, here's how the asynchronous movement is triggered:

```kotlin
override fun onCreate(savedInstanceState: Bundle?) {
  super.onCreate(savedInstanceState)
  setContent {
    ComposeDashTheme {
      Surface(color = Color.Black) {
        ComposeDash()
      }
    }
  }
  lifecycleScope.launch {
    while (isActive) moveEnemies()
  }
}
```

The movement is not bound to a composable. The coroutine runs as long as the activity is running. This is fine because as long as there are no enemies, nothing happens. And the list is filled after the level data has been constructed.

### Conclusion

As you have seen, it is really easy and fun to do simple games using Jetpack Compose. I hope I have spawned your interest to try it for yourself. I am more than curious to see what games you will be implementing. 

This prototype still has a few loose endings:

- If you click during movement of the player it will be cloned
- Spiders cannot be hurt by rocks and gems

I will eventually be fixing this but I would love to see your pull requests. You can find the source on [GitHub](https://github.com/tkuenneth/compose_dash). Also, I would love to hear your thoughts. Please share them in the comments.