Pocket Studio Academy
HomePart 55.5

Dice Duel 5 — scores, winning and polish

Full course15 min read·4 questions

Give the game an ending. A named target, a winner that stops being zero, a banner that springs in over a dimmed board, progress bars on both panels, and one button that puts everything back to nothing.

A game with no finish line

Chapter 4 left you with something genuinely nice to look at. Tap the die, it tumbles, a number lands, a score climbs.

And then it keeps climbing. 30. 47. 112. Nobody wins, because nothing in the code has ever heard of winning. Two players can sit there tapping until one of them gets bored, and being bored is not the same as losing.

This chapter is the last 10% — the part that turns a working toy into a finished thing. An ending, a rematch button, and three small touches that make the board look designed rather than assembled.

Think of it like this

Think about a running track.

You can jog round it forever. The track does not care. But paint one white line across the lanes and everything changes: now there is a before the line and an after, and every stride means something because it is closer to that line.

TARGET = 30 is the white line. The progress bars are the marker boards at the side of the track telling you how much is left. The banner is the person with the stopwatch shouting a name.

None of it changes how fast anyone runs. All of it changes whether the running is a race.

The rule gets a name

Right at the top of the file, above every composable:

GameScreen.ktkotlin
1// Reach this score and the duel is over.
2private const val TARGET = 30

is a value fixed when the app is built. The number 30 is baked straight into the compiled code wherever TARGET appears — there is no lookup at runtime, so it costs exactly what typing 30 would have cost.

The point is not speed. The point is that 30 now lives in one place. The subtitle changes to read from it:

GameScreen.ktkotlin
1    Text(
2        text = "First to $TARGET wins the duel",
3        color = scheme.onSurfaceVariant,
4        fontSize = 15.sp,
5        fontWeight = FontWeight.Medium,
6        letterSpacing = 1.sp
7    )

So does the progress bar, and so does the line under the banner. Change 30 to 50 and four things on screen agree with each other without you touching any of them. A rule written down twice is a rule that will eventually disagree with itself.

Somebody has to win

One new piece of state, alongside die, p1, p2 and turn:

GameScreen.ktkotlin
1    // 0 means nobody has won yet.
2    var winner by remember { mutableStateOf(0) }

It is an , not a , because it has three answers to give, not two: nobody yet, player 1, player 2. Using 0 as "nobody" works because there is no player 0 — the sentinel value is impossible by construction, which is the only kind of sentinel worth trusting.

Now the scoring block inside the grows two words.

Two guards and a reset

Note that both of these are — declared inside GameScreen, so they can see and change p1, winner and the rest directly. Nothing outside this screen can call them, which is exactly right for one game's private rules.

Stacking the banner over the board

The board has been a Column since chapter 1. A Column puts things under each other, and the banner needs to sit on top. So the whole thing goes inside a :

GameScreen.ktkotlin
1    Box(modifier = modifier.fillMaxSize()) {
2        Column(
3            modifier = Modifier
4                .fillMaxSize()
5                .background(sky)
6                .safeDrawingPadding()
7                .padding(pagePad),
8            horizontalAlignment =
9                Alignment.CenterHorizontally
10        ) {
11            TitleBlock()
12            Spacer(Modifier.height(24.dp))
13            ScoreBoard(p1 = p1, p2 = p2, turn = turn)
14            Spacer(Modifier.weight(1f))
15            DieFace(
16                value = die,
17                size = 172.dp,
18                modifier = Modifier.graphicsLayer {
19                    rotationZ = spin
20                    scaleX = lift
21                    scaleY = lift
22                },
23                onRoll = { roll() }
24            )
25            Spacer(Modifier.weight(1f))
26            TurnHint(turn = turn, rolling = rolling)
27        }
28
29        if (winner != 0) {
30            WinBanner(
31                winner = winner,
32                onPlayAgain = { playAgain() }
33            )
34        }
35    }

A Box draws its children in the order you write them, each one over the last. The board is written first, so the banner lands on top of it.

Everything between TitleBlock() and TurnHint(...) is chapter 4's code, untouched apart from one number: the gap under the subtitle drops from 26dp to 24dp, because the panels are taller now that they carry progress bars.

The page padding moves into a named value declared with the other state:

GameScreen.ktkotlin
1    val pagePad = PaddingValues(
2        horizontal = 20.dp,
3        vertical = 18.dp
4    )

Same 20dp and 18dp as before. Naming it just keeps the Column's modifier chain readable now that there is a Box wrapped round it.

And then four lines do the entire ending. if (winner != 0) is not a special Compose feature — it is an ordinary Kotlin if in a place where composables are allowed. When winner changes from 0 to 1, that condition becomes true, Compose notices during and the banner enters the screen. When playAgain() sets it back to 0, the banner leaves. You never call "show" or "hide" anything.

The banner itself

