Compose ❤️ Dino

Building Chrome’s T-Rex Game in Jetpack Compose

Wajahat Karim

wajahatkarim.com

WajahatKarim

🔥 Google Dev Expert (GDE) in Android .
📱 Android Dev. 💻 Open Source Contributor .
  📝 Technical Writer . 🎤 Public Speaker

Compose ❤️ Dino

Building Chrome’s T-Rex Game in Jetpack Compose

Jetpack Compose

a modern UI toolkit which simplifies and accelerates UI development on Android with less code, powerful tools, and intuitive Kotlin APIs.

But... wait?

a modern UI toolkit which simplifies and accelerates UI development on Android with less code, powerful tools, and intuitive Kotlin APIs.

Why Games? Why not UI?

  • Games are more fun.
  • Canvas is more friendly in Compose.
  • Will help in making custom views
  • Perfect for animations.

Game Development 101

Games have different structure than software/apps

  • Startup
  • Intro Movie
  • Main Menu & Settings
  • Loading
  • Main Game
    • Intro
    • Gameplay
    • Pause Options
    • Game Over / Outro
  • End Game / Levels
  • Credits

Game Development 101

Game Loop

// A Simple Game Loop
int main()
{
    initialize();
    while(true)
    {
    	processInputs();
        updateGameData();
        renderGame();
        checkShutdown();
    }
    shutdown();
}

Compose Canvas 101

A simple Spacer() to allow you to draw anything on it

@Composable
fun Canvas
(
   modifier: Modifier, 
   onDraw: DrawScope.() -> Unit
) = Spacer(modifier.drawBehind(onDraw))

fun DrawScope.drawMyShape() { }

Compose Canvas 101

DrawScope - Handles the drawing API

drawRect()

drawOval()

drawLine()

drawImage()

drawRoundRect()

drawCircle()

drawArc()

drawPath()

fun DrawScope.drawMyShape() { }

Compose Canvas 101

Custom View Example in Canvas

Canvas(modifier = Modifier.fillMaxSize()) {
    drawCircle(
        color = Color.Red,
        radius = 300f
    )

    drawCircle(
        color = Color.Green,
        radius = 200f
    )

    drawCircle(
        color = Color.Blue,
        radius = 100f
    )
}

Wanna make Dino Game?

Drawing Game Scene

Basics

// Device Width in Pixels
var deviceMetrics = DisplayMetrics()
windowManager.defaultDisplay.getMetrics(deviceMetrics)
deviceWidthInPixels = deviceMetrics.widthPixels

deviceWithInPixels

X

Y

Earth Y

// Earth Y position
const val EARTH_Y_POSITION = 500f

Drawing Game Scene

Earth

fun DrawScope.EarthView(earthState: EarthState)
{
    // Ground Line
    drawLine(
        color = Color.DarkGray,
        start = Offset(x = 0f, y = EARTH_Y_POSITION),
        end = Offset(x = deviceWidthInPixels.toFloat(), y = EARTH_Y_POSITION),
        strokeWidth = EARTH_GROUND_STROKE_WIDTH
    )
    
    // Dirt Line 1
    drawLine(
        color = Color.DarkGray,
        start = Offset(x = 0f, y = EARTH_Y_POSITION + 20),
        end = Offset(x = deviceWidthInPixels.toFloat(), y = EARTH_Y_POSITION + 20),
        strokeWidth = EARTH_GROUND_STROKE_WIDTH / 5,
        pathEffect = DashPathEffect(floatArrayOf(20f, 40f), 0f)
    )
    
    // Dirt Line 2
    drawLine(
        color = Color.DarkGray,
        start = Offset(x = 0f, y = EARTH_Y_POSITION + 30),
        end = Offset(x = x = deviceWidthInPixels.toFloat(), y = EARTH_Y_POSITION + 30),
        strokeWidth = EARTH_GROUND_STROKE_WIDTH / 5,
        pathEffect = DashPathEffect(floatArrayOf(15f, 50f), 40f)
    )
}

Drawing Game Scene

Vector Icons (SVG)

Icons made by Freepik from www.flaticon.com

Drawing Game Scene

Vector Icons Issues

  • All icons are of different sizes
  • Canvas uses Vector icon's coordinates as pixels
  • Icons need to be of size width/height at least for uniformity

