- high_impact, que recupera la estructura del motor JavaScript Impact de 2010, es un motor en C para juegos de acción 2D con soporte para Windows, Mac, Linux y WASM para la web
- Impact fue creado para demostrar que también era posible hacer juegos web con Canvas2D en medio de la exclusión de Flash en iOS, y tras vender más de 3,000 licencias a $99 fue liberado como software abierto gratuito
- El nuevo motor es un framework pequeño que reúne tilemaps, entidades, física y colisiones, animación de sprites, texto y sonido, y usa backends SDL o Sokol
- La implementación mantiene la simplicidad con almacenamiento de entidades de tamaño fijo, assets QOI/QOA, una sola memoria tipo hunk, renderizadores OpenGL y por software, y el editor de niveles Weltmeister basado en JavaScript
- Fue posible portar y ejecutar Biolab Disaster y Drop de forma muy cercana al código fuente original en JS, y puede ampliarse a múltiples sistemas mediante extensiones de plataforma y renderizador
Resumen de high_impact
- high_impact es un pequeño motor de juegos para acción 2D
- Está escrito en C y se compila para Windows, Mac, Linux y WASM para la web
- Está inspirado en el motor de juegos JavaScript de 2010 Impact, y su nombre alude a la época en que C era considerado un lenguaje de alto nivel
- Se publica bajo licencia MIT y su código está en GitHub
Cómo nació Impact
- En abril de 2010, Steve Jobs publicó la carta abierta “Thoughts on Flash”, donde anunciaba que iOS no daría soporte a Flash
- En ese momento Flash era el centro de la cultura de juegos y animación web basada en plugins de navegador, y sitios como Newgrounds y Kongregate dependían fuertemente de contenido Flash
- El soporte de Flash en Android tenía muchos problemas, y se considera que Adobe tampoco hizo esfuerzos por mejorar sus desventajas en móviles
- Existía la idea de que “sin Flash no hay juegos en el navegador”, pero la API Canvas2D permitía dibujar imágenes y formas en
<canvas> - Canvas2D fue creado por Apple/Safari para renderizar widgets de escritorio, luego recibió soporte de Google y Mozilla, mientras que Microsoft Internet Explorer se quedó atrás
- En ese contexto se creó Biolab Disaster, y junto con él también se desarrollaron el motor de juegos y el editor de niveles
Ventas y casos de uso de Impact
- Impact se puso a la venta por $99 tras ordenar el código y documentarlo; aunque hubo rechazo a la decisión de venderlo, terminó superando las 3,000 licencias vendidas
- Varios juegos web se hicieron con Impact, y también se usó en títulos comerciales multiplataforma
- Al final de su vida útil, Impact fue publicado como software abierto gratuito
- high_impact es un proyecto que reconstruye Impact desde cero, pero en C en lugar de JavaScript
Por qué C
- C se ve como un lenguaje simple pero con profundidad, parecido a los juegos en eso de que “es fácil de aprender y difícil de dominar”
- Tras pasar por varios proyectos, volvió a crecer el interés por C
- Originalmente Impact no era de una escala comparable con motores como Godot, Unreal o Unity, pero sí funcionó como una base sólida para varios juegos
- Reescribir Impact en C comenzó como un ejercicio divertido
Estructura del motor y assets
- high_impact fue implementado de la forma más simple posible y apunta a estar compuesto por la menor cantidad de código posible
- Las funciones básicas son las mismas que en el motor original en JavaScript
- Carga de tilemaps
- Creación, actualización y dibujo de entidades, que son los objetos del juego
- Física y manejo de colisiones entre entidades
- Manejo de colisiones con mapas de colisión
- Animación con hojas de sprites
- Salida de texto
- Reproducción de efectos de sonido y música
- Se parece más a un framework que a una librería, y la lógica del juego se escribe dentro del framework
- Debajo hay un backend de
platform, y actualmente se compila con SDL o Sokol - El código del juego vive dentro de una o más “scene”, donde cada scene es un struct con punteros a función
- Tras llamar a
engine_set_scene(&scene_game), el motor establece la nueva scene scene_game.init()se llama una sola vezscene_game.update()yscene_game.draw()se llaman en cada frame
- Tras llamar a
- Los tilemaps y las entidades iniciales pueden cargarse desde archivos
.jsono generarse dinámicamente - La razón de elegir JSON como formato de nivel fue la compatibilidad hacia atrás con el Impact original
- high_impact usa QOI para imágenes y QOA para sonido y música
- El Makefile del juego demo convierte automáticamente PNG a QOI y WAV a QOA
- No hace falta incluir librerías separadas de decodificación de imagen y sonido
- En el futuro podrían soportarse otros formatos de assets, pero la simplicidad de QOI/QOA encaja muy bien con la dirección del proyecto
Sistema de entidades
- Todas las entidades comparten el mismo
entity_t struct, que contiene las propiedades que high_impact necesita, como posición, velocidad y tamaño - Como todas las entidades tienen el mismo tamaño en bytes, almacenarlas y gestionarlas se vuelve más simple
- Para mover una entidad, basta con definir velocidad o aceleración y el resto lo maneja el motor
- Mediante macros se pueden agregar propiedades específicas del juego al struct base de entidad
- Biolab Disaster usa un
unioncon structs por tipo de entidad - Drop no necesita definir propiedades adicionales
- Biolab Disaster usa un
- Cada tipo de entidad debe tener un
entity_vtab_tcon punteros a funciónupdatese llama en cada frametouchse llama cuando se superpone con otra entidad que cumpla las condiciones- Todos los elementos son opcionales
- El almacenamiento de entidades es de tamaño fijo
- El número predeterminado de entidades activas es 1,024
- Puede configurarse con
ENTITIES_MAX - El motor maneja fácilmente hasta 64k entidades
- Para conservar referencias a entidades por más de un frame, se usa
entity_ref_tentity_ref_tes un struct conuint16_t ideindex- Puede resolverse otra vez a un puntero con
entity_by_ref() - Permite distinguir cuando otra entidad ocupa la misma dirección de almacenamiento
- Debido al índice
uint16_t, el máximo de entidades activas queda limitado a 64k
- En C, implementar estructuras como OOP simple, clases o herencia simple puede sentirse algo incómodo, pero high_impact intenta hacerlo lo más cómodo posible
- El enfoque “ingenuo” de OOP de reunir la lógica de entidades en un solo lugar por tipo ha sido fácil de entender y ha funcionado bien en los juegos hechos hasta ahora
Detección y respuesta a colisiones
- Un manejo simple de colisiones solo verifica si puede moverse a la nueva posición y, si no puede, se detiene, pero en objetos rápidos eso puede producir comportamientos raros
- En un plataformas 2D, si el jugador está 16 px por encima del suelo y con el siguiente movimiento terminaría dentro del suelo, puede parecer un aterrizaje suave en el que se detiene en el aire y baja otra vez en el frame siguiente
- high_impact traza la caja de la entidad contra el tilemap para calcular con precisión el punto de colisión
- Este método es más complejo que una verificación simple de sí/no, pero da mejores resultados y también permite manejar tiles inclinados
- Después de chocar con un tile, puede hacer falta un segundo trazado con la velocidad restante
- Por ejemplo, si toca el suelo en diagonal,
vel.ypasa a0, perovel.xse conserva para que se deslice por el piso
- Por ejemplo, si toca el suelo en diagonal,
- Las colisiones entre entidades se manejan por separado
- Se puede hacer que las partículas colisionen con el tilemap pero no con otras entidades
- Una plataforma móvil puede colisionar con otras entidades, pero no debería moverse como respuesta a la colisión
- La detección de colisiones broad phase ordena las entidades según
pos.x- Como en el frame anterior casi ya estaban ordenadas, el costo de insertion sort es bajo
- Tras ordenar, se recorre de izquierda a derecha y solo se revisan las entidades entre
pos.xypos.x + size.x
- Este método de sweep and prune es rápido siempre que no haya demasiadas entidades superpuestas en posiciones x similares
- Se vuelve un caso desfavorable cuando muchas entidades se concentran en la misma posición x, como en una torre de cajas apiladas
- Si otro eje encaja mejor, como en un shoot'em up vertical, el eje de barrido puede cambiarse con
#define ENTITY_SWEEP_AXIS y
Renderizado
- high_impact actualmente incluye un renderizador OpenGL y un renderizador por software incompleto
- Todo el renderizado pasa por una API muy delgada, y las llamadas reales de dibujo se realizan en una sola función, así que implementar otros backends es relativamente simple
- Las funciones clave que debe soportar un backend de renderizado adicional son inicialización, limpieza, configuración del tamaño de pantalla, preparación y cierre de frame, y dibujo de quads
- Para el manejo de texturas también hacen falta funciones de mark, reset y create
- Las funciones son simples: solo puede dibujar quads y no se pueden usar efectos con shaders, pero eso es suficiente para el propósito de este motor
- El renderizador por software tiene 140 líneas de código y solo soporta quads alineados al eje
- El renderizador OpenGL busca meter todo el renderizado de un frame en una sola llamada de dibujo de OpenGL
- Reúne todos los quads en un gran buffer y los envía de una vez con
glDrawElements() - Combina todas las texturas en un solo atlas de texturas para evitar rebinding de texturas
- Reúne todos los quads en un gran buffer y los envía de una vez con
- El atlas de texturas es una técnica antigua y tiene desventajas, pero se usa porque bindless texture no está soportado en todos lados
- high_impact solo soporta un atlas de texturas, pero su tamaño puede configurarse con
#define- Los GPU móviles normalmente soportan texturas de 8k×8k
- Los GPU de escritorio modernos parecen soportar hasta 32k×32k
- Biolab Disaster y Drop usan un atlas de 512×512
Sonido
- La salida de sonido la manejan SDL2 o Sokol, mientras que el motor se encarga de la carga, decodificación y mezcla de múltiples sonidos
- El sistema de sonido se divide en
sound_source_t, que contiene muestras, ysound_t, que representa un sonido reproduciéndose en ese momento - Este sistema se basa en uno creado para la reescritura de wipEout, y puede descomprimir QOA bajo demanda
- Todo está asignado de forma estática
- La cantidad de source que pueden cargarse es fija
- La cantidad de sound que pueden reproducirse al mismo tiempo es fija
- Los sound que terminan de reproducirse se descartan automáticamente y se reutilizan
- Los sonidos pueden cambiar volumen, paneo izquierdo/derecho y pitch
- Si el pitch se define como negativo, el sonido se reproduce al revés
- El resampling necesario para pitch variable usa un método de baja calidad con interpolación por vecino más cercano
Gestión de memoria
- En high_impact se considera que, si el juego no tiene assets generados por usuarios, es posible saber exactamente cuánta memoria se necesita
- El motor asigna estáticamente un único arreglo de bytes llamado “hunk”, y esa es toda la memoria que usa high_impact
- El tamaño del hunk puede configurarse con
#define ALLOC_SIZE - Dentro del hunk se asigna memoria de dos maneras
- Un bump allocator que crece desde el frente, o arena, contiene los assets del juego, las entidades y los datos de la scene actual
- Un asignador temporal que crece desde el final hacia abajo funciona como
malloc()yfree(), y se usa para almacenamiento temporal, como después de descomprimir una imagen y antes de enviarla al GPU
- El bump allocator tiene varios “high water mark” y vuelve automáticamente a cierto punto cuando corresponde
- La memoria asignada con bump no necesita
free()explícito - La vida útil conceptual se divide en
game,sceneyframe- Lo asignado antes de establecer la primera scene solo se libera al terminar el programa
- Lo asignado durante
scene.load()se libera al terminar la scene - Lo asignado durante la ejecución de la scene se libera al final del frame
- El
load()por tipo de entidad se llama en la etapa 1, porque no se sabe de antemano qué entidades se usarán en la scene - Los contextos de asignación adicionales pueden envolverse con
alloc_pool(), que internamente es una abreviatura debump_mark()ybump_reset(mark)
Editor de niveles Weltmeister
- El Impact original tenía un editor de niveles llamado Weltmeister, y high_impact también lo incluye
- Sigue estando escrito en JavaScript y reutiliza mucho del código original, pero fue actualizado para ajustarse a las funciones modernas del navegador
- Weltmeister funciona de forma totalmente independiente
- Se puede empezar a crear niveles haciendo doble clic en
weltmeister.html - Antes necesitaba una API backend en PHP o NodeJS para cargar y guardar archivos
- Ahora puede pedir permiso para acceder a una carpeta específica mediante FileSystemAPI
- Se puede empezar a crear niveles haciendo doble clic en
- Safari y Firefox todavía no soportan completamente showDirectoryPicker(), así que se necesita un navegador basado en Chrome
- Weltmeister lee archivos fuente en C y recopila los tipos de entidad
- high_impact ofrece macros que el editor entiende, aunque no hacen nada en el código C
EDITOR_SIZE(X, Y): tamaño en el editor, valor predeterminado(8, 8)EDITOR_RESIZE(RESIZE): si el tamaño puede ajustarse en el editorEDITOR_COLOR(R, G, B): color de la caja en el editor, valor predeterminado(128, 255, 128)EDITOR_IGNORE(IGNORE): si puede crearse desde el editor
Juegos demo
- Para comprobar que high_impact funciona como un motor de juegos real, se portaron a C dos juegos originales de Impact
- El trabajo de portarlos se pareció más a una “transliteración” del código fuente JS existente, reutilizando los assets ya disponibles
- El hecho de que hubiera pocos desafíos puede verse como evidencia de que high_impact funcionó como se esperaba
-
Biolab Disaster
- Fue el título de lanzamiento del Impact original
- Es un juego lateral de Jump'n'Gun
- El código está en github.com/phoboslab/high_biolab
- La versión original en JS está disponible en playbiolab.com
-
Drop
- Es un juego arcade muy simple
- El código está en github.com/phoboslab/high_drop
- La versión original en JS está en impactjs.com/drop/
- Actualmente se presenta como mejor puntaje 26789 Points
Extensibilidad
- high_impact tiene una estructura donde el código específico de cada juego se escribe de forma aditiva, como en un motor de juegos tradicional
- No hace falta modificar el código fuente del motor, pero se busca que sea lo bastante simple como para poder cambiarlo directamente si hace falta
- La plataforma y el renderizador están diseñados para ampliarse sin cambiar el resto del código
- Si hay interés, se reciben con gusto pull requests para renderizadores Vulkan, DirectX y Metal, así como soporte de backend de plataforma para PSX, N64, Dreamcast y similares
- Al estar escrito en C, la idea es que pueda correr en cualquier lugar
1 comentarios
Comentarios de Hacker News
Gran parte del trabajo de programación con el que más aprendí fue gracias a Impact
Impact iba realmente adelantado a su época, y me enorgullece haber sido uno de sus 3000 licenciatarios. Fue de las mejores compras que hice, y el único juego que de verdad terminé por completo también lo hice con Impact
Me gustaba que la licencia incluyera el código fuente, y modifiqué tanto el motor como el editor según mis necesidades. A partir de esa influencia pasé varios años creando mi propio motor de juegos en JS, aunque al final fui posponiendo terminar juegos; aun así aprendí muchísimo en el proceso e hice muchos juegos para game jams
También me inspiró Ejecta, el soporte nativo para iOS de Impact, pero en ese tiempo me frustraba que no funcionara en Android, así que para correr mi motor en Android sin webview hice bindings de JVM para V8 e implementé parte de WebGL. Inesperadamente, el repositorio público de esos bindings para V8 terminó usándose en software comercial: https://github.com/namuol/jv8
Inspirado por el modelo de negocio de Impact, incluso intenté una startup bootstrap vendiendo acceso a repositorios privados de GitHub, pero esa historia sería muy larga. En fin, me da gusto y hasta me enternece ver que Impact se actualiza para la web “moderna” con un port a C. Quisiera decir que la web está en una época rara, pero no recuerdo una época en que la web no haya sido rara
CrossCode es un gran juego. Sabía que usaba tecnologías web, y siempre me sorprendió que rindiera tan bien en el hardware de Nintendo Switch
Supongo que este motor también tiene parte del mérito
Eso de hecho lo hace aún más genial. Está bien que un desarrollador pueda adaptar el motor a su juego, y del mismo modo high_impact debería verse más como un punto de partida conveniente que como un motor de juegos “completo en funciones”
Como anécdota curiosa, todo el mundo quería una versión para Switch, pero por las limitaciones técnicas el equipo respondió que “CrossCode saldrá en Switch cuando los Hedgehags aprendan a volar”: https://www.radicalfishgames.com/?p=6581
Cuando por fin lograron hacer el port, agregaron una misión extra llamada “A switch in attitude” y, como era de esperarse, aparecen hedgehags voladores: https://www.radicalfishgames.com/?p=6668
“Thoughts on Flash” quizá salvó a la plataforma web justo en el momento en que más lo necesitaba, cuando el dominio de un solo software iba creciendo poco a poco
También parece que ahí había molestia hacia Adobe, que daba la impresión de descuidar el soporte para MacOS al priorizar la base de usuarios mucho mayor de Windows. Por ejemplo, la versión para Mac siempre iba detrás de la de Windows
Puede que Jobs sintiera que, así como Apple hizo posible a Adobe, Adobe también le debía algo a Apple, pero eso ya es más especulación. El juego en sí se ve realmente pulido
Aunque no tan cercanas como ensamblador; más bien he estado reduciendo cosas aquí y allá en una dirección que me haga profundizar más
Como alguien que quiere salir algún día del trabajo corporativo y meterse en serio a un proyecto paralelo, me gustaría saber más sobre la parte de sostenerse con ingresos
Me pesa de una forma rara la idea de cobrar por algo que originalmente hacía por diversión, aunque también sé que eso podría permitirme dedicarme de tiempo completo a algo que me gusta
Por eso es importante entender por qué te pesa esa idea. Razones comunes son que la gente a tu alrededor suele desanimarte, que te faltan habilidades para ejecutar bien lo que quieres hacer, que te da pena pedir ayuda o sientes que molestarías a otros, que te asusta que evalúen tu trabajo, o perder la “seguridad” que te da tu ingreso actual, sobre todo si tienes personas dependientes
En la mayoría de los casos, esas razones no son tanto buenas razones, sino cosas que requieren cierto reajuste y por eso se sienten como un riesgo, lo que hace difícil salir de la zona de confort. Con esa mentalidad, cualquier oportunidad parece un riesgo, así que se vuelve muy difícil encontrar el momento adecuado para empezar a hacer lo que de verdad quieres
Relacionado con eso: hacer algo que te parece divertido y mostrarlo al mundo es importante, pero convertirlo en tu sustento es un reto completamente distinto. La mayoría no logra convertir lo que ama en su profesión, y aun cuando lo logran, las expectativas de clientes que pagan y la presión por mantener ingresos pueden quitarle ese cariño. No lo digo para desanimar a nadie, solo es bueno saberlo antes de lanzarse
Inicié sesión en mi cuenta de HN, que casi nunca uso, solo para decir que hace años jugué Biolab Disaster una y otra vez, pero había olvidado su nombre
Es bastante curioso haberlo encontrado de nuevo por casualidad
Normalmente yo lo habría dicho de forma mucho más negativa. Porque veía un “framework” como una biblioteca que simplemente no se lleva bien con otras cosas
Aun así, me gusta escuchar una formulación positiva tan convincente como esta
Fue un placer usar un framework rico y bien diseñado que resolvía realmente bien el 99% de lo que necesitaba. Agregar mi propio código también fue de las cosas más fáciles que me ha tocado hacer desarrollando, y simplemente funcionaba. Se sentía mágico, y todavía lo extraño
Mi framework ideal es, por dentro, una biblioteca o un conjunto de bibliotecas que colaboran entre sí, y con la menor cantidad posible de “carácter de framework”
Por ejemplo, Qt es un framework y Qt “me llama”, pero puedes ejecutar código de QPainter sin arrancar el event loop de Qt ni pensar demasiado en QObject. Idealmente, deberías poder usar el event loop sin tener que adoptar por completo signals y slots, aunque la experiencia quizá sería menos cómoda
No siempre es posible ni siempre vale la pena, pero si todo lo demás es igual, prefiero la opción sin framework en absoluto
Entiendo por qué en un motor de juegos se necesita un poco de framework. Al compilar para plataformas peculiares como teléfonos o consolas, el motor tiene que involucrarse en el proceso de build y, a veces, hasta en partes relacionadas con libc, así que no puedes simplemente crear un ejecutable de Win32 y decir que ya es un juego de PlayStation
Si combinas el formato de archivo sin pérdida QOI con 7Zip, el rendimiento es mejor que el de PNG sin pérdida. Es un trabajo sorprendente
“for No Reason” quizá era para respetar la duración de la batería del jugador
Me gusta la parte de gestión de memoria. La asignación con arena es realmente simple
El servidor web de juguete que uso también empezó con arena, pero pronto me di cuenta de que en realidad no había necesidad de aumentar y reducir memoria
Ahora asigno de entrada toda la memoria que voy a necesitar y luego la divido en fragmentos para que los use cada módulo. Al programar solemos asumir que podríamos necesitar una cantidad arbitraria de memoria, pero no necesariamente es así
Muchas cosas en realidad tienen límites claros, y para el resto casi siempre se pueden definir límites. Si los enumeras, terminas sabiendo cuánta memoria necesitas. Pensar y definir esos límites de antemano es divertido, da confianza y fomenta hábitos saludables de austeridad
Claro que puede serlo si empiezas con un vector vacío y le agregas miles de elementos sin reservar memoria. Pero muy a menudo puedes encontrar el máximo real que necesitas y asignarlo por adelantado, y entonces todo queda bien
Me gusta mucho cómo hicieron la estructura de datos ENTITY de tipo polimórfico con union. El diseño es bueno
Todavía me gusta trastear con C. Fue el primer lenguaje que aprendí, y realmente sufrí bastante con él durante algunos años. Como también dice el artículo original, C es genial por ser un lenguaje compacto, y puedes profundizar todo lo que quieras
El juego me gustó porque tiene vibra del viejo Commander Keen, y en su momento me gustó bastante esa franquicia que Carmack hacía antes de la era 3D