Caso de éxito: Bouncy Mouse

Introducción

Mouse Bouncy

Después de publicar Bouncy Mouse en iOS y Android a fines del año pasado, aprendí algunas lecciones muy importantes. Entre ellos, la clave era que es difícil ingresar a un mercado establecido. En el mercado completamente saturado de iPhone, fue muy difícil ganar tracción. En el mercado de Android menos saturado, el progreso fue más fácil, pero no sencillo. Debido a esta experiencia, vi una oportunidad interesante en Chrome Web Store. Si bien la tienda web no está vacía, su catálogo de juegos de alta calidad basados en HTML5 recién comienza a madurar. Para un desarrollador de apps nuevo, esto significa que es mucho más fácil crear gráficos de clasificación y obtener visibilidad. Con esta oportunidad en mente, me puse a portar Bouncy Mouse a HTML5 con la esperanza de poder ofrecer mi experiencia de juego más reciente a una nueva base de usuarios emocionante. En este caso de éxito, hablaré un poco sobre el proceso general de portar Bouncy Mouse a HTML5 y, luego, profundizaré en tres áreas que resultaron interesantes: audio, rendimiento y monetización.

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

Actualmente, el Mouse Bouncy está disponible en Android(C++), iOS (C++), Windows Phone 7 (C#) y Chrome (Javascript). Esto, a veces, genera la pregunta: ¿Cómo se escribe un juego que se pueda portar fácilmente a varias plataformas? Tengo la sensación de que las personas esperan una solución mágica que puedan usar para lograr este nivel de portabilidad sin recurrir a un puerto manual. Lamentablemente, no sé si existe una solución de este tipo (lo más cercano es probablemente el framework PlayN de Google o el motor Unity, pero ninguno de ellos alcanza todos los objetivos que me interesan). Mi enfoque fue, de hecho, un puerto manual. Primero, escribí la versión para iOS y Android en C++, y luego portamos este código a cada plataforma nueva. Si bien esto puede parecer mucho trabajo, las versiones para WP7 y Chrome no tardaron más de 2 semanas en completarse. Ahora la pregunta es: ¿se puede hacer algo para que una base de código sea fácilmente transportable de forma manual? Hice algunas cosas que ayudaron en este caso:

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

Si bien esto puede parecer obvio, es la razón principal por la que pude portar el juego tan rápido. El código cliente de Bouncy Mouse tiene solo alrededor de 7,000 líneas de C++. 7,000 líneas de código no son nada, pero son lo suficientemente pequeñas como para poder administrarlas. Tanto las versiones de C# como las de JavaScript del código del cliente terminaron teniendo aproximadamente el mismo tamaño. Mantener mi base de código pequeña básicamente se redujo a dos prácticas clave: no escribir código en exceso y hacer todo lo posible en el código de procesamiento previo (no en el tiempo de ejecución). Puede parecer obvio no escribir código innecesario, pero es algo con lo que siempre lucho. A menudo, siento la necesidad de escribir una clase o función auxiliar para todo lo que se pueda factorizar en un auxiliar. Sin embargo, a menos que realmente planees usar un auxiliar varias veces, por lo general, solo termina aumentando el tamaño de tu código. Con Bouncy Mouse, tuve cuidado de no escribir un ayudante, a menos que lo fuera a usar al menos tres veces. Cuando escribí una clase de ayuda, intenté que fuera limpia, portátil y reutilizable para mis proyectos futuros. Por otro lado, cuando escribí código solo para Bouncy Mouse, con baja probabilidad de reutilización, mi enfoque fue realizar la tarea de programación de la forma más simple y rápida posible, incluso si esta no era la forma “más bonita” de escribir el código. La segunda parte, y más importante, de mantener la base de código pequeña fue enviar tanto como fuera posible a los pasos de procesamiento previo. Si puedes tomar una tarea de 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. A modo de ejemplo, originalmente almacené mis datos de geometría de nivel como un formato bastante sin procesar y armé los búferes de vértices OpenGL/WebGL reales durante el tiempo de ejecución. Esto requirió un poco de configuración y unos cientos de líneas de código de tiempo de ejecución. Más tarde, trasladé este código a un paso de procesamiento previo y escribí los búferes de vértices OpenGL/WebGL completamente empaquetados en el tiempo de compilación. La cantidad real de código era aproximadamente la misma, pero esos cientos de líneas se habían trasladado a un paso de procesamiento previo, lo que significa que nunca tuve que portarlos a ninguna plataforma nueva. Hay muchos ejemplos de esto en Bouncy Mouse, y lo que es posible variará de un juego a otro, pero presta atención a todo lo que no sea necesario que suceda durante el tiempo de ejecución.

No tomes dependencias que no necesites

Otro motivo por el que Bouncy Mouse 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 Box2D Box2D Box2D.js Box2D.xna

Eso es todo. No se usaron bibliotecas de terceros grandes, 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 del sonido, las bibliotecas reales eran diferentes. 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 no fue un gran problema. Mantener Bouncy Mouse libre de bibliotecas grandes no portátiles significa que la lógica del código del entorno de ejecución puede ser casi la misma entre las versiones (a pesar del cambio de lenguaje). Además, nos evita quedar encerrados en una cadena de herramientas no portátil. Me preguntaron si codificar directamente con 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). De hecho, creo todo lo contrario. La mayoría de los juegos para teléfonos celulares o HTML5 (al menos los que son como Bouncy Mouse) son muy simples. En la mayoría de los casos, el juego solo dibuja algunos sprites y, tal vez, alguna geometría con textura. La suma total del código específico de OpenGL en Bouncy Mouse es probablemente inferior a 1,000 líneas. Me sorprendería si el uso de una biblioteca de ayuda realmente redujera esta cantidad. Incluso si se redujera a la mitad, tendría que dedicar mucho tiempo a aprender nuevas bibliotecas o herramientas solo para ahorrar 500 líneas de código. Además, aún no encuentro una biblioteca de ayuda que sea portátil en todas las plataformas que me interesan, por lo que tomar una dependencia de este tipo perjudicaría significativamente la portabilidad. Si estuviera escribiendo un juego en 3D que necesitara mapas de luz, LOD dinámico, animación con texturas, etcétera, mi respuesta ciertamente cambiaría. En este caso, estaría reinventando la rueda para intentar codificar todo mi motor de forma manual en OpenGL. Mi punto es que la mayoría de los juegos para dispositivos móviles o HTML5 aún no se encuentran en esta categoría, por lo que no es necesario complicar las cosas antes de que sea necesario.

