Cómo construir un sistema de guardado y carga
Este tutorial fue escrito en febrero de 2026, para la v2 del motor.
Los juegos pierden el progreso cuando se cierra la pestaña. El motor no maneja la persistencia — la construyes tú mismo con JavaScript puro. localStorage, cookies y JSON es todo lo que necesitas. Vamos a construir un pequeño recolector de gemas e ir añadiendo guardado progresivamente: desde una simple llamada a localStorage.setItem hasta escrituras limitadas, validación, cookies y cadenas de guardado exportables. Al final tendrás un patrón reutilizable para cualquier juego. Si quieres ver persistencia en un proyecto real, el playground de tower defense guarda mejoras y progreso de oleadas entre sesiones.
Cubriremos cómo organizar el estado en un objeto, serializar con JSON, guardar en localStorage y cookies, validar datos cargados, limitar escrituras y exportar cadenas de guardado.
Mantener el estado en un solo lugar
Lo primero — pon todo el estado mutable del juego en un solo objeto simple. Nada de estado disperso en variables sueltas. Esto hace que la serialización sea trivial: haces JSON.stringify de todo y obtienes todo de una vez. Nuestro recolector de gemas rastrea gemas, tasa por clic, total recolectado y un modo doble. Presiona Z para recolectar gemas, X para gastar 10 gemas en una mejora y C para alternar el modo doble:
engine.scope(({ start, cls, text, btnp }) => {
const state = {
gems: 0,
gemsPerClick: 1,
totalGems: 0,
doubleMode: false,
};
function update() {
if (btnp('z')) {
const amount = state.doubleMode
? state.gemsPerClick * 2
: state.gemsPerClick;
state.gems += amount;
state.totalGems += amount;
}
if (btnp('x') && state.gems >= 10) {
state.gems -= 10;
state.gemsPerClick += 1;
}
if (btnp('c')) {
state.doubleMode = !state.doubleMode;
}
}
function draw() {
cls(1);
text('Gems: ' + state.gems, 4, 4, 7);
text('Per click: ' + state.gemsPerClick, 4, 14, 6);
text('Total: ' + state.totalGems, 4, 24, 6);
text(
'Double: ' + (state.doubleMode ? 'ON' : 'OFF'),
4,
34,
state.doubleMode ? 11 : 8,
);
text('Z collect X upgrade(10)', 4, 54, 5);
text('C double mode', 4, 64, 5);
}
start({ sprites: {}, sounds: {}, init() {}, update, draw, target });
});
Guardar en localStorage
localStorage es la API de persistencia más simple. setItem escribe una cadena bajo una clave, getItem la lee de vuelta. Como nuestro estado es un objeto simple, JSON.stringify lo convierte a cadena y JSON.parse lo convierte de vuelta. Cargamos en init() con un respaldo a valores predeterminados para que el juego funcione en el primer lanzamiento, y guardamos después de cambios de estado:
const SAVE_KEY = 'gem-collector-save';
function save(state) {
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
}
function load() {
const raw = localStorage.getItem(SAVE_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch {
return null;
}
}
// inside init()
const saved = load();
if (saved) {
Object.assign(state, saved);
}
// after state changes (e.g. end of update())
save(state);
Qué se guarda bien (y qué no)
JSON.stringify maneja números, cadenas, booleanos, arrays y objetos simples. Descarta silenciosamente funciones, referencias DOM y undefined. La regla general: si podrías escribirlo como un literal JSON en un editor de texto, se guarda. Si no, no.
const good = {
score: 42,
name: 'Player 1',
alive: true,
items: ['sword', 'shield'],
position: { x: 10, y: 20 },
};
const bad = {
update: function () {},
element: document.body,
missing: undefined,
};
JSON.stringify(good);
// '{"score":42,"name":"Player 1","alive":true,...}'
JSON.stringify(bad);
// '{"element":{}}' — functions and undefined vanish
Mantén tu objeto de estado plano y solo con datos. Almacena IDs o cadenas de tipo en lugar de referencias a objetos — por ejemplo weaponId: 'sword' en vez de weapon: swordObject.
Validar datos cargados
Nunca confíes en datos cargados. Versiones antiguas del juego, guardados editados a mano o corrupción pueden producir valores que tu código no espera. Una función validate() verifica el tipo de cada campo, limita números a rangos seguros y rellena campos faltantes con valores predeterminados. Un campo version te permite migrar formatos de guardado antiguos más adelante — cuando la versión cargada es menor que la versión actual, puedes ejecutar lógica de actualización antes de aplicar los datos:
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function validate(data) {
return {
version: 1,
gems: clamp(Number(data.gems) || 0, 0, 999999),
gemsPerClick: clamp(Number(data.gemsPerClick) || 1, 1, 100),
totalGems: clamp(Number(data.totalGems) || 0, 0, 999999),
doubleMode: data.doubleMode === true,
};
}
// inside init()
const saved = load();
if (saved) {
Object.assign(state, validate(saved));
}
Limitar la frecuencia de guardado
Guardar en cada frame desperdicia CPU en serialización JSON. En su lugar, rastreamos si el estado cambió con una bandera dirty y solo escribimos cuando está dirty Y ha pasado al menos un segundo. Guardados suficientemente frecuentes, sin el costo de rendimiento:
let dirty = false;
let lastSave = 0;
const SAVE_INTERVAL = 1000;
function markDirty() {
dirty = true;
}
function throttledSave(state) {
if (!dirty) {
return;
}
const now = Date.now();
if (now - lastSave < SAVE_INTERVAL) {
return;
}
save(state);
dirty = false;
lastSave = now;
}
// inside update(), after state changes
markDirty();
throttledSave(state);
Guardar en cookies
Las cookies funcionan donde localStorage no — algunos navegadores borran localStorage pero mantienen las cookies, y las cookies viajan con las solicitudes HTTP si necesitas conciencia del servidor. La contrapartida: un límite de ~4KB versus los ~5MB de localStorage. Codificamos la cadena JSON con URI y establecemos una fecha de expiración para que la cookie no desaparezca cuando se cierre el navegador:
const COOKIE_NAME = 'gem-collector-save';
const COOKIE_DAYS = 30;
function saveToCookie(state) {
const json = JSON.stringify(state);
if (json.length > 4000) {
console.warn('Save too large for cookie');
return;
}
const expires = new Date();
expires.setDate(expires.getDate() + COOKIE_DAYS);
document.cookie =
COOKIE_NAME +
'=' +
encodeURIComponent(json) +
';expires=' +
expires.toUTCString() +
';path=/';
}
function loadFromCookie() {
const prefix = COOKIE_NAME + '=';
const match = document.cookie
.split('; ')
.find((c) => c.startsWith(prefix));
if (!match) {
return null;
}
try {
return JSON.parse(decodeURIComponent(match.slice(prefix.length)));
} catch {
return null;
}
}
Exportar una cadena de guardado
Permite a los jugadores compartir o respaldar guardados codificando el estado como una cadena Base64. btoa(JSON.stringify(state)) produce una cadena que se puede copiar y pegar. atob y JSON.parse lo revierten. Siempre pasa el resultado por validate() antes de aplicarlo — no puedes confiar en cadenas importadas más que en guardados almacenados:
function exportSave(state) {
return btoa(JSON.stringify(state));
}
function importSave(string) {
try {
const data = JSON.parse(atob(string));
return validate(data);
} catch {
return null;
}
}
// for testing in the browser console
window.exportSave = () => exportSave(state);
window.importSave = (s) => {
const data = importSave(s);
if (data) {
Object.assign(state, data);
}
};
btoa es codificación, no cifrado. Cualquiera puede decodificar una cadena de guardado con atob. Si eso importa para tu juego, trata los datos de guardado como públicos.
Todo junto
Aquí está el recolector de gemas completo con todo combinado: objeto de estado, guardado en localStorage con limitación, carga validada y funciones de exportar/importar expuestas en window para pruebas desde la consola. Presiona Z para recolectar, X para mejorar, C para alternar modo doble. Recarga la página — tu progreso persiste:
engine.scope(({ start, cls, text, btnp }) => {
const SAVE_KEY = 'gem-collector-save';
const SAVE_INTERVAL = 1000;
let dirty = false;
let lastSave = 0;
const defaults = {
version: 1,
gems: 0,
gemsPerClick: 1,
totalGems: 0,
doubleMode: false,
};
const state = { ...defaults };
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function validate(data) {
return {
version: 1,
gems: clamp(Number(data.gems) || 0, 0, 999999),
gemsPerClick: clamp(Number(data.gemsPerClick) || 1, 1, 100),
totalGems: clamp(Number(data.totalGems) || 0, 0, 999999),
doubleMode: data.doubleMode === true,
};
}
function save() {
localStorage.setItem(SAVE_KEY, JSON.stringify(state));
}
function load() {
try {
const data = JSON.parse(localStorage.getItem(SAVE_KEY));
if (data) {
Object.assign(state, validate(data));
}
} catch {}
}
function exportSave() {
return btoa(JSON.stringify(state));
}
function importSave(string) {
try {
Object.assign(state, validate(JSON.parse(atob(string))));
dirty = true;
} catch {}
}
function init() {
load();
window.exportSave = exportSave;
window.importSave = importSave;
}
function update() {
if (btnp('z')) {
const amount = state.doubleMode
? state.gemsPerClick * 2
: state.gemsPerClick;
state.gems += amount;
state.totalGems += amount;
dirty = true;
}
if (btnp('x') && state.gems >= 10) {
state.gems -= 10;
state.gemsPerClick += 1;
dirty = true;
}
if (btnp('c')) {
state.doubleMode = !state.doubleMode;
dirty = true;
}
if (dirty && Date.now() - lastSave >= SAVE_INTERVAL) {
save();
dirty = false;
lastSave = Date.now();
}
}
function draw() {
cls(1);
text('Gems: ' + state.gems, 4, 4, 7);
text('Per click: ' + state.gemsPerClick, 4, 14, 6);
text('Total: ' + state.totalGems, 4, 24, 6);
text(
'Double: ' + (state.doubleMode ? 'ON' : 'OFF'),
4,
34,
state.doubleMode ? 11 : 8,
);
text('Z collect X upgrade(10)', 4, 54, 5);
text('C double mode', 4, 64, 5);
}
start({ sprites: {}, sounds: {}, init, update, draw, target });
});
Para ir más allá
- Múltiples ranuras de guardado — usa una clave de localStorage diferente por ranura (por ejemplo,
save-slot-1,save-slot-2) y deja que el jugador elija - Indicador de autoguardado — muestra un pequeño ícono o destello de texto cuando el guardado limitado se ejecuta, para que el jugador sepa que su progreso está seguro
- Sincronización en la nube — envía el JSON de guardado a un servidor con
fetch()y recupéralo en otro dispositivo - Migración de guardados — cuando
state.versiones menor que la versión actual, ejecuta funciones de actualización en secuencia (v1→v2,v2→v3, etc.) - Compresión — para guardados grandes, usa
CompressionStreamo una biblioteca como lz-string para reducir los datos antes de almacenarlos