OASIS_LINUX/docs/devs/architecture/03-modulos-y-juegos.md
SITO ddd2787b73 Oasis 0.9.5 con Karvan para escritorio
Rama de desarrollo experimental sobre el Oasis de epsylon, sin afiliacion con el
proyecto original. Base: 0.9.5.

Trae el modulo Karvan —salas efimeras en memoria con autodestruccion por TTL, chat
que funciona sin JavaScript y llamadas de audio y video sobre WebRTC— con el enlace
en el menu lateral por el mismo patron que el resto de modulos: renderKarvanLink
calcado de renderPollsLink, entrada en modules_view y en la lista de /modules.

Los estilos del modulo van en su propia hoja, styles/karvan.css, siguiendo el patron
de highlight.css: asi se ve igual con cualquier tema, sin depender del tema movil.

Sin servidores ICE de terceros: no hay STUN de fabrica, porque un STUN aprende la IP
publica y el momento de cada llamada. Solo candidatos host salvo que se configure un
TURN propio en oasis-config.json, con credenciales efimeras por el mecanismo REST de
coturn.

El codigo propio vive separado —views/fork/, backend/fork_routes.js,
models/karvan_model.js y translations/fork/— de modo que sobre los ficheros de
upstream solo hay enganches de una linea y cada version nueva se integra sin
arrastrar nada.

Respecto al arbol de Android: fuera el wrapper y main.js, que es su arranque; el
tema vuelve a Dark-SNH y se restauran los modulos que en movil se recortan por peso.

Verificado arrancando sin OASIS_MOBILE: nueve rutas OK, menu lateral de upstream con
27 grupos y el enlace de Karvan, cero rastro de la interfaz movil (ni hexagonos, ni
topbar, ni barra inferior), y una sala creada y servida.
2026-08-19 00:35:08 +02:00

9.1 KiB
Raw Permalink Blame History

3. El módulo de juegos y la anatomía de un módulo

Hallazgo clave que cambia las expectativas: el módulo de juegos de Oasis NO es un sistema multijugador asíncrono por SSB. Es mucho más simple:

  • Cada juego es un único index.html autocontenido (HTML + CSS + JS inline), 100% client-side, servido como archivo estático y embebido en un <iframe>. No hay JS externos, ni sprites, ni carpetas de assets: cada carpeta de juego tiene solo 2 archivos: index.html + thumbnail.svg.
  • La única integración con SSB es publicar un mensaje tipo gameScore (puntuación individual) para un Hall of Fame (ranking global). No hay retos, ni partidas, ni movimientos sincronizados. El "multijugador" se reduce a comparar las puntuaciones de distintos autores leyendo el log SSB.

3.1 El modelo: src/models/games_model.js (67 líneas)

Minimalista. Solo gestiona puntuaciones. Patrón factory estándar: module.exports = ({ cooler }) => {...}.

  • Líneas 5-9: lista blanca VALID_GAMES (Set de ids permitidos). ⚠️ Incluye 14 juegos pero NO rockpaperscissors ni audiopendulum (que sí están en la vista): esos dos no pueden enviar score (el modelo los rechaza).
  • Líneas 18-25 readAll(): vuelca todo el log con createLogStream.
  • Líneas 28-38 submitScore(game, score) — único método de escritura:
async submitScore(game, score) {
  if (!VALID_GAMES.has(game)) throw new Error('invalid game');
  const n = Number(score);
  if (!Number.isFinite(n) || n < 0 || n > 9999999) throw new Error('invalid score');
  const ssbClient = await openSsb();
  return new Promise((resolve, reject) => {
    ssbClient.publish({ type: 'gameScore', game, score: Math.round(n) },
      (err, msg) => err ? reject(err) : resolve(msg));
  });
}

El mensaje SSB resultante: { type: 'gameScore', game: '<id>', score: <int> }.

  • Líneas 40-65 getHallOfFame(): recorre el log, filtra content.type === 'gameScore', y por cada par game:author guarda solo la mejor puntuación. Devuelve hall[game] = [{author, score, game, ts}, ...] ordenado descendente y truncado a top-10 por juego.

3.2 La vista: src/views/games_view.js (151 líneas)

  • Líneas 5-22 getGames(): catálogo hardcodeado de 16 juegos. Cada entrada es { id, title: () => i18n.<key>, desc: () => i18n.<key> }. Es el registro maestro de la vista.
  • Línea 58 VALID_GAME_IDS: un tercer Set, usado para validar la ruta del shell. Incluye los 16.
  • Líneas 26-56 renderHallOfFame(hall): tabla por juego con thumbnail (/game-assets/${id}/thumbnail.svg), título, descripción y filas (posición, userLink(author), score, fecha).
  • Líneas 60-93 gameShellView(name): así se embebe cada juego. Valida name contra VALID_GAME_IDS y renderiza un iframe:
iframe({
  src: `/game-assets/${name}/index.html`,
  class: `game-iframe game-iframe-${name}`,
  scrolling: 'no',
  allowfullscreen: true
})
  • Líneas 95-151 gamesView(filter, hall): el lobby. Barra de filtros (all / scoring). En modo all lista cada juego con thumbnail, título, récord top y botón PLAY → /games/${id}. En modo scoring invoca renderHallOfFame.

⚠️ Hay TRES listas que deben mantenerse sincronizadas: getGames() y VALID_GAME_IDS (vista) + VALID_GAMES (modelo). La inconsistencia de rockpaperscissors/audiopendulum lo demuestra.


3.3 Estructura de un juego: src/games/<nombre>/

