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ÓNDECISIÓNRAZÓN
v1.0 diseñoTree-walk interpreter con GeneratorsPermite el game loop sin bloquear el browser. Sin callbacks ni async/await en el intérprete.
v1.0 diseñoResolución virtual independientePixel-perfect scaling automático. El intérprete no necesita saber el tamaño de la pantalla visible.
v1.0 diseñoInput unificadoTeclado, mouse y touch abstractos. El programa QBJr. no sabe si corre en desktop o mobile.
v1.0 diseñoMódulos desacopladosCada runtime es reemplazable independientemente. Portabilidad por diseño.
v1.0 diseñoSin 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 TEMPORALVERSIÓN DEFINITIVACONTENIDO
v1.0.0v0.9.4Render trapecios PS1 completo
v1.0.1v0.9.5Fix _ctx, RECTANGULO, .qdat multilínea
v1.0.2v0.9.6Convención de teclado corregida
v1.0.3v0.9.7Fix textos superpuestos
v1.0.4v0.9.8Resolución 256×240, 10 funciones de texto
v1.0.5v0.9.9Eventos persistentes, mensajeDelay
v1.0.6v0.9.10Fix LEER_DATO: argStr
v1.0.7v0.9.11Fix render bcy, inventarios, Escape
v1.0.8v0.9.12Fix cargarDatos Node.js 18
v1.0.9v0.9.13Estado 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ÓNPENDIENTE
horror.qbjrCombinación de objetos, teclado numérico (código 4512), audio en eventos S/C, glitch visual criatura completo.
FAP bridgeAdaptar Reverse FAP para reutilizar los módulos del Player (Lexer/Parser principalmente).
v1.0.0 realEspecificación publicada + intérprete sin bugs estructurales conocidos + suite de demos representativa completa.

Extensiones futuras

VERSIÓNEXTENSIÓN
Física avanzadaSwept AABB para eliminar tunneling. Cuerpos cinemáticos. Fricción configurable por tileset.
Tiles animadosTiles con animación de fotogramas. Carga desde Tiled (.tmx) via CARGAR DATOS.
Port C++/SDL2Lexer + Parser + Intérprete en C++ con coroutines C++20. Runtimes: SDL3 + SDL_mixer.
Port GodotIntérprete como GDExtension. Runtimes nativos de Godot.