Caso de éxito: Bouncy Mouse

Introducción

Ratón saltarín

Luego de publicar Bouncy Mouse en iOS y Android a fines del año pasado, aprendí algunas lecciones muy importantes. Una de ellas es que entrar en un mercado establecido es difícil. En el completamente saturado mercado de iPhone, ganar popularidad fue muy difícil. En el mercado de Android menos saturado, el progreso fue más fácil, pero aún no lo fue. Dada esta experiencia, vi una oportunidad interesante en Chrome Web Store. Si bien Web Store no está vacío en absoluto, su catálogo de juegos de alta calidad basados en HTML5 está empezando a madurar. Para un nuevo desarrollador de apps, esto significa que es mucho más fácil crear los gráficos de clasificación y ganar visibilidad. Con esta oportunidad en mente, me propuse portar Bouncy Mouse a HTML5 con la esperanza de ofrecer mi última experiencia de juego a una nueva y emocionante base de usuarios. En este caso práctico, hablaremos un poco sobre el proceso general de trasladar el mouse Bouncy a HTML5. Luego, profundizaremos un poco más en tres áreas que resultaron interesantes: audio, rendimiento y monetización.

Cómo migrar un juego de C++ a HTML5

Actualmente, Bouncy Mouse está disponible en Android(C++), iOS (C++), Windows Phone 7 (C#) y Chrome (JavaScript). Esto ocasionalmente genera la siguiente pregunta: ¿cómo puedes escribir un juego que se pueda transferir fácilmente a varias plataformas? Tengo la sensación de que la gente espera una solución mágica que pueda usar para lograr este nivel de portabilidad sin recurrir a un puerto manual. Por desgracia, aún no sé que exista una solución de ese tipo (probablemente lo más cercano sea el framework PlayN de Google o el motor Unity, pero ninguna de estas opciones alcanza todos los objetivos que me interesaban). Mi enfoque fue, de hecho, un puerto manual. Primero escribí la versión para iOS y Android en C++ y, luego, porté este código a cada plataforma nueva. Si bien esto puede parecer mucho trabajo, las versiones de WP7 y Chrome no tardaron más de 2 semanas en completarse. Entonces, la pregunta es si se puede hacer algo para que una base de código sea fácil de transportar a mano. Tuve un par de cosas que me ayudaron con esto:

Mantén la base de código pequeña

Aunque esto puede parecer obvio, es en realidad la razón principal por la que pude transferir el juego tan rápido. El código de cliente de Bouncy Mouse solo tiene unas 7,000 líneas de C++. 7,000 líneas de código no es nada, pero es lo suficientemente pequeño para ser manejable. Las versiones del código de cliente, C# y JavaScript, tenían más o menos el mismo tamaño. Mantener mi base de código pequeña básicamente consistía en dos prácticas clave: no escribir ningún código excedente y hacer todo lo posible en código de procesamiento previo (que no es de tiempo de ejecución). No escribir un exceso de código puede parecer obvio, pero es algo por lo que siempre lucho contra mí mismo. Con frecuencia tengo la necesidad de escribir una clase o función de ayuda para cualquier cosa que pueda convertirse en un ayudante. Sin embargo, a menos que planees usar un asistente varias veces, por lo general, el código se sobrecargue. Con Bouncy Mouse, pude no escribir nunca un ayudante, a menos que lo usara al menos tres veces. Cuando escribí una clase auxiliar, intenté que fuera limpia, portátil y reutilizable para mis futuros proyectos. Por otro lado, al escribir código solo para Bouncy Mouse, con poca probabilidad de reutilización, me enfoqué en realizar la tarea de programación de la manera más simple y rápida posible, aunque esta no fuera la forma "más bella" de escribir el código. La segunda parte y más importante de mantener la base de código pequeña era incluir tanto como fuera posible a los pasos de procesamiento previo. Si puedes tomar una tarea en tiempo de ejecución y moverla a una tarea de procesamiento previo, no solo tu juego se ejecutará más rápido, sino que no tendrás que portar el código a cada plataforma nueva. Para dar un ejemplo, originalmente almacené mis datos de geometría de niveles en un formato bastante sin procesar, ensamblando los búferes de vértice de OpenGL/WebGL reales en el tiempo de ejecución. Esto requirió un poco de configuración y unas cientos de líneas de código de tiempo de ejecución. Más tarde, moví este código a un paso de procesamiento previo y escribí búferes de vértices de OpenGL/WebGL completamente empaquetados en el tiempo de compilación. La cantidad real de código era casi la misma, pero esos cientos de líneas se habían movido a un paso de procesamiento previo, lo que significa que nunca tuve que transferirlas a ninguna plataforma nueva. Hay muchísimos ejemplos de esto en Bouncy Mouse, y lo que es posible variará de un juego a otro, pero presta atención a cualquier cosa que no tenga que suceder durante el tiempo de ejecución.

No ocupe las dependencias que no necesita

Otra razón por la que el mouse Bouncy es fácil de portar es porque casi no tiene dependencias. En el siguiente gráfico, se resumen las principales dependencias de bibliotecas de Bouncy Mouse por plataforma:

Android iOS HTML5 WP7
Gráficos OpenGL ES OpenGL ES WebGL XNA
Sonido OpenSL ES OpenAL Audio web XNA
Física Cuadro2D Cuadro2D Box2D.js Box2D.xna

Eso es casi todo. No se usaron bibliotecas grandes de terceros, excepto Box2D, que es portátil en todas las plataformas. En el caso de los gráficos, WebGL y XNA se asignan casi 1:1 con OpenGL, por lo que esto no fue un gran problema. Solo en el área de sonido eran diferentes las bibliotecas. Sin embargo, el código de sonido en Bouncy Mouse es pequeño (alrededor de cien líneas de código específico de la plataforma), por lo que ese no era un gran problema. Mantener el mouse Bouncy Mouse libre de bibliotecas grandes que no sean portátiles significa que la lógica del código del tiempo de ejecución puede ser casi la misma entre versiones (a pesar del cambio de lenguaje). Además, nos evita quedar atados a una cadena de herramientas no portátil. Se me preguntó si programar directamente en OpenGL/WebGL aumenta la complejidad en comparación con el uso de una biblioteca como Cocos2D o Unity (también hay algunos asistentes de WebGL disponibles). De hecho, creo que es exactamente lo opuesto. La mayoría de los juegos para teléfonos celulares o HTML5 (al menos, como Bouncy Mouse) son muy simples. En la mayoría de los casos, el juego solo dibuja algunos objetos y quizás geometría con textura. La suma total del código específico de OpenGL en Bouncy Mouse probablemente sea inferior a 1,000 líneas. Me sorprendería que el uso de una biblioteca de ayuda reducira ese número. Incluso si reducira este número a la mitad, necesitaría dedicar mucho tiempo a aprender bibliotecas y herramientas nuevas solo para ahorrar 500 líneas de código. Además de esto, todavía no encontré una biblioteca auxiliar portátil en todas las plataformas que me interesan, por lo que tomar esa dependencia afectaría significativamente la portabilidad. Si escribiera un juego en 3D que necesite mapas de luz, nivel de detalle dinámico, animación con piel, etc., mi respuesta cambiaría. En este caso, reinventaría la rueda para intentar codificar a mano todo el motor con OpenGL. Mi punto aquí es que la mayoría de los juegos HTML5 o para dispositivos móviles no están en esta categoría (todavía) por lo que no es necesario complicar las cosas antes de que sea necesario.

No subestimes las similitudes entre idiomas

Un último truco que me ahorró mucho tiempo en la portabilidad de mi base de código C++ a un nuevo lenguaje fue darse cuenta de que la mayor parte del código es casi idéntico entre cada lenguaje. Si bien algunos elementos clave pueden cambiar, estos son muchos menos que los que no cambian. De hecho, para muchas funciones, pasar de C++ a JavaScript simplemente implicaba ejecutar algunos reemplazos de expresiones regulares en mi base de código C++.

Conclusiones sobre la portabilidad

Eso es todo en relación con el proceso de portabilidad. Mencionaré algunos desafíos específicos de HTML5 en las próximas secciones, pero el mensaje principal es que, si usas un código sencillo, la portabilidad será un pequeño dolor de cabeza, no una pesadilla.

Audio

Un área que me causó problemas, y aparentemente todos los demás, fue el audio. En iOS y Android, hay una serie de opciones de audio disponibles (OpenSL, OpenAL), pero en el mundo de HTML5, las cosas se veían más sórdidas. Si bien el audio HTML5 está disponible, encontré que tiene algunos problemas importantes cuando se usa en los juegos. Incluso en los navegadores más nuevos, con frecuencia cometí un comportamiento extraño. Chrome, por ejemplo, parece tener un límite en la cantidad de elementos de audio simultáneos (fuente) que puedes crear. Incluso cuando se reproducía sonido, a veces terminaba distorsionado de manera inexplicable. En general, me preocupa un poco. Realizar búsquedas en línea reveló que casi todas las personas tienen el mismo problema. La solución inicial fue una API llamada SoundManager2. Esta API utiliza audio HTML5 cuando está disponible, y recurre a Flash en situaciones complicadas. Si bien esta solución funcionaba, aún presentaba errores e impredecible (un poco menos que el audio HTML5 puro). Una semana después del lanzamiento, hablé con el equipo de ayuda de Google, que me dijeron que es la API de Web Audio de Webkit. En un principio, había considerado usar esta API, pero la eché debido a la cantidad de complejidad innecesaria (para mí) que parecía tener. Solo quería reproducir algunos sonidos: con el audio HTML5, esto equivale a un par de líneas de JavaScript. Sin embargo, en mi breve repaso de Web Audio, me sorprendió la enorme especificación (70 páginas), la pequeña cantidad de muestras en la web (habitual en una API nueva) y la omisión de las funciones de "reproducir", "pausar" o "detener" en cualquier parte de la especificación. Con la garantía de Google de que mis preocupaciones no estaban bien fundadas, volví a investigar la API. Después de mirar algunos ejemplos más y investigar un poco más, descubrí que Google tenía razón: la API sin duda puede satisfacer mis necesidades y puede hacerlo sin los errores que afectan a las otras APIs. Es muy útil el artículo Cómo comenzar a usar la API de Web Audio, que es un excelente recurso para conocer mejor la API. Mi verdadero problema es que, incluso después de comprender y usar la API, me parece una API que no está diseñada para "solo reproducir algunos sonidos". Para evitar este error, escribí una pequeña clase de ayuda que me permitió usar la API como quería: reproducir, pausar, detener y consultar el estado de un sonido. Llamé a esta clase de ayudante AudioClip. La fuente completa está disponible en GitHub bajo la licencia Apache 2.0, y analizaré los detalles de la clase a continuación. Pero antes, información sobre la API de Web Audio:

Gráficos de audio web

Lo primero que hace que la API de Web Audio sea más compleja (y más potente) que el elemento de audio HTML5 es su capacidad para procesar / mezclar audio antes de enviarlo al usuario. Aunque es potente, el hecho de que cualquier reproducción de audio involucre un gráfico hace que todo sea un poco más complejo en situaciones simples. Para ilustrar la potencia de la API de Web Audio, observa el siguiente gráfico:

Gráfico de audio web básico
Gráfico de audio web básico

Si bien el ejemplo anterior muestra la potencia de la API de Web Audio, no necesitaba la mayor parte de esta potencia en este caso. Solo quería reproducir un sonido. Si bien esto todavía requiere un gráfico, el grafo es muy simple.

Los gráficos pueden ser simples

Lo primero que hace que la API de Web Audio sea más compleja (y más potente) que el elemento de audio HTML5 es su capacidad para procesar / mezclar audio antes de enviarlo al usuario. Aunque es potente, el hecho de que cualquier reproducción de audio involucre un gráfico hace que todo sea un poco más complejo en situaciones simples. Para ilustrar la potencia de la API de Web Audio, observa el siguiente gráfico:

Trivial Web Audio Graph
Gráfico de audio web trivial

El gráfico trivial que se muestra arriba puede lograr todo lo necesario para reproducir, pausar o detener un sonido.

Pero ni siquiera nos preocupamos por el gráfico

Si bien entender el gráfico es bueno, no es algo que quiero tratar cada vez que toco un sonido. Por lo tanto, escribí una clase de wrapper simple “AudioClip”. Esta clase administra este gráfico internamente, pero presenta una API mucho más sencilla para el usuario.

AudioClip
AudioClip

Esta clase no es más que un gráfico de audio web y un estado auxiliar, pero me permite usar un código mucho más simple que si tuviera que compilar un gráfico de audio web para reproducir cada sonido.

// At startup time
var sound = new AudioClip("ping.wav");

// Later
sound.play();

Detalles de la implementación

Veamos rápidamente el código de la clase auxiliar: Constructor: el constructor controla la carga de los datos de sonido usando un XHR. Aunque no se muestra aquí (para simplificar el ejemplo), un elemento de audio HTML5 también se puede usar como nodo fuente. Esto es especialmente útil para muestras grandes. Ten en cuenta que la API de Web Audio requiere que recuperemos estos datos como un “búfer de array”. Una vez que se reciben los datos, creamos un búfer de Web Audio a partir de ellos (se decodifica de su formato original a un formato PCM de tiempo de ejecución).

/**
* Create a new AudioClip object from a source URL. This object can be played,
* paused, stopped, and resumed, like the HTML5 Audio element.
*
* @constructor
* @param {DOMString} src
* @param {boolean=} opt_autoplay
* @param {boolean=} opt_loop
*/
AudioClip = function(src, opt_autoplay, opt_loop) {
// At construction time, the AudioClip is not playing (stopped),
// and has no offset recorded.
this.playing_ = false;
this.startTime_ = 0;
this.loop_ = opt_loop ? true : false;

// State to handle pause/resume, and some of the intricacies of looping.
this.resetTimout_ = null;
this.pauseTime_ = 0;

// Create an XHR to load the audio data.
var request = new XMLHttpRequest();
request.open("GET", src, true);
request.responseType = "arraybuffer";

var sfx = this;
request.onload = function() {
// When audio data is ready, we create a WebAudio buffer from the data.
// Using decodeAudioData allows for async audio loading, which is useful
// when loading longer audio tracks (music).
AudioClip.context.decodeAudioData(request.response, function(buffer) {
    sfx.buffer_ = buffer;
    
    if (opt_autoplay) {
    sfx.play();
    }
});
}

request.send();
}

Reproducir: La reproducción de nuestro sonido implica dos pasos: configurar el gráfico de reproducción y llamar a una versión de "noteOn" en la fuente del gráfico. Una fuente solo se puede reproducir una vez, por lo que debemos volver a crear la fuente o el gráfico cada vez que lo jugamos. La mayor parte de la complejidad de esta función proviene de los requisitos necesarios para reanudar un clip pausado (this.pauseTime_ > 0). Para reanudar la reproducción de un clip pausado, usamos noteGrainOn, que permite reproducir una subregión de un búfer. Lamentablemente, noteGrainOn no interactúa con el bucle de la manera deseada en este caso (creará bucles en la subregión, no en todo el búfer). Por lo tanto, debemos solucionar esto reproduciendo el resto del clip con noteGrainOn y, luego, reiniciando el clip desde el principio con el bucle habilitado.

/**
* Recreates the audio graph. Each source can only be played once, so
* we must recreate the source each time we want to play.
* @return {BufferSource}
* @param {boolean=} loop
*/
AudioClip.prototype.createGraph = function(loop) {
var source = AudioClip.context.createBufferSource();
source.buffer = this.buffer_;
source.connect(AudioClip.context.destination);

// Looping is handled by the Web Audio API.
source.loop = loop;

return source;
}

/**
* Plays the given AudioClip. Clips played in this manner can be stopped
* or paused/resumed.
*/
AudioClip.prototype.play = function() {
if (this.buffer_ && !this.isPlaying()) {
// Record the start time so we know how long we've been playing.
this.startTime_ = AudioClip.context.currentTime;
this.playing_ = true;
this.resetTimeout_ = null;

// If the clip is paused, we need to resume it.
if (this.pauseTime_ > 0) {
    // We are resuming a clip, so it's current playback time is not correctly
    // indicated by startTime_. Correct this by subtracting pauseTime_.
    this.startTime_ -= this.pauseTime_;
    var remainingTime = this.buffer_.duration - this.pauseTime_;

    if (this.loop_) {
    // If the clip is paused and looping, we need to resume the clip
    // with looping disabled. Once the clip has finished, we will re-start
    // the clip from the beginning with looping enabled
    this.source_ = this.createGraph(false);
    this.source_.noteGrainOn(0, this.pauseTime_, remainingTime)

    // Handle restarting the playback once the resumed clip has completed.
    // *Note that setTimeout is not the ideal method to use here. A better 
    // option would be to handle timing in a more predictable manner,
    // such as tying the update to the game loop.
    var clip = this;
    this.resetTimeout_ = setTimeout(function() { clip.stop(); clip.play() },
                                    remainingTime * 1000);
    } else {
    // Paused non-looping case, just create the graph and play the sub-
    // region using noteGrainOn.
    this.source_ = this.createGraph(this.loop_);
    this.source_.noteGrainOn(0, this.pauseTime_, remainingTime);
    }

    this.pauseTime_ = 0;
} else {
    // Normal case, just creat the graph and play.
    this.source_ = this.createGraph(this.loop_);
    this.source_.noteOn(0);
}
}
}

Reproducir como efecto de sonido: La función de reproducción anterior no permite que el clip de audio se reproduzca varias veces con superposición (una segunda reproducción solo es posible cuando el clip finaliza o se detiene). En ocasiones, un juego querrá reproducir un sonido muchas veces sin esperar a que se complete cada reproducción (recolectar monedas en un juego, etcétera). Para habilitar esto, la clase AudioClip tiene un método playAsSFX(). Como pueden ocurrir varias reproducciones de forma simultánea, la reproducción de playAsSFX() no está vinculada 1:1 con el AudioClip. Por lo tanto, la reproducción no se puede detener, pausar ni consultar el estado. También se inhabilita la creación de bucles, ya que no hay forma de detener un sonido en bucle que se reproduce de esta manera.

/**
* Plays the given AudioClip as a sound effect. Sound Effects cannot be stopped
* or paused/resumed, but can be played multiple times with overlap.
* Additionally, sound effects cannot be looped, as there is no way to stop
* them. This method of playback is best suited to very short, one-off sounds.
*/
AudioClip.prototype.playAsSFX = function() {
if (this.buffer_) {
var source = this.createGraph(false);
source.noteOn(0);
}
}

Estado de detención, pausa y consulta: el resto de las funciones son bastante sencillas y no requieren mucha explicación:

/**
* Stops an AudioClip , resetting its seek position to 0.
*/
AudioClip.prototype.stop = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.startTime_ = 0;
this.pauseTime_ = 0;
if (this.resetTimeout_ != null) {
    clearTimeout(this.resetTimeout_);
}
}
}

