Skip to content

Online Play

Pablo Martínez López edited this page Oct 29, 2026 · 13 revisions

Online Play

English · Español

Playing the Game Table with other people. The game itself is peer to peer over iroh (QUIC addressed by public key, direct path with relay fallback): one app is the host and orders every event; the others are guests. A small room server (Node + TypeScript, a separate repository cloned at server/) only brings people together: identity without accounts, playgroups, invitations, rooms, signalling, choosing the host by latency and naming another one if it drops. There is also a direct code (PB1-…) that works with no server at all.

Source: src-tauri/src/red/ (network), src-tauri/src/red/servidor/ (room-server client), src-tauri/src/commands/{red,salas}.rs, src/features/mesa/red/ (direct code and network status on the table), src/features/salas/ (playgroups and rooms), src/stores/{red-store,salas-store}.ts, and the server in server/ (src/, docs/protocolo.md, docs/despliegue.md).

Topology and trust

flowchart LR
  subgraph Partida["Game (P2P, iroh)"]
    H["Host app<br/>orders events"]
    G1["Guest"]
    G2["Guest"]
    S["Spectator"]
    G1 -- "intentions" --> H
    G2 -- "intentions" --> H
    H -- "events + hash every 50" --> G1
    H -- "events + hash every 50" --> G2
    H -- "spectator view + patches" --> S
  end
  SRV["Room server<br/>(WebSocket, SQLite)"]
  R["iroh-relay<br/>(own, with callout)"]
  H -.-> SRV
  G1 -.-> SRV
  G2 -.-> SRV
  R -. "may this key relay?" .-> SRV
  H <-. "only if no direct path" .-> R
