Pocket Studio Academy
HomePart 55.2

Dice Duel 2 — drawing a die face with Canvas

Free lesson15 min read·4 questions

You draw a real die with no picture files at all — a rounded square and up to six pips placed on a 3×3 grid, computed from whatever size the composable is given, so it stays razor sharp from 88dp to 400dp.

Where we got to, and what is missing

Last time you ended with a violet title, two player panels reading zero, and a large empty space under them. The app installs, it follows dark mode, it has your own icon. It also does nothing.

Today you fill that empty space with a die.

Not a photograph of a die. Not a picture file that somebody drew in an art program. An actual drawing, made by your code, out of one rounded square and up to six circles.

That decision has consequences you will feel all the way to chapter 5:

  • It is sharp at any size. The same composable draws the 172dp die in the middle of the board and the 88dp die in the win banner. No blurring, no second file.
  • It costs zero kilobytes. A set of six die-face PNGs at four screen densities is twenty-four files. This is one function.
  • You can change it. Want square pips? Change one line. With a picture you would be back in an art program.
Think of it like this

Imagine describing a die to somebody over the phone, so they can draw it.

You would not read out the colour of every dot on a grid of graph paper. You would say: draw a square with rounded corners, about as wide as your hand. Now imagine a noughts-and-crosses grid on it. Put a dot in the top-left box, the top-right box, the middle, the bottom-left and the bottom-right.

That is a five. It works whether they are drawing on a postcard or a whiteboard, because you described the die in proportions, not in measurements.

A is somebody on the other end of that phone. It hands you a blank rectangle and a set of instructions you can shout at it. Every number you will write in this lesson is a fraction of the rectangle's own size, which is exactly why the die never blurs.

The shape of the file

DieFace.kt is new, and it holds three things:

WhatJob
DieFaceA [[composablecomposable]] — the thing you put on screen
pipsForAnswers "where do the dots go for a 4?"
DrawScope.drawDieDoes the actual painting

Only the first is public. The other two are private helpers, because nothing outside this file should ever need them.

Here are the imports. They are worth reading rather than skipping, because half the errors in this lesson are a missing one:

DieFace.ktkotlin
1package com.nativeworks.diceduel
2
3import androidx.compose.foundation.Canvas
4import androidx.compose.foundation.layout.size
5import androidx.compose.foundation.shape.RoundedCornerShape
6import androidx.compose.runtime.Composable
7import androidx.compose.ui.Modifier
8import androidx.compose.ui.draw.shadow
9import androidx.compose.ui.geometry.CornerRadius
10import androidx.compose.ui.geometry.Offset
11import androidx.compose.ui.geometry.Size
12import androidx.compose.ui.graphics.Color
13import androidx.compose.ui.graphics.drawscope.DrawScope
14import androidx.compose.ui.graphics.drawscope.Stroke
15import androidx.compose.ui.tooling.preview.Preview
16import androidx.compose.ui.unit.Dp
17import androidx.compose.ui.unit.dp
18import com.nativeworks.diceduel.ui.theme.DiceDuelTheme
19import com.nativeworks.diceduel.ui.theme.DieFaceLight
20import com.nativeworks.diceduel.ui.theme.DiePipDark

Notice Offset and Size come from ui.geometry, while Color comes from ui.graphics. Two different packages, and Pocket Studio will happily offer you the wrong one if you are not reading. Size in particular has several namesakes.

The composable

Where do the dots go?

A standard die is not six arbitrary patterns. Every face is dots placed on a three-by-three grid, and once you see that, the whole thing collapses into a small lookup:

text
1   col 0   col 1   col 2
2    +-------+-------+-------+
3row0|  •    |       |   •   |
4    +-------+-------+-------+
5row1|       |   •   |       |
6    +-------+-------+-------+
7row2|  •    |       |   •   |
8    +-------+-------+-------+
9        a five: 0,0  2,0  1,1  0,2  2,2

The painting

Now the part that actually puts colour on the screen.

Add a preview so you can look at it without building the whole app:

DieFace.ktkotlin
1@Preview(showBackground = true)
2@Composable
3private fun DieFacePreview() {
4    DiceDuelTheme {
5        DieFace(value = 5)
6    }
7}
Tip

Change value = 5 to each number from 1 to 6 in turn and look at the preview. This is the fastest possible way to check a lookup table, and it takes about fifteen seconds. If a face looks mirrored, you have written row to col somewhere instead of col to row.

Putting it on the board

GameScreen.kt needs four new lines inside its Column, and no other change:

GameScreen.ktkotlin
1        TitleBlock()
2        Spacer(Modifier.height(26.dp))
3        ScoreBoard(p1 = 0, p2 = 0, turn = 1)
4        Spacer(Modifier.weight(1f))
5        DieFace(value = 5, size = 172.dp)
6        Spacer(Modifier.weight(1f))
7        Text(
8            text = "Tap the die to roll",
9            color = scheme.onSurfaceVariant,
10            fontSize = 15.sp,
11            fontWeight = FontWeight.Medium
12        )

The two Spacer(Modifier.weight(1f)) calls are doing something clever and completely invisible. A with a claims a share of whatever space is left after everything with a fixed size has been measured. Two of them, both 1f, split the leftover space exactly in half — so the die floats in the middle of the gap and the hint is pinned to the bottom, on a tall phone and a short one alike.

The alternative — Spacer(Modifier.height(120.dp)) — works perfectly on the phone you are holding and looks wrong on every other one.

