DEV Community is a community of 695,394 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Creating a retro-style game with Jetpack Compose: movement

Thomas Künneth
Developer. Speaker. Listener. Loves writing. GDE Android. Confessing mobile computing addict ;-)

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

Source