Repository navigation
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).
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
-
Star around the host. Guests send intentions (
Accionwith an id); the host resolves them with the engine (permissions, randomness), applies them and broadcasts the resulting events inseqorder. 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::vistawithMirador::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 withcartas_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 inapp_settings(red.apodo,red.avatar).
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_HOLA4 KiB,TOPE_GENERAL64 KiB,TOPE_PREPARACION2 MiB,TOPE_PARTIDA4 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.
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).
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}andEncuentro {encuentro, primero}.PROTOCOLOis 2: a host and a guest with different protocols reject each other in theHola.
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 (
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;llenabeyond that) and 20 spectators; once the game has started a player only gets back in with the node it played with (anyone new getsempezada), while spectators can join at any time; keeps the room (nickname, role, deck); limits intentions to 30 per second and drops repeated ids;difundirsends each event to players (withconfirmafor 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 withModoDeshacer::UltimaPropia(undo only your own last action, if nobody acted after). -
Guest (
Invitado): keeps the whole game from theBienvenidaand applies events inseqorder (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_accionanswersRejected {motivo: "espectador"}(and the host answersespectadorto 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.
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 answersEco {t, t_host}. That gives the app's own RTT and the clock offsett_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_msis set by the host), so the screen reads the time asDate.now() + desfaseMsand 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 asAvisoRed::Enlacesthroughred://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_fantasmaandmesa_senalsend them; the host sets who sent it (it never trusts thejugadorin 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.Fantasmacarries 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 inmas(at mostMAX_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-storekeeps 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 withmarcarArrastradas, 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::vewithMirador::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).
-
Who is missing. The host knows when each player's connection dropped (
Anfitrion::ausentes, fromausente_desde); only players who are not expelled count. The table of links (Enlaces, every 2 s) carriesausentes: Vec<Ausente { jugador, hace_ms }>(withserde(default), so an app without the field still reads it); on the host's own screenvigilar_ausentesraisesAvisoRed::Ausentesonce a second when the list changes. The screen keepsuseRed.ausenteswith the time it was told (visto) and counts from the host'shace_msplus 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_ausentesasks the engine forquedarse_por_defectoand applies it withSesion::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 toexpulsados, sends itRechazo { motivo: "expulsado" }, closes its connection (code 4) and any laterHolafrom it is refused; in a game that has started it returns its seat and the host appliesEliminar { jugador, por: None }throughSesion::suplir. Who may ask: with the room server, the person who created the room, throughsala_expulsar { clave }; the server remembers the expelled in the room (expulsados, they cannotsala_unirseagain: errorexpulsado), tells the expelled withsala_fuera { motivo: "expulsado" }and the others withsala_expulsado { clave }, and the host (who may be someone else) does the rest inred/servidor/sala.rs::expulsar_en_host. With a direct code, the host callsexpulsar_en_hostitself (Rejected { motivo: "no_anfitrion" }for anyone else). Commands:salas_sala_expulsar(clave)from the lobby andsalas_sala_expulsar_jugador(jugador)from the table (it finds the node of the seat inInvitado.nodosorAnfitrion::nodos). The expelled player sees «Quien creó la sala te ha expulsado de ella.».
- 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 tofuentes::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 callsimagenes::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 toapp_cache_dir/mesa/r/(they are the ones whose hash matches, to pass on). Only images of the current game are served.
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.
«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.
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
NodoJugadorcarries the seat, the node and the address (direccion, theEndpointAddras JSON). Each guest sends its own in theHola, and the host keeps it only if it matches the node of the connection. Players get the list in theBienvenida, spectators in their firstVistaEspectador. 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). AfterINTENTOS_ANTES= 3 attempts with the old host (1 + 2 + 4 s, each one with aESPERA_INTENTO= 3 s limit), the guest tries the other players by seat order, without the one that dropped, forESPERA_CANDIDATO= 10 s each. A player whose turn comes up becomes the host with its own copy (hacerse_anfitrion:Anfitrion::desdewith the same secret,vigilar_ausentes,atenderandMesa::ser_anfitrion, which in the app putsSala::Anfitrionwith 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 withRejectedsin_mayoria, its own included.vigilar(started byatender) stops the host afterPARAR_TRAS= 2 s with fewer than half connected, and starts it afterACTIVAR_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 withno_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 callssalirDeRed()first),Sala::cerrarcallsAnfitrion::irse. With the game started, a direct code and at least one connected player (puede_ceder, also inEstadoRed.puedeCederso 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 connectionDelHost::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 tobuscarwith 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 asido(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 freshred_estado, andMayoria { 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 withSIGKILL(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).
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.
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_urlandget_current()for the link that started the app;tauri-plugin-single-instancewith thedeep-linkfeature 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 withPB_PERFIL, which exists to run several instances. -
codigo_deaccepts only that scheme and path, a code of exactly 10 Crockford characters afternormalizar(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, andsalas://enlaceis emitted;enlace_pendientehands over (once) the one that arrived before the screen was listening. - Screen:
features/salas/enlaces-entrantes.ts(useEnlacesEntrantes, mounted byapp-shell.tsx) switches to the «Jugar» tab and leaves the code insalas-store.enlaceEntrante;entrada.tsxopens the dialog andUnirseConCodigofills 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 buildsregister_all()does it at start (AppImage andpnpm tauri devhave no installer). -
rust-iniis pinned to 0.21.1 inCargo.lock: the deep-link plugin does not compile against 0.21.3. Remove the pin when the plugin catches up.
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 signsproxy-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/Oforgiven). -
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 givessin_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 receivessala_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 theepoch. The host changes if (a) its WebSocket closes and does not come back within 4 s; (b) at least half of the other players reportsala_host_caidofor 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_unirsewithregistro), and the server adopts the highestepochbrought, never lowering it. -
Relay callout:
POST /relay/permitir(local only, with the relay's bearer token ifPB_RELAY_TOKENis set) answers200 truewhen 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.
-
cliente.rs: the WebSocket (tokio-tungstenite with rustls and the system verifier), the signed challenge, reconnection (1, 2, 4… up to 30 s), apingevery 20 s (nginx cuts 120 s of silence),pedirwith a 10 s timeout (server errors asErrorCode::Serverwithparams.code, no connection asErrorCode::Networksin_servidor) andal_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 lightpingconnection with each other member and reports RTT and path (sala_latencias, andsalas://enlacesto the screen), and a watcher that, while the guest has lost the host, sendssala_host_caidoevery 2 s. - On
sala_host: the elected host opens theAnfitrion(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 newepoch), the new host continues with its copy (Anfitrion::desdewith the players' nodes) and the others reconnect with their lastseq. 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 debugPB_SERVIDORoverrides 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.
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.
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.
- 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,Sesionand 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).
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
- 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