Pocket Studio Academy
HomePart 55.4

Dice Duel 4 — the roll animation

Full course15 min read·4 questions

A counter that only ever goes up, a random tilt, a nine-frame shuffle inside a coroutine and a graphicsLayer. Together they turn a number that changes into a die that is genuinely thrown.

The difference between working and good

Play chapter 3 for a minute and you will notice something. The game is correct. Every rule works. And it is completely flat.

Tap. The number is 4. Tap. The number is 1. It reads like a spreadsheet updating.

Compare that to a real die. You shake it, you let go, it tumbles across the table, and for about half a second nobody — including you — knows what it is going to be. That half second is the entire game. The number is just what is left over afterwards.

This chapter buys that half second back. It is the difference between an app that works and an app a kid shows a friend, and it costs about thirty lines.

Think of it like this

Think about a kettle that plays a note when it boils.

The kettle boiled fine before. The water was exactly as hot. Nothing about the function changed. But now you trust it, because it told you something happened, and you can walk out of the room.

Animation is that note. It is not decoration. It is the app confirming that your tap landed, and buying itself a moment to look like it is doing something. Take the roll animation out of a dice game and people will tell you it feels broken, even though they cannot say why.

Three ideas, and that is all

Everything in this chapter is one of these:

  1. A counter that only goes up. rollId starts at 0 and rises by one per throw. Feed rollId * 360f to a rotation and every throw adds a full extra turn — and because the number never goes down, the die never spins backwards.
  2. A shuffle that is theatre. For just over half a second the face changes every 60 milliseconds, showing nine random values that mean nothing. The tenth one is the real roll, and only it touches the score.
  3. Moving pixels, not layout. graphicsLayer rotates and scales the die without the Column around it being measured again. Nothing else on screen so much as twitches.

New imports first:

GameScreen.ktkotlin
1import androidx.compose.animation.core.FastOutSlowInEasing
2import androidx.compose.animation.core.animateFloatAsState
3import androidx.compose.animation.core.tween
4import androidx.compose.runtime.LaunchedEffect
5import androidx.compose.ui.graphics.graphicsLayer
6import kotlinx.coroutines.delay

The animation state

The throw itself

Here is the important shift. In chapter 3, roll() did everything: pick a number, score it, change turns. It cannot do that any more, because a throw now takes time — and an ordinary Kotlin function cannot wait.

So the work moves into a , and roll() shrinks to a starter pistol.

Note

Notice what roll() no longer does. It does not pick the scoring number, it does not touch a score, and it does not know how long a throw lasts. It flips a flag, picks a wobble and bumps a counter.

Functions that only change small plain values, with the interesting work happening in things that watch those values, is the shape almost all good Compose code ends up in.

Moving the pixels

The die now needs to spin and swell. It must do that without shoving the score panels or the bottom hint around, and is exactly the tool.

The bottom line reports the throw

GameScreen.ktkotlin
1@Composable
2private fun TurnHint(turn: Int, rolling: Boolean) {
3    val scheme = MaterialTheme.colorScheme
4    val who = if (turn == 1) "PLAYER 1" else "PLAYER 2"
5    val tint =
6        if (turn == 1) scheme.secondary else scheme.tertiary
7    val message = if (rolling) {
8        "$who IS ROLLING"
9    } else {
10        "$who - TAP TO ROLL"
11    }
12    Text(
13        text = message,
14        color = tint,
15        fontSize = 15.sp,
16        fontWeight = FontWeight.Black,
17        letterSpacing = 2.sp
18    )
19}

One new , one new if. The call site becomes TurnHint(turn = turn, rolling = rolling).

There is a subtlety worth catching here. turn does not change until the very end of the effect, so during the whole throw the hint still names the player who threw — "PLAYER 1 IS ROLLING". The instant the number lands, both values change together and it becomes "PLAYER 2 - TAP TO ROLL". Two pieces of state, one moment, no coordinating code.

The choreography

Here is exactly what happens in the 820 milliseconds after a tap:

TimeWhat is happening
0 msrolling becomes true, tilt is picked, rollId rises. Spin and swell both start.
0–280 msThe die grows from 100% to 116%. It is turning fast — FastOutSlowInEasing front-loads the speed.
0–540 msThe face changes every 60 ms, nine times. The shadow sweeps round with the die.
~540 msThe real roll lands. The score jumps. The ring moves to the other panel. The hint flips.
540–640 msThe last of the rotation, arriving gently at 360° plus the tilt.
~820 msThe swell has finished unwinding. The die rests, very slightly crooked.

Nothing else on the screen moves for the whole of it. That is deliberate. One thing in motion reads as an event; three things in motion read as a glitch.

9:41▲ ▮
DICE DUEL
First to 30 wins the duel
PLAYER 1
12
PLAYER 2
9
PLAYER 1 IS ROLLING

About 300ms into a throw: swollen, mid-spin, showing a face that means nothing yet.

