Framework para generar juegos RTS en interfaz web, usando datapacks.
  • Rust 72.2%
  • TypeScript 14.5%
  • Svelte 5.7%
  • JavaScript 4.5%
  • Python 1.6%
  • Other 1.5%
Find a file
2026-11-01 20:06:12 -03:00
.cargo M1. Fix seleccion box. Agrega Wiki. 2026-10-05 15:31:54 -03:00
.zed Refactor libreria sebarts-lib. Mejora de bridge y glue 2026-10-19 18:50:37 -03:00
crates ADR 0015. Inspeccion de entidades 2026-11-01 20:06:12 -03:00
docs ADR 0015. Inspeccion de entidades 2026-11-01 20:06:12 -03:00
examples/web Sandbox. Mejoras y ADR 0012. 2026-10-26 17:31:35 -03:00
packages/sebarts-lib ADR 0015. Inspeccion de entidades 2026-11-01 20:06:12 -03:00
scripts ADR 0009. Implementado. Libreria npm sebarts-lib. 2026-10-19 22:38:48 -03:00
webapp ADR 0015. Inspeccion de entidades 2026-11-01 20:06:12 -03:00
.gitignore ADR 0009. Implementado. Libreria npm sebarts-lib. 2026-10-19 22:38:48 -03:00
agents.md ADR 0009. Implementado. Libreria npm sebarts-lib. 2026-10-19 22:38:48 -03:00
Cargo.lock M2. Completo. 2026-10-06 19:39:29 -03:00
Cargo.toml M1. Implementado. 2026-10-05 11:13:20 -03:00
README.md ADR 0015. Inspeccion de entidades 2026-11-01 20:06:12 -03:00

SebaRTS

Motor de RTS para web, escrito en Rust y compilado a WebAssembly, con Bevy como shell de presentación y un datapack que define unidades, edificios y tecnologías. El juego incluido es la implementación de referencia; el objetivo de largo plazo es que otras personas construyan sus propios RTS sobre el motor.

Estado: M0, el spike de M0.5, M1, M2, M2.1 y M2.5 cerrados; el ADR 0010 implementado, el ADR 0012 implementado, el ADR 0013 implementado, el ADR 0014 implementado, el ADR 0015 implementado y el combate de C implementado. Dos equipos con tres unidades y cuatro edificios por lado en un mapa de 40 × 30 celdas; se seleccionan unidades y edificios con el clic izquierdo o un rectángulo, se los mueve con el derecho al terreno y se ataca con el derecho sobre un enemigo. Un enemigo se puede inspeccionar con el clic izquierdo para leer su vida y sus stats sin recibir órdenes. El cuartel, el establo y la arquería entrenan su unidad con una cola de 30 s por unidad; la unidad nace en una celda adyacente y, si no hay lugar, espera sin perderse. La caja de órdenes suma detener, mantener posición, atacar-moviendo y, con un edificio seleccionado, entrenar. En sandbox se pueden crear unidades de los dos equipos y darles órdenes; en partida solo se comanda lo propio. Las unidades ociosas adquieren solas un blanco dentro de su visión y no se superponen cuando se acercan demasiado. Todo por comandos agendados a un tick futuro y con tick fijo de 20 Hz. Un comando inválido se rechaza con motivo y queda en la consola del navegador. En partida la aniquilación cierra la partida con una ventana de victoria o derrota; en sandbox la barra cambia el puntero entre seleccionar y crear, elige el equipo de lo que se crea y el reloj arranca pausado, con un botón de play/pause para reanudarlo. La cámara tiene zoom con la rueda y paneo con el botón central o las flechas. Qué entra en cada milestone siguiente está en el ROADMAP.

La visión, los principios y las reglas de arquitectura están en docs/00-vision-y-arquitectura.md; el avance y el alcance por milestone, en docs/ROADMAP.md; las decisiones cerradas, con sus alternativas y consecuencias, en docs/adr/; el contrato entre el motor y quien lo use, en docs/api/command-event.md. Los conceptos de arquitectura que se explican aparte —qué es la selección, por qué hay dos posiciones de una unidad, qué diferencia hay entre picking y colisión— están en la wiki. El estudio comparado sobre movimiento, selección y colisiones, con el análisis y las decisiones que quedan abiertas, está en docs/estudios/movimiento-seleccion-colisiones.md; las decisiones que salieron de él, en el ADR 0013.

