Blog Infos
Author
Published
Topics
Published

In this post we will build a small clone of the classic snake game using Jetpack Compose. We will focus on the building the game loop and manage the state of the game and draw the basic shapes on the screen. We will not be managing the menus, high scores, etc… how ever these are implemented on my GitHub repo attached to the end of the post.

Possible Snake Movements

The classic snake could only move in a 4 directions so we will create an object class that will have the different directions the snake could move likewise.

object SnakeDirection {
const val Right = 1
const val Left = 2
const val Up = 3
const val Down = 4
}
State

Let’s start by create the main data class that will hold the state of the game.

data class State(
val food: Pair<Int, Int>,
val snake: List<Pair<Int, Int>>,
val currentDirection: Int
)
view raw State.kt hosted with ❤ by GitHub
  • food — will hold the x and y coordinates of the food.
  • snake — will hold a list of the x and y coordinates of all the snake body parts
  • currentDirection — will hold the direction in which the snake is moving.
Game Engine

Next we will create our game engine.

Let’s start by creating a class called GameEngine and declare a mutableState that will hold the State object we created in the previous step as that is the single source of truth. Which would then be managed using the flow API.

Class GameEngine{
private val mutableState =
MutableStateFlow(
State(
food = Pair(5, 5),
snake = listOf(Pair(7, 7)),
currentDirection = SnakeDirection.Right
)
)
val state: Flow<State> = mutableState
}
view raw GameEngine.kt hosted with ❤ by GitHub

Next let’s define another variable to keep the direction of the snakes movement and we would require a CoroutineScope to make this movement endless.

private val currentDirection = mutableStateOf(SnakeDirection.Right)
var move = Pair(1, 0)
set(value) {
scope.launch {
mutex.withLock {
field = value
}
}
}
view raw GameEngine.kt hosted with ❤ by GitHub

Now when our engine is created we want to immediately start the game play. Let’s use the init method the make sure the game starts immediately and play for us to be able to play the game endlessly we would need CoroutineScope to avoid clogging the main thread. We will also create two lambda functions what would act like listeners to relay back whether the game has ended or the food has been eaten.

