Skip to content

Game Engine

Claude edited this page Oct 26, 2026 · 17 revisions

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/.

Why a separate crate

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)"]
Loading
  • Intencion is what a player asks for (Accion).
  • Evento is what the host resolved (Cambio). It is what is stored, sent to the others and replayed.
  • Actor is Jugador(id) or Mesa (events the host generates on its own: the sparring reaction).

The model (modelo.rs)

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, BTreeSet and Vec, never HashMap: 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 to POS_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.
  • zonas is serialised as a list of pairs (zonas_como_lista) because a RefZona cannot 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 from siguiente_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.

From the app's deck to the table (preparar.rs)

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.layout or the design's card.layout planar/phenomenon → Plano; scheme → Plan;
  • MarcaCarta::Opcional and the Maybeboard → out; MarcaCarta::Banquillo and Sideboard → Banquillo; of the design variants, one (same rule as model::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.

Game lists (lista.rs)

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. comandantes holds 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) and FueraDeIdentidad. 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.
  • AvisoLista is tagged with "tipo"; the screen words each one with t().

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.

The action catalogue (accion.rs)

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.

Randomness (azar.rs)

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 }; aplicar derives 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_azar lists the actions that get a seed.

Resolve and apply

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_por and 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::Lanzar from the command zone adds one to the commander's cast count; Poner does 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 with primero_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.

Who sees what (visibilidad.rs, vista.rs)

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_por shows (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.

Log, undo, redo and replay (registro.rs)

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 a Cambio::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): SinLimite in solitaire and local games (also «undo up to this line», Deshacer { hasta }); UltimaPropia online: 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 from k on, a restart (deshecho: None) drops everything before it, and Restaurar events 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. Reiniciar is a Restaurar without deshecho that clears the undo stack.

Fingerprint (huella.rs)

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.

Tests

cargo test -p mesa:

  • tests/acciones.rs: every catalogue action with before → event → after; an exhaustive match (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, wrong seq, 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 a Restaurar, en(n) for every n, London mulligan cases, nobody starts until all keep, fingerprint stability.
  • tests/preparar.rs: sections from the written list, marks as cuentan, 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.rs unit 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.

Where to start when extending

  • New action: add the variant at the end of its group in Accion (with TS), its case in aplicar.rs::accion_ and in resolver.rs::permisos if it touches cards, necesita_azar if it is random, raise VERSION_CATALOGO and write tests/catalogo/v<N>.json, add it to nombre and TODAS in tests/acciones.rs with its test, and to the random generator in tests/comun/mod.rs. Run pnpm bindings.
  • New per-card or per-player state: add it to Carta/Jugador and to VistaCarta/VistaJugador; decide whether it is cleared on leaving the battlefield (Carta::limpiar_de_campo).
  • Never use HashMap in the model, the clock or outside randomness in the crate.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally