π§© What is Tetris?
π₯ Why is Tetris So Popular?
π️ The past History of Tetris
π Global Boom: How Tetris Took Over the World
Build a Screen Recorder Android App using Jetpack Compose
π± Tetris in the Modern Age (1990s to 2020s)
Tetris has been reinvented across every era:
- 1990s–2000s: Versions on NES, Sega, Game Boy Advance, and PCs. Multiplayer and 3D Tetris arrived.
- Mobile Era: Played on Nokia brick phones. Later, smartphones brought touch controls and vibrant redesigns.
- Online & AI Era: Games like Tetris 99 introduced battle royale mechanics.
π ️ How to Build a Tetris Game in Jetpack Compose
Let’s break down the process.
- How to render a grid
- How to move and rotate Tetris blocks (Tetrominoes)
- How to manage score and game-over
- How to build a real-time game loop using coroutines
- Bonus: Add repeatable touch input and restart button
π Step 1: Define the Cell and Tetromino Classes
Cell.kt
package com.example.tetrafall
data class Cell(
var isFilled: Boolean = false,
var color: Int = 0xFF000000.toInt() // Default: black
)
Tetromino.kt
package com.example.tetrafall
enum class TetrominoType { I, O, T, S, Z, J, L }
data class Tetromino(
val type: TetrominoType,
val color: Int,
val shape: List>
)
fun getTetromino(type: TetrominoType): Tetromino {
return when (type) {
TetrominoType.I -> Tetromino(type, 0xFF00BCD4.toInt(), listOf(0 to 0, 1 to 0, 2 to 0, 3 to 0))
TetrominoType.O -> Tetromino(type, 0xFFFFEB3B.toInt(), listOf(0 to 0, 1 to 0, 0 to 1, 1 to 1))
TetrominoType.T -> Tetromino(type, 0xFFE91E63.toInt(), listOf(1 to 0, 0 to 1, 1 to 1, 2 to 1))
TetrominoType.S -> Tetromino(type, 0xFF4CAF50.toInt(), listOf(1 to 0, 2 to 0, 0 to 1, 1 to 1))
TetrominoType.Z -> Tetromino(type, 0xFFF44336.toInt(), listOf(0 to 0, 1 to 0, 1 to 1, 2 to 1))
TetrominoType.J -> Tetromino(type, 0xFF3F51B5.toInt(), listOf(0 to 0, 0 to 1, 1 to 1, 2 to 1))
TetrominoType.L -> Tetromino(type, 0xFFFF9800.toInt(), listOf(2 to 0, 0 to 1, 1 to 1, 2 to 1))
}
}
Step 2: Create the GameViewModel
GameViewModel.kt
package com.example.tetrafall
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class GameViewModel : ViewModel() {
val rows = 20
val cols = 10
val grid = List(rows) { MutableList(cols) { Cell() } }
var currentShape by mutableStateOf(getRandomShape())
var shapePositionX by mutableIntStateOf(3)
var shapePositionY by mutableIntStateOf(0)
var score by mutableIntStateOf(0)
private val _isPaused = MutableStateFlow(false)
val isPaused: StateFlow = _isPaused
private var gameOver by mutableStateOf(false)
init {
startGameLoop()
}
private fun startGameLoop() {
viewModelScope.launch {
while (true) {
delay(600)
if (!isPaused.value && !gameOver) {
moveDown()
}
}
}
}
fun togglePause() {
_isPaused.value = !_isPaused.value
}
fun moveDown() {
if (canMove(0, 1)) {
shapePositionY++
} else {
placeShape()
clearFilledRows()
spawnNewShape()
// ✅ Now check game over by scanning top rows
if (isGameOver()) {
gameOver = true
}
}
}
fun moveLeft() {
if (canMove(-1, 0)) shapePositionX--
}
fun moveRight() {
if (canMove(1, 0)) shapePositionX++
}
fun rotateCurrentShape() {
val rotated = rotateShape(currentShape.shape)
if (canMove(0, 0, rotated)) {
currentShape = currentShape.copy(shape = rotated)
}
}
fun instantDrop() {
while (canMove(0, 1)) shapePositionY++
moveDown()
}
private fun canMove(
dx: Int,
dy: Int,
shape: List> = currentShape.shape
): Boolean {
return shape.all { (x, y) ->
val newX = shapePositionX + x + dx
val newY = shapePositionY + y + dy
newX in 0 until cols && newY in 0 until rows && !grid[newY][newX].isFilled
}
}
private fun placeShape() {
currentShape.shape.forEach { (x, y) ->
val gx = shapePositionX + x
val gy = shapePositionY + y
if (gx in 0 until cols && gy in 0 until rows) {
grid[gy][gx] = Cell(true, currentShape.color)
}
}
}
private fun clearFilledRows() {
val newGrid = mutableListOf>()
// Step 1: Keep only rows that are NOT completely filled
for (row in grid) {
if (!row.all { it.isFilled }) {
newGrid.add(row.toMutableList())
}
}
// Step 2: Calculate how many rows were cleared
val clearedRows = rows - newGrid.size
// Step 3: Add new empty rows at the top
repeat(clearedRows) {
newGrid.add(0, MutableList(cols) { Cell() })
}
// Step 4: Copy newGrid back to the actual grid
for (y in 0 until rows) {
for (x in 0 until cols) {
grid[y][x] = newGrid[y][x]
}
}
// Step 5: Update score
score += clearedRows * 100
}
fun isGameOver(): Boolean {
return currentShape.shape.any { (x, y) ->
val gx = 3 + x // the spawn position X
val gy = 0 + y // the spawn position Y
gx !in 0 until cols || gy !in 0 until rows || grid[gy][gx].isFilled
}
}
private fun spawnNewShape() {
currentShape = getRandomShape()
shapePositionX = 3
shapePositionY = 0
}
fun resetGame() {
for (y in 0 until rows) {
for (x in 0 until cols) {
grid[y][x] = Cell()
}
}
score = 0
gameOver = false
spawnNewShape()
}
private fun getRandomShape(): Tetromino {
val types = TetrominoType.entries.toTypedArray()
return getTetromino(types.random())
}
} Shape Rotation Logic
package com.example.tetrafall
fun rotateShape(shape: List>): List> {
val rotated = shape.map { (x, y) -> -y to x }
val minX = rotated.minOf { it.first }
val minY = rotated.minOf { it.second }
return rotated.map { (x, y) -> (x - minX) to (y - minY) }
}
Step 3: Build the Tetris UITetrisScreen.kt
package com.example.tetrafall
import androidx.compose.foundation.background
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.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun TetrisScreen(viewModel: GameViewModel = remember { GameViewModel() }) {
val grid = viewModel.grid
val shape = viewModel.currentShape
val offsetX = viewModel.shapePositionX
val offsetY = viewModel.shapePositionY
val score = viewModel.score
val isGameOver = viewModel.isGameOver()
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Tetris", style = MaterialTheme.typography.headlineLarge)
Text("Score: $score", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(12.dp))
// Game Grid
Box(modifier = Modifier.background(Color.Black)) {
Column {
for (y in 0 until viewModel.rows) {
Row {
for (x in 0 until viewModel.cols) {
val isShapeCell = shape.shape.any { (dx, dy) ->
dx + offsetX == x && dy + offsetY == y
}
val cell = grid[y][x]
val color = when {
isShapeCell -> Color(shape.color)
cell.isFilled -> Color(cell.color)
else -> Color.DarkGray
}
Box(
modifier = Modifier.size(24.dp).padding(1.dp).background(color)
)
}
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
MovementControls(viewModel)
Row {
Button(onClick = { viewModel.rotateCurrentShape() }) { Text("⟳ Rotate") }
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { viewModel.instantDrop() }) { Text("Drop ⬇") }
}
}
Spacer(modifier = Modifier.height(16.dp))
if (isGameOver) {
RewardScreen(
score = viewModel.score,
// highScore = viewModel.highScore,
onPlayAgain = {
// viewModel.scoreSaved = false
viewModel.resetGame()
},
modifier = Modifier
.fillMaxSize()
.background(Color.White.copy(alpha = 0.95f)) // optional dimming
)
}
}
Step 4: Add Controls with Repeat Touch
package com.example.tetrafall
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.delay
@Composable
fun MovementControls(viewModel: GameViewModel) {
val isPaused by viewModel.isPaused.collectAsState() // ✅ Collect the StateFlow
Row {
RepeatPressButton("←") { viewModel.moveLeft() }
Spacer(modifier = Modifier.width(8.dp))
Text((if (isPaused) "▶️" else "⏸️") ,fontSize = 34.sp,
modifier = Modifier.clickable { viewModel.togglePause() })
Spacer(modifier = Modifier.width(8.dp))
RepeatPressButton("→") { viewModel.moveRight() }
}
}
@Composable
fun RepeatPressButton(text: String, onRepeat: () -> Unit) {
val isPressed = remember { mutableStateOf(false) }
LaunchedEffect(isPressed.value) {
if (isPressed.value) {
while (isPressed.value) {
onRepeat()
delay(100)
}
}
}
Box(
modifier = Modifier
.background(Color.Gray)
.padding(horizontal = 16.dp, vertical = 8.dp)
.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed.value = true
tryAwaitRelease()
isPressed.value = false
}
)
}
) {
Text(text = text, color = Color.White)
}
}
Step 5: MaiinActiivityMainActivity.kt
package com.example.tetrafall
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.example.tetrafall.ui.theme.TetraFallTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TetraFallTheme {
/*Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Greeting(
name = "Android",
modifier = Modifier.padding(innerPadding)
)
}*/
TetrisScreen()
}
}
}
}
π§ͺStep 5: RewardScreen
package com.example.tetrafall
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun RewardScreen(score: Int, onPlayAgain: () -> Unit,modifier: Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.background(Color.White),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("π Game Over!", fontSize = 30.sp, fontWeight = FontWeight.Bold)
Text("Your Score: $score", fontSize = 24.sp)
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = onPlayAgain) {
Text("Play Again")
}
}
}
Output:
✅ Step-by-Step: Add a "Next Block" Preview
var nextShape by mutableStateOf(getRandomShape())
Update spawnNewShape() to use it:fun spawnNewShape() {
currentShape = nextShape
nextShape = getRandomShape()
shapePositionX = 3
shapePositionY = 0
}π§© Step 2: Add a Composable for Preview
@Composable
fun NextBlockPreview(nextShape: Tetromino) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Next", style = MaterialTheme.typography.bodyLarge)
Box(
modifier = Modifier
.size(100.dp)
.background(Color.LightGray)
.padding(4.dp)
) {
val blockSize = 20.dp
val shape = nextShape.shape
for ((x, y) in shape) {
Box(
modifier = Modifier
.offset(x.dp * 20, y.dp * 20)
.size(blockSize)
.background(Color(nextShape.color))
.border(1.dp, Color.Black)
)
}
}
}
}πΌ️ Step 3: Place It in TetrisScreen
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text("Tetris", style = MaterialTheme.typography.headlineLarge)
Text("Score: $score", style = MaterialTheme.typography.headlineMedium)
}
NextBlockPreview(viewModel.nextShape)
}