class GameEngine(
private val scope: CoroutineScope,
private val onGameEnded: () -> Unit,
private val onFoodEaten: () -> Unit
) {
init {
scope.launch {
}
}
view raw GameEngine.kt hosted with ❤ by GitHub

Inside our scope is where the real magic happens. We will be maintaining the size of the snake, generating new location for the food and checking for collision with the edges or with itself. The code would look like this.

var snakeLength = 2
while (true) {
delay(150)
mutableState.update {
}
}
view raw GameEngine.kt hosted with ❤ by GitHub

The snakeLength variable is responsible for maintaining the length of the snake. We run a while loop with a delay because we want the code in the loop to keep repeating itself after a small delay which is 150 in this case.

Now since our source of truth is the mutableState. We just need to update the State values and we are good to go. We do this by calling mutableState.update{} we will calculate the value of the new food location and check if the game has ended or not.

We first check if the snake has reached the end of the screen. We do this by getting the location of the snake head by calling it.snake.first() and then checking if the direction is still left. We do the same for all for all the four sides. If any of the 4 conditions come back as true we call the onGameEnded function and reset the size of the snake.

val hasReachedLeftEnd =
it.snake.first().first == 0 && it.currentDirection == SnakeDirection.Left
val hasReachedTopEnd =
it.snake.first().second == 0 && it.currentDirection == SnakeDirection.Up
val hasReachedRightEnd =
it.snake.first().first == BOARD_SIZE - 1 && it.currentDirection == SnakeDirection.Right
val hasReachedBottomEnd =
it.snake.first().second == BOARD_SIZE - 1 && it.currentDirection == SnakeDirection.Down
if (hasReachedLeftEnd || hasReachedTopEnd || hasReachedRightEnd || hasReachedBottomEnd) {
snakeLength = 2
onGameEnded.invoke()
}
view raw GameEngine.kt hosted with ❤ by GitHub

Next let’s check the direction the snake is heading in. We do this with the below code.

if (move.first == 0 && move.second == -1) {
currentDirection.value = SnakeDirection.Up
} else if (move.first == -1 && move.second == 0) {
currentDirection.value = SnakeDirection.Left
} else if (move.first == 1 && move.second == 0) {
currentDirection.value = SnakeDirection.Right
} else if (move.first == 0 && move.second == 1) {
currentDirection.value = SnakeDirection.Down
}
view raw GameEngine.kt hosted with ❤ by GitHub

Then we calculate the newPosition of the snake. We can simply do this by calculating the position of the head and adding block size to it.

val newPosition = it.snake.first().let { poz ->
mutex.withLock {
Pair(
(poz.first + move.first + BOARD_SIZE) % BOARD_SIZE,
(poz.second + move.second + BOARD_SIZE) % BOARD_SIZE
)
}
}
view raw GameEngine.kt hosted with ❤ by GitHub

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

We then need to check if the if the newPosition contains the snake or food. If it contains the food we need to invoke onFoodEaten and increase the size of the snake. If the newPosition contains the snake then we call onGameEnded and reset the snake size.

Lastly we update the game state by calling the below and updating the new values so our engine has up to date values to work with.

it.copy(
food = if (newPosition == it.food) Pair(
Random().nextInt(BOARD_SIZE),
Random().nextInt(BOARD_SIZE)
) else it.food,
snake = listOf(newPosition) + it.snake.take(snakeLength - 1),
currentDirection = currentDirection.value,
)
view raw GameEngine.kt hosted with ❤ by GitHub

Next let’s create a new method to reset the game engine when something wrong happens or when we need to restart the game. We can do this likewise.

fun reset() {
mutableState.update {
it.copy(
food = Pair(5, 5),
snake = listOf(Pair(7, 7)),
currentDirection = SnakeDirection.Right
)
}
currentDirection.value = SnakeDirection.Right
move = Pair(1, 0)
}
view raw GameEngine.kt hosted with ❤ by GitHub

Now that our game engine is ready. Let’s build the UI for our Game Engine to work with.

D-PAD

Let’s create a new composable that will simply hold the four directional key and will trigger and event based of the key pressed.

@Composable
fun Controller(onDirectionChange: (Int) -> Unit) {
val buttonSize = Modifier.size(size64dp)
val currentDirection = remember { mutableStateOf(SnakeDirection.Right) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(padding24dp)
) {
AppIconButton(icon = Icons.Default.KeyboardArrowUp) {
if (currentDirection.value != SnakeDirection.Down) {
onDirectionChange.invoke(SnakeDirection.Up)
currentDirection.value = SnakeDirection.Up
}
}
Row {
AppIconButton(icon = Icons.Default.KeyboardArrowLeft) {
if (currentDirection.value != SnakeDirection.Right) {
onDirectionChange.invoke(SnakeDirection.Left)
currentDirection.value = SnakeDirection.Left
}
}
Spacer(modifier = buttonSize)
AppIconButton(icon = Icons.Default.KeyboardArrowRight) {
if (currentDirection.value != SnakeDirection.Left) {
onDirectionChange.invoke(SnakeDirection.Right)
currentDirection.value = SnakeDirection.Right
}
}
}
AppIconButton(icon = Icons.Default.KeyboardArrowDown) {
if (currentDirection.value != SnakeDirection.Up) {
onDirectionChange.invoke(SnakeDirection.Down)
currentDirection.value = SnakeDirection.Down
}
}
}
}
view raw DPad.kt hosted with ❤ by GitHub

AppIconButton is just a simply Composable I created to avoid the boilerplate code for the asthetics of the game.

@Composable
fun AppIconButton(modifier: Modifier = Modifier, icon: ImageVector, onClick: () -> Unit) {
IconButton(
onClick = onClick,
modifier = modifier
.size(size64dp)
.background(
color = MaterialTheme.colorScheme.onBackground,
shape = RoundedCornerShape(corner4dp)
),
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
Board

Now let’s the create the board that will draw the edges, food and the snake.

@Composable
fun Board(state: State) {
BoxWithConstraints(Modifier.padding(padding16dp)) {
val tileSize = maxWidth / GameEngine.BOARD_SIZE
}
}
view raw Board.kt hosted with ❤ by GitHub

The board will the game state as a parameter to draw the up to date state of the game. Let’s draw the edges first

Box(Modifier
.size(maxWidth)
.border(border2dp, DarkGreen))
view raw Edges.kt hosted with ❤ by GitHub

Now let’s the draw the food for the snake.

Box(Modifier
.offset(x = tileSize * state.food.first, y = tileSize * state.food.second)
.size(tileSize)
.background(DarkGreen, CircleShape))
view raw Food.kt hosted with ❤ by GitHub

Lastly let’s draw the snake body. We will loop through the snake and draw each block.

state.snake.forEach {
Box(modifier = Modifier
.offset(x = tileSize * it.first, y = tileSize * it.second)
.size(tileSize)
.background(DarkGreen, RoundedCornerShape(corner4dp)))
}
view raw Snake.kt hosted with ❤ by GitHub

Now that we have the Board, Controller and the GameEngine. let’s put them all together in a single activity to make them work efficiently.

The Game Activity

Let’s start by declaring a few variables in out activity.

private lateinit var scope: CoroutineScope // Needed by our game engine
// Our instance of the game engine
private var gameEngine = GameEngine(
scope = lifecycleScope,
onGameEnded = {
//TODO show Endgame screen
},
onFoodEaten = {
//TODO Increment game srore
}
)
view raw GameActivity.kt hosted with ❤ by GitHub

Now in the onCreate we will render the UI of the game and assign the game engine to the board and update the gameEngine when someone clicks on the dpad.

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
SnakeTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) { SnakeGame() }
}
}
}
@Composable
override fun SnakeGame() {
scope = rememberCoroutineScope()
val state = gameEngine.state.collectAsState(initial = null)
Column {
state.value?.let { Board(it) }
Controller {
when (it) {
SnakeDirection.Up -> gameEngine.move = Pair(0, -1)
SnakeDirection.Left -> gameEngine.move = Pair(-1, 0)
SnakeDirection.Right -> gameEngine.move = Pair(1, 0)
SnakeDirection.Down -> gameEngine.move = Pair(0, 1)
}
}
}
}
view raw GameActivity.kt hosted with ❤ by GitHub

And there you have it a fully functional classic snake game using Jetpack compose. You can check the complete game with menu and high score from the repo below.

Feel free to drop a clap or comment or reach out to me on LinkedInTwitterWebsite.

This article was originally published on proandroiddev.com on May 27, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu