Dice Duel 4 — the roll animation
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 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:
- A counter that only goes up.
rollIdstarts at 0 and rises by one per throw. FeedrollId * 360fto a rotation and every throw adds a full extra turn — and because the number never goes down, the die never spins backwards. - 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.
- Moving pixels, not layout.
graphicsLayerrotates and scales the die without the Column around it being measured again. Nothing else on screen so much as twitches.
New imports first:
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.delayThe 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.
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
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:
| Time | What is happening |
|---|---|
| 0 ms | rolling becomes true, tilt is picked, rollId rises. Spin and swell both start. |
| 0–280 ms | The die grows from 100% to 116%. It is turning fast — FastOutSlowInEasing front-loads the speed. |
| 0–540 ms | The face changes every 60 ms, nine times. The shadow sweeps round with the die. |
| ~540 ms | The real roll lands. The score jumps. The ring moves to the other panel. The hint flips. |
| 540–640 ms | The last of the rotation, arriving gently at 360° plus the tilt. |
| ~820 ms | The 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.
About 300ms into a throw: swollen, mid-spin, showing a face that means nothing yet.
- Open Dice Duel in Pocket Studio, or start from the chapter 3 ZIP.
- In
GameScreen.kt, add the six new imports.delaycomes fromkotlinx.coroutines— check the package Pocket Studio suggests before accepting it. - Below the four existing state values, add
rollId,tiltandrolling. - Below those, add the
spinandliftanimations exactly as shown. - Add the whole
LaunchedEffect(rollId) { ... }block underneath them, abovefun roll(). - Now delete the body of
roll()and replace it with the four-line version: the guard,rolling = true, the tilt, androllId++. - Add
modifier = Modifier.graphicsLayer { ... }to theDieFacecall. Watch the commas — it goes betweensizeandonRoll. - Change
TurnHint(turn: Int)toTurnHint(turn: Int, rolling: Boolean), add themessagevalue, and use it in theText. Update the call site toTurnHint(turn = turn, rolling = rolling). - Tap Run, then tap the die. It should tumble for about half a second and land crooked.
- Tap the die four times as fast as you can. Exactly one throw should happen. Now comment out the
if (rolling) returnline and try again — you will see why it is there. Put it back. - Change
durationMillis = 640to1600and Run. Far too slow. Try200. Too twitchy. Put it back to640and notice that you now have an opinion about it. - Change
repeat(9)torepeat(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.
delay(60) inside the ordinary fun roll(). delay — it pauses and resumes later — and an ordinary function has no way to do that.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.animateFloatAsState 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.import 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.rotationZ and scaleX are unresolved too — they are properties of the graphics layer, not free-floating names.import 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.animateFloatAsState(...) inside roll(). Animations are declared while the screen is being described, not while a click is being handled — animateFloatAsState is itself a composable.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.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.Random.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.
- A counter that only ever rises (
rollId) fed intorollId * 360fgives 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.