No subestimes las similitudes entre los idiomas

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

Conclusiones de la portabilidad

Eso es todo lo que necesitas saber sobre el proceso de portación. En las siguientes secciones, hablaré de algunos desafíos específicos de HTML5, pero el mensaje principal es que, si mantienes tu código simple, la portabilidad será un pequeño dolor de cabeza, no una pesadilla.

Audio

Un área que me causó problemas (al parecer, a todos los demás también) fue el audio. En iOS y Android, hay varias opciones de audio sólidas disponibles (OpenSL, OpenAL), pero en el mundo de HTML5, las cosas se veían más sombrías. Si bien el audio HTML5 está disponible, descubrí que tiene algunos problemas graves cuando se usa en juegos. Incluso en los navegadores más nuevos, a menudo me encontraba con comportamientos extraños. Chrome, por ejemplo, parece tener un límite para la cantidad de elementos de audio simultáneos (fuente) que puedes crear. Además, incluso cuando se reproducía el sonido, a veces terminaba distorsionado de forma inexplicable. En general, estaba un poco preocupado. Cuando busqué en línea, descubrí que casi todos tienen el mismo problema. La solución a la que llegué inicialmente fue una API llamada SoundManager2. Esta API usa audio HTML5 cuando está disponible y recurre a Flash en situaciones complicadas. Si bien esta solución funcionaba, aún tenía errores y era impredecible (menos que el audio HTML5 puro). Una semana después del lanzamiento, hablé con algunas de las personas útiles de Google, que me recomendaron la API de Web Audio de Webkit. Originalmente, había considerado usar esta API, pero me aleje de ella 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 unas pocas líneas de JavaScript. Sin embargo, en mi breve análisis de Web Audio, me llamó la atención su gran especificación (70 páginas), la pequeña cantidad de muestras en la Web (algo típico de una API nueva) y la omisión de una función “reproducir”, “pausar” o “detener” en cualquier parte de la especificación. Con las garantías de Google de que mis preocupaciones no estaban bien fundadas, volví a analizar la API. Después de ver algunos ejemplos más y hacer un poco más de investigación, descubrí que Google tenía razón: la API puede satisfacer mis necesidades y hacerlo sin los errores que plagan a las otras APIs. Es especialmente útil el artículo Cómo comenzar a usar la API de Web Audio, que es una excelente opción si deseas obtener una comprensión más profunda de la API. Mi verdadero problema es que, incluso después de comprender y usar la API, todavía me parece que no está diseñada para “solo reproducir algunos sonidos”. Para evitar esta duda, escribí una pequeña clase de ayuda que me permitió usar la API de la forma que quería: reproducir, pausar, detener y consultar el estado de un sonido. Llamé a esta clase auxiliar AudioClip. El código fuente completo está disponible en GitHub con la licencia Apache 2.0. A continuación, analizaré los detalles de la clase. Pero primero, algunos antecedentes 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 Audio de HTML5 es su capacidad de procesar o mezclar audio antes de enviarlo al usuario. Si bien es potente, el hecho de que cualquier reproducción de audio implique un gráfico hace que las cosas sean un poco más complejas en situaciones simples. Para ilustrar la potencia de la API de Web Audio, considera el siguiente gráfico:

Gráfico básico de Web Audio
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 mi caso. Solo quería reproducir un sonido. Si bien esto aún requiere un gráfico, este 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 Audio de HTML5 es su capacidad de procesar o mezclar audio antes de enviarlo al usuario. Si bien es potente, el hecho de que cualquier reproducción de audio implique un gráfico hace que las cosas sean un poco más complejas en situaciones simples. Para ilustrar la potencia de la API de Web Audio, considera el siguiente gráfico:

Gráfico trivial de Web Audio
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 no nos preocupemos por el gráfico

Si bien es bueno comprender el gráfico, no es algo con lo que quiera lidiar cada vez que reproduzca un sonido. Por lo tanto, escribí una clase de wrapper simple “AudioClip”. Esta clase administra este gráfico de forma interna, pero presenta una API para el usuario mucho más simple.

AudioClip
AudioClip

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

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

// Later
sound.play();

Detalles de la implementación

Echemos un vistazo rápido al código de la clase de ayuda: Constructor: El constructor controla la carga de los datos de sonido con un XHR. Aunque no se muestra aquí (para mantener el ejemplo simple), también se puede usar un elemento de audio HTML5 como nodo de origen. Esto es especialmente útil para muestras grandes. Ten en cuenta que la API de Web Audio requiere que recuperemos estos datos como un "arraybuffer". Una vez que se reciben los datos, creamos un búfer de Web Audio a partir de ellos (decodificándolos 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: Reproducir 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 jugamos. La mayor parte de la complejidad de esta función proviene de los requisitos necesarios para reanudar un clip en pausa (this.pauseTime_ > 0). Para reanudar la reproducción de un clip en pausa, usamos noteGrainOn, que permite reproducir una subregión de un búfer. Lamentablemente, noteGrainOn no interactúa con el bucle de la manera deseada para esta situación (repetirá la subregión, no todo el búfer). Por lo tanto, debemos solucionar este problema 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 termina o se detiene). A veces, un juego quiere reproducir un sonido muchas veces sin esperar a que se complete cada reproducción (recoger monedas en un juego, etcétera). Para habilitar esto, la clase AudioClip tiene un método playAsSFX(). Debido a que se pueden producir varias reproducciones de forma simultánea, la reproducción de playAsSFX() no está vinculada 1:1 con el AudioClip. Por lo tanto, no se puede detener, pausar ni consultar el estado de la reproducción. El bucle también está inhabilitado, ya que no habría forma de detener un sonido en bucle que se reproduzca 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);
}
}

Detener, pausar y consultar el estado: 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 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 condiciones.

Rendimiento

Otra área que me preocupaba con respecto a un puerto de JavaScript era el rendimiento. Después de terminar la versión 1 de mi portabilidad, descubrí que todo funcionaba bien en mi computadora de escritorio de cuatro núcleos. Lamentablemente, el rendimiento no fue del todo bueno en una netbook o una Chromebook. En este caso, el generador de perfiles de Chrome me salvó, ya que me mostró exactamente dónde se estaba gastando todo el tiempo de mis programas. Mi experiencia destaca la importancia de crear perfiles antes de realizar cualquier optimización. Esperaba que la física de Box2D o tal vez el código de renderización fueran una fuente importante de lentitud. Sin embargo, la mayor parte de mi tiempo se dedicaba a mi función Matrix.clone(). Dado que mi juego es muy matemático, sabía que había creado y clonado muchas matrices, pero nunca esperé que este fuera el cuello de botella. Al final, resultó que un cambio muy simple permitió que el juego redujera su uso de CPU en más de 3 veces, pasando del 6 al 7% de CPU en mi computadora de escritorio al 2%. Quizás esto sea de conocimiento general para los desarrolladores de JavaScript, pero como desarrollador de C++, este problema me sorprendió, así que explicaré un poco más en detalle. Básicamente, mi clase de matriz original era una matriz de 3 × 3: un array de 3 elementos, cada uno de los cuales contiene un array de 3 elementos. Lamentablemente, esto significó que, 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 solo array de 9 elementos y actualizar mis cálculos según corresponda. Este cambio fue el responsable de la reducción de 3 veces en la CPU que observé y, después de este cambio, el rendimiento fue aceptable en todos mis dispositivos de prueba.

