Creating a retro-style game with Jetpack Compose: movement

Welcome to the second part of Creating a retro-style game with Jetpack Compose. In this installment we will take a look at how to move players, enemies, and other objects.

Moving around

To understand how this works, please recall that this series uses Jetpack Compose to emulate the text or pseudo graphics mode of home computers. The screen is divided in a grid, often 40 by 25 cells. Each cell displays one character (a byte). In text mode, the look of the character is defined by the character set in use. In pseudo graphics mode each character is represented by a small bitmap, usually 8 by 8 pixels.

We focus on text mode. The game screen of Compose Dash consists of 40 columns and 14 rows. So we can address 560 cells. This can be done in two ways:

  • By index: the cells are numbered from 0 to 559. The first row contains cells 0 to 39. The second 40 to 79. And so on.
  • By location: Here we specify the row (or y) and column (x)

To convert between these two representations is really simple. If we have a location consisting of x and y we get the index through y * 40 + x. The other way round is equally easy: x = index % 40 and y = index / 40.

Now, what does this mean regarding movement? Generally speaking, depending on the direction x, y, or both change. To move upward (north) y is decremented by 1. To go south, we increment y by 1. The same applies to x regarding west (-1) and east (+1). If we want to implement movement using the index we add or subtract 40 to go north or south. To go west or east we subtract or add 1. Let's see how I make use of this in Compose Dash. Please recall that currently each cell is represented through a Text().

Text(
  modifier = Modifier
    .background(background)
    .clickable {
      movePlayerTo(levelData, index, gemsCollected)
    },
  text = symbol.unicodeToString()
)

When a cell is clicked movePlayer() is invoked. It receives the index of the location to move to. Please recall that I have defined the game screen like this:

private fun createLevelData(): SnapshotStateList<Char> {
  val data = mutableStateListOf<Char>()
  var rows = 0
  level.split("\n").forEach {
    if (it.length != COLUMNS)
      throw RuntimeException("length of row $rows is not $COLUMNS")
    data.addAll(it.toList())
    rows += 1
  }
  if (rows != ROWS)
    throw RuntimeException("number of rows is not $ROWS")
  return data
}

We parse a multiline string that contains the level data and convert it to a SnapshotStateList<Char>. This list is used inside LazyVerticalGrid() and passed to itemsIndexed(). Now let's take a look at movePlayer(). Please remember, it is passed an index which represents the new location.

private fun movePlayerTo(
  levelData: SnapshotStateList<Char>,
  desti: Int,
  gemsCollected: MutableState<Int>
) {
  val start = levelData.indexOf(CHAR_PLAYER)
  if (start == desti) return
  val startX = start % COLUMNS
  val startY = start / COLUMNS
  val destiX = desti % COLUMNS
  val destiY = desti / COLUMNS
  val dirX = if (destiX > startX) 1 else -1
  val dirY = if (destiY > startY) 1 else -1
  var current = start
  lifecycleScope.launch {
    var x = startX
    var y = startY
    while (current != -1 && y != destiY) {
      current = walk(levelData, current, x, y, gemsCollected)
      y += dirY
    }
    while (current != -1 && current != desti) {
      current = walk(levelData, current, x, y, gemsCollected)
      x += dirX
    }
  }
}

To calculate the direction it is not enough to know the new location. We also require the current one. I do it like this: val start = levelData.indexOf(CHAR_PLAYER) (a rather simplistic approach, by the way). As there is always exactly one player on the game screen I can search for it in the levelData list. Another (and possibly faster) way would be to just store it in some variable. Now, let's look at movement.

if (start == desti) return means: if there has been no movement we don't have anything to do.

val dirX = if (destiX > startX) 1 else -1
val dirY = if (destiY > startY) 1 else -1

By using index we move through simple arithmetics. The two lines above make this particularly convenient as we need not bother if we want to add or subtract. We can always add dirX or dirY because if we go upward or left, the value is -1. Adding -1 is the same as subtracting 1.

Detecting (and reacting to) collisions

Compose Dash splits movement into two steps. First we walk along the y axis (vertically), then along the x axis (horizontally). Movement takes place until we reach the desired location on the corresponding axis or the index (stored in current) is -1.

var x = startX
var y = startY
while (current != -1 && y != destiY) {
  current = walk(levelData, current, x, y, gemsCollected)
  y += dirY
}
while (current != -1 && current != desti) {
  current = walk(levelData, current, x, y, gemsCollected)
  x += dirX
}

So, movePlayerTo() orchestrates the movement of the player. Inside a lifecycleScope.launch { a function named walk() is invoked repeatedly.

But why do I need a coroutine here? Well, movement should take some time. As movePlayerTo() has been called from a composable it must return as soon as it can. That's why the actual movement must take place asynchronously. And, as you shall see shortly, moving around may well trigger other concurrent tasks. But before that, let's see what walk()does.

private suspend fun walk(
  levelData: SnapshotStateList<Char>,
  current: Int,
  x: Int,
  y: Int,
  gemsCollected: MutableState<Int>
): Int {
  val newPos = (y * COLUMNS) + x
  when (levelData[newPos]) {
    CHAR_GEM -> {
      gemsCollected.value += 1
    }
    CHAR_ROCK, CHAR_BRICK -> {
      return -1
    }
  }
  levelData[current] = ' '
  levelData[newPos] = CHAR_PLAYER
  delay(200)
  if (current != -1) {
    freeFall(levelData, current - COLUMNS, CHAR_ROCK)
    freeFall(levelData, current - COLUMNS, CHAR_GEM)
  }
  return newPos
}

The main idea of this function is to check the value in levelData at the current position and act accordingly. For example, detecting a collision with a rock or brick is as simple as

CHAR_ROCK, CHAR_BRICK -> {
 return -1
}

To control the duration of the movement, delay(200) makes sure that the function does not return too early.

Objects and enemies

If the new location is not -1 (the player has moved in some direction) I invoke yet another function called freeFall(). It let's rocks or gems fall down. Have you noticed that I pass current - COLUMNS? This means check what's above the player. So we trigger the movement of objects that are directly above the current location.

private suspend fun freeFall(
  levelData: SnapshotStateList<Char>,
  current: Int,
  what: Char
) {
  if (levelData[current] == what) {
    lifecycleScope.launch {
      delay(200)
      freeFall(levelData, current - COLUMNS, what)
      val x = current % COLUMNS
      var y = current / COLUMNS + 1
      var pos = current
      while (y < ROWS) {
        val newPos = y * COLUMNS + x
        when (levelData[newPos]) {
          CHAR_BRICK, CHAR_ROCK, CHAR_GEM -> {
            break
          }
        }
        levelData[pos] = ' '
        levelData[newPos] = what
        y += 1
        pos = newPos
        delay(200)
      }
    }
  }
}

Movement happens inside lifecycleScope.launch {. After a short delay freeFall() is invoked with a location above the current object. This recursion is necessary to have everything above the player fall down eventually. Besides that, the movement of the current object takes place. Here, too, we have some collision checks to determine the fate of the player, rock, or gem.

Conclusion

So far our player is not hurt by rocks (which should probably be the case ). We will turn our attention to this in the next episode. And we will introduce other enemies - seeing the rocks as enemies is probably far fetched given that they currently do not hurt the player. 🤣 Is there anything else you would like to see covered? Please do not hesitate to share your thoughts in the comments.

23