Creating a retro-style game with Jetpack Compose: level completed

tkuenneth

Thomas Künneth

Posted on July 8, 2021

Creating a retro-style game with Jetpack Compose: level completed

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.

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(
Enter fullscreen mode Exit fullscreen mode

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

lateinit var enemies: SnapshotStateList<Enemy>
lateinit var levelData: SnapshotStateList<Char>
lateinit var lives: MutableState<Int>
lateinit var key: MutableState<Long>
Enter fullscreen mode Exit fullscreen mode

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.

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)
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

key = remember { mutableStateOf(0L) }
levelData = remember(key.value) {
  createLevelData()
}
Enter fullscreen mode Exit fullscreen mode

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:

@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)
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

@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
        }
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

val level = """
    ########################################
    #...............................X......#
    #.......OO.......OOOOOO................#
    #.......OO........OOOOOO...............#
    #.......XXXX!.......!X.................#
    #......................................#
    #.........................##############
    #.........OO...........................#
    #.........XXX..........................#
    ##################.....................#
    #......................XXXXXX..........#
    #       OOOOOOO........................#
    #      !.X......................@......#
    ########################################
    """.trimIndent()
Enter fullscreen mode Exit fullscreen mode

and

const val SPIDER = 0x1F577
const val CHAR_SPIDER = '!'
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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).

enemies = remember(key.value) { createEnemies(levelData) }
Enter fullscreen mode Exit fullscreen mode

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

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

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. Also, I would love to hear your thoughts. Please share them in the comments.

💖 💪 🙅 🚩
tkuenneth
Thomas Künneth

Posted on July 8, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related