Más optimización

Si bien mi rendimiento era aceptable, seguía viendo algunos inconvenientes menores. Después de generar más perfiles, me di cuenta de que esto se debía a la recolección de basura de JavaScript. Mi app se ejecutaba a 60 fps, lo que significaba que cada fotograma tenía solo 16 ms para dibujarse. Lamentablemente, cuando se iniciaba la recolección de elementos no utilizados en una máquina más lenta, a veces consumía alrededor de 10 ms. Esto generaba una intermitencia cada pocos segundos, ya que el juego necesitaba casi los 16 ms completos para dibujar un fotograma completo. Para tener una mejor idea de por qué generaba tanta basura, usé el generador de perfiles de montón de Chrome. Para mi desdicha, resultó que la gran mayoría de los datos basura (más del 70%) se generaban con Box2D. Eliminar basura en JavaScript es un asunto complicado, y reescribir Box2D estaba fuera de discusión, así que me di cuenta de que me había metido en un callejón sin salida. Afortunadamente, aún tenía uno de los trucos más antiguos a mi disposición: cuando no puedes alcanzar los 60 fps, ejecuta a 30 fps. Se acepta ampliamente que ejecutar a 30 fps de forma coherente es mucho mejor que ejecutar a 60 fps con inestabilidad. De hecho, aún no recibo ninguna queja o comentario sobre el hecho de que el juego se ejecuta a 30 fps (es muy difícil saberlo, a menos que compares las dos versiones en paralelo). Estos 16 ms adicionales por fotograma significaban que, incluso en el caso de una recolección de elementos no utilizados deficiente, aún tenía mucho tiempo para renderizar el fotograma. Si bien la API de temporización que usaba (la excelente requestAnimationFrame de WebKit) no habilita de forma explícita la ejecución a 30 fps, se puede lograr de una manera muy sencilla. Si bien es posible que 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 la VSYNC del monitor (por lo general, 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", esto se puede lograr de la siguiente manera:

var skip = false;

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

// OTHER CODE
}

Si quieres ser más cauteloso, debes verificar que la VSYNC de la computadora no esté en 30 fps o menos al inicio y, en este caso, inhabilitar el omitir. Sin embargo, aún no he visto esto en ninguna configuración de computadoras de escritorio o portátiles que probé.

Distribución y monetización

Un último aspecto que me sorprendió del puerto de Bouncy Mouse para Chrome fue la monetización. Cuando comencé con este proyecto, imaginé los juegos HTML5 como un experimento interesante para aprender sobre tecnologías emergentes. Lo que no sabía era que el puerto llegaría a un público muy grande y tendría un potencial significativo para la monetización.

Bouncy Mouse se lanzó a fines de octubre en Chrome Web Store. Cuando lancé la app 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 me había acostumbrado en las plataformas para dispositivos móviles. Lo que me sorprendió fue el alcance de la tienda. En un mes después del lanzamiento, llegué a casi cuatrocientas mil instalaciones y ya me beneficiaba de la participación de la comunidad (informes de errores y comentarios). Otro aspecto que me sorprendió fue el potencial de monetización de una app web.

Bouncy Mouse tiene un método de monetización simple: un anuncio de banner junto al contenido del juego. Sin embargo, debido al amplio alcance del juego, descubrí que este anuncio de banner pudo generar ingresos significativos y, durante su período de mayor actividad, la aplicación 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 de AdMob más pequeños que se muestran en Android. Además, el anuncio de banner en la versión HTML5 es mucho menos intrusivo que en la versión para Android, lo que permite una experiencia de juego más fluida. 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 ascender rápidamente al puesto 9 entre los juegos más populares de Chrome Web Store, la tasa de usuarios nuevos que llegaban al sitio disminuyó considerablemente desde el lanzamiento inicial. Dicho esto, el juego sigue creciendo de forma constante y me entusiasma ver en qué se convertirá la plataforma.

Conclusión

Diría que la portabilidad de Bouncy Mouse a Chrome fue mucho más sencilla de lo que esperaba. Aparte de algunos problemas menores de audio y rendimiento, descubrí que Chrome era una plataforma perfectamente capaz para un juego de smartphone existente. Recomiendo a los desarrolladores que se hayan estado alejando de la experiencia que le den una oportunidad. Estoy muy contento con el proceso de portabilidad y con el nuevo público de jugadores al que me conectó tener un juego HTML5. Si tienes alguna pregunta, no dudes en enviarme un correo electrónico. También puedes dejar un comentario a continuación. Intentaré revisarlos con regularidad.