Estructura

crates/rts-sim/       # simulación determinista: solo bevy_ecs, sin render
crates/rts-shell/     # shell de presentación (Bevy): ventana, render, input
crates/rts-shell/assets/   # arte y sonido (por ahora, placeholders)
packages/sebarts-lib/ # la librería del cliente: paquete npm en TypeScript
webapp/               # la aplicación: Svelte 5 + Vite, se compila a webapp/dist/
examples/web/         # ejemplo de uso del paquete, sin build ni bundler
scripts/build_wasm.py # compila a wasm y deja el glue y sus tipos en el paquete
scripts/serve_example.py  # sirve el ejemplo con el paquete y los assets montados
docs/                 # visión, ADRs, contrato de la API y resumen del spike de M0.5
docs/wiki/            # conceptos de arquitectura explicados; no decide
docs/estudios/        # estudios comparados y propuestas; no deciden
docs/dev-logs/        # resúmenes de lo hecho por milestone; no decide

Las cuatro capas y sus fronteras están en el ADR 0008 y el empaquetado de la librería en el ADR 0009. La guía de build —qué compila cada capa, en qué orden y qué artefacto deja— está en docs/build.md.

Cómo se juega (hoy)

Clic izquierdo sobre una unidad para seleccionarla (queda con un recuadro, teñida y con su card de stats abajo a la izquierda); clic izquierdo en terreno vacío para soltar la selección; arrastrar para seleccionar un grupo. En partida solo se comandan las unidades y los edificios propios; un enemigo se inspecciona con el clic izquierdo —la card muestra su vida y sus stats, con un recuadro celeste— y no recibe órdenes. En sandbox cualquier equipo es comandable y se selecciona con el recuadro amarillo. Clic derecho sobre terreno para moverla y clic derecho sobre un enemigo —unidad o edificio— para atacarlo; el enemigo se resuelve contra el dueño de la unidad seleccionada. El clic derecho fuera del mapa o sobre una entidad que bloquea se rechaza, y el motivo aparece en la consola del navegador.

La caja de órdenes vive abajo a la izquierda, con botones cuadrados: Detener (S), Mantener posición (H) y Atacar y mover (A). Detener y mantener posición son comandos inmediatos sobre la selección; atacar y mover arma el siguiente clic izquierdo para elegir el destino. La card de la selección queda a la derecha de la caja.

Cada unidad tiene vida, ataque, armadura, rango y visión. El ataque es una orden persistente: la unidad persigue al blanco hasta estar en rango y lo ataca hasta que muera o reciba otra orden. Una unidad ociosa adquiere sola al enemigo más cercano dentro de su visión, con las unidades por delante de los edificios. La vida se dibuja en el canvas, debajo de cada entidad, y el cadáver se desvanece; la simulación lo conserva (ADR 0011). Cuando un equipo se queda sin unidades ni edificios, la partida termina: el shell pausa y la webapp muestra victoria o derrota, con el único camino de volver al menú.

Las unidades tienen un cuerpo y no se superponen: una orden nunca se rechaza por haber otra unidad en la celda, pero la unidad que no cabe espera, desliza y repathea; solo se rinde al vencer el timeout, con un MoveBlocked y su motivo. Los edificios siguen bloqueando la celda con Blocks. El estudio documenta el costo y las alternativas de la separación activa.

La cámara: rueda para acercar o alejar (zoom limitado a 0.5–1.4 de escala, con el punto bajo el cursor como ancla), botón central arrastrando para paneo y flechas para moverla. WASD ya no panea: A, S y H son atajos de las órdenes. El centro queda recortado a los bordes del mapa para no mostrar el vacío de afuera.

La entrada es la webapp: un menú con los dos modos, cada uno en su página (partida.html y sandbox.html). El modo se elige al crear la partida y no se cambia, así que para pasar de uno a otro hay que volver al menú.

