Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building a Mobile Game Without an Engine, a Bud...

Building a Mobile Game Without an Engine, a Budget, or a Clue

Every developer dreams of building a game at some point. I did too. But with no time, no budget, and no prior game dev experience, it felt like something I’d never realistically pull off. This talk is the war story of how I proved myself wrong.

I'll walk you through how I turned an idea, a game I could play one-handed while walking my dog, into a working Kotlin-powered mobile game with in-app purchases, localization, and community traction. No Unity. No backend. No big budget. Just a roadmap, some creativity, and a lot of train rides for development.

We’ll look at how I made strategic tech decisions to avoid unnecessary complexity (like skipping an engine and going full Kotlin), how I kept costs near zero (using Fiverr, AI-generated assets, and open licensed music), and how I kept momentum alive with a clear MVP goal and low-pressure side project mindset.

If you’ve ever wanted to ship your own game but felt overwhelmed by the barrier to entry, this talk will give you a realistic, developer-first blueprint that starts with Kotlin and ends with a playable, publishable game.

Key Takeaways / Learning Points
- Why you don’t need a game engine to start building games as an Android dev.
- How to approach a side project with minimal time and budget and still ship.
- How to build a working MVP game in Kotlin, from graphics to gameplay.
- Tips for low-cost asset creation: AI tools, Fiverr, free music, and DIY design.
- How to start monetizing early (ads, IAPs) without a backend.
- Lessons from launching: app store assets, community traction, GDWC, and early feedback loops.
- The importance of scoping, accessibility, and localization from day one.

Avatar for James Cullimore

James Cullimore

November 01, 2025
Tweet

More Decks by James Cullimore

Other Decks in Programming

