Jeu vidéo / Moteur 3D web

SelixyCARD

Jeu de cartes type UNO en 3D dans le navigateur, moteur Rust en wasm

RustWebAssemblywgpuWGSLAxumPostgreSQLWebSocket

Je voulais un jeu de cartes dont les cartes soient celles des joueurs, et un rendu 3D sans passer par un moteur existant : j’ai écrit le mien. Le projet est en cours, le premier commit date du 19 octobre 2026 et l’essentiel du moteur de table s’est fait dans les jours qui ont suivi.

Le jeu et les sets

Un set compte 108 emplacements aux identifiants fixes, comme un paquet de UNO. Une page de bibliothèque permet de les illustrer avec un éditeur à recadrage 2:3, et une carte sans image reçoit un visuel par défaut. Un cadre commun (bord de couleur, valeur dans les coins) est dessiné par-dessus chaque illustration, donc toutes les cartes restent lisibles.

Page de bibliothèque d'un set : le dos des cartes et les cartes rouges
L'éditeur de recadrage 2:3 et la carte telle qu'elle sera rendue

Les règles de base et les options sont écrites une seule fois, dans un module Rust (commun) exécuté par le serveur, qui est le seul juge de la partie. Les options couvrent entre autres le cumul des +2 et +4, la contestation du +4, le saut en cours de tour (jump_in), la règle du 7 et du 0 et la pénalité de UNO. Le générateur de mélange est un xorshift maison : à graine égale, la partie se rejoue à l’identique, ce qui sert à reproduire un bug.

Le salon : code de la partie, joueurs, robot et règles optionnelles

Le salon permet de créer ou de rejoindre une partie par WebSocket, la connexion se rétablit seule en cas de coupure. Des robots peuvent compléter la table : ils tournent dans le navigateur de l’hôte du salon et jouent par la même WebSocket qu’un joueur.

Le moteur 3D en Rust et wasm

Le moteur ne s’occupe que du rendu, de l’animation et du pointage : le JavaScript garde la WebSocket, l’état de la partie et l’interface. Le rendu passe par wgpu, avec des shaders en WGSL, en WebGPU quand le navigateur le permet et en WebGL2 sinon. Le JSON de l’état de la table est lu par un analyseur écrit à la main, pour ne pas embarquer serde dans le wasm.

Quelques choix :

  • toutes les cartes sont posées à plat et se superposent par décalage en hauteur, avec une caméra de table basse : un éventail de cartes inclinées se traverserait au z-buffer ;
  • chaque carte rejoint sa pose cible par lissage exponentiel, indépendant du débit d’images, avec des trajets en arc pour les longs déplacements ;
  • le pointage ne lance pas de rayon : il projette les coins des cartes et teste le quadrilatère, de la carte du dessus à celle du dessous ;
  • l’éventail de la main s’adapte à la place visible, notamment en portrait sur téléphone, où la caméra se rapproche ;
  • les textures sont un tableau de 128 couches de 256×384, plutôt qu’un atlas : pas de calcul d’UV par case, pas de bavure entre cartes.

Rendu et décor

Le plan de travail est une ardoise générée dans le shader, avec des traits de craie, sans texture, donc sans étirement à l’écran. Les cartes projettent des ombres (shadow mapping en 2048, filtrées en PCF), et une carte jouée soulève un nuage de poussière de sa couleur à l’impact. Un joker posé en souffle un plus dense et laisse un dépôt de poudre de la couleur choisie.

Le décor de l'accueil : cartes et ardoise rendues par le moteur, puis bakées en image

La lueur autour de la main ou de la pioche est calculée dans l’espace écran : on dessine les formes dans un masque, on en tire une distance au contour, puis on peint un dégradé qui part du contour. Il n’y a ainsi aucun trait entre deux cartes voisines. Limite connue : près de la frontière, un objet incliné presque à la même profondeur que la sélection peut être mal classé.

Les fonds des pages sont des rendus du même moteur, bakés par outils/cuire-fond.js. Un second module, graphe_pbr, décrit des matériaux sous forme de graphe de nœuds, traduit en GLSL et en WGSL.

Serveur et déploiement

