Wednesday, September 16, 2026

El juego en kotlin

¿Cómo separar la lógica de un videojuego Android de su interfaz con Jetpack Compose?

AUPCiel es un pequeño simulador de vida para Android que estoy desarrollando con Kotlin y Jetpack Compose. El objetivo inicial no es crear un juego enorme, sino construir una versión jugable muy reducida que permita comprobar si su mecánica principal resulta interesante.

En esta primera fase se implementa algo especialmente importante desde el punto de vista técnico: la lógica del juego se mantiene separada de la interfaz. Esto permite modificar por completo la presentación visual sin tener que reescribir las reglas internas.

1. El problema: no mezclar reglas de juego con la interfaz

En un prototipo pequeño resulta tentador guardar directamente dentro de la pantalla todos los valores del juego: dinero, energía, día, tiempo disponible, objetos comprados y condiciones de victoria.

Ese enfoque funciona al principio, pero se vuelve difícil de mantener cuando el proyecto crece. Si la interfaz decide también cuánto dinero se gana, cuánto cuesta una acción o cuándo termina el día, cualquier cambio visual puede afectar accidentalmente al funcionamiento del juego.

Por eso, en AUPCiel se utiliza una estructura sencilla:

UI -> GameAction -> GameEngine -> GameState

La interfaz indica qué acción intenta realizar el jugador. El motor procesa esa acción y devuelve un nuevo estado.

2. Centralizar el equilibrio con Balance.kt

Las principales constantes económicas y de equilibrio se almacenan en un único objeto:

object Balance {
    const val TOTAL_DAYS = 4
    const val DAILY_BLOCKS = 4

    const val INITIAL_MONEY = 10
    const val INITIAL_ENERGY = 4
    const val MAX_ENERGY = 4

    const val RENT = 16

    const val WORK_PAY = 12
    const val WORK_ENERGY_COST = 1

    const val FOOD_COST = 4
    const val FOOD_ENERGY = 2

    const val BIKE_COST = 18

    const val TARGET_SUNS = 7
    const val FINAL_MIN_MONEY = 10
    const val FINAL_MIN_ENERGY = 2
}

La ventaja es inmediata: si durante las pruebas se decide que trabajar debe pagar 14 € en lugar de 12 €, no es necesario buscar ese valor por todo el proyecto. Se modifica una única constante.

3. Representar todo el juego con GameState

El estado se define mediante una data class de Kotlin:

data class GameState(
    val day: Int = 1,
    val money: Int = Balance.INITIAL_MONEY,
    val energy: Int = Balance.INITIAL_ENERGY,
    val blocks: Int = Balance.DAILY_BLOCKS,
    val suns: Int = 0,
    val hasBike: Boolean = false,
    val ateToday: Boolean = false,
    val result: GameResult = GameResult.PLAYING,
    val message: String =
        "Consigue 7 ☀ y termina el día 4 con al menos 10 € y energía 2."
)

En lugar de tener variables independientes repartidas por diferentes pantallas, el estado completo de la partida queda agrupado en una sola estructura.

Además, el estado es inmutable: para cambiarlo no se modifican sus propiedades directamente, sino que se crea una nueva versión utilizando copy(). Este enfoque encaja muy bien con Jetpack Compose, porque la interfaz puede redibujarse a partir del nuevo estado.

4. Convertir las decisiones del jugador en GameAction

Las acciones posibles se representan mediante una interfaz sellada:

sealed interface GameAction {
    data object Work : GameAction
    data object Eat : GameAction
    data object FreeTime : GameAction
    data object BuyBike : GameAction
    data object Restart : GameAction
}

Esto permite que la interfaz no tenga que conocer los detalles internos de cada regla. Cuando el jugador pulsa un botón, la pantalla simplemente envía una acción.

Por ejemplo:

state = GameEngine.reduce(
    state,
    GameAction.Work
)

5. GameEngine: el núcleo de las reglas

El motor recibe el estado actual y una acción, y devuelve el nuevo estado:

fun reduce(
    state: GameState,
    action: GameAction
): GameState {
    if (action is GameAction.Restart) {
        return GameState()
    }

    if (state.result != GameResult.PLAYING) {
        return state
    }

    return when (action) {
        GameAction.Work -> work(state)
        GameAction.Eat -> eat(state)
        GameAction.FreeTime -> freeTime(state)
        GameAction.BuyBike -> buyBike(state)
        GameAction.Restart -> GameState()
    }
}