Careful

"Tap the die to roll" is a lie until chapter 3. Nothing happens when you tap it.

Writing the label before the behaviour is a real technique, not a mistake: the screen now tells you what it is missing every time you look at it. Chapter 3 makes it true, then replaces it with something better.

What you get

9:41▲ ▮
DICE DUEL
First to 30 wins the duel
PLAYER 1
0
PLAYER 2
0
Tap the die to roll

End of chapter 2. The die is drawn, not photographed — and it is stuck on five.

In dark mode the board goes from #121022 to #2A2545 and the die stays bright white, which makes it the obvious thing on the screen to touch. That is not an accident — it is why DieFaceLight and DiePipDark were kept outside the in chapter 1.

Try it in Pocket Studio
  1. Open your Dice Duel project in Pocket Studio. If you skipped chapter 1, download the ZIP at the bottom of that lesson and open it instead.
  2. In the file tree, tap the folder app/src/main/java/com/nativeworks/diceduel.
  3. Tap New → Kotlin File and name it DieFace. Pocket Studio adds the package line for you.
  4. Type the imports, then DieFace, pipsFor and drawDie. Take the imports seriously — if Pocket Studio offers you androidx.compose.ui.geometry.Size, accept it; if it offers android.util.Size, do not.
  5. Add the @Preview function at the bottom and look at it. You should see a five.
  6. Change DieFace(value = 5) in the preview to value = 3, then value = 6. Check each face. Set it back to 5.
  7. Open GameScreen.kt. Inside the Column, after the ScoreBoard(...) line, add the two weighted Spacers, the DieFace call and the Text hint exactly as shown.
  8. Tap Run. The die should appear, centred, in the middle of the empty space.
  9. Change size = 172.dp to size = 260.dp and Run again. Look closely at the corners and the pips: they are still perfectly proportioned, and not a single pixel is blurred.
  10. Put it back to 172.dp.
Error Doctor5 common errors
e: DieFace.kt:77:16 Unresolved reference 'size'. e: DieFace.kt:83:5 Unresolved reference 'drawRoundRect'. e: DieFace.kt:102:9 Unresolved reference 'drawCircle'.
MeansThree or four errors from one cause: you wrote private fun drawDie(...) instead of private fun DrawScope.drawDie(...). size, drawRoundRect and drawCircle all belong to DrawScope; outside it, none of them exists.
FixPut DrawScope. back in front of the function name, and keep import androidx.compose.ui.graphics.drawscope.DrawScope. When one missing word produces a cluster of unresolved references, always look at the line the cluster is inside, not at the errors themselves.
e: GameScreen.kt:151:29 None of the following candidates is applicable: fun Modifier.height(intrinsicSize: IntrinsicSize): Modifier fun Modifier.height(height: Dp): Modifier
MeansYou wrote Modifier.height(24). Layout sizes are Dp values, never plain Ints, so Kotlin lists both overloads to show that neither takes a number.
FixModifier.height(24.dp), with import androidx.compose.ui.unit.dp. Same story for size(172) — it wants 172.dp.
e: GameScreen.kt:181:5 Unresolved reference 'Text'.
MeansText is an ordinary function living in a package. If the import is missing, Kotlin has genuinely never heard of it. The identical message appears for Canvas, Offset and even your own DieFace if the file is in a different package.
FixAdd import androidx.compose.material3.Text. In Pocket Studio, tap the red name and take the import it offers — but read which package it picked before accepting.
e: GameScreen.kt:434:5 Functions which invoke @Composable functions must be marked with the @Composable annotation e: GameScreen.kt:435:5 @Composable invocations can only happen from the context of a @Composable function
MeansYou called DieFace(...) or Text(...) from a plain fun. Composables can only be called from other composables — and drawDie is a plain function, on purpose, because drawing commands are not composables.
FixAdd @Composable above the calling function. If you were trying to call DieFace from inside drawDie, that is the wrong idea entirely: drawDie paints pixels, DieFace describes a piece of screen. Keep them separate.
The die shows the right number of pips but they are in the wrong corners
MeansNot a compiler error — a logic one. Somewhere in pipsFor a pair is written row to col instead of col to row, so the face is mirrored along the diagonal. It is invisible on 1, 4 and 5, and obvious on 2 and 3.
FixCheck every entry reads column first. 2 to 0 is the top-right box. If your 2 runs bottom-left to top-right instead of top-left to bottom-right, the pairs are swapped.

Dice Duel — end of chapter 2

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

Download ZIP
Recap
  • A is a composable that occupies space and hands you drawing commands. It has no appearance of its own — only what you paint.
  • Inside it you are in a , which knows your exact size in pixels and provides drawRoundRect, drawCircle and the rest. Write your drawing helper as fun DrawScope.drawDie(...) so it can reach them.
  • Express every measurement as a fraction of the size you were given. That is what makes one DieFace correct at 88dp and at 260dp.
  • A die face is six patterns on a 3×3 grid, so pipsFor is a when returning a of . A when used as an expression needs else.
  • style = Stroke(...) outlines a shape instead of filling it — used here for a 10% hairline so a near-white die does not dissolve into a pale background.
  • Two Spacers with equal centre the die in whatever room is left, on any phone.
  • Next: memory. Four pieces of — the die, two scores, whose turn it is — plus Random.nextInt(1, 7) and a tap listener, and this becomes a game you can actually play.