Why I Made This Game:
Building Interactive UIs:
Applying Layouts and Design Principles:
Implementing Animations:
Time and Score Tracking:
Handling Edge Cases:
What You Can Learn from This Project:
UI and Layout Techniques:
Interactive and Dynamic Features:
Animation Basics:
Working with Time and Delays:
Game Logic and Flow:
Let's Build
In this tutorial, you'll build a fully functional memory match game in Jetpack Compose using emojis as cards. The game includes:
- Card flipping
- Match checking
- Win condition
- Time tracking
- Reset button with shuffle
π ️ Step 1: Add Required Dependencies
implementation ("androidx.datastore:datastore-preferences:1.1.7")π΅ Step 2: Add Sound Files
Place your sound files inside the res/raw/ folder:
π Step 3: Create GameDataStore.kt
package com.cdingbihar.memorygame
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
// DataStore setup
private val Context.dataStore by preferencesDataStore(name = "game_prefs")
object GameDataStore {
private val SCORE_KEY = intPreferencesKey("score")
private val TIME_KEY = intPreferencesKey("time")
suspend fun saveScore(context: Context, score: Int, time: Int) {
context.dataStore.edit { prefs ->
prefs[SCORE_KEY] = score
prefs[TIME_KEY] = time
}
}
suspend fun readScore(context: Context): Pair {
val prefs = context.dataStore.data.first()
val score = prefs[SCORE_KEY] ?: 0
val time = prefs[TIME_KEY] ?: 0
return Pair(score, time)
}
} π️ Step 4: Create SoundPlayer.kt
When the player matches all pairs, display a celebration dialog:
package com.cdingbihar.memorygame
import android.content.Context
import android.media.MediaPlayer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
@Composable
fun rememberSoundPlayer(context: Context): (Int) -> Unit {
val ctx = rememberUpdatedState(context)
return remember {
{ soundResId: Int ->
val mediaPlayer = MediaPlayer.create(ctx.value, soundResId)
mediaPlayer?.start()
mediaPlayer?.setOnCompletionListener { it.release() }
}
}
}π§ Step 5: Main Game Code (MemoryGameScreen.kt)
Use a grid layout and card styling to display all the cards:
package com.cdingbihar.memorygame
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
data class MemoryCard(
val id: Int,
val emoji: String,
var isFlipped: Boolean = false,
var isMatched: Boolean = false
)
@Composable
fun MemoryGameScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val emojis = listOf("πΆ", "π±", "π¦", "πΈ", "π΅", "π·")
val soundPlayer = rememberSoundPlayer(context)
var cards = remember { shuffleCards(emojis).toMutableStateList() }
var flippedCard by remember { mutableStateOf(null) }
var matchedPairs by remember { mutableIntStateOf(0) }
var isBusy by remember { mutableStateOf(false) }
var timeElapsed by remember { mutableIntStateOf(0) }
var gameStarted by remember { mutableStateOf(false) }
var showDialog by remember { mutableStateOf(false) }
var lastScore by remember { mutableIntStateOf(0) }
var lastTime by remember { mutableIntStateOf(0) }
// Load last saved score/time when screen starts
LaunchedEffect(Unit) {
val (s, t) = GameDataStore.readScore(context)
lastScore = s
lastTime = t
}
// Timer for elapsed time
LaunchedEffect(gameStarted) {
while (gameStarted) {
delay(1000)
timeElapsed++
}
}
val allFlipped = cards.all { it.isFlipped }
val gameWon = matchedPairs == emojis.size
// Save result and reload last score/time
if (allFlipped) {
LaunchedEffect("saveResult") {
gameStarted = false
scope.launch {
GameDataStore.saveScore(context, matchedPairs, timeElapsed)
val (s, t) = GameDataStore.readScore(context)
lastScore = s
lastTime = t
showDialog = true
}
}
}
// Show Win or Game Over dialog
if (showDialog) {
AlertDialog(
onDismissRequest = {},
title = { Text(if (gameWon) "π You Win!" else "Game Over") },
text = {
Column {
Text("Score: $matchedPairs / ${emojis.size}")
Text("Time: ${timeElapsed}s")
}
},
confirmButton = {
TextButton(onClick = {
cards.clear()
cards.addAll(shuffleCards(emojis))
flippedCard = null
matchedPairs = 0
isBusy = false
timeElapsed = 0
showDialog = false
gameStarted = false
}) {
Text("Play Again")
}
}
)
}
Column(
modifier = Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(16.dp)
) {
Text(
"πΎ Memory Match Game",
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(8.dp))
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Score: $matchedPairs / ${emojis.size}")
Text("Time: ${timeElapsed}s")
}
Spacer(Modifier.height(4.dp))
Text("Last Score: $lastScore, Last Time: ${lastTime}s")
Spacer(Modifier.height(12.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize()
) {
items(cards, key = { it.id }) { card ->
val rotation by animateFloatAsState(
targetValue = if (card.isFlipped || card.isMatched) 180f else 0f,
label = "rotation"
)
val alpha by animateFloatAsState(
targetValue = if (!card.isMatched && !card.isFlipped) 0f else 1f,
label = "alpha"
)
Box(
modifier = Modifier
.padding(8.dp)
.aspectRatio(1f)
.rotate(rotation)
.background(
color = if (card.isMatched) Color(0xFF81C784) else Color(0xFF90CAF9),
shape = MaterialTheme.shapes.medium
)
.clickable(enabled = !isBusy) {
soundPlayer(R.raw.flip)
if (card.isFlipped && !card.isMatched) {
card.isFlipped = false
if (flippedCard == card) flippedCard = null
return@clickable
}
if (!gameStarted) gameStarted = true
card.isFlipped = true
if (flippedCard == null) {
flippedCard = card
} else {
isBusy = true
if (flippedCard!!.emoji == card.emoji) {
card.isMatched = true
flippedCard!!.isMatched = true
matchedPairs++
flippedCard = null
isBusy = false
soundPlayer(R.raw.match)
} else {
soundPlayer(R.raw.nomatch)
flippedCard = null
isBusy = false
}
}
},
contentAlignment = Alignment.Center
) {
if (card.isFlipped || card.isMatched) {
Text(card.emoji, fontSize = 32.sp, modifier = Modifier.alpha(alpha))
}
}
}
}
}
}
fun shuffleCards(emojis: List): MutableList {
val all = (emojis + emojis).shuffled()
return all.mapIndexed { index, emoji -> MemoryCard(id = index, emoji = emoji) }.toMutableStateList()
} π Step 6: Setup MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MemoryGameTheme {
/* Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}
*/
MemoryGameScreen()
}
}
}π¦ Final Tip
Make sure you use the latest Jetpack Compose dependencies and call MemoryGameScreen() from your main activity's setContent.
OUTPUT:
Happy coding! π―