Try it in Pocket Studio
  1. Open Dice Duel in Pocket Studio, or start from the chapter 3 ZIP.
  2. In GameScreen.kt, add the six new imports. delay comes from kotlinx.coroutines — check the package Pocket Studio suggests before accepting it.
  3. Below the four existing state values, add rollId, tilt and rolling.
  4. Below those, add the spin and lift animations exactly as shown.
  5. Add the whole LaunchedEffect(rollId) { ... } block underneath them, above fun roll().
  6. Now delete the body of roll() and replace it with the four-line version: the guard, rolling = true, the tilt, and rollId++.
  7. Add modifier = Modifier.graphicsLayer { ... } to the DieFace call. Watch the commas — it goes between size and onRoll.
  8. Change TurnHint(turn: Int) to TurnHint(turn: Int, rolling: Boolean), add the message value, and use it in the Text. Update the call site to TurnHint(turn = turn, rolling = rolling).
  9. Tap Run, then tap the die. It should tumble for about half a second and land crooked.
  10. Tap the die four times as fast as you can. Exactly one throw should happen. Now comment out the if (rolling) return line and try again — you will see why it is there. Put it back.
  11. Change durationMillis = 640 to 1600 and Run. Far too slow. Try 200. Too twitchy. Put it back to 640 and notice that you now have an opinion about it.
  12. Change repeat(9) to repeat(30) and Run. The shuffle now finishes long after the spin does, and the die sits still while the number keeps flickering. That mismatch is worth seeing once — then set it back to 9.
Error Doctor5 common errors
e: GameScreen.kt:127:9 Suspend function 'suspend fun delay(timeMillis: Long): Unit' should be called only from a coroutine or another suspend function.
MeansYou put delay(60) inside the ordinary fun roll(). delay — it pauses and resumes later — and an ordinary function has no way to do that.
FixKeep all the waiting inside LaunchedEffect { ... }, whose block is a coroutine. roll() should only flip rolling, pick a tilt and bump rollId. If you genuinely need to wait from a click, the tool is rememberCoroutineScope() — but here the effect is simpler and cancels itself.
e: GameScreen.kt:84:14 Type 'androidx.compose.runtime.State<kotlin.Float>' has no method 'getValue(Nothing?, KProperty0<*>)', so it cannot serve as a delegate.
MeansanimateFloatAsState returns a State<Float>, and val spin by ... needs the getValue extension in scope. Note this one is read-only, so getValue alone is enough — no setValue.
Fiximport androidx.compose.runtime.getValue. You probably already have it from chapter 3; if you removed the state imports while tidying, this is the line that went.
e: GameScreen.kt:157:37 Unresolved reference 'graphicsLayer'. e: GameScreen.kt:158:21 Unresolved reference 'rotationZ'. e: GameScreen.kt:159:21 Unresolved reference 'scaleX'.
MeansOne missing import, three red lines. Without the modifier, the lambda after it has no receiver, so rotationZ and scaleX are unresolved too — they are properties of the graphics layer, not free-floating names.
Fiximport androidx.compose.ui.graphics.graphicsLayer. There is also a graphicsLayer in androidx.compose.ui.draw; either works here, but pick one and let the IDE insert it rather than typing it from memory.
e: GameScreen.kt:434:5 Functions which invoke @Composable functions must be marked with the @Composable annotation
MeansYou moved animateFloatAsState(...) inside roll(). Animations are declared while the screen is being described, not while a click is being handled — animateFloatAsState is itself a composable.
FixDeclare spin and lift at the top of GameScreen, alongside the state. Have roll() change only the plain values those animations watch: rollId, tilt and rolling.
e: GameScreen.kt:110:16 Assignment type mismatch: actual type is 'kotlin.Int', but 'kotlin.Float' was expected.
MeansYou wrote tilt = Random.nextInt(-8, 9). tilt was created as mutableStateOf(0f), so its type is Float, and Kotlin never silently widens an Int into a Float.
FixRandom.nextInt(-8, 9).toFloat(). The same rule bites on rollId * 360f — the f is what keeps that multiplication in Float territory rather than doing whole-number maths and then converting.

Dice Duel — end of chapter 4

A complete project. Unzip it, open it in Pocket Studio, and press Run.

Download ZIP
Recap
  • A counter that only ever rises (rollId) fed into rollId * 360f gives one extra full revolution per throw, in a consistent direction, forever.
  • hands back a number that slides towards a target. You change the target; it does the rest. sets the duration and sets the feel.
  • Waiting belongs in a . starts one when the key changes and cancels it if the screen goes away — guard against its first, uninvited run with if (rollId == 0) return@LaunchedEffect.
  • Keep the theatre and the rules separate: nine random faces that mean nothing, then one real roll that scores.
  • moves pixels at draw time without re-measuring the layout, so a spinning die never disturbs the board around it.
  • One flag (rolling) drives the swell, the bottom line and the double-tap guard.
  • Next: an ending. A target score, a win banner that springs in over a dimmed board, progress bars on both panels, and a Play again button that puts everything back to zero.