/**
* Pauses an AudioClip. The offset into the stream is recorded to allow the
* clip to be resumed later.
*/
AudioClip.prototype.pause = function() {
if (this.playing_) {
this.source_.noteOff(0);
this.playing_ = false;
this.pauseTime_ = AudioClip.context.currentTime - this.startTime_;
this.pauseTime_ = this.pauseTime_ % this.buffer_.duration;
this.startTime_ = 0;
if (this.resetTimeout_ != null) {
    clearTimeout(this.resetTimeout_);
}
}
}

/**
* Indicates whether the sound is playing.
* @return {boolean}
*/
AudioClip.prototype.isPlaying = function() {
var playTime = this.pauseTime_ +
            (AudioClip.context.currentTime - this.startTime_);

return this.playing_ && (this.loop_ || (playTime < this.buffer_.duration));
}

Conclusión de audio

Espero que esta clase de ayuda sea útil para los desarrolladores que tienen los mismos problemas de audio que yo. Además, una clase como esta parece ser un punto de partida razonable, incluso si necesitas agregar algunas de las funciones más potentes de la API de Web Audio. De cualquier manera, esta solución satisfizo las necesidades de Bouncy Mouse y permitió que el juego fuera un verdadero juego HTML5 sin ningún compromiso.

Rendimiento