En sandbox la barra suma la paleta de herramientas: Seleccionar y Crear. Con Crear aparecen el select de unidades que publica game.defs() y el selector de jugador que publica el snapshot; cada clic izquierdo emite un Spawn en la celda bajo el cursor, para ese dueño, y la herramienta queda armada para colocar otra. El clic derecho vuelve a Seleccionar. El cursor lo dibuja el shell: cruz al crear, flecha al seleccionar. En partida la paleta no está porque el modo no admite crear entidades. El sandbox también arranca con el reloj pausado y suma un botón de play/pause arriba a la izquierda: el icono muestra play cuando está pausado —el clic reanuda— y pausa cuando corre. El tick y la pausa se leen en el panel de al lado. Mientras un Spawn espera su tick, el shell dibuja una previsualización translúcida de la entidad en la celda: es solo presentación, no se puede seleccionar y desaparece cuando el alta se aplica o se rechaza.

Como el sandbox admite órdenes ajenas, sus unidades de cualquier equipo se pueden seleccionar y comandar; la simulación valida el ataque contra el dueño de la unidad que ataca, no contra el jugador local. En partida la regla de propiedad sigue vigente: las entidades ajenas se rechazan con NotOwner.

En la consola, la partida queda a mano: game.state() para leer el snapshot, game.move(ids, x, y), game.attack(ids, target), game.halt(ids), game.hold(ids), game.attackMove(ids, x, y), game.spawn("Swordsman", x, y) o game.spawn("Archer", x, y, 1) para crear un enemigo, game.setTool({ t: "Spawn", def: "Archer", owner: 1 }) y game.setTool({ t: "AttackMove" }), game.defs(), game.pause() y game.resume(). En partida un spawn se rechaza con motivo —el modo no lo admite—; en sandbox la entidad se crea.

El camino con nombre propio es el paquete sebarts-lib, que es el que arranca el motor y hace el poll del puente. Ofrece game.on("state" | "events" | "trace" | "error", cb), game.move, game.attack, game.halt, game.hold, game.attackMove, game.spawn, game.setTool, game.defs, game.players, game.pause / game.resume y game.raw como escape al wire. El catálogo de jugadores se pide con game.players(), no viaja en el snapshot; el HUD lo usa para no hardcodear los ids. Los window.* de la consola son atajos de la webapp, no de la librería.

Targets: manda el navegador

El target soportado es WebAssembly en el navegador. Ahí corre el juego y ahí se verifica lo que el autor ve. El build nativo existe como herramienta de desarrollo y de referencia, no como producto:

  • cargo test y cargo run-native dan el mismo código sin compilar wasm ni levantar un servidor: es el bucle rápido para trabajar en la simulación.
  • El hash de estado que produce nativo es el guardián de regresión barato: si el determinismo se rompe, se rompe ahí primero y en segundos. Es una verificación del mismo target, no una comparación entre targets: el .wasm es un solo artefacto, y el riesgo de determinismo vive dentro del navegador, no en la diferencia con nativo (ADR 0003 §Alcance de la verificación).

No se garantiza soporte de nativo como producto: ni instaladores, ni las tres plataformas de escritorio, ni X11 y Wayland. El código específico de plataforma que existe hoy son las cinco líneas de cfg que fijan la raíz de assets en main.rs; todo lo demás de ventana lo abstrae Bevy.

Requisitos

  • Rust estable reciente (probado con 1.97).

  • Python 3.11 o superior: los scripts de scripts/ usan tomllib de la stdlib, y http.server viene en la misma instalación.

  • Node 22 o superior para la webapp y para el paquete: Vite y el runner de tests.

  • El target de WebAssembly y el CLI de wasm-bindgen:

    rustup target add wasm32-unknown-unknown
    # wasm-bindgen-cli lo instala scripts/build_wasm.py en .tools/ si falta
    

Correr nativo

cargo run-native

Es un alias de cargo run -p rts-shell definido en .cargo/config.toml; los argumentos extra van al binario: cargo run-native -- --debug.

Correr en el navegador

El motor y la app se compilan por separado, porque son dos artefactos distintos (ADR 0009):

./scripts/build_wasm.py              # el wasm + el glue + sus tipos, al paquete
cd webapp && npm install && npm run dev   # la app en http://127.0.0.1:5173

build_wasm.py compila rts-shell y corre wasm-bindgen --typescript; el glue y el .wasm quedan en packages/sebarts-lib/dist/wasm/. El paquete los lleva adentro, así que la app no configura ninguna ruta: importa sebarts-lib y nada más. Es incremental, así que después de la primera vez tarda segundos.

