Rendering the Portal's 50-shard mosaic with zero bitmaps
30 AUGUST 2026Number Run: Pro Edition's long-term progression arc is the Portal — collect 50 Shards across many runs and it fully activates. The obvious way to show that progress is a ring divided into 50 wedges that light up one at a time. The less obvious part was doing it without a single piece of bitmap art, which is a rule the whole project holds itself to: every visual — orb skins, world backgrounds, UI, particles — is drawn on Canvas at runtime.
Why not just 50 pre-baked wedge images
A sprite sheet of 50 identical pie-slice wedges would have been the fast way out, but it's also the boring way out — uniform wedges read as a plain progress bar wearing a circle costume. The actual goal was for the mosaic to look like something that was assembled, with each fragment slightly irregular, the way a real shattered-and-reassembled object would look.
Deterministic, seeded geometry
Each shard's shape is generated once, from a fixed seed, the first time the mosaic composable is built:
private fun buildShardGeometry(required: Int, seed: Int = 0x506F7274): List<ShardGeo> {
val rnd = Random(seed)
val step = 360f / required
val gap = step * 0.16f
return List(required) { i ->
ShardGeo(
startDeg = i * step + gap / 2f,
endDeg = (i + 1) * step - gap / 2f,
innerR = 0.40f + rnd.nextFloat() * 0.05f,
outerR = 0.90f + rnd.nextFloat() * 0.07f,
jitter = FloatArray(4) { (rnd.nextFloat() - 0.5f) * 0.05f }
)
}
}
The fixed seed matters more than it looks like it should. Without it, every recomposition would reshuffle the wedge shapes and the mosaic would visibly "reshuffle" itself on screen for no reason. With it, the 50 shapes are stable for the lifetime of the app but still look organically irregular rather than mechanically uniform — each one gets its own inner radius, outer radius and four corner-jitter values, all derived from the same seeded stream.
Lit vs. unlit is a completely different draw path
An unlit shard isn't a dimmed version of a lit one — it's drawn as a bare 1px outline at 8% opacity, nothing more. A lit shard gets a two-pass soft glow (layered translucent strokes, not a blur filter — Compose's Canvas has no cheap blur, so "glow" everywhere in this game means several overlapping semi-transparent shapes instead), a radial gradient fill from white-hot at the center to the Portal's accent color at the edges, and a crisp white rim stroke. The visual gap between "not earned" and "earned" needed to be large, because 49-out-of-50 lit should still read as meaningfully different from all 50.
The pop-in animation only plays for genuinely new shards
This was the part that actually took longest to get right. If you open the Portal screen with 30 shards already owned, none of them should animate in — they were earned in previous sessions and should just be there. Only a shard earned during the current session gets the 700ms scale-and-settle entrance. That distinction lives in a small piece of state that snapshots "already owned" on first composition and only animates the delta:
LaunchedEffect(Unit) {
repeat(shardsOwned) { settled[it] = true }
}
LaunchedEffect(shardsOwned) {
if (shardsOwned > lastKnownOwned) {
for (i in lastKnownOwned until shardsOwned) {
val anim = Animatable(0f)
entrance[i] = anim
launch {
anim.animateTo(1f, tween(700, easing = FastOutSlowInEasing))
settled[i] = true
entrance.remove(i)
}
}
}
lastKnownOwned = shardsOwned
}
Get this backwards and every visit to the Portal screen replays all 30 entrance animations at once, which looks like a bug even though nothing is technically wrong.
The takeaway
None of this needed a single texture. A seeded random generator, some polygon math, and layered-alpha shapes standing in for glow got a result that looks hand-crafted rather than programmatically tiled — which was really the whole bet behind building this game without bitmap assets in the first place.
← Back to Devlog