Esta función actúa como punto central de entrada. La interfaz nunca modifica directamente el dinero, la energía o el tiempo.

6. Ejemplo: trabajar

La acción de trabajar comprueba primero si existe suficiente energía y si todavía quedan bloques de tiempo.

private fun work(state: GameState): GameState {
    if (state.energy < Balance.WORK_ENERGY_COST) {
        return state.copy(
            message = "No tienes energía suficiente para trabajar."
        )
    }

    if (state.blocks <= 0) return state

    return checkEndOfDay(
        state.copy(
            money = state.money + Balance.WORK_PAY,
            energy = state.energy - Balance.WORK_ENERGY_COST,
            blocks = state.blocks - 1,
            message = "+${Balance.WORK_PAY} € · -1 energía"
        )
    )
}

La interfaz no necesita conocer ninguna de estas reglas. Solo muestra el resultado que devuelve el motor.

7. La bici cambia el uso del tiempo

Una de las primeras decisiones estratégicas de AUPCiel consiste en comprar una bicicleta de segunda mano.

La bici no aumenta directamente el dinero. Su función es ahorrar tiempo: una vez comprada, comer deja de consumir un bloque.

val blockCost = if (state.hasBike) 0 else 1

Esta regla resume parte de la idea central del juego: progresar no significa únicamente acumular dinero, sino conseguir más control sobre el propio tiempo.

8. Final de día automático

Cada día dispone de cuatro bloques. Cuando el cuarto bloque se consume, el motor ejecuta automáticamente el cierre de jornada.

private fun checkEndOfDay(state: GameState): GameState =
    if (state.blocks > 0) state else endDay(state)

En ese momento se descuenta el alojamiento. Si no existe dinero suficiente, la partida termina. Si todavía quedan días, se inicia automáticamente el siguiente.

En el cuarto día se comprueban las condiciones finales: cantidad de tiempo personal conseguido, dinero restante y energía.

9. Primera interfaz: comprobar antes de embellecer

La primera pantalla se construye deliberadamente de forma sencilla. El objetivo es comprobar primero que todas las reglas funcionan correctamente en un dispositivo Android real.

Una vez validado el motor, se sustituye la pantalla inicial por una primera versión denominada Urban Cozy Board.

El juego pasa a presentar tres lugares principales:

  • Casa.
  • Trabajo.
  • Supermercado.

Cada lugar es pulsable y muestra sus acciones posibles junto con las consecuencias antes de confirmarlas.

10. Consecuencias visibles antes de actuar

Una decisión de diseño importante consiste en enseñar siempre qué va a ocurrir antes de ejecutar una acción.

Por ejemplo, antes de trabajar se puede mostrar:

Tiempo: 3 → 2 bloques
Dinero: 14 → 26 €
Energía: 4 → 3

De esta forma, el jugador no necesita memorizar reglas ocultas. La estrategia se basa en decisiones conscientes sobre tiempo, dinero y energía.

11. Diseño adaptable con Jetpack Compose

La interfaz utiliza BoxWithConstraints para detectar si existe suficiente ancho disponible.

val wide = maxWidth >= 700.dp

En una pantalla ancha, como una tablet, el tablero y el panel de acciones aparecen uno junto al otro. En un móvil más estrecho se reorganizan verticalmente.

Esto permite trabajar con una única interfaz Compose adaptable a diferentes tamaños de pantalla.

12. Qué se consigue en esta primera fase

En este punto AUPCiel ya dispone de un prototipo funcional ejecutándose en Android real. La lógica económica permanece separada de la interfaz, las decisiones se procesan mediante acciones y el tablero puede seguir evolucionando sin modificar las reglas centrales.

El siguiente paso se centra únicamente en la presentación: convertir el tablero actual en un pequeño barrio ilustrado, manteniendo intacto el motor que ya funciona.


Tecnologías utilizadas: Kotlin, Android Studio y Jetpack Compose.

Proyecto: AUPCiel.

Estado: prototipo jugable V0.1 en desarrollo.

El juego en kotlin

¿Cómo separar la lógica de un videojuego Android de su interfaz con Jetpack Compose? AUPCiel es un pequeño simulador de vida para An...