Skip to main content

Cosmetics

LiquidBounce features a cosmetics system that allows users to display capes and other visual customizations. The system is designed to be efficient, reducing API stress while providing reliable cosmetic loading.

Overview

The cosmetics system consists of three main components:

CosmeticService

Manages cosmetic carriers and fetching

CapeCosmeticsManager

Handles cape texture loading and caching

ClientAccountManager

Manages authenticated client account

Cosmetic Service

The CosmeticService implements a carrier-based system to reduce API load:
object CosmeticService : EventListener, ValueGroup("Cosmetics") {
    // Collection of MD5-hashed UUIDs of players with cosmetics
    internal var carriers = emptySet<String>()
    
    // Cache of fetched cosmetics per UUID
    internal val carriersCosmetics = hashMapOf<UUID, Set<Cosmetic>>()
}

How It Works

1

Carrier List

Maintains a set of MD5-hashed UUIDs of all players who have cosmetics
2

Lazy Loading

Only fetches cosmetics when a player with cosmetics is encountered
3

Caching

Caches fetched cosmetics to avoid repeated API calls
4

Periodic Refresh

Updates the carrier list every 60 seconds
This approach significantly reduces API stress compared to checking every player individually.

Cosmetic Categories

Cosmetics are categorized by type:
enum class CosmeticCategory {
    CAPE,
    // Future categories can be added here
}
Currently, only capes are supported, but the system is designed to be extensible.

Fetching Cosmetics

Fetch a cosmetic for a specific player:
CosmeticService.fetchCosmetic(uuid, category) { cosmetic ->
    // Use the cosmetic
}
The fetch process (from CosmeticService.kt:99):
  1. Check if Local Client
    if (uuid == mc.user.profileId || uuid == mc.player?.uuid) {
        // Use client account cosmetics
        clientAccount.cosmetics?.let { cosmetics ->
            done(cosmetics.find { it.category == category } ?: return)
        }
    }
    
  2. Refresh Carrier List
    refreshCarriers {
        if (uuid.toMD5() !in carriers) {
            return@refreshCarriers  // Player has no cosmetics
        }
    }
    
  3. Check Cache
    carriersCosmetics[uuid]?.let { cosmetics ->
        done(cosmetics.find { it.category == category } ?: return)
    }
    
  4. Fetch from API
    val cosmetics = CosmeticApi.getCarrierCosmetics(uuid)
    carriersCosmetics[uuid] = cosmetics
    done(cosmetics.find { it.category == category } ?: return)
    

Carrier Refresh

The carrier list is refreshed periodically:
fun refreshCarriers(force: Boolean = false, done: () -> Unit) {
    // Only refresh if 60 seconds elapsed or forced
    if (lastUpdate.hasElapsed(REFRESH_DELAY) || force) {
        task = withScope {
            carriers = CosmeticApi.getCarriers()
            lastUpdate.reset()
            mc.execute(done)
        }
    } else {
        done()  // No refresh needed
    }
}
Refresh operations use a task lock to prevent concurrent refreshes.

Cape System

The CapeCosmeticsManager handles cape-specific functionality:

Loading Player Capes

fun loadPlayerCape(player: GameProfile, callback: Consumer<Identifier>) {
    CosmeticService.fetchCosmetic(player.id, CosmeticCategory.CAPE) { cosmetic ->
        val name = getCapeName(cosmetic) ?: return@fetchCosmetic
        
        // Check cache
        cachedCapes[name]?.let { capeId ->
            callback.accept(capeId)
            return@fetchCosmetic
        }
        
        // Download cape texture
        val texture = CapeApi.getCape(name)
        val id = LiquidBounce.identifier("cape-$name")
        
        // Register and cache
        texture.registerTexture(id)
        cachedCapes[name] = id
        callback.accept(id)
    }
}

Cape Caching

Capes are cached in memory to avoid repeated downloads:
private val cachedCapes = hashMapOf<String, Identifier>()
The cache is automatically cleared on disconnect:
private val disconnectHandler = handler<DisconnectEvent> {
    cachedCapes.values.forEach { mc.textureManager.release(it) }
    cachedCapes.clear()
}
Capes are cached by name, not UUID, allowing multiple players with the same cape to share textures.

Client Account Integration

For the authenticated client, cosmetics are managed through ClientAccountManager:
object ClientAccountManager : Config("account") {
    var clientAccount by value(
        "account",
        ClientAccount.ENV_ACCOUNT ?: ClientAccount.EMPTY_ACCOUNT
    )
}
The client account includes:
  • User information
  • Owned cosmetics
  • Session tokens

Updating Client Cosmetics

clientAccount.updateCosmetics()
This fetches the latest cosmetics for the authenticated user.

Temporary Ownership Transfer

Cosmetics can be temporarily transferred to another UUID:
private suspend fun transferTemporaryOwnership(uuid: UUID) {
    clientAccount.transferTemporaryOwnership(uuid)
    
    // Refresh carrier list after transfer
    refreshCarriers(true) {
        logger.info("Successfully loaded ${carriers.size} cosmetics carriers.")
    }
}
This is automatically triggered on session changes:
private val sessionHandler = suspendHandler<SessionEvent> {
    val uuid = it.session.profileId
    transferTemporaryOwnership(uuid)
}

Checking Cosmetic Availability

Check if a player has a specific cosmetic:
if (CosmeticService.hasCosmetic(uuid, CosmeticCategory.CAPE)) {
    // Player has a cape
}
This performs a non-blocking check using cached data.

Performance Optimizations

Player UUIDs are MD5-hashed in the carrier set to reduce memory usage and improve lookup performance.
if (uuid.toMD5() !in carriers) {
    return  // No cosmetics
}
Empty sets are pre-allocated to prevent multiple concurrent requests for the same cosmetic.
// Pre-allocate to prevent multiple requests
carriersCosmetics[uuid] = emptySet()
Cosmetics are only fetched when needed, not preloaded for all players.
Caches are cleared on disconnect to prevent memory leaks.

Error Handling

All API calls use runCatching for robust error handling:
runCatching {
    val cosmetics = CosmeticApi.getCarrierCosmetics(uuid)
    carriersCosmetics[uuid] = cosmetics
}.onFailure {
    logger.error("Failed to get cosmetics of carrier $uuid", it)
}

Code References

CosmeticService.kt

Core cosmetic service - line 51
features/cosmetic/CosmeticService.kt

CapeCosmeticsManager.kt

Cape loading and caching - line 42
features/cosmetic/CapeCosmeticsManager.kt

ClientAccountManager.kt

Client account management - line 25
features/cosmetic/ClientAccountManager.kt

Best Practices

1

Use Callbacks

Always use callbacks when fetching cosmetics as operations are asynchronous
2

Check Carriers First

The carrier set provides a fast pre-check before fetching cosmetics
3

Handle Missing Cosmetics

Not all players have cosmetics - handle null/empty results gracefully
4

Respect Cache

Don’t force refresh unless necessary - trust the 60-second refresh cycle

Build docs developers (and LLMs) love