Propósito de este documento
Este documento no es una especificación técnica ni un manual de referencia. Es algo más parecido a un diario de desarrollo: el relato de cómo un sistema fue concebido, implementado, corregido y refinado. Su propósito es preservar no solo lo que se construyó, sino el camino que llevó hasta allí.
Cualquier implementación de software de cierta complejidad produce dos tipos de conocimiento: el que queda en el código, y el que queda en la memoria de quien lo escribió. Este documento convierte el segundo tipo en el primero.
Parte I — El diseño original
El primer intérprete de referencia de QBJr. se planificó antes de existir. El documento de arquitectura v1.0 describía un sistema completo: lexer, parser, intérprete con generators, cinco módulos de runtime, y un coordinador. Todo diseñado con un objetivo claro: ser portable a otras plataformas con el mínimo de cambios posibles.
La separación entre el núcleo del intérprete y los módulos de runtime quedó plasmada en cuatro contratos formales. La estructura planificada original: interpreter/ con lexer, parser e intérprete, y runtime/ con canvas, sprites, audio, input y layers. La estructura final del proyecto resultó más amplia — con módulos adicionales para tiles, física, archivos y markdown extendido — pero la organización se mantuvo exactamente como fue diseñada.
Parte II — Decisiones fundamentales
Estas cinco decisiones marcaron el diseño desde el principio. Todas sobrevivieron a la implementación sin cambios:
| VERSIÓN | DECISIÓN | RAZÓN |
| v1.0 diseño | Tree-walk interpreter con Generators | Permite el game loop sin bloquear el browser. Sin callbacks ni async/await en el intérprete. |
| v1.0 diseño | Resolución virtual independiente | Pixel-perfect scaling automático. El intérprete no necesita saber el tamaño de la pantalla visible. |
| v1.0 diseño | Input unificado | Teclado, mouse y touch abstractos. El programa QBJr. no sabe si corre en desktop o mobile. |
| v1.0 diseño | Módulos desacoplados | Cada runtime es reemplazable independientemente. Portabilidad por diseño. |
| v1.0 diseño | Sin eval() ni Function() | Seguridad. El evaluador de expresiones propio también permite precedencia correcta. |
Parte III — Historial de versiones
v0.0.1 — v0.1.0
Player UI + Core
Stub del Player con layout neon oscuro, boot sequence y controles táctiles. Luego: Lexer formal + Parser + Intérprete Generator + Canvas runtime. demo.qbjr incluido.
v0.2.0 — v0.3.0
Input + Engine + UI fixes
Input runtime completo. Sistema two-canvas implementado. Game loop RAF. Fuente Press Start 2P. textBaseline="top". Slider de escala con 7 pasos fijos.
v0.4.0
Sprites + Cambio incompatible
Motor de sprites/instancias/animaciones. AND OR XOR NOT — cambio incompatible con v1.1 del lenguaje. COLISIONA binario e infijo. varKey(), rawS().
v0.5.0 — v0.5.3
Audio + Demos
Síntesis Web Audio API + archivos externos. cubo3d.qbjr, intro_consola.qbjr. horror.qbjr: port de HORROR.BAS (v0.5.1), reescritura con IA (v0.5.2), renderer PS1 wireframe (v0.5.3).
v0.6.0 — v0.8.0
Layers + Tiles + Física
6 capas con parallax. Motor de tiles con RIGIDO y COLISIONA_TILE. Física: gravedad, velocidad, impulso, resolución AABB.
v0.9.0 — v0.9.3
Archivos + horror data-driven
ABRIR/LEER/ESCRIBIR, formato .qdat. horror.qbjr primera versión data-driven con .qdat externos. Fixes: RECTANGULO al revés, argStr para LEER_DATO, TECLA_X.
v0.9.4
Render trapecios PS1
horror.qbjr completamente reescrito con trapecios rellenos + wireframe. Inspirado en Phantasy Star 1. Painter's algorithm. 6 niveles de perspectiva.
v0.9.5 — v0.9.9
Fixes y UX
Resolución 256×240. 10 funciones de texto (BUSCAR, EXTRAER, etc.). Inventarios navegables. LINT→PILA. Eventos persistentes (evPersist). mensajeDelay.
v0.9.10 — v0.9.15
Correcciones post-renumeración
argStr refinado. Fix LEER_DATO. archivoSala vs archivo. Estado persistente. Fix cargarDatos Node.js 18. Fix render bcy. Escape solo finaliza.
v0.9.16 — v0.9.31
Markdown extendido + estabilización
qbjr-text.js: TEXTO_MD y ALTURA_MD. horror.qbjr estabilizado. IMPRIMIR. PAUSAR/REANUDAR. v0.9.31 = versión actual.
La renumeración 1.0.x → 0.9.10+
Después de v0.9.9, se decidió incorrectamente saltar a la versión 1.0.0. Esta numeración estuvo vigente durante las versiones v1.0.0 a v1.0.9 del repositorio. Se tomó la decisión de corregirla con la siguiente justificación:
"MAJOR 0: pre-release. La especificación del lenguaje y la API no están cerradas. Criterio para 1.0.0: especificación publicada, intérprete sin bugs estructurales conocidos, suite de demos representativa completa."
| ZIP TEMPORAL | VERSIÓN DEFINITIVA | CONTENIDO |
| v1.0.0 | v0.9.4 | Render trapecios PS1 completo |
| v1.0.1 | v0.9.5 | Fix _ctx, RECTANGULO, .qdat multilínea |
| v1.0.2 | v0.9.6 | Convención de teclado corregida |
| v1.0.3 | v0.9.7 | Fix textos superpuestos |
| v1.0.4 | v0.9.8 | Resolución 256×240, 10 funciones de texto |
| v1.0.5 | v0.9.9 | Eventos persistentes, mensajeDelay |
| v1.0.6 | v0.9.10 | Fix LEER_DATO: argStr |
| v1.0.7 | v0.9.11 | Fix render bcy, inventarios, Escape |
| v1.0.8 | v0.9.12 | Fix cargarDatos Node.js 18 |
| v1.0.9 | v0.9.13 | Estado persistente y reset limpio |
Divergencias — D-01: Operadores lógicos
El cambio más significativo al lenguaje de toda la historia del proyecto. Incompatible con v1.1.
D-01
Y O OX NO → AND OR XOR NOT
🔴 PROBLEMAEl lexer tokenizaba "y" como operador Y (AND). La variable y (coordenada vertical) era imposible de usar. Las variables de una sola letra o, r, i, n colisionaban con operadores en español.
✅ SOLUCIÓNRenombrar a palabras completas: AND OR XOR NOT. Solo son KEYWORD en MAYÚSCULAS. En minúsculas son IDENTIFIER válidos.
📚 LECCIÓNEn un lenguaje con vocabulario en español, los operadores de una sola letra inevitablemente colisionan con variables comunes. La solución correcta es usar palabras que no sean términos comunes del idioma.
D-02: Two-canvas
D-02
Pantalla negra sin error visible
🔴 PROBLEMALa primera versión usaba el mismo elemento canvas para virtual y display. copiarADisplay() hacía drawImage(canvas, canvas). En Chrome/WebKit es un no-op. La pantalla quedaba negra.
✅ SOLUCIÓNDos elementos <canvas> distintos en el DOM. Assertion al inicio: if (virtual === display) throw.
📚 LECCIÓNEl diseño correcto no garantiza la implementación correcta. "Two-canvas" en papel es una descripción; en código hay que asegurarse de que existan literalmente dos objetos canvas distintos.
D-03: SI NO (else) silencioso
D-03
El bloque else nunca ejecutaba
🔴 PROBLEMA"SI NO" en el stream parece el inicio de un nuevo SI con NOT. parseBlock() procesando el cuerpo de un OTRO SI no sabía detenerse. El bloque SI NO quedaba vacío sin error de parseo.
✅ SOLUCIÓNEn parseBlock(): si token actual es SI y siguiente es NO, detener el bloque sin consumirlos. Luego parsear SI+NO como bloque else.
📚 LECCIÓNLas sintaxis con tokens reutilizados en múltiples contextos requieren lookahead explícito en el parser. El bug silencioso (sin error) es más peligroso que el que falla.
D-04: rawS() — constantes evaluadas como variables
D-04
PALETA retro no cambiaba la paleta
🔴 PROBLEMAevalExpr(Identifier("retro")) buscaba vars["retro"] → undefined → 0. String(0) = "0". resolverColor("0") no encontraba ningún color. La paleta nunca cambiaba.
✅ SOLUCIÓNrawS(i): retorna el nombre del Identifier directamente como string, sin pasar por variable lookup.
📚 LECCIÓNHay dos tipos de argumentos en un lenguaje de comandos: expresiones que se evalúan, y constantes nombradas que se leen literalmente. El intérprete necesita distinguirlos explícitamente.
D-05: varKey() — variables case-insensitive
D-05
La misma variable con múltiples claves
🔴 PROBLEMAUna variable declarada como "y" y luego referenciada como "Y" (el lexer la normalizaba a UPPERCASE durante la transición) generaba dos entradas distintas en estado.vars. El lookup fallaba silenciosamente.
✅ SOLUCIÓNvarKey(): busca la clave exacta, luego lowercase, luego uppercase, luego case-insensitive completo. Es la defensa central contra futuros cambios en el lexer.
📚 LECCIÓNLa implementación de case-insensitivity en variables requiere un punto de lookup centralizado. Sin él, la misma variable puede existir bajo múltiples claves.
D-06: RECTANGULO con args al revés
D-06
Todas las barras horizontales aparecían verticales
🔴 PROBLEMAEl canvas runtime implementaba rectangulo(x,y,alto,ancho). La barra de batería de la linterna en horror.qbjr se extendía hacia abajo en lugar de hacia la derecha.
✅ SOLUCIÓNCorregir a rectangulo(x,y,ancho,alto). Test inmediato: RECTANGULO 10 10 100 20 RELLENO debe ser horizontal (100×20), no vertical (20×100).
📚 LECCIÓNLas primitivas de dibujo deben validarse con un caso asimétrico y visible antes de construir sistemas sobre ellas.
D-07: argStr() — LEER_DATO evaluaba claves como variables
D-07
El jugador no podía salir de ninguna sala
🔴 PROBLEMALEER_DATO(mapa sala_1 n) con n=5 como variable buscaba la clave "5" en vez de la clave literal "n". Las conexiones de sala (cN, cE, cS, cO) devolvían 0. El juego era completamente ineludible.
✅ SOLUCIÓNargStr(): semántica híbrida. Variable string no vacía → su valor (uso dinámico). TODO_MAYÚSCULAS → lowercase como clave literal. Mixto no-variable → nombre literal. NUNCA usar argNom() para LEER_DATO.
📚 LECCIÓNLEER_DATO es el único comando donde un argumento puede ser tanto variable dinámica como clave literal. La distinción argStr/argNom debe documentarse explícitamente.
D-08: dt = 0 en el game loop
D-08
La física estaba estática en todos los programas
🔴 PROBLEMAEl delta time se calculaba DESPUÉS de actualizar lastFrame. Resultado: dt siempre 0. La gravedad no tiraba, las animaciones no avanzaban, los sprites no se movían por física.
✅ SOLUCIÓNCalcular const _dt = now - lastFrame ANTES de lastFrame = now. Un cambio de una línea con impacto en todos los módulos que dependen de delta time.
📚 LECCIÓNLos bugs de orden de operaciones son los más difíciles de detectar porque el código "parece correcto". Un audit sistemático del engine antes de cada versión mayor puede revelarlos.
D-09: TECLA_A..Z violaban la spec
D-09
Los inventarios no respondían a las teclas esperadas
🔴 PROBLEMASe habían agregado constantes TECLA_A..Z y TECLA_0..9 por conveniencia. La spec del autor establece que las teclas de escritura se referencian con el carácter entre comillas: TECLADO("W"), no TECLA_W.
✅ SOLUCIÓNRemover todas las constantes TECLA_A..Z y TECLA_0..9. Corregir horror.qbjr para usar TECLADO("W"), TECLADO("I"), etc.
📚 LECCIÓNLas constantes de conveniencia que violan decisiones pedagógicas del lenguaje deben rechazarse incluso cuando simplifican la implementación.
D-10: archivoSala — colisión de nombre de variable
D-10
La primera sala cargaba bien, todas las demás no
🔴 PROBLEMALa variable "archivo" colisionaba con la clave "archivo" del .qdat. argStr() encontraba que la variable ya tenía valor "SALA_VESTIBULO.qdat" y usaba ese valor en vez del literal "archivo". Todas las salas después de la primera devolvían "" para el campo archivo.
✅ SOLUCIÓNRenombrar la variable a "archivoSala". Documentar la restricción: los nombres de variables no deben coincidir con las claves del .qdat que se va a leer.
📚 LECCIÓNLa semántica híbrida de argStr() es poderosa pero requiere disciplina en los nombres de variables. Un nombre que colisiona con una clave de .qdat produce un bug que solo falla en el segundo uso — difícil de diagnosticar.
Camino hacia adelante
Pendientes del proyecto
| VERSIÓN | PENDIENTE |
| horror.qbjr | Combinación de objetos, teclado numérico (código 4512), audio en eventos S/C, glitch visual criatura completo. |
| FAP bridge | Adaptar Reverse FAP para reutilizar los módulos del Player (Lexer/Parser principalmente). |
| v1.0.0 real | Especificación publicada + intérprete sin bugs estructurales conocidos + suite de demos representativa completa. |
Extensiones futuras
| VERSIÓN | EXTENSIÓN |
| Física avanzada | Swept AABB para eliminar tunneling. Cuerpos cinemáticos. Fricción configurable por tileset. |
| Tiles animados | Tiles con animación de fotogramas. Carga desde Tiled (.tmx) via CARGAR DATOS. |
| Port C++/SDL2 | Lexer + Parser + Intérprete en C++ con coroutines C++20. Runtimes: SDL3 + SDL_mixer. |
| Port Godot | Intérprete como GDExtension. Runtimes nativos de Godot. |