I resized all to 200x200 using this website

https://www.iloveimg.com/resize-image/resize-svg

Drawing Game Scene

Clouds

Text

No method to draw VectorDrawables in Canvas yet!

Drawing Game Scene

Clouds

// Cloud Path
private var CLOUD_PATH_STR = "M169.895,88.699C167.27,71.887 152.695,58.984 135.156,58.984C128.559,58.984 122.219,60.809 116.719,64.215C108.355,50.156 93.293,41.406 76.563,41.406C50.715,41.406 29.688,62.434 29.688,88.281C29.688,88.441 29.688,88.609 29.691,88.766C13.082,91.566 0,106.047 0,123.438C0,142.824 16.16,158.594 35.547,158.594L164.453,158.594C183.84,158.594 200,142.824 200,123.438C200,105.898 186.707,91.324 169.895,88.699ZM169.895,88.699"
private var cloudPath = PathParser().parsePathString(CLOUD_PATH_STR)
fun CloudPathNodes() = cloudPath.toNodes()
fun CloudPath(): Path {
    var path = cloudPath.toPath()
    var scaleMatrix = Matrix()
    scaleMatrix.setScale(BASE_SCALE, BASE_SCALE, 0f, 0f)
    var androidPath = path.asAndroidPath()
    androidPath.transform(scaleMatrix)
    return androidPath.asComposePath()
}

// Draw Cloud
fun DrawScope.CloudsView(cloudState: CloudState)
{
   drawPath(
       path = cloud.path,
       color = Color(0xFFC5C5C5),
       style = Stroke(2f)
   )
}

Drawing Game Scene

Cactus

// Cactus Path
private var CACTUS_PATH_STR = "M57.449,111.191L85.246,111.191L85.246,200.145L118.605,200.145L118.605,137.137L142.695,137.137C149.859,137.137 155.668,131.328 155.668,124.164L155.668,59.301C155.668,52.137 149.859,46.328 142.695,46.328C135.531,46.328 129.723,52.137 129.723,59.301L129.723,111.191L118.605,111.191L118.605,16.68C118.605,7.469 111.137,0 101.926,0C92.715,0 85.246,7.469 85.246,16.68L85.246,85.246L70.422,85.246L70.422,37.063C70.422,29.898 64.613,24.09 57.449,24.09C50.285,24.09 44.477,29.898 44.477,37.063L44.477,98.219C44.477,105.383 50.285,111.191 57.449,111.191ZM57.449,111.191"
private var cactusPath = PathParser().parsePathString(CACTUS_PATH_STR)
fun CactusPathNodes() = cactusPath.toNodes()
fun CactusPath(): Path {
    var path = cactusPath.toPath()
    var scaleMatrix = Matrix()
    scaleMatrix.setScale(BASE_SCALE, BASE_SCALE, 0f, 0f)
    var androidPath = path.asAndroidPath()
    androidPath.transform(scaleMatrix)
    return androidPath.asComposePath()
}

// Draw Cactus
fun DrawScope.CactusView(cactusState: CactusState)
{
    drawPath(
       path = cactus.path,
       color = Color(0xFF000000),
       style = Fill
    )
}

Drawing Game Scene

T-Rex