Transcript

  1. <com.mapbox.maps.MapView android:id="@+id/mapView" android:layout_width="match_parent" android:layout_height="match_parent"/> private val onIndicatorPositionChangedListener = OnIndicatorPositionChangedListener {

    viewModel!!.handlePositionChanged(it) binding!!.mapView.mapboxMap.setCamera(cameraOptions.center(it).build()) binding!!.mapView.gestures.focalPoint = binding!!.mapView.mapboxMap.pixelForCoordinate(it) } mapView.mapboxMap.loadStyle( style(style = STYLE_URI) { +projection(ProjectionName.GLOBE) +rasterDemSource(SOURCE) { url(TERRAIN_URL_TILE_RESOURCE) tileSize(512) } +terrain(SOURCE) { exaggeration(1.1) } } ) { initLocationComponent() setupGesturesListener() } …
  2. data class Soldier ( val identity: Identity, override val attributes:

    Attributes, override val status: Status = Status(attributes.health), val skills: Skills, val equipment: ArrayList<Equipment> = arrayListOf() ): Combatant() { … } data class Attributes ( var health: Float = 50f, // Max health points available var stamina: Float = 1f, // How often certain types of attack that can be done var strength: Float = 1f, // Melee var dexterity: Float = 1f, // Ranged var intelligence: Float = 1f, // Magic var endurance: Float = 1f, // Phy Def var willpower: Float = 1f, // Mag Def var agility: Float = 1f, // Evasion var accuracy: Float = 1f, // Rolls against evasion var speed: Float = 1f, // Movement speed var luck: Float = 1f, // Critical attack chance ): Serializable { … } data class Status ( var health: Float, var stamina: Float = 100f, …
  3. while (soldier.health > 0.1 && enemy.health > 0.1) { if

    (Random.nextInt(soldier.accuracy) >= Random.nextInt(enemy.agility)) { val attack = calculateAttack(soldier, enemy) addToBattleLog(BattleLogItem( "${soldier.getName()} dealt ${attack.toInt()} damage to ${enemy.getName()}", )) enemy.status.health -= attack } else { addToBattleLog(BattleLogItem("${enemy.getName()} evaded ${combatant.getName()}")) } … } if (staA.health > attrA.health * 0.1) { combatant.status.isFighting = false if (team.any { it.id == combatant.id }) { if (enemies.any { it.status.health > it.getTotalAttributes().health * 0.1 }) { fight(combatant) } } else { if (team.any { it.status.health > it.getTotalAttributes().health * 0.1 }) { fight(combatant) } …
  4. var closestEnemy: Soldier? = null val enemyTeam = if (team.any

    { it.id == soldier.id }) enemies else team while (!soldier.status.isFighting) { //TODO check for how many people are already attacking this combatant enemyTeam.filter { it.status.health > it.health * 0.1 }.minByOrNull { val dx = it.status.position.first - combatant.status.position.first val dy = it.status.position.second - combatant.status.position.second dx * dx + dy * dy }?.let { closestEnemy = it battleInterface?.moveToPosition(combatant, it) } … } … private fun calculateAttack(self: Combatant, enemy: Combatant): Float { var attack = (BASE_ATTACK * self.getTotalAttributes().strength) / enemy.getTotalAttributes().endurance val selfLuck = Random.nextInt(self.getTotalAttributes().luck.toInt()) val enemyLuck = Random.nextInt(enemy.getTotalAttributes().luck.toInt()) if (enemyLuck.times(2) <= selfLuck) { attack *= 1.5f addToBattleLog("${self.getName()} landed a critical hit on ${enemy.getName()}") } …
  5. val animator = ValueAnimator.ofFloat(0f, 1f) animator.duration = 500L animator.addUpdateListener {

    animation -> val fraction = animation.animatedValue as Float val cX = startX + (newDestinationX - startX) * fraction val cY = startY + (newDestinationY - startY) * fraction combatant.status.position = Pair(cX, cY) positions[combatant.id] = Pair(cX, cY) val currentDistance = getDistance(cX, cY, enemyPos.first, enemyPos.second) if (currentDistance < minDistance) { animator.removeAllUpdateListeners() … } } animator.doOnStart { animations.add(animator) } animator.doOnEnd { animations.remove(animator) } animator.start() https://github.com/nicole-terc
  6. abstract class Combatant { var element: Element? = Element.random() abstract

    val attributes: Attributes abstract val status: Status abstract fun getName(): String abstract fun onDefeat(showInfo: (String) -> Unit) … } data class Soldier ( val identity: Identity, override val attributes: Attributes, override val status: Status, val skills: Skills, val equipment: ArrayList<Equipment> ): Combatant(), Claimable { … } sealed class Beast: Combatant(), Event { abstract val attributeMultiplier: Float abstract val attributeFocus: Attributes abstract val appearanceProbability: Int, … } data class Chimera(…): Beast() { … } abstract class Item: Purchasable() { … } abstract class Purchasable: Claimable { var amount = 1 abstract val stack: Int abstract val iconRes: Int abstract val stringRes: Int abstract val baseValue: Float abstract val category: ItemCategory … } sealed class Equipment: Item(), Equippable, Quality { abstract val material: MaterialType var condition: Int = Random.nextInt(20, 100) var enchantment: Enchantment? = null var equipmentSlot: EquipmentSlot? = null override fun onEquip() { … } … } sealed class Weapon: Equipment(), Craftable { data class Sword (…): Weapon() { … }
  7. class ItemsViewModel : BaseViewModel() { var travelling: Boolean = true

    private val inventory get() = Inventory.getItems(travelling) val inventorySize get() = Inventory.getInventorySize(travelling) val selectedItem: ObservableField<Item?> = ObservableField() val items: ObservableList<Item> = ObservableArrayList() val storageItems: ObservableList<Item> = ObservableArrayList() val overflow: ObservableList<Item> = ObservableArrayList() val itemBinding: OnItemBindClass<Item> = OnItemBindClass<Item>() .map(Item::class.java) { b: ItemBinding<*>, _: Int, item: Item -> b.clearExtras() b[BR.item] = R.layout.item_inventory_item b.bindExtra(BR.item, item) b.bindExtra(BR.viewModel, this) } fun selectItem(item: Item) { … } fun itemAction(soldier: Soldier?) { … } fun setCategory(itemCategory: ItemCategory) { … } fun discardItem(item: Item, amount: Int = 1) { … } …
  8. fun setupBanner(adContainerView: FrameLayout) { adView = AdView(requireContext()) adContainerView.addView(adView) adContainerView.viewTreeObserver.addOnGlobalLayoutListener {

    if (!initialLayoutComplete) { initialLayoutComplete = true loadBanner(adContainerView) } } } private fun loadBanner(adContainerView: FrameLayout) { adView.adUnitId = BuildConfig.AD_MOB_SHOP adView.setAdSize(getAdSize(adContainerView)) adView.loadAd(AdRequest.Builder().build()) } private fun getAdSize(adContainerView: FrameLayout): AdSize { val width = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val windowMetrics = windowManager.currentWindowMetrics windowMetrics.bounds.width().toFloat() } else { val displayMetrics = DisplayMetrics() windowManager.defaultDisplay.getMetrics(displayMetrics) displayMetrics.widthPixels.toFloat() …
  9. fun loadDailyRewardAd(ctx: Context, onFinish: (RewardedAd?) -> Unit) { val adRequest

    = AdRequest.Builder().build() RewardedAd.load(ctx, AD_MOB_DAILY, adRequest, object: RewardedAdLoadCallback() { override fun onAdFailedToLoad(adError: LoadAdError) { onFinish(null) dailyAdLoaded.set(false) } override fun onAdLoaded(ad: RewardedAd) { onFinish(ad) dailyAdLoaded.set(true) } }) } fun claimDaily() { dailyRewardAd?.fullScreenContentCallback = object: FullScreenContentCallback() { override fun onAdDismissedFullScreenContent() { if (Rewards.canClaimDaily()) { loadDailyAd(true) } } override fun onAdFailedToShowFullScreenContent(error: AdError) { …
  10. override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) billingClient = BillingClient.newBuilder(requireContext()) .setListener(purchasesUpdatedListener)

    .enablePendingPurchases( … ) .build() billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(br: BillingResult) { if (br.responseCode == BillingResponseCode.OK) { viewModel?.queryProducts(billingClient) } } override fun onBillingServiceDisconnected() { … } }) } override fun launchPurchaseFlow(productDetails: ProductDetails) { val productDetailsParamsList = listOf( BillingFlowParams.ProductDetailsParams.newBuilder() .setProductDetails(productDetails) .build() ) val billingFlowParams = BillingFlowParams.newBuilder() .setProductDetailsParamsList(productDetailsParamsList) .build() …
  11. private fun onMapReady() { mapView.gestures.apply { rotateEnabled = true rotateDecelerationEnabled

    = true pitchEnabled = ALLOW_MAP_GESTURES pinchScrollEnabled = ALLOW_MAP_GESTURES pinchToZoomEnabled = ALLOW_MAP_GESTURES quickZoomEnabled = ALLOW_MAP_GESTURES scrollEnabled = ALLOW_MAP_GESTURES } mapView.scalebar.enabled = false mapView.compass.fadeWhenFacingNorth = false mapView.mapboxMap.setCamera( cameraOptions.build() ) mapView.mapboxMap.loadStyle( style(style = STYLE_URI_DAY) { +projection(ProjectionName.GLOBE) +rasterDemSource(SOURCE) { url(TERRAIN_URL_TILE_RESOURCE) tileSize(512) } … MapboxMap( modifier = Modifier.fillMaxSize(), style = { MapStyle( STYLE_URI_DAY, styleState = rememberStyleState { projection(ProjectionName.GLOBE) rasterDemSource(SOURCE) { url(TERRAIN_URL_TILE_RESOURCE) tileSize(512) } terrain(SOURCE) { exaggeration(1.1) } } ) }, mapViewportState = mapViewportState, mapState = rememberMapState{ gesturesSettings = GesturesSettings { rotateEnabled = true rotateDecelerationEnabled = true pitchEnabled = ALLOW_MAP_GESTURES pinchScrollEnabled = ALLOW_MAP_GESTURES pinchToZoomEnabled = ALLOW_MAP_GESTURES quickZoomEnabled = ALLOW_MAP_GESTURES …
  12. “You don’t need an engine or design skills.” “You don’t

    need a budget.” “You just need to start.”