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(
    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>
    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)
          }
        }
      }
    }
    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()
    }
    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)
        )
      }
    }
    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
            }
        )
      }
    }
    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()
    and
    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.
    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).
    enemies = remember(key.value) { createEnemies(levelData) }
    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()
      }
    }
    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.

    31

    This website collects cookies to deliver better user experience

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