El build de producción de la app:

cd webapp && npm run build           # escribe webapp/dist/

Ese directorio es lo que se sirve, con cualquier host estático. Los assets del juego —el arte que pide Bevy, relativo a la página— los pone el host: en desarrollo los sirve un middleware de Vite desde crates/rts-shell/assets/, y en el build se copian a webapp/dist/assets/.

Para ver el paquete sin bundler, está el ejemplo:

./scripts/serve_example.py           # http://127.0.0.1:8100

El ejemplo usa un import map y nada más: es la prueba de que sebarts-lib se usa desde JavaScript plano (examples/web).

El cliente tiene que servirse por HTTP: abrir los HTML como archivo no funciona, porque son módulos ES que descargan el .wasm.

Tests

cargo test -p rts-sim    # solo la simulación: ~28 s en frío, sin stack gráfico ni ventana
cargo test               # agrega el camino completo de JavaScript, sin render

rts-sim cubre el escenario, el punto fijo, el hash de estado, el determinismo con guion de comandos, el orden canónico, el comando multi-entidad y los motivos de rechazo. rts-shell agrega el camino completo del puente (enqueue → ticks → poll_events / poll_trace / snapshot, con un rechazo incluido) y las funciones puras del input: el mapeo de clic a celda y a entidad, el despacho por herramienta y el catálogo de defs. Queda fuera solo lo que necesita una GPU.

Uno de los tests de rts-sim es distinto de los demás: el_hash_no_se_movio_desde_la_ultima_verificacion compara contra un hash fijado a mano en HASH_DE_REFERENCIA, no contra otra corrida del mismo proceso. Si falla, es un bug o un cambio de comportamiento deliberado; el comentario del test explica los dos casos. No lo arregles actualizando el número sin decidir cuál de los dos es.

La librería tiene sus propios tests, con el runner de Node y sin dependencias. Corren sin navegador: inyectan un glue falso y un DOM mínimo, así que no compilan wasm ni lo cargan. Desde packages/sebarts-lib/:

npm test     # tsc --noEmit + node --test

Cubren el armado de los Envelope, el reparto del poll entre varios HUD, la forma del dato y el orden de arranque (el canvas existe antes del wasm y el modo viaja en el start). Lo único que no cubren es el wasm real corriendo en un navegador: para eso está examples/web.

La webapp tiene los suyos con Vitest y DOM (ADR 0008); prueban el reparto del estado al HUD —tick, pausa y herramienta— sin tocar el wasm:

cd webapp && npm test

El hash que fija el test de la simulación es el que la página publica en la consola, en el state_hash del estado: window.game.state() lo devuelve, y sirve para verlo cambiar cuando el mundo cambia y quedarse quieto cuando no.

Los tests no alcanzan para hacer cumplir las reglas de la simulación: los lints que prohíben los flotantes y la aritmética sin semántica explícita corren con clippy, no con cargo test (ADR 0003):

cargo clippy -p rts-sim

Notas del entorno

  • .cargo/config.toml fija getrandom_backend="wasm_js", que getrandom necesita para compilar a wasm. No lo saques sin leer esto.
  • wasm-bindgen-cli se instala en .tools/ (gitignored) y se invoca desde ahí: así el CLI coincide siempre con la versión del crate wasm-bindgen del lockfile, sin depender de lo que haya instalado el sistema.
  • Solo se habilita el grupo de features 2d de Bevy: 3D, UI y audio se excluyen para no inflar el .wasm.
  • Tamaño del bundle web (perfil web-release, medido al cerrar M0): 25 MB de .wasm, 5.5 MB comprimido con gzip. La sección name (nombres de funciones) era la mitad de eso y se saca con --remove-name-section.
  • En Zed, .zed/settings.json limita a rust-analyzer. Bevy es un grafo de ~400 crates y el comportamiento por defecto (cargo check en cada guardado, con todos los núcleos) deja la máquina inusable.
  • No hay scripts de shell en el repo: los scripts de scripts/ son Python. build_wasm.py necesita 3.11 o superior (usa tomllib); serve_example.py solo usa la biblioteca estándar. Correr nativo es el alias run-native de .cargo/config.toml.