Jetpack Compose Game Development: How One Dev Built an RPG Engine on Canvas
TL;DR
- A solo dev shipped an isometric RPG built 100% in Kotlin and Jetpack Compose, crossing 4,000 downloads and earning a Golden Kodee Community Award shortlist.
- A tiered system manager runs ECS systems at 60, 30, and 10 FPS depending on cost, keeping the game loop single-threaded and jank-free.
- Grayscale hue shifting at runtime generates 40 sprite variants from one base sheet, cutting RAM by 50% and APK size by 40%.
- Micro-optimizations like squared distance checks and fractional multiplication add up to meaningful frame-time savings.
- Jetpack Compose Canvas is more capable than most Android developers realize for real-time rendering work.
When most Android developers hear “jetpack compose game development,” their first instinct is skepticism. Compose is a UI toolkit. Games need a game engine. Those are different things, right?
One developer proved that instinct wrong. The project, Adventurers Guild, is a fully isometric RPG built entirely in Kotlin with Jetpack Compose as its rendering layer. No third-party game frameworks. No OpenGL shortcuts. Just Compose Canvas, a well-designed entity component system, and a lot of careful profiling. The game crossed 4,000 downloads and was shortlisted for the Golden Kodee Community Award.
What makes the project interesting isn’t the achievement itself. It’s the engineering decisions behind it. The developer shared the full technical breakdown in a discussion on r/androiddev, and the response from the community was immediate. Developers who build production apps for a living started copying notes.
This post walks through the key architectural choices, with code patterns you can apply to your own projects.
Why Compose Canvas Works for Game Rendering
Compose’s Canvas composable gives you direct access to a DrawScope on every frame. You can draw paths, bitmaps, shapes, and gradients with full control over transform matrices. It’s lower-level than most Compose developers ever use, but it’s exactly what a 2D game engine needs.
The mental model shift is this: instead of building a declarative UI that Compose recomposes reactively, you build a game loop that drives Compose like a display output. The game loop owns the state. Compose just draws whatever the loop hands it.
@Composable
fun GameScreen(gameEngine: GameEngine) {
val gameState by gameEngine.state.collectAsState()
Canvas(modifier = Modifier.fillMaxSize()) {
// gameState is a snapshot; draw it here
gameState.visibleEntities.forEach { entity ->
drawSprite(entity.sprite, entity.position, entity.tint)
}
}
LaunchedEffect(Unit) {
gameEngine.start()
}
}This pattern keeps Compose out of the game’s internal logic. The engine updates state, Compose draws it. Concerns stay separated.
The Tiered System Manager: 60, 30, and 10 FPS
The most discussed architectural decision was the tiered system manager. Instead of running all 28 ECS systems at 60 FPS, the engine groups them by update frequency:
- Fast tier (60 FPS): movement, input handling, animation, rendering
- Medium tier (30 FPS): combat resolution, status effects, collision detection
- Slow tier (10 FPS): pathfinding, AI decision-making, world simulation
One community member summed it up well: “the tiered system approach is smart. 10fps for pathfinding is one of those things that sounds wrong until you actually measure it.”
And that’s exactly right. Pathfinding results don’t change meaningfully between frames. A character following a path calculated 100ms ago looks identical to one recalculated every 16ms. The CPU cost is not identical. Running A* at 10 FPS instead of 60 FPS is a 6x reduction in pathfinding cost. Applied across 20 AI agents, that compounds quickly.
Here’s a simplified version of the tiered manager:
class TieredSystemManager(
private val fastSystems: List<ECSSystem>, // 60 FPS
private val mediumSystems: List<ECSSystem>, // 30 FPS
private val slowSystems: List<ECSSystem> // 10 FPS
) {
private var mediumAccumulator = 0L
private var slowAccumulator = 0L
private val mediumInterval = 1_000L / 30 // ~33ms
private val slowInterval = 1_000L / 10 // 100ms
fun tick(deltaMs: Long, world: World) {
// Fast systems run every frame
fastSystems.forEach { it.update(deltaMs, world) }
mediumAccumulator += deltaMs
if (mediumAccumulator >= mediumInterval) {
mediumSystems.forEach { it.update(mediumAccumulator, world) }
mediumAccumulator = 0L
}
slowAccumulator += deltaMs
if (slowAccumulator >= slowInterval) {
slowSystems.forEach { it.update(slowAccumulator, world) }
slowAccumulator = 0L
}
}
}The entire engine runs on a single thread. No thread synchronization. No race conditions. The game loop calls tick() every frame and each tier processes when its interval has elapsed. Simple, predictable, and easy to profile.
Another developer in the thread called the loop-to-tiered evolution “simply GENIUS” and noted they’d be retrofitting the approach into their own projects.
ECS Architecture in Kotlin: 28 Systems, One Thread
Entity Component System (ECS) is the dominant pattern in game development for good reason. It separates data (components) from behavior (systems), which makes it easy to add features without touching existing code and keeps data cache-friendly.
In Adventurers Guild, an entity is just an ID. Components are plain data classes. Systems operate on all entities that have a specific set of components.
// Component: pure data
data class Position(var x: Float, var y: Float)
data class Velocity(var dx: Float, var dy: Float)
data class Health(var current: Int, val max: Int)
data class Sprite(val sheet: ImageBitmap, val tint: Color)
// World: stores components by entity ID
class World {
val positions = mutableMapOf<EntityId, Position>()
val velocities = mutableMapOf<EntityId, Velocity>()
val health = mutableMapOf<EntityId, Health>()
val sprites = mutableMapOf<EntityId, Sprite>()
}
// System: operates on matching entities
class MovementSystem : ECSSystem {
override fun update(deltaMs: Long, world: World) {
val dt = deltaMs / 1000f
world.velocities.forEach { (id, vel) ->
world.positions[id]?.let { pos ->
pos.x += vel.dx * dt
pos.y += vel.dy * dt
}
}
}
}With 28 systems spanning day/night cycles, weather, procedural effects, combat, and pathfinding, the architecture stays manageable because each system is small and focused. Adding a new mechanic means writing a new system, not touching existing ones.
For a deeper look at ECS theory, Game Programming Patterns by Robert Nystrom has a free online chapter on the component pattern that’s worth reading before you start.
Grayscale Hue Shifting: 40 Variants from One Sprite Sheet
This was the memory optimization that surprised developers most. The problem: RPGs need lots of character variants. Warriors in different armor colors, the same enemy type with different faction colors, day/night palette shifts. Storing 40 separate sprite sheets would bloat the APK significantly.
The solution: store one grayscale sprite sheet and tint it at runtime using Compose’s ColorFilter.
fun DrawScope.drawSprite(
sheet: ImageBitmap,
position: Offset,
tint: Color
) {
val colorMatrix = ColorMatrix().apply {
// Apply hue-shifted tint while preserving luminance from grayscale source
setToScale(
redScale = tint.red,
greenScale = tint.green,
blueScale = tint.blue,
alphaScale = 1f
)
}
drawImage(
image = sheet,
topLeft = position,
colorFilter = ColorFilter.colorMatrix(colorMatrix)
)
}The math is straightforward. A grayscale pixel has equal R, G, and B values representing luminance. Multiplying each channel by a target color’s channel values applies that color while preserving the original shading and detail. A dark gray area stays dark. A light area stays light. The tint color determines the hue.
The results:
- 50% RAM reduction because you hold one sheet in memory instead of 40
- 40% smaller APK because you ship one asset instead of 40
- Zero visual quality loss because the grayscale source preserves all lighting information
This technique is standard in desktop and console game development but rarely discussed in Android contexts. The Android Developer documentation on ColorFilter covers the API surface, though the game development application takes some experimentation to get right.
The Optimization Triangle: CPU, RAM, and GPU
The developer framed performance decisions around a three-way tradeoff that every game developer eventually discovers:
You can optimize for CPU, RAM, or GPU. Optimizing for one usually costs another.
- Pre-computing pathfinding results saves CPU but uses RAM to store them
- Loading sprite variants as separate textures saves CPU (no runtime tinting) but uses RAM and APK space
- Using more draw calls gives the GPU fine-grained control but increases CPU overhead
Getting this balance right requires profiling under real conditions, not assumptions. The developer used Android Studio’s profiler alongside custom frame-time logging to identify which systems were actually the bottleneck before optimizing them.
Some practical decisions from the project:
- Day/night and weather effects run on the Canvas with procedural math rather than pre-rendered textures, saving RAM at acceptable GPU cost
- The combat system runs at 30 FPS (medium tier) because combat state doesn’t need per-frame precision
- Collision detection uses squared distance checks instead of
sqrteverywhere it can
That last point leads into the micro-optimizations.
Micro-Optimizations That Actually Matter
Individual micro-optimizations look trivial. They matter when you’re calling them thousands of times per frame.
Squared distance instead of sqrt:
// Slow: requires square root computation
fun isInRange(a: Position, b: Position, range: Float): Boolean {
val dx = b.x - a.x
val dy = b.y - a.y
return sqrt(dx * dx + dy * dy) <= range
}
// Fast: compare squared values, skip sqrt entirely
fun isInRangeFast(a: Position, b: Position, range: Float): Boolean {
val dx = b.x - a.x
val dy = b.y - a.y
val squaredRange = range * range
return (dx * dx + dy * dy) <= squaredRange
}sqrt is measurably expensive on ARM processors. For range checks where you’re comparing two distances, you don’t need the actual distance. You need to know which is larger. Squaring both sides of the comparison removes the sqrt entirely.
Multiply by fraction instead of divide:
// Slightly slower on some architectures
val half = value / 2f
// Consistently faster
val half = value * 0.5fDivision on floating-point units requires more cycles than multiplication on ARM. The difference per call is nanoseconds. Called 50,000 times per frame across all active entities and systems, it accumulates.
Avoid object allocation in hot paths:
// Bad: allocates a new Offset object on every frame for every entity
entity.position = Offset(entity.position.x + dx, entity.position.y + dy)
// Better: mutate in place with a mutable data class
entity.position.x += dx
entity.position.y += dyEvery allocation in a hot path means more garbage collection pressure. Kotlin’s value classes help here, as does pre-allocating objects and reusing them. The Kotlin documentation on value classes is relevant if you’re building data structures that need to minimize heap allocation.
Compose Canvas Features Used for Visual Effects
Beyond basic sprite rendering, the project uses Compose Canvas for sophisticated visual effects that would typically require a shader pipeline:
Day/night cycle: A semi-transparent dark overlay with a radial gradient “light source” drawn over the scene. The overlay opacity shifts on a time curve, with the gradient representing lanterns or fire. All pure Canvas draw calls.
Weather system: Rain draws as short diagonal lines using drawLine in a loop, with alpha varying per drop for depth. Snow uses drawCircle with varying radii and randomized positions that update each frame.
Procedural effects: Spell effects combine drawPath for shapes with animated transforms. Fire uses layered drawArc calls with changing sweep angles and opacity.
fun DrawScope.drawRainEffect(rainDrops: List<RainDrop>, alpha: Float) {
val paint = Paint().apply {
color = Color.White.copy(alpha = alpha)
strokeWidth = 1.5f
}
rainDrops.forEach { drop ->
drawLine(
color = Color.White.copy(alpha = drop.opacity * alpha),
start = Offset(drop.x, drop.y),
end = Offset(drop.x + drop.angle * 8f, drop.y + 16f),
strokeWidth = 1f
)
}
}None of this requires OpenGL or custom shaders. The Canvas API is expressive enough for most 2D visual effects when you understand its draw primitive set.
What This Means for Your Android Projects
You don’t need to build a game to apply these ideas. Several of the techniques from Adventurers Guild translate directly to production app development:
- Tiered update frequencies apply anywhere you have background processing at different importance levels. Analytics batching, sync jobs, and cache invalidation all have natural frequency tiers.
- Grayscale tinting works for any app with user-selectable themes or avatar customization. One asset plus a
ColorFilterbeats maintaining N themed asset variants. - ECS-style separation of data and behavior maps well to reactive architectures. Components are your state; systems are your use cases.
- Squared distance checks apply anywhere you’re doing proximity detection, hit testing, or spatial queries in app code.
The broader lesson is that Compose Canvas is significantly more capable than most Android developers explore. If you’re building data visualizations, custom drawing surfaces, or animation-heavy features, the Canvas API is worth deep investment before reaching for a third-party library.
If you’re growing an Android app and want to understand how technical quality improvements affect user perception, ExtensionBooster tracks Play Store ratings and review trends across release cycles. It’s a practical way to correlate engineering work with the user sentiment it produces.
Frequently Asked Questions
Is Jetpack Compose fast enough for real game development?
For 2D games with reasonable complexity, yes. Compose Canvas draws via the same hardware-accelerated rendering pipeline as the rest of Android’s UI. The constraint isn’t raw throughput but predictability. As long as you keep your draw calls efficient and avoid allocating objects in hot paths, Compose Canvas can sustain 60 FPS on mid-range devices.
Do I need an ECS architecture to build a game in Compose?
No, but it helps once your game grows beyond a handful of interacting systems. ECS keeps individual systems small, testable, and composable (in the programming sense). For a simple game with a few mechanics, a plain class hierarchy works fine.
How does the tiered system manager handle systems that need to share state across tiers?
Since everything runs on a single thread, there’s no synchronization concern. Slow systems write their results (like pathfinding waypoints) to components. Fast systems read those components every frame. The data is always from the most recent slow-tier update, which is recent enough for smooth gameplay.
Can the grayscale hue-shifting technique work for UI theming in non-game apps?
Yes. Any time you have iconography or illustrations that need to adapt to different color themes, storing a single grayscale version and applying ColorFilter at render time reduces your asset count. It works best when your source artwork has high contrast and clear luminance variation.
What are the biggest limitations of building a game on Compose Canvas?
No built-in physics engine, no 3D support, and no shader pipeline. You’re implementing game systems from scratch rather than reaching for engine features. For 2D games, this is manageable. For anything requiring 3D rendering or complex physics simulation, a dedicated engine like Unity or Godot is a better starting point.
Where can I learn more about ECS for Android game development?
The r/androiddev community has ongoing discussions about game development on Android. Game Programming Patterns by Robert Nystrom covers ECS and related patterns in depth. The official Compose Canvas documentation is the best reference for the drawing API itself.
Share this article
Build better extensions with free tools
Icon generator, MV3 converter, review exporter, and more — no signup needed.
Related Articles
AI Tools for Android Development: What Works, What Breaks, and What to Skip
Honest look at AI coding tools for Android in 2026. Where Claude Code and Cursor excel, where they fail, and how to avoid shipping broken code.
Android Adaptive Layouts: Your App on Foldables, Tablets, and Everything in Between
300M+ large-screen Android devices. Android 17 mandates adaptive support. Window size classes, canonical layouts, and foldable posture handling.
Android App Marketing & ASO: The Full-Funnel Google Play Strategy for 2026
Google Play now ranks retention over downloads. The 2026 Android ASO framework — keyword research to Day-7 retention loops that compound growth.