This is the biggest new composable in Dice Duel, so it comes in two halves. First the values it works out before drawing anything.

Then the drawing.

Two touches on the board

A bar that fills as you climb

Each player panel gains a slim track under its score.

It is called from inside PlayerPanel, after the score:

GameScreen.ktkotlin
1        Spacer(Modifier.height(10.dp))
2        ScoreTrack(
3            score = score,
4            accent = accent,
5            modifier = Modifier.padding(horizontal = 20.dp)
6        )

The panel that is not your turn steps back

GameScreen.ktkotlin
1    val pop by animateFloatAsState(
2        targetValue = if (active) 1f else 0.95f,
3        animationSpec = tween(durationMillis = 220),
4        label = "panel"
5    )

…applied at the very top of the panel's modifier chain:

GameScreen.ktkotlin
1    Column(
2        modifier = modifier
3            .graphicsLayer {
4                scaleX = pop
5                scaleY = pop
6            }
7            .clip(shape)
8            .background(panel)
9            .border(3.dp, ring, shape)
10            .padding(vertical = 16.dp),

Five per cent is almost nothing measured with a ruler, and impossible to miss when it moves. Combined with the coloured ring that was already there, the active panel now has two independent signals — colour and size — which is what an interface needs if one of them is unavailable to a particular reader.

The panel's vertical padding drops from 18dp to 16dp at the same time, because the progress bar has made the card taller.

The die stops being flat

Two new imports in DieFace.kt:

DieFace.ktkotlin
1import androidx.compose.ui.graphics.Brush
2import androidx.compose.ui.graphics.lerp

and the flat fill becomes a gradient:

DieFace.ktkotlin
1    // A faint diagonal shade makes the flat square read
2    // as a solid object catching the light.
3    val shade = lerp(faceColor, pipColor, 0.09f)
4    drawRoundRect(
5        brush = Brush.linearGradient(
6            colors = listOf(faceColor, shade),
7            start = Offset.Zero,
8            end = Offset(side, side)
9        ),
10        size = Size(side, side),
11        cornerRadius = radius
12    )

mixes two colours: 9% of the way from the near-white face towards the near-black pips gives a very slightly grey off-white, about #E9E7EF. paints from Offset.Zero — the top-left corner — down to Offset(side, side), the bottom-right, so the shade runs diagonally.

Nine per cent is small on purpose. Turn it up to 0.4f and Run it once: the die stops looking like plastic and starts looking like a badly lit photograph. Then put it back.

9:41▲ ▮
DICE DUEL
First to 30 wins the duel
PLAYER 1
24
PLAYER 2
18
PLAYER 1 - TAP TO ROLL

Mid-game. Progress bars on both panels, player 2's panel drawn at 95%.

9:41▲ ▮
DICE DUEL
PLAYER 1 WINS
Reached 30 first
Play again

Player 1 has reached 30. The board is still there, at 7% through the scrim.

Now make it yours

Dice Duel is finished, which is a good moment to break it. Three changes, in rising order of difficulty, and none of them needs anything you have not already seen.

A different target. The easiest and the most instructive. Change TARGET to 20 and play a round; change it to 100 and play another. Notice that the subtitle, the progress bars and the banner's second line all follow without a single other edit. That is what naming a rule buys you. Then try making it a choice: replace the constant with var target by remember { mutableStateOf(30) } and put three small buttons on the win banner — 20, 30, 50 — that set it before the rematch. The compiler will tell you every place that assumed a constant.

Best of five. Add two more values, wins1 and wins2, and a round counter. playAgain() already resets a board; what you need now is a second level above it — a match that resets only when somebody reaches three. The interesting design question is what the banner says between rounds ("Player 1 takes round 2 — 2 to 1") versus at the end of the match, and you will find the answer is two different banners sharing one layout, in the same way EmptyMessage will be shared in Pocket Notes.

A third player. The hardest, and the one that teaches most. turn stops being 1-or-2 and becomes 1, 2 or 3, so if (turn == 1) … else … no longer covers the cases — that is a job for a . Three panels will not fit side by side at 46sp, so something has to shrink. And you will very quickly notice that p1, p2 and p3 as separate values is the wrong shape: a List<Int> of scores with turn as an index into it makes every one of these rules shorter. Rewriting the state before you rewrite the screen is the right order.

Try it in Pocket Studio

Two files change. Start from your chapter 4 project or the chapter 4 ZIP.

  1. Open Pocket StudioProjectsDice Duel, then open GameScreen.kt.
  2. Add the six new imports: Spring, spring, Box, PaddingValues, Button and ButtonDefaults. Let Pocket Studio insert them so the packages are right.
  3. Above @Composable fun GameScreen, add the two TARGET lines.
  4. In TitleBlock, change "First to 30 wins the duel" to "First to $TARGET wins the duel".
  5. Add var winner by remember { mutableStateOf(0) } under the four existing state values, and val pagePad = PaddingValues(...) under sky.
  6. Inside the LaunchedEffect, replace turn = 2 with if (p1 >= TARGET) winner = 1 else turn = 2, and the same for player 2.
  7. Change the first line of roll() to if (rolling || winner != 0) return, and add the whole playAgain() function beneath it.
  8. Wrap the Column in Box(modifier = modifier.fillMaxSize()) { ... }, change .padding(horizontal = ..., vertical = ...) to .padding(pagePad), change the 26dp spacer to 24dp, and add the if (winner != 0) { WinBanner(...) } block after the Column's closing brace.
  9. Add the whole WinBanner composable at the bottom of the file, then ScoreTrack above it.
  10. In PlayerPanel, add the pop animation, put .graphicsLayer { ... } first in the modifier chain, change the vertical padding to 16dp, and add the spacer plus ScoreTrack(...) after the score Text.
  11. Open DieFace.kt, add the Brush and lerp imports, and replace the first drawRoundRect with the gradient version.
  12. Tap Run. Tap the die until one score passes 30. The banner should cover the screen with the winner's colour on the border.
  13. Tap Play again. Both scores go to 0, both bars empty, and the die shows a 5 again.
  14. Now change private const val TARGET = 30 to 5 and Run. Two or three taps end the duel — which is the fastest way to check the banner and the reset without playing a full game. Put it back to 30.
  15. Try if (p1 > TARGET) instead of >= and Run with TARGET = 5. Sooner or later somebody lands exactly on 5 and the game refuses to end. Change it back.
Error Doctor4 common errors
e: file:///.../GameScreen.kt:411:41 None of the following candidates is applicable: fun buttonColors(): ButtonColors fun buttonColors(containerColor: Color = ..., contentColor: Color = ..., disabledContainerColor: Color = ..., disabledContentColor: Color = ...): ButtonColors
MeansYou wrote background = accent inside ButtonDefaults.buttonColors(...). background was the Material 2 name; Material 3 calls the fill containerColor. Kotlin cannot match your arguments to any overload, so it prints every one it has — which conveniently includes the real parameter names.
FixcontainerColor = accent, contentColor = onAccent. Whenever you see "None of the following candidates is applicable", read the list underneath as documentation rather than as noise: the answer is almost always spelled out in it.
e: file:///.../GameScreen.kt:306:31 Argument type mismatch: actual type is 'kotlin.Int', but 'kotlin.Float' was expected.
MeansfillMaxWidth(1) — the fraction is a Float between 0 and 1, and Kotlin never silently widens an Int into a Float. The same message appears if grown ended up an Int because you divided two whole numbers.
FixWrite 1f rather than 1. And in ScoreTrack, make one side of the division a float: score / TARGET.toFloat(). Plain score / TARGET compiles perfectly happily and gives 0 until the score actually reaches 30 — a bug with no error message attached.
e: file:///.../GameScreen.kt:127:9 'val' cannot be reassigned.
MeansYou tried to change TARGET while the app was running. private const val TARGET = 30 is fixed at compile time — the number is baked into the code, so there is nothing left at runtime to assign to.
FixLeave TARGET alone. If you want a target the player can choose, it has to be state instead: var target by remember { mutableStateOf(30) } inside GameScreen. Note that const also has to go — a const val can only ever hold a compile-time constant.
e: file:///.../GameScreen.kt:407:13 Unresolved reference 'Button'. e: file:///.../GameScreen.kt:416:17 @Composable invocations can only happen from the context of a @Composable function
MeansOne missing , two errors. Because Button is unknown, Kotlin cannot tell that the block after it is a composable slot, so the Text inside looks like it is sitting in ordinary code.
Fiximport androidx.compose.material3.Button and import androidx.compose.material3.ButtonDefaults. The second error disappears on its own — when a list of errors includes an unresolved reference, always fix that one first and rebuild before reading the rest.

Dice Duel — end of chapter 5 (finished)

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

Download ZIP
Recap
  • Dice Duel is finished. Five chapters: a screen, a hand-drawn die, state and turns, animation, and an ending.
  • A rule with a name — — is a rule that cannot disagree with itself. The subtitle, the progress bars and the banner all read the same number.
  • winner is an where 0 means nobody, because there is no player 0. Use >= for a finish line, never ==.
  • A draws its children over each other, so if (winner != 0) { WinBanner(...) } is the whole show-and-hide mechanism.
  • animates by physics rather than by duration, and LaunchedEffect(Unit) flipping one is how you start an entrance animation exactly once.
  • fillMaxWidth(fraction) turns a number between 0 and 1 into a progress bar. Guard it with and beware eating the fraction before you see it.
  • A at 93% leaves the board faintly visible, which says "paused" rather than "replaced".
  • Next: a new app, and a much bigger one. Pocket Notes stores real data in a real SQLite database using , and chapter 1 ends with the most valuable zero in the course.