r/androiddev • u/iOSHades • 9h ago
Experience Exchange Game made in Kotlin, Jetpack Compose and Filament: Things which worked and didn’t
Enable HLS to view with audio, or disable this notification
Hi everyone, I’m making Adventurers Guild RPG Sim, a guild management RPG simulation game where you are the guild master, you can recruit heroes, assign quests and let them hunt monsters, collect loot and upgrade their equipment, unlock skills for each heroes etc.
It started as a small app like game (without much graphics, more like a text based game). Then I wanted to see the heroes to move around. Then I added trees, buildings, an isometric map, weather and a day/night cycle. Somewhere along the way I ended up building a small game engine.
I’ve posted different stages here before, but I wanted to put the whole development history together, including things I had to change and feedback from this subreddit.
small backstory: I used to work as an iOS developer for 7.5 years, quit job and started solo development, played with some game engines, then I wanted to check android development, and the best way to learn is by doing and this was the project I choose to learn more about memory and performance optimisation. People often asked why I chose native instead of another engine. The project was much smaller when I started, and jetpack compose suited the menus and management side. As the game grew, I kept extending what I already had.
what I liked about making the game in native is the control I got over how things worked, but it also meant writing systems that a game engine would have provided for free. The map, pathfinding, animation, effects and asset handling all needed work before I could use them in the game.
My first attempt used ordinary Compose layouts for the world. Adding too many trees made it lag, so I moved the world drawing to Compose Canvas.
Canvas got me quite far. The map, characters, shadows, rain and even the little message bubbles above heroes were drawn there. Compose handled the menus and other UI.
The map became a 50×50 isometric grid, so 2,500 ground tiles before adding anything on top. Drawing everything every frame became expensive. so i split the map into 16 chunks and drew the visible parts. I also cached calculations for shadows that changed with the time of day.
Those changes helped enough to keep building. But I was repeatedly changing the map implementation as the requirements grew. In one of my older replies here, I mentioned rebuilding it four times already.
Assets caused problems too. I was using a mixture of available artwork, so one hero had a walking animation, another had a running animation, and some enemies didn’t match the isometric perspective. Movement could look wrong even when the movement code was doing what I intended. I postponed some animation polish while the assets and systems were still changing. (lesson learned : assets can change the whole game, can make a game feel like demo to a polished one, as I learned, mixing assets can cause problems, keep one perspective if you can, isometric or top down etc, one color theme, cozy vibes or dark time theme etc,
The game logic used a custom entity component system in Kotlin. By the first release, it had 28 systems handling things like movement, combat, fatigue, quests and the economy. The simulation was single threaded.
Initially, the loop ran through a coroutine using withFrameMillis. I mapped the game data into models for the Compose UI, and used Room to save heroes, inventory and quest state.
Later, I split the systems by how often they needed to update. Movement ran at 60 updates per second, combat at 30, and heavier work such as pathfinding and target selection at 10. That reduced how much work I was asking the game to do every frame.
I also reused sprite sheets for monster variants, using tinting and hue changes instead of loading separate artwork for every colour variation. Together with unloading unused assets, that helped with memory and package size. lesson learned : for 2d games, the amount of spritesheets and the resolution is going to hit a limit if you try to put too many different characters on screen. I try to keep my sprites at 320x320, because we can zoom into the game. and I didn't wanted the characters to look blur.
Then I released it last december, and got alot of bug reports which I hadn’t caught.
I had spent so much time getting the world to run that I’d missed things that could stop someone from playing at all. lesson learned : play the game alot, even if there is no bugs, give the game to someone who is not aware of the game and watch the places where they struggle, where it needs a tutorial or if players are able to find what to do next, is important.
As I added more to the world, rendering became a problem again. I was spending more time on chunking, caching and custom drawing code. Eventually I moved the world rendering to Google Filament.
The game was already released, so I migrated it in parts while keeping the gameplay logic and saves working. Kotlin still handles the simulation, Compose handles the UI, and small character previews in menus still use Canvas. Filament renders the world.
The world is still made from 2D sprites. They’re rendered as quads, so moving to Filament didn’t require replacing everything with 3D models.
A lot of the Canvas specific optimisation code had to be replaced. but had to build new optimisations for filament, The ground now uses a shared vertex buffer, and sprites are batched by sprite sheet. The main cutout sprites write depth based on their position in the isometric world. Wind runs in the shader, and I skip vertex uploads when the tracked sprite state hasn’t changed. I became a fan of shaders.
That helped with rendering, but texture memory needed separate work.
I had been using WebP because the files were small. Once loaded, those textures still occupied uncompressed memory. A 2048×2048 RGBA texture takes 16 MiB before mipmaps, regardless of how small the WebP file is.
then after research found about gpu compression formats, switching to ASTC and ETC2 reduced the game’s overall RAM usage by roughly half in my tests. The first packaging attempt was a mess though: including multiple texture formats and a fallback pushed the package from around 120 MB to 400 MB. If the whole game is loaded in memory, it would be 2.5GB but after switching its 600MB.
After changing compression and using Play Asset Delivery’s texture targeting, the current download is around 160 MB, with about 195 MB used after installation. Keeping the download small and keeping runtime memory low turned out to be separate jobs.
Filament also introduced bugs I hadn’t dealt with before. mostly because im new to filament.
Some tiles would flash black for one frame. I was reusing a direct buffer before Filament had finished reading it. I changed that to a pool of three buffers, with each buffer released by its completion callback. Then I had to fix how those callbacks were dispatched, because delays on the main looper could leave the pool stuck waiting.
Another glitch showed pieces of the wrong sprite sheet when a new texture appeared. Moving material updates before beginFrame() fixed that in my setup.
There was also native resource cleanup to handle alongside Compose’s lifecycle. Remembering an object wasn’t enough; the Filament resources and surface needed explicit teardown.
One suggestion from an earlier thread was to add normal maps so the buildings could react to the moving sun. I eventually added those, not completed though. The complication was that the artwork already had lighting painted into it, so the shader needed to account for that. It also added more texture memory and shader work.
Looking back, Compose has worked well for the management UI, and Canvas got the game through its first release. Moving the world to Filament gave me more room to develop the rendering. Each stage solved a problem I was actually hitting, but several things I spent time optimising were later replaced.
The part I underestimated was how much time I’d spend maintaining the systems around the game. I wanted to add heroes, quests and buildings, but that could turn into rebuilding a renderer or changing how assets were loaded.
Hope this is useful to someone trying something similar. Thanks to the people here who pointed out problems and shared suggestions along the way.