Otra área que me preocupaba con respecto a un puerto JavaScript era el rendimiento. Después de finalizar la versión 1 de mi puerto, descubrí que todo funcionaba bien en mi computadora de escritorio de cuatro núcleos. Lamentablemente, las cosas no eran tan buenas en un netbook o una Chromebook. En este caso, el generador de perfiles de Chrome me ahorró al mostrar exactamente dónde pasaba todo el tiempo de mis programas. Mi experiencia destaca la importancia de la generación de perfiles antes de realizar optimizaciones. Esperaba que la física de Box2D o tal vez el código de renderización fuera una gran fuente de ralentización. Sin embargo, la mayor parte de mi tiempo pasaba realmente en la función Matrix.clone(). Dada la naturaleza matemática de mi juego, sabía que realicé muchas tareas de creación y clonación de matrices, pero nunca esperé que este fuera un cuello de botella. Al final, resultó que un cambio muy simple permitió que el juego redujera el uso de CPU en más de 3 veces, pasando del 6 al 7% de la CPU en mi computadora de escritorio al 2%. Quizá este sea un conocimiento común para los desarrolladores de JavaScript, pero como desarrollador de C++, este problema me sorprendió, así que entraré en más detalle. Básicamente, mi clase de matriz original era una matriz de 3 × 3: un array de 3 elementos, cada uno con un array de 3 elementos. Lamentablemente, cuando llegó el momento de clonar la matriz, tuve que crear 4 arrays nuevos. El único cambio que tuve que hacer fue mover estos datos a un único array de 9 elementos y actualizar mis cálculos según corresponda. Este cambio fue completamente responsable de la reducción de CPU 3 veces que vi. Después de este cambio, mi rendimiento fue aceptable en todos mis dispositivos de prueba.