17 carpetas: 8ball, arkanoid, artillery, asteroids, audiopendulum, cocoland, cocoman, ecoinflow, flipflop, labyrinth, neoninfiltrator, pingpong, rockpaperscissors, spaceinvaders, tetris, tiktaktoe.

TODOS los juegos tienen exactamente 2 archivos:

  • index.html (6-10 KB): documento completo y autosuficiente, con <style> y <script> inline. Sin dependencias externas, sin CDN.
  • thumbnail.svg (~1-2 KB): miniatura SVG vectorial (viewBox ~0 0 400 220).

No existe distinción "arcade" vs "social SSB": todos son arcade HTML5/canvas o DOM puro, client-side, en iframe. La única diferencia es si envían score o no.

Mecanismo de envío de score (idéntico en todos los juegos puntuables; ejemplo tiktaktoe/index.html):

<div id="scoreSubmit" style="display:none">
  <form method="POST" action="/games/submit-score" target="_top">
    <input type="hidden" name="game" value="tiktaktoe">
    <input type="hidden" id="scoreInput" name="score" value="0">
    <button type="submit">Submit Score to Hall of Fame</button>
  </form>
</div>

Claves del patrón:

  • target="_top" → el POST rompe el iframe y navega la ventana superior (necesario porque el juego corre sandboxeado dentro de /games/<name>).
  • El JS del juego rellena #scoreInput al terminar (document.getElementById('scoreInput').value = score;) y muestra el div.
  • Cada juego tiene una topbar con <a href="/games" target="_top">← Back</a>.

El resto del index.html es lógica de juego propia en JS vanilla (tiktaktoe usa minimax con poda alfa-beta; tetris usa canvas).

gameScore además es un tipo SSB de primera clase indexado en búsqueda (src/views/search_view.js lo mapea a /games/<game> y lo renderiza con su thumbnail).


3.4 Cómo se sirve y enruta

Servido estáticosrc/client/middleware.js (líneas 109-111):

const gamesStatic = new Koa();
gamesStatic.use(koaStatic(join(__dirname, "..", "games")));
app.use(mount("/game-assets", gamesStatic));

Sub-app Koa con koa-static sobre src/games/, montada en /game-assets. Por eso /game-assets/tetris/index.html sirve src/games/tetris/index.html. Independiente del router principal → una carpeta nueva queda servida automáticamente.

Rutas dinámicassrc/backend/backend.js:

  • Línea 598: const gamesModel = require('../models/games_model')({ cooler });
  • Línea 1168: const { gamesView } = require("../views/games_view");
  • Líneas 1358-1373:
.get('/games', async (ctx) => {
  if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
  const filter = ctx.query.filter === 'scoring' ? 'scoring' : 'all';
  const hall = await gamesModel.getHallOfFame();
  ctx.body = gamesView(filter, hall);
})
.get('/games/:name', async (ctx) => {
  if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
  const { gameShellView } = require('../views/games_view');
  ctx.body = gameShellView(ctx.params.name);
})
.post('/games/submit-score', koaBody(), async (ctx) => {
  if (!checkMod(ctx, 'gamesMod')) { ctx.redirect('/modules'); return; }
  const { game, score } = ctx.request.body;
  try { await gamesModel.submitScore(game, score); } catch (_) {}
  ctx.redirect('/games?filter=scoring');
})

Flujo completo:

  /games  (lobby)
     └─ PLAY → /games/:name  (shell con iframe)
                  └─ iframe carga /game-assets/:name/index.html
                        └─ al terminar: POST /games/submit-score  (target="_top")
                              └─ redirect /games?filter=scoring  (Hall of Fame)

El guard checkMod(ctx, 'gamesMod') (líneas 170-177) revisa la config del servidor + una cookie por-usuario; default on. Si está off → /modules.


3.5 Cómo se registra el módulo (puntos de integración)

Lugar Archivo Qué hay
Menú src/views/main_views.js:898 renderGamesLink() (emite el navLink si gamesMod==="on"); se invoca en línea 1280 dentro del navGroup "creative"
Config src/configs/oasis-config.json "gamesMod": "on" dentro de modules
Página /modules src/views/modules_view.js:23 { name: 'games', label, description }
Listas de backend src/backend/backend.js 'games' en línea 1346 (GET /modules), 7054 (POST /save-modules), 7038 (ALL_MODULES) y presets (7040-7042)
i18n src/client/assets/translations/oasis_*.js (×11) gamesTitle, gamesDescription, modulesGamesLabel/Description, y por juego games<Nombre>Title + games<Nombre>Desc

El nombre del módulo está hardcodeado y duplicado en muchos sitios; no hay un registro central único. Por eso, al crear un módulo, cada punto es obligatorio. → 04-tutorial-modulo-desde-cero.md


3.6 Receta rápida: añadir un juego NUEVO al sistema existente

Para añadir un juego foobar:

  1. src/games/foobar/index.html — HTML autocontenido (CSS+JS inline). Incluir la topbar ← Back y, si puntúa, el form oculto a /games/submit-score con target="_top" y name="game" value="foobar".
  2. src/games/foobar/thumbnail.svg — miniatura.
  3. src/views/games_view.js:
    • Añadir a getGames(): { id: 'foobar', title: () => i18n.gamesFoobarTitle, desc: () => i18n.gamesFoobarDesc }.
    • Añadir 'foobar' a VALID_GAME_IDS.
  4. src/models/games_model.js — añadir 'foobar' a VALID_GAMES (obligatorio si puntúa, o submitScore lo rechaza).
  5. i18n — añadir gamesFoobarTitle y gamesFoobarDesc en TODOS los oasis_*.js.

No hay que tocar el backend ni el static serving (la carpeta nueva ya se sirve por /game-assets/).