Developing Mobile Game Inventory System
Inventory is not just a list of items. It's a transactional system with business rules: can't spend more coins than you have; can't take two unique items; stack must merge correctly. Errors here cost money: duplicated items from race condition, negative currency from unsynchronized requests — these are real production bugs.
Inventory Data Structure
@Entity(tableName = "inventory_items")
data class InventoryItemEntity(
@PrimaryKey val instanceId: String, // unique ID of each item
val playerId: String,
val itemDefinitionId: String, // reference to item template
val quantity: Int, // for stackable
val durability: Int? = null, // for items with durability
val enchantments: String = "[]", // JSON array of additional properties
val acquiredAt: Long,
val slotIndex: Int? = null // for equipped items
)
// Item definition (static data — loaded from assets)
data class ItemDefinition(
val id: String,
val name: String,
val type: ItemType, // WEAPON, ARMOR, CONSUMABLE, CURRENCY
val isStackable: Boolean,
val maxStackSize: Int = 1,
val isUnique: Boolean = false, // can't have more than one
val maxQuantity: Int = Int.MAX_VALUE
)
Separation of InstanceData (unique for each item the player has) and ItemDefinition (item template) — classic pattern. Load templates from JSON in assets on startup, don't put in database — they don't change at runtime.
Transactional Operations
Race condition on wallet replenishment — classic problem. Two parallel requests "add 100 coins" both read current value 500, both write 600. Should be 700.
@Dao
interface InventoryDao {
// Atomic stack increment — don't read and write separately
@Query("""
UPDATE inventory_items
SET quantity = MIN(quantity + :amount, :maxStackSize)
WHERE instance_id = :instanceId AND player_id = :playerId
""")
suspend fun incrementQuantity(instanceId: String, playerId: String,
amount: Int, maxStackSize: Int): Int
// Atomic deduction with check — doesn't go negative
@Query("""
UPDATE inventory_items
SET quantity = quantity - :amount
WHERE instance_id = :instanceId AND player_id = :playerId
AND quantity >= :amount
""")
suspend fun decrementQuantity(instanceId: String, playerId: String, amount: Int): Int
// Returns count of updated rows — if 0, not enough resources
@Transaction
suspend fun transferItem(fromPlayerId: String, toPlayerId: String,
instanceId: String): Boolean {
val updated = updateOwner(instanceId, fromPlayerId, toPlayerId)
return updated > 0
}
}
decrementQuantity returns affected rows count. If 0 — operation failed due to insufficient resources. No read-check-write — single atomic SQL operation.
Server-Side Validation
Local inventory — for display. Everything related to real monetization (purchases, gem spending, real money income) must pass server validation:
class InventoryRepository(
private val localDao: InventoryDao,
private val api: InventoryApi
) {
suspend fun spendGems(amount: Int, reason: String): Result<Unit> {
return try {
// Server checks balance, spends, returns new state
val serverState = api.spendGems(SpendGemsRequest(amount, reason))
// Sync local state with server
localDao.updateCurrencyBalance(
playerId = serverState.playerId,
gems = serverState.newGemsBalance
)
Result.success(Unit)
} catch (e: InsufficientFundsException) {
Result.failure(e)
}
}
}
Optimistic update on client with rollback on error — only for non-critical operations. For monetization — always server-first.
Sorting and Filtering
Inventory with 500 items can't be kept all in memory and filtered on client. Room Paging 3:
@Dao
interface InventoryDao {
@Query("""
SELECT * FROM inventory_items
WHERE player_id = :playerId
AND (:typeFilter IS NULL OR item_type = :typeFilter)
AND (:searchQuery IS NULL OR item_name LIKE '%' || :searchQuery || '%')
ORDER BY
CASE :sortBy WHEN 'rarity' THEN rarity_value ELSE acquired_at END DESC
""")
fun pagingSource(playerId: String, typeFilter: String?, searchQuery: String?,
sortBy: String): PagingSource<Int, InventoryItemEntity>
}
// ViewModel
val inventoryItems = Pager(PagingConfig(pageSize = 20)) {
dao.pagingSource(playerId, selectedType, searchQuery, sortBy)
}.flow.cachedIn(viewModelScope)
Paging 3 loads 20 items on scroll — no lag with large inventory.
Drag-and-Drop Slot Rearrangement
In Jetpack Compose via reorderable library (burnoutcrew/reorderable) or via detectDragGesturesAfterLongPress. Key — apply new order optimistically in UI immediately, batch in database after drop:
fun onItemDropped(fromIndex: Int, toIndex: Int) {
// Optimistically update list in memory
val newList = _inventoryState.value.toMutableList().apply {
add(toIndex, removeAt(fromIndex))
}
_inventoryState.value = newList
// Save new order to database in batch
viewModelScope.launch(Dispatchers.IO) {
dao.updateSlotIndices(newList.mapIndexed { index, item ->
SlotUpdate(item.instanceId, index)
})
}
}
Inventory system development with transactional operations, server validation, and Paging: 2–4 weeks. Cost calculated individually.