Más optimización

Si bien mi rendimiento fue aceptable, aún presentaba algunos inconvenientes menores. Después de un poco más de perfil, me di cuenta de que esto se debía a la recolección de elementos no utilizados de JavaScript. Mi app se ejecutaba a 60 fps, lo que significaba que cada fotograma tenía solo 16 ms para dibujar. Desafortunadamente, cuando la recolección de elementos no utilizados se activa en una máquina más lenta, a veces consume alrededor de 10 ms. Esto provocaba un salto de unos pocos segundos, ya que el juego requería casi los 16 ms completos para dibujar un fotograma completo. Para entender mejor por qué generaba tanta cantidad de elementos no utilizados, utilicé el generador de perfiles de montón de Chrome. Para mi desesperación, resultó que la gran mayoría de los elementos no utilizados (más del 70%) los generaba Box2D. Eliminar los elementos no utilizados en JavaScript es un negocio complicado, y reescribir Box2D no era relevante, así que me di cuenta de que había llegado a un rincón. Por suerte, aún tenía uno de los trucos más antiguos del libro disponible: cuando no se alcanzan los 60 FPS, se ejecutan a 30 FPS. Es bastante acordado que ejecutar a 30 FPS constantes es mucho mejor que ejecutarlo a 60 fps. De hecho, aún no he recibido ninguna queja ni comentario de que el juego se ejecuta a 30 FPS (es muy difícil saberlo, a menos que comparen las dos versiones). Estos 16 ms adicionales por fotograma significa que, incluso en el caso de una recolección de elementos no utilizados, aún tenía tiempo suficiente para procesar el fotograma. Si bien la ejecución a 30 FPS no está habilitada explícitamente por la API de tiempo que estaba usando (la excelente requestAnimationFrame de WebKit), se puede lograr de una manera muy trivial. Aunque quizás no sea tan elegante como una API explícita, se pueden lograr 30 fps si se sabe que el intervalo de RequestAnimationFrame está alineado con el VSYNC del monitor (generalmente, 60 fps). Esto significa que solo tenemos que ignorar todas las demás devoluciones de llamada. Básicamente, si tienes una devolución de llamada “Tick” a la que se llama cada vez que se activa “RequestAnimationFrame”, puedes hacerlo de la siguiente manera:

var skip = false;

function Tick() {
skip = !skip;
if (skip) {
return;
}

// OTHER CODE
}

Si deseas ser más cauteloso, debes comprobar que la VSYNC de la computadora no esté al inicio o a menos de 30 FPS y, en este caso, inhabilita la omisión. Sin embargo, aún no la he visto en ninguna configuración de computadora de escritorio o laptop que probé.

Distribución y monetización

Un último aspecto que me sorprendió acerca del puerto de Bouncy Mouse en Chrome fue la monetización. Al comenzar este proyecto, imaginé que los juegos HTML5 eran un experimento interesante para aprender tecnologías emergentes. Lo que no sabía es que el puerto llegaría a un público muy numeroso y tendría un potencial significativo para la monetización.

Bouncy Mouse se lanzó a fines de octubre en Chrome Web Store. Con el lanzamiento en Chrome Web Store, pude aprovechar un sistema existente para la visibilidad, la participación de la comunidad, las clasificaciones y otras funciones a las que había crecido acostumbrado en las plataformas móviles. Lo que me sorprendió fue lo amplio que era el alcance de la tienda. En un mes después del lanzamiento, había alcanzado cerca de cuatrocientas mil instalaciones y ya me estaba beneficiando del compromiso de la comunidad (informes de errores y comentarios). Otra cosa que me sorprendió fue el potencial de monetización de una aplicación web.

