- Rust 72.2%
- TypeScript 14.5%
- Svelte 5.7%
- JavaScript 4.5%
- Python 1.6%
- Other 1.5%
| .cargo | ||
| .zed | ||
| crates | ||
| docs | ||
| examples/web | ||
| packages/sebarts-lib | ||
| scripts | ||
| webapp | ||
| .gitignore | ||
| agents.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
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
Cimplementado. 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. Enpartidala 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 testycargo run-nativedan 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
.wasmes 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/usantomllibde la stdlib, yhttp.serverviene 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.tomlfijagetrandom_backend="wasm_js", quegetrandomnecesita para compilar a wasm. No lo saques sin leer esto.wasm-bindgen-clise instala en.tools/(gitignored) y se invoca desde ahí: así el CLI coincide siempre con la versión del cratewasm-bindgendel lockfile, sin depender de lo que haya instalado el sistema.- Solo se habilita el grupo de features
2dde 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ónname(nombres de funciones) era la mitad de eso y se saca con--remove-name-section. - En Zed,
.zed/settings.jsonlimita a rust-analyzer. Bevy es un grafo de ~400 crates y el comportamiento por defecto (cargo checken 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.pynecesita 3.11 o superior (usatomllib);serve_example.pysolo usa la biblioteca estándar. Correr nativo es el aliasrun-nativede.cargo/config.toml.