Cómo Construir un RPG de Captura de Monstruos
Este tutorial fue escrito en febrero de 2026, para la v2 del motor.
Hay algo en los RPGs de captura de monstruos que te atrapa de inmediato — caminar entre la hierba alta, sin saber con qué te vas a encontrar, debilitar a una criatura y esperar que la captura funcione. Vamos a construir uno. El motor nos da todo lo que necesitamos: tilemaps, flags de sprites, desplazamiento de cámara, entrada de teclado y renderizado de texto. Al final tendremos un mundo con desplazamiento, encuentros aleatorios, combate por turnos con barras de HP, una mecánica de captura con probabilidad riesgo-recompensa, y un equipo de hasta tres monstruos.
Este tutorial se basa en el patrón de movimiento alineado a cuadrícula del tutorial de Aventura Top-Down. Si no lo has hecho, cubre la construcción del mundo, colisiones con tiles y desplazamiento de cámara en detalle. Aquí pasaremos rápido por esas partes y nos enfocaremos en lo que hace únicos a los JRPGs — encuentros, combate, captura y gestión del equipo.
El Mundo
Todo RPG empieza con un mundo para explorar. Necesitamos cuatro tipos de tiles — grass, path, tree y wall — y el sistema de tilemaps para organizarlos en una aldea rodeada de naturaleza.
Cada sprite tiene un array de flags. Flag 0 significa sólido — los árboles y muros lo usan para bloquear el movimiento. Flag 1 significa zona de encuentro — así marcamos los tiles de hierba donde pueden aparecer monstruos salvajes. Los tiles de camino tienen ambos flags desactivados, haciéndolos seguros para caminar.
Para los sprites: la hierba es verde oscuro (3) con acentos verde brillante (11) dispersos. Los caminos son marrón (4) con motas melocotón (15). Los árboles tienen una copa verde sobre un tronco marrón, y los muros usan el mismo patrón de ladrillos gris oscuro (5) del tutorial de aventura top-down.
buildWorld llena una cuadrícula de 24×24 con hierba, la bordea con árboles, coloca una aldea de tiles de camino en el centro con algunos edificios de muro, y traza caminos en cada dirección. Árboles dispersos en la hierba rompen el espacio abierto:
const sprites = {
grass: [
[
3, 3, 11, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 11, 3, 3,
3, 11, 3, 3, 3, 3, 3, 11,
3, 3, 3, 11, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 11, 3,
11, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 11, 3, 3, 3,
3, 3, 11, 3, 3, 3, 3, 11,
],
[false, true, false, false, false, false, false, false],
],
path: [
[
4, 4, 4, 4, 4, 4, 4, 4,
4, 15, 4, 4, 4, 4, 15, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 15, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 15, 4, 4,
4, 15, 4, 4, 4, 4, 4, 4,
4, 4, 4, 4, 4, 4, 4, 4,
],
[false, false, false, false, false, false, false, false],
],
tree: [
[
-1, 3, 11, 3, 3, 11, 3, -1,
3, 11, 3, 11, 11, 3, 11, 3,
3, 3, 11, 3, 3, 11, 3, 3,
-1, 3, 3, 11, 11, 3, 3, -1,
-1, -1, -1, 4, 4, -1, -1, -1,
-1, -1, -1, 4, 4, -1, -1, -1,
-1, -1, -1, 4, 4, -1, -1, -1,
-1, -1, -1, 4, 4, -1, -1, -1,
],
[true, false, false, false, false, false, false, false],
],
wall: [
[
5, 5, 5, 6, 5, 5, 5, 6,
5, 5, 5, 6, 5, 5, 5, 6,
6, 6, 6, 6, 6, 6, 6, 6,
5, 6, 5, 5, 5, 6, 5, 5,
5, 6, 5, 5, 5, 6, 5, 5,
6, 6, 6, 6, 6, 6, 6, 6,
5, 5, 5, 6, 5, 5, 5, 6,
5, 5, 5, 6, 5, 5, 5, 6,
],
[true, false, false, false, false, false, false, false],
],
};
function buildWorld() {
for (let y = 0; y < MAP_H; y++) {
for (let x = 0; x < MAP_W; x++) {
mset(x, y, 'grass');
}
}
for (let x = 0; x < MAP_W; x++) {
mset(x, 0, 'tree');
mset(x, MAP_H - 1, 'tree');
}
for (let y = 0; y < MAP_H; y++) {
mset(0, y, 'tree');
mset(MAP_W - 1, y, 'tree');
}
// centro de la aldea
for (let y = 8; y <= 15; y++) {
for (let x = 8; x <= 15; x++) {
mset(x, y, 'path');
}
}
// edificios
for (let y = 9; y <= 10; y++) {
for (let x = 9; x <= 11; x++) mset(x, y, 'wall');
}
for (let y = 9; y <= 10; y++) {
for (let x = 13; x <= 15; x++) mset(x, y, 'wall');
}
for (let y = 13; y <= 14; y++) {
for (let x = 10; x <= 13; x++) mset(x, y, 'wall');
}
// caminos fuera de la aldea
for (let y = 1; y <= 7; y++) { mset(11, y, 'path'); mset(12, y, 'path'); }
for (let y = 16; y <= 22; y++) { mset(11, y, 'path'); mset(12, y, 'path'); }
for (let x = 1; x <= 7; x++) { mset(x, 11, 'path'); mset(x, 12, 'path'); }
for (let x = 16; x <= 22; x++) { mset(x, 11, 'path'); mset(x, 12, 'path'); }
// árboles dispersos
mset(3, 3, 'tree'); mset(5, 5, 'tree'); mset(7, 3, 'tree');
mset(4, 6, 'tree'); mset(18, 3, 'tree'); mset(20, 5, 'tree');
mset(17, 6, 'tree'); mset(21, 2, 'tree'); mset(3, 18, 'tree');
mset(6, 20, 'tree'); mset(4, 21, 'tree'); mset(19, 18, 'tree');
mset(21, 20, 'tree'); mset(17, 21, 'tree');
}
La cámara empieza centrada en la aldea. Con 24×24 tiles de 8 píxeles cada uno, el mundo mide 192×192 píxeles — más grande que el canvas de 128×128. Necesitaremos desplazamiento cuando el jugador empiece a moverse.
Caminando por el Mundo
Es hora de crear un personaje jugador. Necesitamos cuatro sprites direccionales — el entrenador lleva un gorro rojo (8), piel melocotón (15), cuerpo azul oscuro (1) y botas marrones (4). Lo suficientemente distintivo para distinguirlo contra cualquier tipo de terreno.
El movimiento está alineado a la cuadrícula. tileX/tileY rastrean la posición lógica en la cuadrícula, y px/py rastrean la posición en píxeles para el renderizado. Cada frame, px y py interpolan hacia el tile objetivo a una speed fija. Cuando llegan, el jugador puede moverse de nuevo.
Antes de iniciar un movimiento, verificamos el tile destino con fget — si el flag 0 está activado, es sólido y el movimiento se bloquea:
function isSolid(x, y) {
const tile = mget(x, y);
return tile && fget(tile, 0);
}
La cámara sigue al jugador y se limita a los bordes del mundo para nunca mostrar espacio vacío:
camX = Math.max(0, Math.min(MAP_W * 8 - CANVAS, px - 60));
camY = Math.max(0, Math.min(MAP_H * 8 - CANVAS, py - 60));
Verificamos la entrada horizontal y vertical por separado. Si ambos ejes tienen entrada al mismo tiempo, el presionado más recientemente gana — lastAxis rastrea qué dirección se presionó sola, y la más nueva tiene prioridad. Esto previene el movimiento diagonal en una cuadrícula sin sentirse lento:
let dx = 0, dy = 0;
if (btn('ArrowLeft') || btn('a')) dx = -1;
else if (btn('ArrowRight') || btn('d')) dx = 1;
if (btn('ArrowUp') || btn('w')) dy = -1;
else if (btn('ArrowDown') || btn('s')) dy = 1;
if (dx !== 0 && dy === 0) lastAxis = 'h';
if (dy !== 0 && dx === 0) lastAxis = 'v';
if (dx !== 0 && dy !== 0) {
if (lastAxis === 'h') dx = 0;
else dy = 0;
}
Esto usa el mismo patrón de movimiento alineado a cuadrícula del tutorial de Aventura Top-Down. Si es la primera vez que lo ves, ese tutorial cubre los detalles.
Encuentros Aleatorios
Caminar funciona, pero la hierba es inofensiva. Eso no es muy JRPG. Necesitamos encuentros aleatorios — una probabilidad de iniciar una batalla cada vez que el jugador pisa un tile de hierba. Eso significa dos cosas: una forma de detectar zonas de encuentro, y una máquina de estados para alternar entre el mundo y la batalla.
La máquina de estados es una sola variable mode — 'overworld' o 'battle'. Cada modo tiene su propia lógica de actualización y dibujado. El mundo se congela completamente durante una batalla.
Después de que el jugador termina un paso, verificamos el flag 1 en el tile actual. La hierba lo tiene en true, el camino y los muros no. Si estamos en un tile de encuentro, tiramos rnd(1) < 0.15 — un 15% de probabilidad por paso:
function isEncounterZone(x, y) {
const tile = mget(x, y);
return tile && fget(tile, 1);
}
// después de que el jugador termina un paso:
if (px === tx && py === ty) {
moving = false;
if (isEncounterZone(tileX, tileY) && rnd(1) < 0.15) {
startEncounter();
return;
}
}
Cuando se activa un encuentro, elegimos un monstruo aleatorio de un grupo y cambiamos al modo batalla. Cada monstruo es un objeto simple — nombre, sprite, HP, ataque, defensa. Lo copiamos con spread para que la plantilla quede limpia para el próximo encuentro:
const monsterPool = [
{ name: 'Flamepup', sprite: 'flamepup', maxHp: 20, attack: 8, defense: 3 },
];
function startEncounter() {
const template = monsterPool[Math.floor(rnd(monsterPool.length))];
wildMonster = { ...template, hp: template.maxHp };
mode = 'battle';
}
Por ahora la pantalla de batalla muestra el sprite del monstruo, un mensaje "A wild X appeared!", y Z para cerrar. El combate real viene después.
La tasa de encuentro del 15% por paso se siente bien para un mundo demo pequeño. En un juego completo la ajustarías por área — 5% para rutas cortas, 20% para cuevas profundas.
Combate por Turnos
La pantalla de batalla provisional se cierra con Z. Hagamos que haga algo. Necesitamos un menú, cálculo de daño, turnos y condiciones de victoria/derrota.
La batalla tiene sus propios sub-estados: 'menu', 'message', y transiciones para victoria/derrota/escape. El mode de nivel superior sigue alternando entre 'overworld' y 'battle', pero dentro de la batalla, battleState controla lo que sucede.
El menú empieza con dos opciones: FIGHT y RUN. Un cursor de flecha se mueve con Arriba/Abajo, Z selecciona. El cursor se envuelve para que presionar Abajo en la última opción salte a la primera:
const menuOptions = ['FIGHT', 'RUN'];
if (btnp('ArrowUp') || btnp('w'))
menuCursor = (menuCursor + menuOptions.length - 1) % menuOptions.length;
if (btnp('ArrowDown') || btnp('s'))
menuCursor = (menuCursor + 1) % menuOptions.length;
La fórmula de daño es directa: ataque del atacante menos defensa del defensor, más una tirada aleatoria entre -2 y +2, con un mínimo de 1. Las diferencias de estadísticas importan — un monstruo con alta defensa resiste los golpes — pero siempre haces al menos 1 de daño:
function calcDamage(atk, def) {
return Math.max(1, atk - def + randomIntegerBetween(-2, 2));
}
FIGHT hace daño al monstruo salvaje, muestra el resultado, y luego el enemigo contraataca con la misma fórmula. RUN tiene un 60% de probabilidad de éxito — si falla, el enemigo obtiene un golpe gratis. La batalla termina cuando el HP de cualquier lado llega a 0. Perder reinicia tu HP y te teletransporta de vuelta a la aldea:
if (menuOptions[menuCursor] === 'FIGHT') {
const dmg = calcDamage(playerAtk, wildMonster.defense);
wildMonster.hp = Math.max(0, wildMonster.hp - dmg);
showMessage('You deal ' + dmg + ' damage!', 40);
if (wildMonster.hp <= 0) {
nextBattleState = 'endWin';
} else {
nextBattleState = 'enemyTurn';
}
} else if (menuOptions[menuCursor] === 'RUN') {
if (rnd(1) < 0.6) {
showMessage('Got away safely!', 40);
nextBattleState = 'endReturn';
} else {
showMessage("Can't escape!", 40);
nextBattleState = 'runFail';
}
}
El patrón showMessage / nextBattleState es el pegamento aquí. Cada mensaje se muestra durante un número fijo de frames, y luego se activa el siguiente estado. Sin callbacks anidados — el temporizador controla toda la secuencia.
Barras de HP e Interfaz de Batalla
Los números para el HP cumplen su función, pero no se sienten como un JRPG real. Necesitamos barras de HP visuales, un diseño de batalla apropiado y algunos efectos de pantalla.
Una barra de HP son dos llamadas a rectfill superpuestas — un fondo gris oscuro (5) con el ancho completo, y un relleno de color cuyo ancho escala con el porcentaje de HP. El color del relleno cambia según umbrales: verde (11) por encima del 50%, amarillo (10) entre 25-50%, rojo (8) por debajo del 25%:
function hpBarColor(hp, maxHp) {
const pct = hp / maxHp;
if (pct > 0.5) return 11;
if (pct > 0.25) return 10;
return 8;
}
function drawHpBar(x, y, hp, maxHp) {
const fillW = Math.floor((hp / maxHp) * HP_BAR_W);
rectfill(x, y, x + HP_BAR_W, y + 3, 5);
if (fillW > 0)
rectfill(x, y, x + fillW, y + 3, hpBarColor(hp, maxHp));
}
Colocamos la info del enemigo arriba a la izquierda con su sprite a la derecha, y la info del jugador abajo a la izquierda con su sprite debajo. La disposición diagonal le da espacio a cada lado en el canvas de 128×128.
Dos efectos venden la sensación de combate. Primero, una transición de batalla — cuando se activa un encuentro, la pantalla parpadea entre negro y blanco durante unos frames antes de que aparezca la interfaz de batalla:
function startEncounter() {
const template = monsterPool[Math.floor(rnd(monsterPool.length))];
wildMonster = { ...template, hp: template.maxHp };
mode = 'transition';
transitionTimer = 12;
}
// en draw:
if (mode === 'transition') {
cls(transitionTimer % 2 === 0 ? 0 : 7);
return;
}
Segundo, un destello de golpe. Cuando se hace daño, el objetivo parpadea invisible durante unos frames — flashTimer cuenta hacia atrás, y en frames pares el sprite se oculta:
function startFlash(target) {
flashTarget = target;
flashTimer = 12;
}
// en drawBattle:
const enemyVisible = !(flashTarget === 'enemy'
&& flashTimer > 0 && Math.floor(flashTimer / 2) % 2 === 0);
if (enemyVisible) spr(wildMonster.sprite, 92, 8);
Capturando Monstruos
Pelear y huir funciona, pero el punto central del género es capturar cosas. Necesitamos un array party, una opción CATCH en el menú de batalla, y una fórmula de captura que recompense debilitar al objetivo.
Las estadísticas del jugador ahora vienen del monstruo líder de su equipo en vez de variables planas. party[0] es el monstruo activo en batalla:
let party = [
{ name: 'Flamepup', sprite: 'flamepup', hp: 25, maxHp: 25, attack: 8, defense: 4 },
];
function lead() {
return party[0];
}
El menú se expande a tres opciones: FIGHT, CATCH, RUN. La fórmula de captura es lineal con el HP restante del enemigo — 30% a salud completa, cerca del 80% a 1 HP:
const chance = 0.3 + 0.5 * (1 - wildMonster.hp / wildMonster.maxHp);
if (rnd(1) < chance) {
showMessage('Got ' + wildMonster.name + '!', 60);
nextBattleState = 'catchSuccess';
} else {
showMessage('It broke free!', 40);
nextBattleState = 'catchFail';
}
Si tiene éxito, el monstruo salvaje se copia al equipo con HP completo restaurado. El equipo tiene un máximo de 3 — intenta capturar cuando está lleno y recibes un mensaje "Party is full!". Si falla, el enemigo obtiene un ataque gratis, igual que un intento de huida fallido.
El mundo ahora muestra un HUD con el conteo del equipo en la esquina usando creset() para dibujar en espacio de pantalla:
creset();
text('Party: ' + party.length + '/' + PARTY_MAX, 2, 2, 7);
La fórmula de captura crea una decisión de riesgo-recompensa: debilita al monstruo para mejores probabilidades, pero arriesga noquearlo. Esta tensión es lo que hace que la mecánica de captura se sienta tan bien.
Gestión del Equipo
Capturar monstruos no tiene sentido si no puedes alternar entre ellos. Necesitamos un tercer modo de juego — 'partyMenu' — accesible presionando X en el mundo.
El menú de equipo muestra cada monstruo como una fila: ícono de sprite, nombre, una mini barra de HP y números de HP. El monstruo líder tiene una etiqueta [LEAD]. Un cursor se mueve con Arriba/Abajo, y presionar Z en cualquier monstruo que no sea el líder lo intercambia al frente:
function updatePartyMenu() {
if (btnp('x') || btnp('Escape')) {
mode = 'overworld';
return;
}
if (btnp('ArrowUp') || btnp('w'))
partyCursor = (partyCursor + party.length - 1) % party.length;
if (btnp('ArrowDown') || btnp('s'))
partyCursor = (partyCursor + 1) % party.length;
if ((btnp('z') || btnp(' ')) && partyCursor !== 0) {
const temp = party[0];
party[0] = party[partyCursor];
party[partyCursor] = temp;
partyCursor = 0;
}
}
Dibujarlo es directo — recorremos el equipo, espaciando cada fila para el sprite y la barra de HP:
function drawPartyMenu() {
cls(0);
text('PARTY', 48, 4, 7);
for (let i = 0; i < party.length; i++) {
const y = 20 + i * 30;
const m = party[i];
if (i === partyCursor) text('>', 4, y + 4, 10);
spr(m.sprite, 14, y);
text(m.name, 28, y, 7);
drawMiniHpBar(28, y + 10, m.hp, m.maxHp);
text(m.hp + '/' + m.maxHp, 28, y + 16, 6);
if (i === 0) text('[LEAD]', 80, y, 10);
}
text('Z=swap X=close', 12, 118, 6);
}
Abrir el menú de equipo desde el mundo es una sola línea — verificar X antes de procesar el movimiento:
if (btnp('x') || btnp('Escape')) {
mode = 'partyMenu';
partyCursor = 0;
return;
}
Las estadísticas del monstruo líder son las que se usan en batalla, así que intercambiar te permite elegir la combinación correcta — un monstruo resistente contra un atacante fuerte, o un cañón de cristal para rematar a un objetivo debilitado.
Curación y Variedad de Monstruos
Una sola especie de monstruo no es mucho ecosistema. El juego completo tiene cinco, cada uno usando regiones de paleta distintas para ser reconocibles a 8×8:
- Flamepup — rojo (8) / naranja (9), alto ataque, baja defensa
- Shellbit — azul (12) / gris (6), bajo ataque, alta defensa
- Zapfin — amarillo (10) / blanco (7), estadísticas equilibradas
- Mossgrowl — verde (3) / verde brillante (11), alto HP, bajo ataque
- Shadowclaw — púrpura (2) / lavanda (13), cañón de cristal (alto ataque, bajo HP)
Diferentes áreas de hierba tienen diferentes tablas de encuentro. La ruta norte tiene Zapfin y Mossgrowl, la ruta sur tiene Shellbit y Shadowclaw. Un helper elige el grupo correcto según la posición Y del jugador:
const northPool = [
{ name: 'Zapfin', sprite: 'zapfin', maxHp: 22, attack: 7, defense: 5 },
{ name: 'Mossgrowl', sprite: 'mossgrowl', maxHp: 30, attack: 5, defense: 4 },
];
const southPool = [
{ name: 'Shellbit', sprite: 'shellbit', maxHp: 25, attack: 5, defense: 7 },
{ name: 'Shadowclaw', sprite: 'shadowclaw', maxHp: 16, attack: 10, defense: 3 },
];
function getEncounterPool() {
if (tileY < 8) return northPool;
if (tileY > 15) return southPool;
return rnd(1) < 0.5 ? northPool : southPool;
}
Las batallas te desgastan, así que la aldea tiene un NPC sanador. Acércate y presiona Z para restaurar completamente todos los monstruos del equipo y rellenar las pociones a 3. El sprite del sanador tiene un gorro blanco (7) con acentos de cruz roja (8) — el look universal de "yo arreglo cosas":
if (btnp('z') || btnp(' ')) {
if (isAdjacentToHealer()) {
for (const m of party) m.hp = m.maxHp;
potions = 3;
mode = 'healMessage';
messageText = 'Monsters healed!';
messageTimer = 60;
return;
}
}
Las pociones son la cuarta opción del menú de batalla. El menú se expande de una lista vertical a una cuadrícula de 2×2 — FIGHT y CATCH arriba, HEAL y RUN abajo — navegada con las cuatro flechas. Cada poción restaura 10 HP al monstruo líder, y luego el enemigo obtiene un turno:
const menuGrid = [['FIGHT', 'CATCH'], ['HEAL', 'RUN']];
// cuando se selecciona HEAL:
if (potions <= 0) {
showMessage('No potions left!', 40);
nextBattleState = 'backToMenu';
} else {
potions--;
const healed = Math.min(10, lead().maxHp - lead().hp);
lead().hp += healed;
showMessage('Healed ' + healed + ' HP!', 40);
nextBattleState = 'enemyTurn';
}
El HUD del mundo se actualiza para mostrar tanto el conteo del equipo como el de pociones. Desmayarte también reinicia las pociones a 3 — la amabilidad del sanador se extiende a los entrenadores inconscientes.
Ejemplo Completo
Todo junto ahora. El script completo tiene un mundo de 24×24 con desplazamiento, una aldea y dos rutas de hierba, cinco especies de monstruos con sprites únicos, movimiento alineado a cuadrícula con colisión de muros y cámara, encuentros aleatorios con destello de transición de batalla, combate por turnos con barras de HP y destello de golpe, un menú de batalla de 2×2 (Fight/Catch/Heal/Run), un equipo de hasta tres monstruos intercambiables mediante el menú de equipo (tecla X), pociones (suministro limitado, restauradas por el NPC sanador) y recuperación de desmayo que te devuelve a la aldea.
Para Ir Más Allá
- Puntos de experiencia y niveles — rastrea XP por monstruo, aumenta estadísticas en umbrales, muestra una animación de subida de nivel después de la batalla
- Evolución de monstruos — cuando un monstruo alcanza cierto nivel, cambia su sprite y aumenta sus estadísticas, con un efecto de transición llamativo
- Conjuntos de movimientos — dale a cada monstruo 2-4 ataques con nombre y diferentes valores de poder en vez de una sola opción "Fight", seleccionables desde un sub-menú
- Efectos de estado — veneno (daño cada turno), sueño (pierde turno), parálisis (50% de probabilidad de perder turno), con indicadores visuales en la barra de HP
- Mundo más grande con múltiples áreas — usa
mcleary reconstruye el tilemap cuando el jugador entre por una puerta o salida de ruta, cada área con su propia tabla de encuentros - Sprites de monstruos salvajes en el mundo — coloca monstruos visibles en el mapa que activen batallas al contacto en vez de (o además de) encuentros aleatorios
- Sistema de guardado/carga — serializa equipo, posición y cantidad de pociones a una cookie o localStorage (ver el tutorial de Guardado y Carga)