Bouncy Mouse tiene un método de monetización sencillo: un anuncio de banner junto al contenido del juego. Sin embargo, dado el amplio alcance del juego, descubrí que este anuncio de banner podía generar ingresos significativos y, durante su período de mayor actividad, la app generó ingresos comparables a los de mi plataforma más exitosa, Android. Un factor que contribuye a esto es que los anuncios de AdSense más grandes que se muestran en la versión HTML5 generan ingresos por impresión significativamente más altos que los anuncios más pequeños de AdMob que se muestran en Android. Además de eso, el anuncio de banner en la versión HTML5 es mucho menos invasivo que en la versión para Android, lo que permite una experiencia de juego más limpia. En general, me sorprendió gratamente este resultado.

Ingresos normalizados a lo largo del tiempo.
Ingresos normalizados a lo largo del tiempo

Si bien los ingresos del juego fueron mucho mejores de lo esperado, vale la pena señalar que el alcance de Chrome Web Store sigue siendo menor que el de plataformas más maduras como Android Market. Si bien Bouncy Mouse pudo alcanzar rápidamente el juego núm. 9 más popular de la Chrome Web Store, el índice de usuarios nuevos que ingresaron al sitio se redujo considerablemente desde el lanzamiento inicial. Dicho esto, el juego sigue creciendo y estamos ansiosos por ver los resultados de la plataforma.

Conclusión

Diría que la portabilidad del mouse Bouncy a Chrome fue mucho más fácil de lo que esperaba. Aparte de algunos problemas menores de audio y rendimiento, descubrí que Chrome era una plataforma perfectamente capaz para un juego para smartphones existente. Les recomiendo a los desarrolladores que han estado rehaciendo la experiencia a que lo intenten. Estoy muy contento con el proceso de portabilidad y con el nuevo público de videojuegos al que me conectaste con un juego HTML5. No dudes en enviarme un correo electrónico si tienes alguna pregunta. También puedes dejar un comentario a continuación y trataremos de verificarlo con regularidad.