How I Built My First Android Game in Jetpack Compose: Space War Adventure
When I first decided to build my own Android game, I wanted something fun, visually appealing, and completely made with Jetpack Compose. After brainstorming, I landed on a Space War theme—vibrant, fast-paced, and perfect for experimenting with animations, user interactions, and game logic. Here’s my journey building my first Android game.
Why Jetpack Compose for Game Development?
You might think Jetpack Compose is just for building apps with buttons, lists, and forms—but I wanted to challenge myself. Compose offers:
- Declarative UI: Easy to animate and update components dynamically.
- Canvas & Graphics: I could draw spaceships, bullets, and explosions directly.
- State Management: Managing score, lives, and game levels became intuitive.
Using Compose, I didn’t have to rely on traditional XML layouts or third-party engines. It felt like coding the game entirely in Kotlin, which was exciting.
Step 1: Planning the Game
- Before touching code, I sketched my ideas:
- Player Ship: Controlled by dragging or tapping on the screen.
- Enemy Ships: Appear from the top and move down.
- Bullets/Lasers: Fired automatically from the player ship.
- Power-ups & Explosions: To make the game engaging.
- Score & Lives: Track progress and add challenge.
I decided to keep the game simple at first, then enhance it later with boss fights, confetti effects, and sound.
Step 2: Designing Assets
Even for a beginner game, visuals matter. I created:
- Spaceships – Player, enemy, and boss ships with vibrant colors.
- Laser Bullets – HD, parallel beams for a cinematic feel.
- Explosions – Multiple stages of explosions for action feedback.
- Background – Starry space background with subtle animations.
All assets were made with transparent backgrounds so I could animate them freely on the Compose Canvas.
Lets Build: Spaceship, Bullets, Explosan and Background
1. Player and Enemy:
fun DrawScope.drawPlayerShip(player: Offset) {
// ✨ Glow Effect
drawCircle(
color = Color.Cyan.copy(alpha = 0.3f),
radius = 55f,
center = player
)
// π Main Body (Rocket)
drawRoundRect(
brush = Brush.verticalGradient(
colors = listOf(Color(0xFF00E5FF), Color(0xFF2979FF))
),
topLeft = Offset(player.x- 25f, player.y - 40f),
size = Size(50f, 80f),
cornerRadius = CornerRadius(25f, 25f)
)
// πΊ Top Triangle (Rocket Nose)
val path = Path().apply {
moveTo(player.x, player.y - 70f)
lineTo(player.x - 25f, player.y - 40f)
lineTo(player.x + 25f, player.y - 40f)
close()
}
drawPath(path, Color.Red)
// π Window
drawCircle(
color = Color.White,
radius = 12f,
center = Offset(player.x, player.y - 15f)
)
drawCircle(
color = Color.Blue,
radius = 7f,
center = Offset(player.x, player.y - 15f)
)
// π₯ Bottom Fire
drawCircle(
color = Color.Yellow,
radius = 12f,
center = Offset(player.x, player.y + 45f)
)
drawCircle(
color = Color.Red,
radius = 7f,
center = Offset(player.x, player.y + 50f)
)
// π» Side Wings
drawCircle(
color = Color.Red,
radius = 10f,
center = Offset(player.x - 30f, player.y + 10f)
)
drawCircle(
color = Color.Red,
radius = 10f,
center = Offset(player.x + 30f, player.y + 10f)
)
}
fun DrawScope.drawEnemy(enemy: Offset) {
// π΅ Body (Gradient Circle)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0xFFFF6F00), Color(0xFFFFC107)),
center = enemy,
radius = 40f
),
radius = 40f,
center = enemy
)
// π Left Eye
drawCircle(
color = Color.White,
radius = 10f,
center = Offset(enemy.x - 12f, enemy.y - 8f)
)
drawCircle(
color = Color.Black,
radius = 5f,
center = Offset(enemy.x - 12f, enemy.y - 8f)
)
// π Right Eye
drawCircle(
color = Color.White,
radius = 10f,
center = Offset(enemy.x + 12f, enemy.y - 8f)
)
drawCircle(
color = Color.Black,
radius = 5f,
center = Offset(enemy.x + 12f, enemy.y - 8f)
)
// π Cute Smile
drawArc(
color = Color.Black,
startAngle = 0f,
sweepAngle = 180f,
useCenter = false,
topLeft = Offset(enemy.x - 15f, enemy.y + 5f),
size = Size(30f, 20f),
style = Stroke(width = 4f)
)
// π₯ Bottom Flames
drawCircle(
color = Color.Red,
radius = 8f,
center = Offset(enemy.x - 15f, enemy.y + 40f)
)
drawCircle(
color = Color.Yellow,
radius = 8f,
center = Offset(enemy.x + 15f, enemy.y + 40f)
)
}Output:
2.Laser Bullets:
fun DrawScope.drawPlayerLaser(center: Offset) {
val beamHeight = 40f
val beamWidth = 10f
val top = center.y - beamHeight
// ✨ Outer Glow (aligned properly)
drawRoundRect(
color = Color.Red.copy(alpha = 0.25f),
topLeft = Offset(center.x - beamWidth, top - 5f),
size = Size(beamWidth * 2, beamHeight + 10f),
cornerRadius = CornerRadius(12f, 12f)
)
// π Main Beam
drawRoundRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Cyan, Color.Red)
),
topLeft = Offset(center.x - beamWidth / 2, top),
size = Size(beamWidth, beamHeight),
cornerRadius = CornerRadius(6f, 6f)
)
// π Core
drawRoundRect(
color = Color.Black,
topLeft = Offset(center.x - 2f, top + 5f),
size = Size(4f, beamHeight - 10f),
cornerRadius = CornerRadius(4f, 4f)
)
// π₯ Spark Tip
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color.White, Color.Blue, Color.Transparent),
center = Offset(center.x, top),
radius = 12f
),
radius = 12f,
center = Offset(center.x, top)
)
}
fun DrawScope.drawEnemyBullet(center: Offset) {
// ✨ Outer Glow
drawCircle(
color = Color.Magenta.copy(alpha = 0.3f),
radius = 22f,
center = center
)
// π Main Plasma Body (Gradient)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color.Yellow, Color.Magenta),
center = center,
radius = 15f
),
radius = 15f,
center = center
)
// ⚡ Bright Core
drawCircle(
color = Color.White,
radius = 6f,
center = center
)
// π₯ Motion Tail (below bullet)
drawRoundRect(
brush = Brush.verticalGradient(
colors = listOf(Color.Magenta, Color.Transparent)
),
topLeft = Offset(center.x - 4f, center.y + 15f),
size = Size(8f, 20f),
cornerRadius = CornerRadius(4f, 4f)
)
}Output:
3. Explosions:
// π½ Face Base
drawCircle(
color = Color(0xFF81C784).copy(alpha = alpha),
radius = faceRadius,
center = center
)
// π Left Eye
drawCircle(
color = Color.White.copy(alpha = alpha),
radius = 8f * scale,
center = Offset(center.x - 12f * scale, center.y - 5f * scale)
)
drawCircle(
color = Color.Black.copy(alpha = alpha),
radius = 4f * scale,
center = Offset(center.x - 12f * scale, center.y - 5f * scale)
)
// π Right Eye
drawCircle(
color = Color.White.copy(alpha = alpha),
radius = 8f * scale,
center = Offset(center.x + 12f * scale, center.y - 5f * scale)
)
drawCircle(
color = Color.Black.copy(alpha = alpha),
radius = 4f * scale,
center = Offset(center.x + 12f * scale, center.y - 5f * scale)
)
// π± Sad Open Mouth
drawArc(
color = Color.Black.copy(alpha = alpha),
startAngle = 0f,
sweepAngle = 180f,
useCenter = true,
topLeft = Offset(center.x - 15f * scale, center.y + 5f * scale),
size = Size(30f * scale, 25f * scale)
)
// π§ Tear Drops
drawCircle(
color = Color.Cyan.copy(alpha = alpha),
radius = 5f * scale,
center = Offset(center.x - 18f * scale, center.y + 15f * scale)
)
drawCircle(
color = Color.Cyan.copy(alpha = alpha),
radius = 5f * scale,
center = Offset(center.x + 18f * scale, center.y + 15f * scale)
)Output:
4. Background:
// Background
drawRect(Color.Black)
stars.forEach { star ->
drawCircle(
color = Color.White,
radius = star.radius,
center = Offset(star.x, star.y)
)
star.y += star.speed
if (star.y > size.height) {
star.y = 0f
star.x = Random.nextFloat() * size.width
}
}Output:

Final Code
@Composable
fun GameScreen(vm: GameViewModel = viewModel()) {
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.onSizeChanged { size ->
vm.screenWidth = size.width.toFloat()
vm.screenHeight = size.height.toFloat()
vm.playerX = vm.screenWidth / 2f
vm.playerY = vm.screenHeight * 0.9f
}
.pointerInput(Unit) {
detectDragGestures { _, drag ->
vm.shoot()
vm.movePlayer(drag.x, drag.y)
}
}
) {
val stars = remember {
List(100) {
Star(
x = Random.nextFloat() * vm.screenWidth -100f,
y = Random.nextFloat() * vm.screenHeight -100f,
radius = Random.nextFloat() * 5f,
speed = Random.nextFloat() * 4f + 1f
)
}
}
Canvas(Modifier.fillMaxSize()) {
// Background
drawRect(Color.Black)
stars.forEach { star ->
drawCircle(
color = Color.White,
radius = star.radius,
center = Offset(star.x, star.y)
)
star.y += star.speed
if (star.y > size.height) {
star.y = 0f
star.x = Random.nextFloat() * size.width
}
}
drawPlayerShip(Offset(vm.playerX, vm.playerY))
vm.enemies.forEach { enemy ->
drawEnemy(enemy = Offset(enemy.x, enemy.y))
if (System.currentTimeMillis() < enemy.showHpUntil) {
// π Draw HP number above enemy
drawContext.canvas.nativeCanvas.drawText(
enemy.hp.toString(),
enemy.x,
enemy.y - 45f, // position above enemy
android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
textSize = 32f
textAlign = android.graphics.Paint.Align.CENTER
isFakeBoldText = true
}
)
}
}
vm.playerBullets.forEach {
drawPlayerLaser(Offset(it.x, it.y))
}
vm.enemyBullets.forEach {
drawEnemyBullet(Offset(it.x, it.y))
}
vm.explosions.forEach { explosion ->
val elapsed = System.currentTimeMillis() - explosion.time
val progress = (elapsed / 600f).coerceIn(0f, 1f)
val scale = 1f + progress
val alpha = 1f - progress
val center = Offset(explosion.x, explosion.y)
val faceRadius = 35f * scale
// π½ Face Base
drawCircle(
color = Color(0xFF81C784).copy(alpha = alpha),
radius = faceRadius,
center = center
)
// π Left Eye
drawCircle(
color = Color.White.copy(alpha = alpha),
radius = 8f * scale,
center = Offset(center.x - 12f * scale, center.y - 5f * scale)
)
drawCircle(
color = Color.Black.copy(alpha = alpha),
radius = 4f * scale,
center = Offset(center.x - 12f * scale, center.y - 5f * scale)
)
// π Right Eye
drawCircle(
color = Color.White.copy(alpha = alpha),
radius = 8f * scale,
center = Offset(center.x + 12f * scale, center.y - 5f * scale)
)
drawCircle(
color = Color.Black.copy(alpha = alpha),
radius = 4f * scale,
center = Offset(center.x + 12f * scale, center.y - 5f * scale)
)
// π± Sad Open Mouth
drawArc(
color = Color.Black.copy(alpha = alpha),
startAngle = 0f,
sweepAngle = 180f,
useCenter = true,
topLeft = Offset(center.x - 15f * scale, center.y + 5f * scale),
size = Size(30f * scale, 25f * scale)
)
// π§ Tear Drops
drawCircle(
color = Color.Cyan.copy(alpha = alpha),
radius = 5f * scale,
center = Offset(center.x - 18f * scale, center.y + 15f * scale)
)
drawCircle(
color = Color.Cyan.copy(alpha = alpha),
radius = 5f * scale,
center = Offset(center.x + 18f * scale, center.y + 15f * scale)
)
}
}
Column(Modifier.fillMaxSize()) {
Text(
"www.codingbihar.com", color = Color.Green,
style = MaterialTheme.typography.displaySmall,
)
Row(
modifier = Modifier
.systemBarsPadding()
.fillMaxWidth()
.padding(32.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
AnimatedScore(vm.score)
GlowingHP(vm.playerHP)
Text(
text = "π ${vm.playerLives}",
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = Color.Cyan
)
}
}
if (vm.isGameOver) {
Column(
modifier = Modifier.align(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "GAME OVER",
color = Color.Red,
fontSize = 32.sp
)
Spacer(modifier = Modifier.height(20.dp))
Button(onClick = { vm.restart() }) {
Text("Restart")
}
}
}
}
}
@Composable
fun AnimatedScore(score: Int) {
val scale = remember { Animatable(1f) }
LaunchedEffect(score) {
scale.animateTo(1.3f)
scale.animateTo(1f)
}
Text(
text = "⭐ $score",
fontSize = 22.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.Yellow,
modifier = Modifier.graphicsLayer {
scaleX = scale.value
scaleY = scale.value
}
)
}
@Composable
fun GlowingHP(hp: Int) {
val infiniteTransition = rememberInfiniteTransition()
val alpha by infiniteTransition.animateFloat(
initialValue = 0.5f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(500),
repeatMode = RepeatMode.Reverse
)
)
Row(
modifier = Modifier.alpha(if (hp <= 1) alpha else 1f)
) {
HeartHP(hp)
}
}GameViewModel:
class GameViewModel : ViewModel() {
var screenWidth by mutableFloatStateOf(0f)
var screenHeight by mutableFloatStateOf(0f)
var playerX by mutableFloatStateOf(0f)
var playerY by mutableFloatStateOf(0f)
var playerLives by mutableIntStateOf(5)
var playerHP by mutableIntStateOf(5)
private set
var score by mutableIntStateOf(0)
var isGameOver by mutableStateOf(false)
val enemies = mutableStateListOf()
val playerBullets = mutableStateListOf()
val enemyBullets = mutableStateListOf()
val explosions = mutableStateListOf()
private val enemyTypes = listOf(
EnemyType(1, hp = 5, speed = 4f, bulletSpeed = 8f, image = 0),
EnemyType(2, hp = 10, speed = 2.5f, bulletSpeed = 6f, image = 1),
EnemyType(3, hp = 15, speed = 2f, bulletSpeed = 6f, image = 2),
EnemyType(4, hp = 20, speed = 3f, bulletSpeed = 10f, image = 3)
)
init {
startGameLoop()
}
private fun startGameLoop() {
viewModelScope.launch {
while (isActive) {
if (!isGameOver && screenWidth > 0f) {
updateGame()
}
delay(16)
}
}
}
private fun updateGame() {
movePlayerBullets()
moveEnemyBullets()
moveEnemies()
spawnEnemies()
checkCollisions()
updateExplosions()
}
fun movePlayer(dx: Float, dy: Float) {
if (!isGameOver && screenWidth > 0f) {
playerX = (playerX + dx).coerceIn(40f, screenWidth - 40f)
playerY = (playerY + dy).coerceIn(40f, screenHeight - 40f)
}
}
fun shoot() {
playerBullets.add(
Bullet(
x = playerX,
y = playerY - 80f,
speed = 80f,
damage = if ( score > 200) 5 else 1,
fromEnemy = false,
type = 0
)
)
}
private fun spawnEnemies() {
if ((0..80).random() < 3) {
val type = enemyTypes.random()
enemies.add(
Enemy(
x = (50..(screenWidth.toInt() - 50)).random().toFloat(),
y = -50f,
hp = type.hp,
type = type.id
)
)
}
}
private fun moveEnemies() {
enemies.forEach { enemy ->
val type = enemyTypes.first { it.id == enemy.type }
enemy.y += type.speed
if ((0..100).random() < 2) {
enemyBullets.add(
Bullet(
x = enemy.x,
y = enemy.y,
speed = type.bulletSpeed,
damage = 1,
fromEnemy = true,
type = 0
)
)
}
if (enemy.y > screenHeight + 50f) {
enemy.isAlive = false
}
}
enemies.removeAll { !it.isAlive }
}
private fun movePlayerBullets() {
playerBullets.forEach { it.y -= it.speed }
playerBullets.removeAll { it.y < -50f }
}
private fun moveEnemyBullets() {
enemyBullets.forEach { it.y += it.speed }
enemyBullets.removeAll { it.y > screenHeight + 50f }
}
private fun checkCollisions() {
for (i in playerBullets.lastIndex downTo 0) {
val bullet = playerBullets[i]
for (j in enemies.lastIndex downTo 0) {
val enemy = enemies[j]
if (distance(bullet.x, bullet.y, enemy.x, enemy.y) < 40f) {
enemy.hp -= bullet.damage
enemy.showHpUntil = System.currentTimeMillis() + 600L
playerBullets.removeAt(i)
if (enemy.hp <= 0) {
score += 10
explosions.add(
Explosion(enemy.x, enemy.y, System.currentTimeMillis())
)
enemies.removeAt(j)
enemyBullets.removeAll(enemyBullets)
}
break
}
}
}
for (i in enemyBullets.lastIndex downTo 0) {
val bullet = enemyBullets[i]
if (distance(bullet.x, bullet.y, playerX, playerY) < 40f) {
playerHP--
enemyBullets.removeAt(i)
if (playerHP <= 0) {
playerLives--
if (playerLives > 0) {
// Reset HP when life lost
playerHP = 5
// Optional: Respawn player at center
playerX = screenWidth / 2
playerY = screenHeight - 150f
} else {
playerLives = 0
playerHP = 0
isGameOver = true
}
}
}
}
}
private fun updateExplosions() {
val now = System.currentTimeMillis()
explosions.removeAll { now - it.time > 400 }
}
private fun distance(x1: Float, y1: Float, x2: Float, y2: Float): Float {
return sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2))
}
fun restart() {
enemies.clear()
playerBullets.clear()
enemyBullets.clear()
explosions.clear()
score = 0
playerHP = 5
playerLives = 5
isGameOver = false
}
} DataModel:
data class Explosion(
var x: Float,
var y: Float,
var time: Long
)
data class Enemy(
var x: Float,
var y: Float,
var hp: Int,
var isAlive: Boolean = true,
val type: Int,
var showHpUntil: Long = 0L
)
data class Bullet(
var x: Float,
var y: Float,
val speed: Float,
val damage: Int,
val fromEnemy: Boolean,
val type: Int
)
data class EnemyType(
val id: Int,
val hp: Int,
val speed: Float,
val bulletSpeed: Float,
val image: Int
)
data class Star(
var x: Float,
var y: Float,
val radius: Float,
val speed: Float
)MainActivity:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
BharatSpaceBattleTheme {
GameScreen()
}
}
}
}