Loading
  • Star around the host. Guests send intentions (Accion with an id); the host resolves them with the engine (permissions, randomness), applies them and broadcasts the resulting events in seq order. There is no client prediction: with 20–100 ms between friends it is not noticeable, dragging is local anyway, and prediction would complicate randomness and undo.
  • Every player keeps the whole game, hidden zones included; Rust only shows the screen what that player may see (mesa::vista with Mirador::Jugador). That makes host migration trivial (anyone can carry on) and keeps one code path. Cheating is out of scope: a player could read their own copy. Spectators never receive the game, only their view (Mirador::Espectador) and its patches, each with its event for the log (ParcheEspectador.evento) only when every card the event names is (or was, until this event) in their view (anfitrion.rs::evento_visible, which reads the card fields of the event's JSON with cartas_nombradas); otherwise the patch goes alone. So not even the id of a hidden card reaches them, and an undo (Restaurar, which carries the whole game) never does.
  • The server is not in the game path. If it goes down, games continue; only host migration waits until it is back.
  • Identity = the installation's iroh key (app_data_dir/identidad/clave, ed25519). The same key identifies the player in P2P and on the server; nickname and avatar go in app_settings (red.apodo, red.avatar).

Security

What protects a game, all checked in the code:

  • Encrypted end to end. iroh runs QUIC with TLS 1.3 and each endpoint is identified by its ed25519 key. Relays only forward encrypted bytes: they cannot read or change the game.
  • The direct code is a password. It carries a 16-byte random secret (red/codigo.rs::secreto_nuevo, from the system's entropy) that the host compares in constant time (mismo_secreto). Without it nobody gets in. The host can expel a node, which cannot come back (Anfitrion::expulsar).
  • Only data travels, never code. Guests send intentions; the host's engine checks each one against the permissions (a guest cannot move another player's cards or look into hidden zones).
  • Limits. Frame sizes (TOPE_HOLA 4 KiB, TOPE_GENERAL 64 KiB, TOPE_PREPARACION 2 MiB, TOPE_PARTIDA 4 MiB), 30 intentions per second per player with repeated ids dropped, MAX_CARTAS_DECK = 500, 10 players and 20 spectators, 5 s to say hello.
  • Other players' images are requested by SHA-256, checked against that hash, refused above 4 MiB (8 MiB for a mat) or with huge sides, and re-encoded to JPEG before the screen shows them (red/imagenes.rs::guardar, fuentes::limpiar_imagen_con).
  • Chat is rendered as text by React (no HTML), at most 500 characters. proxybuilder:// links only fill in the code field; joining is always the person's click.

Limits of the design, said plainly:

  • Whoever has the code can join: share it privately. It also contains the host's direct addresses (its IPs), as any direct P2P connection does.
  • Cheating with a modified app is possible. Every player keeps the whole game (that is what lets anyone carry on), and the host decides the randomness. A modified client could read hidden cards, and a modified host could rig shuffles. This is cheating against the game, not an attack on anyone's computer; closing it would need cryptographic shuffling.

The node (red/nodo.rs, red/mod.rs)

Red (in AppState) holds the key, the lazily started Endpoint, the current P2P role (Sala::Anfitrion / Sala::Invitado), the room-server client and the current server room (Salon). ALPNs:

ALPN Use
proxy-builder/mesa/1 the game protocol (one bidirectional stream per connection; further streams are image requests)
proxy-builder/img/1 images, for a direct connection (the mesh)
proxy-builder/ping/1 the room mesh: a light connection only to measure latency and path

Relays: Relay::N0 (n0's public relays), Relay::PropioYN0(url) (production once there is a domain: RELE_PROPIO and, behind it, n0's relays, so iroh keeps the best one and a dead own relay does not cut anyone off; mapa_con_n0), Relay::Propio(url) (debug PB_RELAY=<url>, only that one) or Relay::Ninguno (LAN/tests). While RELE_PROPIO is None, n0's alone are used. Relay certificates are verified with the system's roots (CaTlsConfig::system(), feature platform-verifier), as reqwest and the server client do. In debug, PB_RELAY overrides it (none or a URL).

The game protocol (red/protocolo.rs)

Frames are a u32 big-endian length plus JSON (decision: JSON is small enough, debuggable, and the same shapes serve fixtures and saved replays). Limits are checked before reading: 4 KiB for the Hola (which must be first, within 5 s), 64 KiB in general, 2 MiB for a Preparacion (a prepared deck, validated by validar_deck), 4 MiB for whole-game messages. Datagrams (≤ 1,200 bytes, unreliable on purpose) carry latency and live movement; see Latency, clock and live movement.

  • To the host (AlHost): Hola {protocolo, version_app, version_catalogo, rol, apodo, secreto, ultimo_seq, epoch}, Preparacion {deck} (sent again, it replaces the deck), Apodo {apodo} (a guest renames itself in the waiting room), Intencion {id, accion}, Pide {desde_seq}, PideBanquillo, CambiaBanquillo {sale, entra}, Adios.
  • From the host (DelHost): Sala {jugadores}, Bienvenida {epoch, yo, partida, nodos}, Evento {epoch, evento, confirma, huella}, Rechazo {id, motivo}, VistaEspectador, ParcheEspectador, Cerrada, Traspaso {jugador, sucesor} (the host leaves on purpose, see «Host handover without a server»), Banquillo {vista}, BanquilloNo {motivo} and Encuentro {encuentro, primero}. PROTOCOLO is 2: a host and a guest with different protocols reject each other in the Hola.

Example messages for other clients are generated to src-tauri/src/red/fixtures/ (PB_FIXTURES=1). PROTOCOLO is 4 (catalogue version 3: notes and 16 label colours). App and catalogue versions must match exactly or the host answers Rechazo {motivo: "version"}.

A detail that bit: the engine's ids (IdCarta, IdJugador, IdEquipo) are map keys inside the Partida; inside an internally tagged enum (DelHost, Cambio::Restaurar) serde buffers the JSON and those keys arrive as strings. The ids therefore deserialize from a number or a numeric string (mesa::ids, test de_clave_de_un_mapa_dentro_de_un_enum_etiquetado); the format does not change.

Host and guest (red/anfitrion.rs, red/invitado.rs, mesa/sesion.rs)

  • Host (Anfitrion): admits whoever brings the secret (direct code) or whose node is in the server room (permitir_solo); takes at most 10 players (itself included; llena beyond that) and 20 spectators; once the game has started a player only gets back in with the node it played with (anyone new gets empezada), while spectators can join at any time; keeps the room (nickname, role, deck); limits intentions to 30 per second and drops repeated ids; difundir sends each event to players (with confirma for the sender's intention and the state hash every 50 events) and its patch to spectators (the whole view after an undo). The host is player 0, the others take their seats in order of arrival, and its session is a normal one with ModoDeshacer::UltimaPropia (undo only your own last action, if nobody acted after).
  • Guest (Invitado): keeps the whole game from the Bienvenida and applies events in seq order (Sesion::recibir): a gap asks for the missing events (Pide), an event that does not fit or a different hash asks for the whole game. Pending intentions are resent after reconnecting (the host drops the ones it already applied). If the connection drops it retries 5 times (1, 2, 4, 8, 16 s) and the screen shows «Reconectando…»; it does not save the game (the host does).
  • Spectator (red/mirada.rs, AppState.mirada): no session, only the view and its patches; mesa_accion answers Rejected {motivo: "espectador"} (and the host answers espectador to any intention from them). They read the chat, since the events reach them, but cannot write in it: they have no seat.

The screen does not know whether it is local or online: it subscribes to the same channel (MensajeMesa), plus Rechazo {motivo} for an intention the host refused. Network notices (AvisoRed: lobby, started, reconnecting, connected, lost, closed, rejected, plus Enlaces, Ausentes, Fantasma and Senal, below, and BuscandoAnfitrion, NuevoAnfitrion and Mayoria of the host handover) arrive as red://aviso.

Latency, clock and live movement

red/latencia.rs. Everything here travels as datagrams of the game connection (proxy-builder/mesa/1): losing one does not matter because the next one replaces it. None of it is saved, enters the log or the replay.

  • Heartbeat and echo (every 2 s): a player sends Latido {t} and the host answers Eco {t, t_host}. That gives the app's own RTT and the clock offset t_host − (t + RTT/2), as the median of the last 10 samples (a single slow packet cannot shift it). The turn timer counts from the host's clock (Turno.desde_ms is set by the host), so the screen reads the time as Date.now() + desfaseMs and everybody shows the same seconds even with clocks that disagree.
  • Links (Enlaces, every 2 s): the host measures each connection (iroh RTT and whether the path is direct or through a relay) and sends the table to everyone, with the players that are missing (ausentes). It arrives as AvisoRed::Enlaces through red://aviso (not a separate event); the status chip shows «{ms} ms · directo» or «{ms} ms · por relé» per player.
  • Ghosts and signals (Fantasma, Senal): while you drag a card or move the pointer, the others see a ghost with your name and colour; Alt + click on a card or a player rings everybody. mesa_fantasma and mesa_senal send them; the host sets who sent it (it never trusts the jugador in the datagram), limits each player to 60 a second, drops any that is not newer than the last of the same (player, kind) — the outgoing time is strictly increasing, so two moves in the same millisecond do not erase each other — and forwards them. Fantasma carries the board (tablero) because a card is only meaningful on its owner's board, and, when several cards are dragged at once, the others after the first in mas (at most MAX_MAS = 8, optional so old datagrams still read; the host truncates it and a spectator only gets the datagram if every card of the group is visible to them). On the receiving screen the dragged cards are drawn as a pile at the pointer on the board being looked at and on that player's miniature (FantasmasMini), and a card that someone is dragging is not drawn at its old place (CartaMesa, the miniatures): vivo-store keeps what is in the air (fantasmas, enVuelo) and what was just dropped (soltadas, 0.8 s, hidden only while the card is still where it was, so it does not flicker before the move event arrives; the card that someone has just dropped keeps being drawn where they dropped it, Soltada.donde, CartaSoltada, until its move arrives, so on the other screens it neither vanishes for a moment nor slides in). Marked with marcarArrastradas, the card is not animated again (FLIP) from where it was when the move arrives: it moves and ends where it belongs. A spectator only receives a ghost if the card is visible to them (visibilidad::ve with Mirador::Espectador); a hidden card of someone else is drawn as a generic back. It is a setting (Settings > Game table > «En red, ver cómo mueven los demás y mostrarles mi cursor») and the screen throttles what it sends (features/mesa/en-vivo/limitador.ts, 20 a second for dragging, 10 for the cursor).

Absentees and expelling (phase G)

  • Who is missing. The host knows when each player's connection dropped (Anfitrion::ausentes, from ausente_desde); only players who are not expelled count. The table of links (Enlaces, every 2 s) carries ausentes: Vec<Ausente { jugador, hace_ms }> (with serde(default), so an app without the field still reads it); on the host's own screen vigilar_ausentes raises AvisoRed::Ausentes once a second when the list changes. The screen keeps useRed.ausentes with the time it was told (visto) and counts from the host's hace_ms plus what has passed since. What the screen does with it (the «Saltar el turno de X» button after 30 s) is in Game Table.
  • Mulligan. If a player is still missing at the mulligan after a minute (ESPERA_MULLIGAN), vigilar_ausentes asks the engine for quedarse_por_defecto and applies it with Sesion::suplir (an action done as the absent player, which only the host's session can do): they keep their hand, as the plan says, and the game can start. Their hand stays on the table afterwards.
  • Expelling. Anfitrion::expulsar(nodo) adds the node to expulsados, sends it Rechazo { motivo: "expulsado" }, closes its connection (code 4) and any later Hola from it is refused; in a game that has started it returns its seat and the host applies Eliminar { jugador, por: None } through Sesion::suplir. Who may ask: with the room server, the person who created the room, through sala_expulsar { clave }; the server remembers the expelled in the room (expulsados, they cannot sala_unirse again: error expulsado), tells the expelled with sala_fuera { motivo: "expulsado" } and the others with sala_expulsado { clave }, and the host (who may be someone else) does the rest in red/servidor/sala.rs::expulsar_en_host. With a direct code, the host calls expulsar_en_host itself (Rejected { motivo: "no_anfitrion" } for anyone else). Commands: salas_sala_expulsar(clave) from the lobby and salas_sala_expulsar_jugador(jugador) from the table (it finds the node of the seat in Invitado.nodos or Anfitrion::nodos). The expelled player sees «Quien creó la sala te ha expulsado de ella.».

Images (red/imagenes.rs)

  • Own images (uploaded, edited or designed cards, playmats, custom backs) travel by SHA-256. A guest asks the host over the same connection (one stream per image); the host, if it lacks it, asks the owner and keeps a copy. Nobody needs anyone's address but the host's.
  • Scryfall images do not travel: each app downloads them from cards.scryfall.io (added to fuentes::PERMITIDOS), up to 4 at a time.
  • They are fetched when the screen asks for pbimg://…/m/… and the file is missing (the protocol handler calls imagenes::asegurar), so what is visible comes first.
  • On arrival: size cap (4 MiB a card, 8 MiB a playmat), the hash, then sanitising with fuentes::limpiar_imagen_con (keeps the aspect ratio, always JPEG 85; 745 × 1040 for cards, 2560 × 1600 for playmats). The sanitised image goes where the own one would (app_cache_dir/mesa/<2 hex>/<sha256>.jpg) and the received bytes to app_cache_dir/mesa/r/ (they are the ones whose hash matches, to pass on). Only images of the current game are served.

Originals in my language (mesa/originales.rs)

Per player, screen only: nothing is sent to anyone. It is switched from the context menu of a player (their life panel and the player chips, vida-jugador.tsx and estado-red.tsx: «Cartas originales en mi idioma»), per board, and the chip shows a small language icon while it is on. With it on, mesa_originales receives the cards the screen wants (up to 1,000), looks each Scryfall-linked one up by set and number in the interface language (CardApi::card_lang, falling back to English) and downloads its faces to app_cache_dir/mesa/s/<id>-<face>.jpg, served as pbimg://…/m/s/…. mesa://originales answers per card and the mesa-store swaps origen.caras for the owner's cards (an empty answer = no other printing, the card stays as it is). Own cards with no link and playmats are untouched. Rust has no «how do I see it» state on purpose: the mode lives on the screen.

Direct code (no server)

«En red…» in the «Jugar» entry (red-dialog.tsx): create a game (red_crear) and share the code, or paste one to play or watch (red_unirse, a job that prepares the deck first). The code PB1-<base64url> carries the host's EndpointAddr and a 16-byte secret (red/codigo.rs, constant-time comparison). The host starts with those who sent a deck (red_empezar; players without a deck are rejected with sin_deck; sparring is off online). The guest chooses whether to play or only watch («Solo mirar») and, to play, the deck and game list picked in its own «Jugar» entry; the list is checked with that entry's format. The game itself takes the host's format and options from the host's entry; the variants (teams, Archenemy, Planechase) stay off, there is no «al mejor de» and the first player is drawn. Leaving the table leaves the game; when the host leaves the table, the game closes for everyone. If the host drops instead (closed app, lost network), the others carry on without it: see «Host handover without a server» below.

The waiting room (red-dialog.tsx::SalaDeEspera, eleccion-en-sala.tsx). Everyone, host and guests, can change their own name there; the app remembers the last one chosen (useNombre, localStorage, the same «Tu nombre» field of the «Jugar» entry). The change reaches the others as soon as typing stops: the host with red_apodo (Anfitrion::poner_apodo), a guest with red_apodo too, which sends AlHost::Apodo (Conexion::cambiar_apodo); the host cleans it (limpiar_apodo: collapsed spaces, 32 characters), ignores an empty or unchanged one and, once the game has started, any, and sends the whole room again (DelHost::Sala). A guest that reconnects in the room comes back with the new name. Players also choose their deck and play list there, with the same selectors as a local game: the deck and list are those of the «Jugar» entry (Preparacion.elegirDeck/elegirLista), and once the list of the new deck has been read the change is applied: a guest prepares the deck and sends it again (red_deck, a job, AlHost::Preparacion replaces the previous one and the host announces it), the host only changes the deck name it is seen with (red_anfitrion_deck; its deck is prepared when it starts). Nothing is applied until the person touches the selectors, so joining with a deck does not prepare it twice. Spectators can only rename. In a room of the server (sala-espera.tsx) the same deck and list selectors prepare the deck through salas_deck; the name there is the one the server was told when connecting (the server has no message to change it yet). PROTOCOLO is 3 because of Apodo.

Going back to a game (red/vuelta.rs). A guest who drops out (network cut, app closed or crashed, computer asleep) can come back: the host already readmits a returning node to its seat (Anfitrion::admitir) and sends it the whole game, so the only missing piece was remembering the code. The guest's loop writes it to app_settings (red.vuelta: code, nickname, time) each time it receives the Bienvenida as a player (also after reconnecting or a handover, with the current host's code) through the Mesa::recordar_vuelta hook, and forgets it when there is nothing to go back to: the host closed the game, refused the guest, or the player left the table on purpose while still connected (mesa_salir; leaving after the connection was lost keeps it, and so does closing the window). red_vuelta reads it, red_vuelta_olvidar forgets it and red_volver joins again with that code as a player without a deck (a job, like red_unirse; a Rejected from the host forgets it). It also works with the table still open after Perdida: the existing guest session takes the new union. Screen: features/mesa/red/volver-red.tsx («Partida en red a medias» in the «Jugar» entry, with «Volver a la partida» and «Olvidar») and «Volver a conectar» in the lost-connection notice (estado-red.tsx::AvisoConexion). Inferred: a host whose app died cannot come back this way: with a direct code another player carries on with a different address (see the Phase K handover).

Waiting room settings. Before starting, the host chooses in the same dialog (SalaDeEspera in red-dialog.tsx) what a local game also chooses: the seat order, the teams (none, by teams with friendly fire and shared life, Two-Headed Giant, one archenemy against everyone), «al mejor de» 1, 3 or 5 (only in formats with a sideboard), Planechase and who starts. The controls are the ones of the local game (ajustes-partida.tsx: AjustesDePartida, FilaEmpieza, SelectorDeEquipo, FilaOrdenable) and the config is built by the same construirConfig. What is specific to the room: the host always takes the first seat (its Anfitrion.mi_jugador and Sesion.yo are 0) and guests are dragged below it; config-sala.ts (pure, tested) says who sits (the host and the players with a deck, not spectators or those still preparing) and keeps the order when someone arrives (at the end) or leaves (sincronizarAsientos, which also moves «quien empieza» and the archenemy). Each room row carries EnSala.id (the connection; the host is 0) so the choice names people and not positions. red_empezar(config, invitados) receives the config with the host first and, after it, what was chosen for each guest (colour and team) in the same order as invitados; empezar_partida takes the names and decks from the room, and if the ids are not exactly the players with a deck at that moment (someone came in or left in between) it refuses with sala_cambio and starts nothing. Without teams the options chosen when preparing the table apply; with teams, the format's factory ones. In both cases the host decides Opciones.campo_ajeno_protegido with the «Proteger el campo de los demás» switch, on by default: on the battlefield each player only touches what they control and takes someone else's card with «Tomar el control» (see Game Engine). Format and game list are the ones chosen when creating the game: guests prepared their decks for it.

Host handover without a server (red/sucesion.rs)

With the direct code nobody watches the game from outside, so the players do it themselves. The room server does not use any of this: there the server chooses (see «The room server»).

  • Everyone is ready. Each NodoJugador carries the seat, the node and the address (direccion, the EndpointAddr as JSON). Each guest sends its own in the Hola, and the host keeps it only if it matches the node of the connection. Players get the list in the Bienvenida, spectators in their first VistaEspectador. Every player also has the whole game, and the secret stays the same, so the code still works with whoever takes over.
  • Looking for the next host (buscar, in the guest's loop). After INTENTOS_ANTES = 3 attempts with the old host (1 + 2 + 4 s, each one with a ESPERA_INTENTO = 3 s limit), the guest tries the other players by seat order, without the one that dropped, for ESPERA_CANDIDATO = 10 s each. A player whose turn comes up becomes the host with its own copy (hacerse_anfitrion: Anfitrion::desde with the same secret, vigilar_ausentes, atender and Mesa::ser_anfitrion, which in the app puts Sala::Anfitrion with the new code). Spectators also look, but never become the host.
  • Majority. A direct-code host only lets anyone play with more than half of the players connected, itself included (Anfitrion::recuento, mayoria, minoria; expelled players do not count). While it does not have it, every action is refused with Rejected sin_mayoria, its own included. vigilar (started by atender) stops the host after PARAR_TRAS = 2 s with fewer than half connected, and starts it after ACTIVAR_TRAS = 4 s with more than half. Starting takes longer than stopping on purpose, so two hosts are never both playing. Two groups that cannot see each other cannot both have more than half, so the game never splits in two.
  • Merging. A stopped host tries every CEDER_CADA = 5 s to join another one (ceder). It joins a host in an earlier seat even if that one is stopped too, and a host in a later seat only if it already plays (Hola.solo_si_activo, refused with no_activo), so two hosts never join each other. When it joins, it lets go of its own guests (Anfitrion::soltar, connections closed with code 6), and they look for the host again. Its screen keeps the same channel (Sesion::tomar_canal).
  • Two players. After a crash, the one left is never more than half (1 of 2), so it waits for the other to come back, and the screen says so.
  • Leaving on purpose. When the host leaves (the «¿Salir de la partida?» dialog, red_salir/mesa_salir, or closing the window, which now calls salirDeRed() first), Sala::cerrar calls Anfitrion::irse. With the game started, a direct code and at least one connected player (puede_ceder, also in EstadoRed.puedeCeder so the dialog says «la partida sigue con otro jugador» or «se cierra para todos»), it hands the game over instead of closing it: it sends every connection DelHost::Traspaso { jugador, sucesor } (the successor is the connected player in the lowest seat), waits 250 ms for it to go out and lets go (soltar). Each guest skips the attempts with the old host and goes straight to buscar with the successor first in the list (candidatos), so nobody loses time probing an absent player in a lower seat, and all agree on the same epoch. The new host marks the one who left as ido (Anfitrion::marcar_ido): they do not count for the majority (so with two players the one left carries on at once, 1 of 1, and with three players 2 of 2) and are no longer a candidate nor a merge target (nodos, ceder). If nobody else is connected (only spectators, or players who dropped), the game is closed for everyone as before. Over a room server none of this is used: the server chooses.
  • Screen. AvisoRed::BuscandoAnfitrion { candidato } shows «El anfitrión no vuelve: probando con …», NuevoAnfitrion { jugador, soyYo } a toast and a fresh red_estado, and Mayoria { activa, conectados, total } the notice that nobody plays until more than half are back (estado-red.tsx::AvisoConexion, red-store).
  • What can be lost: events the old host made in its last seconds that the new host never received. Everyone is put back on the new host's game, because joining always brings the whole game.
  • Tests in red/pruebas.rs: the host drops and B carries on with C; with two players the one left waits; the host loses its network, stops, the others choose B, and the old host joins B as a guest; a spectator keeps watching with the new host; the host leaves on purpose (with three players, with two, with an absent player in a lower seat, with a spectator, and with nobody left to follow it, which closes the game). In tests the time unit is 40 ms instead of 1 s (sucesion::UNIDAD).
  • Real processes, red/pruebas_procesos.rs (#[ignore], run with --ignored): the test binary relaunches itself as a host and two guests (real iroh nodes, no relay), they play for a few seconds and then the host either is killed with SIGKILL (no clean QUIC close, unlike the in-process tests) or leaves on purpose (irse). It checks that one guest becomes host, the other follows it in epoch 2, play goes on and both end with the same game. A killed host is noticed after the QUIC idle timeout, about 30 s (measured 30.7 s, three runs), so the table is frozen for that long; leaving on purpose takes 0.3 s in the test clock (1 unit = 40 ms), about 4 s with the real timings (ACTIVAR_TRAS).

Identity: export and import

Settings > Game table > «Identidad en red» shows the key's public id and offers export (red_identidad_exportar(ruta)) and import (red_identidad_importar(ruta)), both through lib/dialogos.ts. The file is JSON ({tipo, version, clave, id}, mode 0600) with the secret key in hexadecimal and the public id to check it is the one meant; import validates type, version, size (≤ 4 KiB) and that the key matches its id, keeps the previous key as identidad/clave.anterior, and Red::cambiar_clave closes the node and the room-server client so they come back up with the new key. A bad file is InvalidInput (motivo: "identidad"); with a game, a spectated game, a direct-code room or a server room open it answers Busy (motivo: "partida") and changes nothing. Whoever holds the file can play as you, which the dialog says. It is how a player carries their playgroups to another computer.

Invitation links (enlace.rs)

proxybuilder://unirse/<code> opens the app (or the already open one) and leads to «Jugar con amigos» with the code in «Unirse con un código». Joining is still the person's click: the link never does anything on its own.

  • Rust listens (tauri-plugin-deep-link, on_open_url and get_current() for the link that started the app; tauri-plugin-single-instance with the deep-link feature passes a second launch's arguments to the first on Windows and Linux; macOS delivers it through the deep-link plugin). The single-instance plugin is not registered with PB_PERFIL, which exists to run several instances.
  • codigo_de accepts only that scheme and path, a code of exactly 10 Crockford characters after normalizar (case, spaces and dashes forgiven, I/L → 1, O → 0), no extra path segments, and at most 200 bytes; anything else is ignored. The valid code is stored, the window is shown and focused, and salas://enlace is emitted; enlace_pendiente hands over (once) the one that arrived before the screen was listening.
  • Screen: features/salas/enlaces-entrantes.ts (useEnlacesEntrantes, mounted by app-shell.tsx) switches to the «Jugar» tab and leaves the code in salas-store.enlaceEntrante; entrada.tsx opens the dialog and UnirseConCodigo fills the field. With a game in progress it only shows a notice. The invitation panel (CodigoInvitacion) also copies the link (enlace_invitacion, so the format lives only in Rust).
  • The scheme is declared in tauri.conf.json (plugins.deep-link.desktop.schemes) and installers register it; on Linux and in Windows debug builds register_all() does it at start (AppImage and pnpm tauri dev have no installer).
  • rust-ini is pinned to 0.21.1 in Cargo.lock: the deep-link plugin does not compile against 0.21.3. Remove the pin when the plugin catches up.

The room server

Separate repository proxy-builder-server, cloned inside the app checkout at server/ and ignored by the app (.gitignore): its pushes do not trigger the app's CI. Node ≥ 22.13 (node:sqlite), ws, zod; SQLite in one file for players, playgroups and invitations; rooms live in memory. Protocol in server/docs/protocolo.md: one WebSocket /v1/ws, one JSON object per message with its type in t, id/re to pair requests and answers, 16 KiB per message, 20 messages per second, three malformed messages in a row close the connection.

  • Identity without accounts: the server sends a 32-byte reto; the app signs proxy-builder/v1/<nonce> with its iroh key (hola). At most 3 connections per key.
  • Playgroups, invitation only: owner, admins and members; owner and admins invite (7 days by default, with optional use cap) and kick; the owner hands the role over before leaving (the last one leaving deletes the group). Limits: 100 members, 50 playgroups per key, 20 live invitations. Codes are 10 Crockford base32 characters (case, dashes, I/L/O forgiven).
  • Rooms: created from a playgroup (members join by id) or loose (only by a room invitation). The creator chooses the format, the maximum of players (2–10) and whether spectators are allowed (sala_crear); without them, joining as a spectator gives sin_permiso, and there are at most 10 spectators. Each member joins as a player or a spectator (como); once the game is under way only those who were playing come back as players (ya_empezada). Only the creator starts (sala_empezar; the lobby enables it with at least 2 players, all with a deck), and a waiting room ends when its creator leaves. Each member sends its iroh address (sala_direccion) and the name of its prepared deck (sala_deck), and everyone receives sala_estado; the creator can expel a member (sala_expulsar, see Absentees and expelling).
  • Choosing the host (eleccion.ts, pure): each candidate's cost is its worst RTT to the others; relay paths ×1.5, missing pairs 1,000 ms, mobile ×3. Lowest cost wins; ties by mean, then by arrival. It runs when the creator starts, with every latency row or 5 s later.
  • Host migration (salas.ts): only the server raises the epoch. The host changes if (a) its WebSocket closes and does not come back within 4 s; (b) at least half of the other players report sala_host_caido for the current epoch and the host does not answer a probe within 3 s; (c) that half keeps reporting for 10 s although the host answers the server (it lost iroh but not its WebSocket). The next online player in the succession becomes host; the old one goes to the end.
  • Restarts: rooms are re-registered by the apps (sala_unirse with registro), and the server adopts the highest epoch brought, never lowering it.
  • Relay callout: POST /relay/permitir (local only, with the relay's bearer token if PB_RELAY_TOKEN is set) answers 200 true when the key has an open connection or is in a live room.

Contract tests keep both sides aligned: the app writes its client messages to src-tauri/src/red/servidor/fixtures/al-servidor.json (PB_FIXTURES=1) and the server validates them with its zod schemas; the app reads the server's examples (server/test/ejemplos/del-servidor.json) and checks it understands them. Each side skips the other's check if it is not there.

Rooms in the app (red/servidor/, commands/salas.rs, features/salas/)

  • cliente.rs: the WebSocket (tokio-tungstenite with rustls and the system verifier), the signed challenge, reconnection (1, 2, 4… up to 30 s), a ping every 20 s (nginx cuts 120 s of silence), pedir with a 10 s timeout (server errors as ErrorCode::Server with params.code, no connection as ErrorCode::Network sin_servidor) and al_volver (what to resend after reconnecting: re-register the room, address, deck).
  • sala.rs (Salon): while in a room the node runs its own acceptor (mesh, images and, if host, the game), a mesh task that every 10 s opens a light ping connection with each other member and reports RTT and path (sala_latencias, and salas://enlaces to the screen), and a watcher that, while the guest has lost the host, sends sala_host_caido every 2 s.
  • On sala_host: the elected host opens the Anfitrion (only the room's nodes may enter) and starts the game, with the room's format, that format's factory options and the default variants, when every player is connected with a deck, or after 20 s; the others connect to it with their deck. With a game already running (a new epoch), the new host continues with its copy (Anfitrion::desde with the players' nodes) and the others reconnect with their last seq. Known limit: if the old host had sent an event to some players only, the new host's copy wins.
  • Screen: «Con amigos…» opens «Jugar con amigos» (salas-dialog.tsx): playgroups with online dots, invite, join with a code, new room; the lobby (sala-espera.tsx) shows each member's deck and this app's link to them (ms, direct or relay). Events: salas://conexion, salas://playgroups, salas://sala, salas://host, salas://fuera, salas://enlaces.
  • Server URL: red::servidor::url(): PRODUCCION (none yet: the dialog says there is no server and points to the direct code); in debug PB_SERVIDOR overrides it.

On the table, the player chips (estado-red.tsx::SelectorTablero) switch between boards (mesa-store.mirando, tableroDe); a rival's hand shows as backs. The bar hides sparring, and restart/finish for non-hosts; spectators get the log, the chat (read only: «Los espectadores no pueden escribir.»), shortcuts, the board layout, full screen and exit.

Deployment

server/docs/despliegue.md (tested locally with nginx, a test CA and iroh-relay 1.4.1): systemd units for the server (127.0.0.1:8787) and the relay (127.0.0.1:3340, HTTP; nginx terminates TLS), nginx for juego.DOMINIO (/v1/ws, /healthz, /relay/ denied) and relay.DOMINIO, the shared callout token, firewall (80/443 only) and a daily SQLite backup timer. QUIC address discovery (needs TLS on the relay) stays off.

Several instances on one machine

Debug only: PB_PERFIL=<name> gives the instance its own data (app_data_dir/perfiles/<name>), cache, webview data and identity, no window-state, and the profile in the window title. With PB_RELAY=none (LAN) and, for rooms, the local server (cd server && pnpm dev) plus PB_SERVIDOR=ws://127.0.0.1:8787/v1/ws. See Development Setup.

Not done yet

  • Full mesh image requests between guests (they go through the host).
  • In an online game the host cannot take any seat but the first (the host is player 0 in Anfitrion, Sesion and the protocol), and the format is the one chosen when creating the game. After a host handover the next game of an «al mejor de» match cannot be opened (sin_mazos): the new host does not have everyone's decks (each player keeps only theirs).

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