// T-Rex Dino
private var TREX_DINO_PATH_STR = "M93.027,18.996L173.41,18.996L173.41,60.836L93.027,60.836ZM93.027,18.996 M99.27,14.727L167.168,14.727L167.168,56.57L99.27,56.57ZM99.27,14.727 M93.027,37.715L127.531,37.715L127.531,79.555L93.027,79.555ZM93.027,37.715 M93.027,71.113L153.223,71.113L153.223,79.555L93.027,79.555ZM93.027,71.113 M107.113,25.676L115.113,25.676L115.113,33.676L107.113,33.676ZM107.113,25.676 M93.027,37.715L120.188,37.715L120.188,122.5L93.027,122.5ZM93.027,37.715 M93.027,98.848L136.707,98.848L136.707,107.289L93.027,107.289ZM93.027,98.848 M130.098,99.008L136.707,99.008L136.707,114.422L130.098,114.422ZM130.098,99.008 M86.629,84.328L113.789,84.328L113.789,137.914L86.629,137.914ZM86.629,84.328 M73.828,91.793L105.762,91.793L105.762,145.379L73.828,145.379ZM73.828,91.793 M63.16,99.262L90.324,99.262L90.324,152.848L63.16,152.848ZM63.16,99.262 M64.953,106.727L79.656,106.727L79.656,160.313L64.953,160.313ZM64.953,106.727 M86.629,106.727L96.539,106.727L96.539,160.313L86.629,160.313ZM86.629,106.727 M91.031,106.727L96.539,106.727L96.539,185.273L91.031,185.273ZM91.031,106.727 M91.031,179.766L106.082,179.766L106.082,185.273L91.031,185.273ZM91.031,179.766 M63.301,106.727L68.805,106.727L68.805,170.59L63.301,170.59ZM63.301,106.727 M63.301,165.086L78.348,165.086L78.348,170.59L63.301,170.59ZM63.301,165.086 M54.285,106.727L68.988,106.727L68.988,153.566L54.285,153.566ZM54.285,106.727 M45.695,106.727L58.324,106.727L58.324,146.301L45.695,146.301ZM45.695,106.727 M39.293,99.25L49.789,99.25L49.789,137.766L39.293,137.766ZM39.293,99.25 M31.828,91.781L42.324,91.781L42.324,130.301L31.828,130.301ZM31.828,91.781 M26.59,84.316L32.723,84.316L32.723,122.832L26.59,122.832ZM26.59,84.316 "
private var trexPath = PathParser().parsePathString(TREX_DINO_PATH_STR)
fun DinoPathNodes() = trexPath.toNodes()
fun DinoPath(): Path {
    var path = trexPath.toPath()
    var scaleMatrix = Matrix()
    scaleMatrix.setScale(BASE_SCALE, BASE_SCALE, 0f, 0f)
    var androidPath = path.asAndroidPath()
    androidPath.transform(scaleMatrix)
    return androidPath.asComposePath()
}

// Draw Dino
fun DrawScope.DinoView(dinoState: DinoState) {
{
    drawPath(
       path = dinoState.path
       color = Color(0xFF000000),
       style = Fill
    )
}

Drawing Game Scene

We need to set positions for each element dynamically

@Composable
fun DinoGameScene()
{
   Canvas(modifier = Modifier.weight(1f)) {
      EarthView(earthState)
      CloudsView(cloudsState)
      DinoView(dinoState)
      CactusView(cactusState)
   }
}

Game Loop in Compose

I assumed this

  • Compose automatically updates when state changes
  • Why we even need game loop?
  • Compose State can do our job.

Game Loop in Compose

Attempt # 1 - Playing with states

// Cloud Model
data class CloudModel(
    var xPos: Int = 0,
    var yPos: Int = 0,
    var path: Path = CloudPath()
)

// Cloud State
data class CloudState(
    val cloudsList: ArrayList<CloudModel> = arrayListOf<CloudModel>(),
    val maxClouds: Int = 3,
    val speed: Int = 1
)

// Setting Cloud State
var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }

// Game Loop
while(true) {
    cloudsState.cloudsList.forEach { cloud ->
        cloud.x++
    }
}

Game Loop in Compose

Attempt # 1 - Playing with states

// Cloud Model
data class CloudModel(
    var xPos: Int = 0,
    var yPos: Int = 0,
    var path: Path = CloudPath()
)

// Cloud State
data class CloudState(
    val cloudsList: ArrayList<CloudModel> = arrayListOf<CloudModel>(),
    val maxClouds: Int = 3,
    val speed: Int = 1
)

// Setting Cloud State
var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }

// Game Loop
while(true) {
    cloudsState.cloudsList.forEach { cloud ->
        cloud.x++
    }
}

Failed. No Cloud Animation

Game Loop in Compose

Attempt # 2 - FrameCallback

// Cloud State
data class CloudState( /* ... */ ) {
    fun moveForward()
    {
        cactusList.forEach { cactus ->
            cactus.xPos -= cactusSpeed
    }
}

// Setting Cloud State
var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }

// Game Loop
val gameLoopCallback = object : Choreographer.FrameCallback {
    override fun doFrame(frameTimeNanos: Long) {
        cloudsState.value = cloudsState.value.moveForward()
        Choreographer.getInstance().postFrameCallback(this)
    }
}
Choreographer.getInstance().postFrameCallback(gameLoopCallback)