Le serveur est en Rust (Axum) avec PostgreSQL : comptes, sets, images et salons. Les images sont envoyées en WebP 512×768 et adressées par leur SHA-256, ce qui permet de les servir avec un cache immuable. Le tout est livré dans un pod Podman (serveur et base) derrière nginx, par un simple git push.

Le dépôt suit des règles strictes que je m’impose : 200 lignes maximum par fichier, 5 fonctions par fichier, aucun commentaire dans le code, le pourquoi vivant dans le README de chaque module. Un script d’audit les vérifie à chaque commit, avec les tests Rust et JavaScript.

Liens

Video Game / Web 3D engine

SelixyCARD

UNO-style 3D card game in the browser, with a Rust engine in wasm

RustWebAssemblywgpuWGSLAxumPostgreSQLWebSocket

I wanted a card game whose cards are the players’ own, and a 3D renderer that does not go through an existing engine: I wrote mine. The project is in progress. The first commit dates from 19 October 2026 and most of the table engine was built in the days that followed.

The game and the sets

A set has 108 slots with fixed identifiers, like a UNO deck. A library page lets you illustrate them with a 2:3 cropping editor, and a card without an image gets a default look. A common frame (colored border, value in the corners) is drawn over every illustration, so all cards stay readable.

Library page of a set: the card back and the red cards
The 2:3 crop editor and the card as it will be rendered

The base rules and the options are written once, in a Rust module (commun) run by the server, which is the only judge of the game. The options include stacking +2 and +4, challenging a +4, jumping in out of turn (jump_in), the 7 and 0 rule and the UNO penalty. The shuffle uses a home-made xorshift generator: with the same seed a game replays identically, which helps reproduce a bug.

The lobby: game code, players, robot and optional rules

The lobby lets you create or join a game over a WebSocket, and the connection restores itself after a drop. Bots can fill the table: they run in the lobby host’s browser and play through the same WebSocket as a player.

The 3D engine in Rust and wasm

The engine only handles rendering, animation and picking: JavaScript keeps the WebSocket, the game state and the interface. Rendering goes through wgpu, with WGSL shaders, on WebGPU when the browser allows it and on WebGL2 otherwise. The JSON of the table state is read by a hand-written parser, to keep serde out of the wasm.

A few choices:

  • all cards lie flat and stack by height offset, with a low table camera: a fan of tilted cards would cut through itself in the z-buffer;
  • each card reaches its target pose through exponential smoothing, independent of frame rate, with arc trajectories for long moves;
  • picking does not cast a ray: it projects the card corners and tests the quadrilateral, from the top card to the bottom one;
  • the hand fan adapts to the visible space, in particular in portrait on a phone, where the camera moves closer;
  • textures are an array of 128 layers of 256×384, instead of an atlas: no per-cell UV computation, no bleeding between cards.

Rendering and scenery

The work surface is a slate generated in the shader, with chalk strokes, no texture, so nothing stretches on screen. Cards cast shadows (2048 shadow mapping, PCF filtered), and a played card kicks up a dust cloud in its own color on impact. A played joker kicks up a denser one and leaves a powder deposit in the chosen color.

The home page backdrop: cards and slate rendered by the engine, then baked into an image

The glow around the hand or the draw pile is computed in screen space: shapes are drawn into a mask, a distance to the outline is derived from it, then a gradient is painted outward from the outline. So there is no line between two neighboring cards. Known limit: near the boundary, a tilted object at almost the same depth as the selection can be misclassified.

The page backgrounds are renders of the same engine, baked by outils/cuire-fond.js. A second module, graphe_pbr, describes materials as a node graph, translated to GLSL and WGSL.

Server and deployment

The server is written in Rust (Axum) with PostgreSQL: accounts, sets, images and lobbies. Images are uploaded as 512×768 WebP and addressed by their SHA-256, so they can be served with an immutable cache. Everything ships in a Podman pod (server and database) behind nginx, with a simple git push.

The repository follows strict rules I set myself: 200 lines maximum per file, 5 functions per file, no comments in the code, the why living in each module’s README. An audit script checks them on every commit, along with the Rust and JavaScript tests.

Links