Repository navigation
Game Engine
English · Español
The game table (solitaire, sparring and online games) runs on a pure, deterministic engine: the mesa crate. The screen and the app session around it are described in Game Table. It keeps track of where every card is and what has been done to it, records every action as an event, decides randomness and tells each player what they may see. It is not a judge: there are no Magic rules (no mana, phases, legal targets or combat). It only checks that an action makes sense (the card exists, the actor may touch it) and offers helpers that can be turned off.
Source: src-tauri/crates/mesa/src/ (lib.rs, ids.rs, error.rs, modelo.rs, formato.rs, preparar.rs, lista.rs, accion.rs, azar.rs, resolver.rs, aplicar.rs, visibilidad.rs, registro.rs, huella.rs, vista.rs, sparring/mod.rs) and its tests in src-tauri/crates/mesa/tests/.
Decision: the engine is its own workspace member (members = [".", "crates/proxy-core", "crates/mesa"] in src-tauri/Cargo.toml), depending on proxy-core only to read decks. Printing and playing are different worlds; a separate crate compiles and tests on its own (cargo test -p mesa) and proxy-core does not grow. Like proxy-core it has no Tauri, no network, no database, no clock and no outside randomness (tests/registro.rs::huella_estable_y_sensible greps the sources for SystemTime, Instant:: and rand::). It is compiled with opt-level = 2 in dev ([profile.dev.package.mesa]) because every action clones and compares the whole game and the property tests play thousands.
One event log serves the solitaire game, the network, undo and replay:
flowchart LR
I["Intencion { actor, accion }"] -->|"resolver(&Partida, &Intencion, &mut Azar, now)"| E["Evento { seq, actor, t_ms, cambio }"]
E -->|"aplicar(&mut Partida, &Evento)"| P["Partida"]
P -->|"vista(&Partida, Mirador)"| V["VistaMesa"]
E -->|"Tocado"| PA["parche(&Partida, &Tocado, Mirador)"]
PA --> PV["ParcheVista"]
E --> R["Registro (log, photos every 50)"]
-
Intencionis what a player asks for (Accion). -
Eventois what the host resolved (Cambio). It is what is stored, sent to the others and replayed. -
ActorisJugador(id)orMesa(events the host generates on its own: the sparring reaction).
Partida holds everything on the table: jugadores (seat order = turn order), equipos, cartas: BTreeMap<IdCarta, Carta>, zonas: BTreeMap<RefZona, Vec<IdCarta>>, turno, flechas, bolitas, designaciones, pausada, fantasma, sparring, mulligan, seq, siguiente_id and fin.
Invariants and why:
- Only
BTreeMap,BTreeSetandVec, neverHashMap: the JSON of a game is always byte-identical, so its fingerprint can be compared across machines. - Positions are integers:
Pos { x: u16, y: u16 }from 0 toPOS_MAX(10,000) inside the board of the card's controller. Exact equality and no floating-point drift between machines (Decision D-A3). The screen only converts them to pixels. -
zonasis serialised as a list of pairs (zonas_como_lista) because aRefZonacannot be a JSON object key. - Zone order: in the library (and the plane and scheme decks) index 0 is the top; in the other zones (graveyard, stack…) the last element is the top (
aplicar.rs::arriba_es_inicio). - Ids are small numbers (
IdJugador(u8),IdCarta(u32),IdEquipo(u8)). Tokens and copies get a new id fromsiguiente_id; an id is never reused.
RefZona is De { jugador, zona } (Biblioteca, Mano, Campo, Cementerio, Exilio, Mando, Banquillo, Planes, PlanesEnMarcha) or Comun { zona } (Pila, Planos, PlanoActivo).
A Carta carries its state on the table: cara, girada, boca_abajo, fase_fuera, no_endereza, contadores, palabras_clave (keyword counters), etiquetas (≤ 4, ≤ 20 characters), pt (the three rows of the power/toughness dialog: otros, hasta_fin_de_turno, fijo), adjunta_a, pos, z, vista_por, resaltada, es_ficha, copia_de, cantidad (a pile of identical tokens is one card with cantidad > 1), agrupada_bajo (a pile of real cards, or exiled cards grouped under the card that exiled them), llego_en_ronda (the «Before» divider of graveyard and exile) and habilidad (an ability on the stack: it disappears when it leaves the stack).
OrigenCarta is what the app knows about a card when the game starts: the DeckCard id, the name, one RefImagen per face, the back, the Scryfall id and an InfoCarta (type, cmc ×10 to avoid floats, cost, colours, land/creature/legendary, power, toughness, loyalty, keywords, functions, fixed aura/equipment bonus, commander flag). RefImagen is Scryfall { id, cara, url } (others download it from cards.scryfall.io), Propia { sha256, ancho, alto } (the final image prepared by the app) or Generica (the app's card back).
formato.rs holds Formato (Commander, Constructed, Limited, Libre), Variantes (ModoEquipos::{Ninguno, Equipos { fuego_amigo }, DosCabezas}, archenemy, planechase) and Opciones with Opciones::de_fabrica(formato, variantes, jugadores): Commander 40 life and London mulligan with a free first one, otherwise 20 and London; Two-Headed Giant 30 per team; the archenemy 40 (VIDA_ARCHIENEMIGO); primero_no_roba on with 1–2 players and off with 3 or more (as Playgroup does); all Ayudas on; todos_tocan_todo off.
secciones_de_mesa(&Deck, Option<&ListaDeJuego>) decides where each card starts, using the written list (proxy_core::decklist::{secciones_de, seccion_de}, made public for this) and the marks:
- tokens (
CardSource::Token, an external custom card, a token type line, or the Tokens section) →Seccion::Ficha(tokens you can create, not game cards); -
CardMeta.layoutor the design'scard.layoutplanar/phenomenon→Plano;scheme→Plan; -
MarcaCarta::Opcionaland the Maybeboard → out;MarcaCarta::Banquilloand Sideboard →Banquillo; of the design variants, one (same rule asmodel::cuentan); - Commander →
Comandante, Companion →Companero, the rest →Mazo; blank slots → out.
With a game list (below), tokens, planes and schemes are decided as above, but of the other cards only the chosen ones go: its comandantes → Comandante, its cartas → Mazo (or Companero if the written list has them in the Companion section, so a chosen companion still starts apart), an unchosen copy marked sideboard → Banquillo, everything else stays out (marks and the written sections no longer matter for them). candidatas_de_lista(&Deck) returns the copies a list may choose from: all but blank slots, tokens, planes and schemes, marked or not.
The Seccion enum lives in mesa (not in proxy-core) because mesa depends on proxy-core; the other way round would be circular.
A deck can hold more cards than are played (design variants of one card, alternatives). A list chooses the copies that go to the game; the deck does not change. Pure data and checking here; the app gathers each card's data and stores the lists (Game Table).
-
ListaDeJuego { id, nombre, comandantes, cartas }: ids of the deck's copies (DeckCard.id), so the chosen design variant is the one played.comandantesholds one, or two with partner or background; empty outside Commander. -
CartaDeLista: what is needed of a copy to choose and check it: English name (copies are counted by name, so design variants are the same card), type, colour identity,puede_ser_comandante,con_pareja,sin_limite, the front's image hash and the mark. -
Construccion::de(formato): Commander exactly 100 and 1 copy with a commander; Constructed at least 60 and 4 copies; Limited at least 40, any copies; Libre has no rules (None).pide_lista(n): more counted cards than the maximum (only Commander has one). -
comprobar(cartas, lista, formato) -> Vec<AvisoLista>:YaNoEstan(ids no longer in the deck, ignored),Tamano { hay, desde, hasta },SinComandante,DemasiadosComandantes,NoPuedeSerComandante,SinPareja,Repetida { nombre, veces }(cards without limit excluded) andFueraDeIdentidad.AvisoLista::impide()is true only for size, missing commander, too many commanders and repeated copies. Decision: colour identity, the commander's eligibility and the pair only warn, because the data can be missing (a custom card not linked to Scryfall) and a kitchen table plays as it likes. -
comandante_por_texto(tipo, oracle, keywords): a legendary creature, a Background or «can be your commander» can lead; partner keywords, «Friends forever», «Choose a Background», a Background or «Doctor's companion» can go in a pair.sin_limite_por_texto: «A deck can have any number of cards named…» or «up to». The app adds basic lands by type. -
AvisoListais tagged with"tipo"; the screen words each one witht().
iniciar(id, Config, semilla, ahora_ms) deals the game for 1–10 players (MAX_JUGADORES): assigns ids, puts commanders and companions in the command zone (and marks es_comandante), the sideboard apart, every player's planes in one shuffled common deck (with the first face up if Planechase is on), schemes in their player's deck, shuffles each library with the seed, pairs seats into teams for Two-Headed Giant, creates the phantom life for solitaire and draws everyone's opening hand. Everyone starts «deciding» the mulligan.
Every action is data. The module doc comment has a table of every Accion with the Playgroup screenshot or inventory section it comes from. Groups: moving (Mover with a Destino { zona, jugador, sitio }; Sitio = Arriba, Abajo, Indice, XDesdeArriba, EnCampo(Pos), AlAzar, BarajarDentro; ComoMover::{Lanzar, Poner} for the commander), card state, library (draw, mill, manifest, exile from top, look + order for scry/surveil, reveal, excavate + resolve the excavation with the presets' Filtro, search, top revealed, shuffle, wheel), whole battlefield (untap all, organize, create token), mulligan, players (life, drain, extort, poison, commander damage, trackers, designations, cast count, reveal), turn, randomness (dice, coins with Krark's Thumbs, planar die), Planechase/Archenemy, stack (Resolver, ApilarDespues), piles, grouping, graveyard/exile bulk actions, sparring, chat, end of game, undo/redo.
Serialisation: Accion is tagged with "que" (not "tipo", because several variants have a field called tipo), Cambio with "tipo", both with camelCase fields.
🛑 Compatibility: a serde-tagged enum rejects a tag it does not know, so a newer app would break an older one. VERSION_CATALOGO goes up with every new or changed variant; variants are never changed or removed. The network handshake (phase E) and saved replays (phase H) carry it. tests/catalogo/v<N>.json holds events written with each catalogue version and tests/catalogo.rs checks they still parse; it is written once (PB_CATALOGO_ESCRIBIR=1 cargo test -p mesa --test catalogo) and never touched again.
Azar is xoshiro256** seeded through SplitMix64, written in the crate (about 40 lines) and tested against the published reference vectors of both. It was chosen over the rand crate (Decision D-A4) because what matters is identical output on every machine and version. Unbiased integers use rejection (menor_que), shuffles are Fisher–Yates.
The app provides the entropy (the session's seed); the engine never draws randomness on its own. How results travel:
- Dice, coins and the planar die are resolved into the event (
Cambio::Dados { tiradas, semilla_animacion },Cambio::Monedas,Cambio::DadoPlanar { cara, coste }), because the screen shows them. - Every other action that shuffles or picks at random carries a fresh seed in
Cambio::Accion { accion, azar };aplicarderives everything from it in a fixed order. Decision: a seed per event rather than the resulting orders, so host and guests never have to agree on intermediate zone lengths (a token shuffled into a library vanishes, for example). The same event gives the same result on any machine.resolver::necesita_azarlists the actions that get a seed.
resolver(&Partida, &Intencion, &mut Azar, ahora_ms) checks permissions and decides randomness. Default permissions (open question Q-A1 answered with the plan's default): each player touches what they control on the battlefield and stack and what is theirs in their own zones; anyone touches anyone's life, arrows, signals and chat; only the active player passes the turn (Rechazo::FueraDeTurno); looking at or searching another player's library is refused. Opciones::todos_tocan_todo opens everything, like a real kitchen table. Deshacer, Rehacer and Reiniciar are refused here: the session does them.
aplicar(&mut Partida, &Evento) -> Result<Tocado, Rechazo> is total: in a network game a guest applies what the host sends, which is outside input too. It checks seq continuity, works on a clone and only replaces the game if everything succeeded, so an Err leaves the game untouched (a guest then asks for a fresh snapshot). Nothing is indexed with []; every named card, player and zone is checked; numbers are bounded (MAX_CANTIDAD, MAX_FICHAS_SUELTAS, MAX_CHAT, MAX_ETIQUETAS, MAX_FLECHAS, MAX_BOLITAS). Tocado (touched cards, zones, players and «general» state) comes from comparing the old and new game; it is what the screen patches with.
Rules applied on every move (aplicar.rs::mover):
- Leaving the battlefield a card loses tap, counters, keywords, labels, P/T rows, attachment, arrows and its controller; what was attached to it or piled under it is set loose where it was.
- Tokens and stack abilities exist only on the battlefield and the stack: anywhere else they leave the game.
- Changing zone resets
vista_porand face-down; hand, library, graveyard, exile, command zone and sideboard are always the owner's (a stolen card sent «to its hand» goes to its owner's hand); only the battlefield can be another player's. - With
contadores_al_entrar, a planeswalker enters with its loyalty and a saga with one lore counter. - With
impuesto_automatico,ComoMover::Lanzarfrom the command zone adds one to the commander's cast count;Ponerdoes not. - A card entering the battlefield without a position takes the first free slot of its row (
pos_libre: 11 slots 900 apart from x = 600, so a card about 8 % wide on a wide screen is never cut at the edge; when all are taken it goes on top of the last one, slightly shifted). Drawn cards go to the end of the hand (its «top»). - Passing the turn clears «until end of turn» P/T and arrows; extra turns go first; the round goes up when play wraps around the table; with the helpers on, the new active player untaps what they control (except
no_endereza) and draws, except on the very first turn withprimero_no_roba. - If the active player is eliminated the turn passes by itself; during the mulligan an eliminated player no longer blocks the start.
The mulligan (Mulligan, QuedarseCon { al_fondo }): London puts as many cards on the bottom as mulligans taken, minus one with LondresPrimeroGratis; Amistoso puts none and has no limit; Libre lets the player choose. Nobody starts until every non-eliminated player has kept; then turn 1 starts with the first player and the start-of-turn helpers.
The rules live in one place. A card is seen if its zone says so or if someone was shown it (Carta::vista_por: Nadie, Dueno, Todos, Jugadores(..)):
- library, schemes and planes deck: nobody (with «play with the top revealed», everyone sees the top card);
- hand and sideboard: the owner (the hand, everyone if revealed);
- battlefield, graveyard, exile, command zone, stack, active plane and ongoing schemes: everyone, except face-down cards, which only
vista_porshows (the controller when played face down, nobody when exiled «hidden»).
Mirador is Jugador(id), Espectador or Todo (only for replaying a finished game). vista(&Partida, Mirador) -> VistaMesa and parche(&Partida, &Tocado, Mirador) -> ParcheVista build what the screen receives. 🛑 From a zone the viewer cannot see, only the number of cards travels (no ids, no order), so a known card cannot be followed by its id after a shuffle; the cards the viewer does know (top revealed, the ones being looked at) travel with their index in visibles. A face-down card the viewer does not know travels without origen, only with its back. VistaCarta::mirada tells the screen which cards the viewer is looking at (scry, search dialogs).
parche re-sends every touched card and every card of a touched zone (and the zones whose known cards changed, and the hand/library/sideboard of touched players, since revealing changes them); a card the viewer no longer sees goes in quitadas. vista::aplicar_parche does in Rust what the screen store does, for the tests.
Registro { inicial, eventos, fotos, modo } keeps a photo every CADA_FOTO (50) events. The host keeps it; a guest joining mid-game starts its own from the snapshot it receives.
-
Undo is computed by the host: the game as it was before the undone event (
en(seq - 1)) travels whole in aCambio::Restaurar { deshecho, partida }and receivers replace their copy. No «inverse» of each action and no older photos needed on guests (one that joined late has none). A 10-player game is about 300 KB; undo is rare. - Redo (solitaire only) re-resolves the undone intentions, so a re-done shuffle gives a new order, as at a real table. That is why intentions, not events, are kept.
- Permissions (
ModoDeshacer):SinLimitein solitaire and local games (also «undo up to this line»,Deshacer { hasta });UltimaPropiaonline: only your last action and only if nobody did anything after it (chat does not count). -
vigentes()lists the events that still count, in order: an undo (Restaurar { deshecho: Some(k) }) drops the events fromkon, a restart (deshecho: None) drops everything before it, andRestaurarevents themselves are left out. The app sends the last 500 with every full view so the screen's game log matches the game (tests/registro.rs). -
en(seq)rebuilds any moment in fewer than 50 applications.Reiniciaris aRestaurarwithoutdeshechothat clears the undo stack.
huella(&Partida): SHA-256 of the game's JSON, first 16 hex characters. seq is excluded: an undone game must equal the game that never did those actions, even though the event counter keeps rising.
cargo test -p mesa:
-
tests/acciones.rs: every catalogue action with before → event → after; an exhaustivematch(nombre) makes a new variant fail to compile until it is tested; permissions, stolen cards returning to their owner, leaving the battlefield, turn helpers, counters on entering. -
tests/propiedad.rs: 1,000 random intentions for 4 players twice with the same seed (same fingerprint, no panic, rejected ones change nothing); visibility for every viewer after every action, checked against the rules re-written by hand (comun::sin_fugas), and patches applied over the first view equal the full final view; 2,000 arbitrary events (unknown actors, invented cards, wrongseq, missing seeds) never panic and failures leave the game untouched. -
tests/registro.rs: undo 20 of 300 equals not having done them, undo up to a log line, redo 10, online undo refused after another player's action, a late guest following the host through aRestaurar,en(n)for everyn, London mulligan cases, nobody starts until all keep, fingerprint stability. -
tests/preparar.rs: sections from the written list, marks ascuentan, with a list only what it chose (con_una_lista_solo_va_lo_elegido), a designed plane to the planes deck, dealing and JSON round trip. -
lista.rsunit tests: one variant of the commander plus 99 is valid; too many, no commander, two variants of the same card counted as repeats; identity and the other warnings. -
tests/catalogo.rs: old catalogue versions still parse.
- New action: add the variant at the end of its group in
Accion(withTS), its case inaplicar.rs::accion_and inresolver.rs::permisosif it touches cards,necesita_azarif it is random, raiseVERSION_CATALOGOand writetests/catalogo/v<N>.json, add it tonombreandTODASintests/acciones.rswith its test, and to the random generator intests/comun/mod.rs. Runpnpm bindings. - New per-card or per-player state: add it to
Carta/Jugadorand toVistaCarta/VistaJugador; decide whether it is cleared on leaving the battlefield (Carta::limpiar_de_campo). - Never use
HashMapin the model, the clock or outside randomness in the crate.
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Icons · Iconos
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Set-Browser · Explorador de sets
Reference · Referencia