Failed. No Cloud Animation

Game Loop in Compose

Attempt # 3 - Compose Couroutines

@Composable
fun animationTimeMillis(gameloopCallback: () -> Unit): State<Long>
{
    val millisState = remember { mutableStateOf(0L) }
    val lifecycleOwner = LifecycleOwnerAmbient.current
    launchInComposition {
        val startTime = withFrameMillis { it }
        lifecycleOwner.whenStarted {
            while(true) {
                withFrameMillis { frameTimeMillis: Long ->
                    millisState.value = frameTimeMillis - startTime
                }
                gameloopCallback.invoke()
            }
        }
    }
    return millisState
}

Game Loop in Compose

Attempt # 3 - Compose Couroutines

@Composable
fun DinoGameScene()
{
    var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }
    
    val timeState = animationTimeMillis {
        cloudsState.moveForward()
    }
    
    var millis = timeState.millis
    
    Canvas(modifier = Modifier.weight(1f)) {
    	EarthView(earthState)
    	CloudsView(cloudsState)
    	DinoView(dinoState)
    	CactusView(cactusState)
    }
}

Game Loop in Compose

@Composable
fun DinoGameScene()
{
    var cloudsState = remember { CloudState(maxClouds = MAX_CLOUDS, speed = CLOUDS_SPEED) }
    
    val animatedProgress = animatedFloat(initVal = 0f)
    onActive {
        animatedProgress.animateTo(
            targetValue = 1f,
            anim = repeatable(
                iterations = AnimationConstants.Infinite,
                animation = tween(durationMillis = 1000, easing = LinearEasing)
            )
        )
    }

    val millis = animatedProgress.value
    
    if (!gameState.isGameOver)
    {
        // Game Loop
        cloudsState.moveForward()
    }
    
    Canvas(modifier = Modifier.weight(1f)) {
    	CloudsView(cloudsState)
    }
}

Attempt # 4 - Compose Way

Canvas Transformations

fun DrawScope.CloudsView(cloudState: CloudState)
{
    cloudState.cloudsList.forEach {cloud ->
        withTransform({
            translate(
                left = cloud.xPos.toFloat(),
                top = cloud.yPos.toFloat()
            )
        })
        {
            drawPath(
                path = cloudState.cloudsList.first().path,
                color = Color(0xFFC5C5C5),
                style = Stroke(2f)
            )
        }
    }
}

- translate

- rotate

- scale

Scrolling Earth

Two blocks chasing each other

Scrolling Earth

Two blocks chasing each other

data class EarthState( /* ... */ ) {
    fun moveForward()
    {
       var endPos = blocksList[maxBlocks-1].xPos
    		+ blocksList[maxBlocks-1].size
	
       for (i in 0 until maxBlocks)
       {
          var block = blocksList[i]
          block.xPos -= speed
        
          // If first block reached end,
          // move it after second
          if ((block.xPos + block.size) < -EARTH_OFFSET ) {
              block.xPos = endPos
          }
       }
   }
}

Clouds & Cactus Generation

Getting spawned one by one

data class CloudState( /* ... */ ) {
    fun moveForward()
    {
        for (i in 0 until maxClouds)
        {
            var cloud = cloudsList[i]
            cloud.xPos -= speed
            
            // If cloud is out of screen,
            // Reposition it at start
            if (cloud.xPos < -100) {
                cloud.xPos = rand(deviceWidthInPixels, 
                	deviceWidthInPixels * rand(1,2))
                cloud.yPos = rand(0, 100)
            }
        }
    }
}

Dino Jump

Detecting Tap first

Column(modifier = Modifier.fillMaxWidth().clickable(
    onClick = {
       if (!gameState.isGameOver)
          dinoState.jump()
       else
       {
          cactusState.initCactus()
          dinoState.init()
          gameState.replay()
      }
    },
    indication = null)
) {
    Canvas(modifier = Modifier.weight(1f)) {
        EarthView(earthState)
        CloudsView(cloudsState)
        DinoView(dinoState)
        CactusView(cactusState)
    }
}

Dino Jump

Making Dino Jump

