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 | |
) |
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 partscurrentDirection
— 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 | |
} |
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 | |
} | |
} | |
} |
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 { | |
} | |
} |
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 { | |
} | |
} |
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() | |
} |
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 | |
} |
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 | |
) | |
} | |
} |
Job Offers
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, | |
) |
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) | |
} |
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 | |
} | |
} | |
} | |
} |
AppIconButton
is just a simply Composable I created to avoid the boilerplate code for the asthetics of the game.
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 | |
} | |
} |
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)) |
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)) |
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))) | |
} |
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 | |
} | |
) |
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) | |
} | |
} | |
} | |
} |
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.
This article was originally published on proandroiddev.com on May 27, 2022