data class DinoState( /* ... */ ) {
   fun move() {
      yPos += velocityY
      velocityY += gravity

      if (yPos > EARTH_Y_POSITION) {
          yPos = EARTH_Y_POSITION
          gravity = 0f
          velocityY = 0f
          isJumping = false
      }
   }

   fun jump() {
      // Adding negative force
      if (yPos == EARTH_Y_POSITION) {
         isJumping = true
         velocityY = -40f
         gravity = 3f
      }
   }
}

Dino Running Steps

Keyframes in Jetpack Compose

data class DinoState(
   // ....
   var keyframe: Int = 0,
   private var pathList: ArrayList<Path> = arrayListOf(),
) {
   // Current Vector Path of keyframe
   val path: Path
        get() = if (keyframe == 0) pathList[0] else pathList[1]
        
   fun changeKeyframe()
   {
        keyframe++
        if (keyframe == 1)
            keyframe = 0
   }
}

Collision Detection

Debugging with Bounding Box

fun DrawScope.drawBoundingBox(color: Color, rect: Rect) {
    if (showBounds.value)
    {
        drawRect(color, rect.topLeft, rect.size, style = Stroke(3f))
        drawRect(color, rect.deflate(DOUBT_FACTOR).topLeft, 
            rect.deflate(DOUBT_FACTOR).size, 
            style = Stroke(width = 3f, 
            pathEffect = DashPathEffect(floatArrayOf(2f, 4f), 0f))
        )
    }
}

fun DrawScope.CactusView(cactusState: CactusState) {
   // ....
   drawBoundingBox(color = Color.Red, rect = cactus.path.getBounds())
}

fun DrawScope.DinoView(dinoState: DinoState) {
   // ....
   drawBoundingBox(color = Color.Green, rect = dino.path.getBounds())
}

Collision Detection

Calculating Overlap

if (!gameState.isGameOver)
{
   // Game Loop
   gameState.increaseScore()
   cloudsState.moveForward()
   earthState.moveForward()
   cactusState.moveForward()
   dinoState.move()

   // Collision Check
   cactusState.cactusList.forEach {
   if (dinoState.getBounds().deflate(DOUBT_FACTOR).overlaps(
         it.getBounds().deflate(DOUBT_FACTOR))) {
       gameState.isGameOver = true
       return@forEach
     }
   }
}

Scoring

Updating Scores

Column(modifier = Modifier.fillMaxWidth().clickable(
    onClick = { /* ... */ },
    indication = null)
) {
    HighScoreTextViews(gameState)
    Canvas(modifier = Modifier.weight(1f)) {
        // Game drawing
    }
}

@Composable
fun HighScoreTextViews(gameState: GameState)
{
    Spacer(modifier = Modifier.padding(top = 50.dp))
    Row(
        modifier = Modifier.fillMaxWidth().padding(end = 20.dp),
        horizontalArrangement = Arrangement.End
    ) {
        Text(text = "HI")
        Spacer(modifier = Modifier.padding(start = 10.dp))
        Text(text = "${gameState.highScore}".padStart(5, '0'))
        Spacer(modifier = Modifier.padding(start = 10.dp))
        Text(text = "${gameState.currentScore}".padStart(5, '0'))
    }
}

Game Over

Finally...

Column(modifier = Modifier.fillMaxWidth().clickable(
    onClick = { /* ... */ },
    indication = null)
) {
    HighScoreTextViews(gameState)
    Canvas(modifier = Modifier.weight(1f)) {
        // Game drawing
    }
    GameOverTextView(gameState.isGameOver, 
        modifier = Modifier.align(Alignment.Center))
}

@Composable
fun GameOverTextView(isGameOver: Boolean = true, modifier: Modifier = Modifier)
{
    Column(modifier = modifier) {
        Text(
            text = if (isGameOver) "GAME OVER" else "",
            // ... More attributes
        )
        if (isGameOver) {
            Image(
                asset = vectorResource(id = R.drawable.ic_replay),
                modifier = Modifier.preferredSize(40.dp)
                    .align(alignment = Alignment.CenterHorizontally)
            )
        }
    }
}

Summary

It was super fun to make a game in Jetpack Compose

  • Canvas
  • Game Loop
  • Vector Paths
  • State Handling
  • Bounding Box

 

Thank You for Listening!

WajahatKarim

wajahatkarim.com/subscribe

wajahatkarim.com

Made with Slides.com