Skip to content
← All projects

2025

ft_transcendence

A real-time social gaming platform built around Pong: local, online and tournament matches, chat, friends and statistics, on a custom JSX single-page app and seven microservices.

My role

Team project. I built the full frontend: the TypeScript single-page app, its custom JSX runtime, the router, and every page and component.

Context

ft_transcendence is the final project of the 42 curriculum: a social gaming platform that combines a Pong game with accounts, chat and friends.

Problem

Pong is simple, but a platform around it is not. Players need secure accounts, real-time matches and chat, friends and blocking, and a history of their games, all in one app.

Objectives

  • Play Pong locally, against a remote player, or in a tournament.
  • Secure accounts: JWT sessions, two-factor authentication (TOTP or email), and Google and 42 OAuth.
  • Real-time chat and direct messages, friend requests, blocking and privacy controls.
  • Match history and statistics, with charts.

How the JSX engine works

Where the design comes from

The runtime is based on a published design for custom JSX with Vite and TypeScript, which I adapted for this project. The router, the pages, the components and the event handlers built on top of it are mine.

JSX becomes plain function calls

TypeScript is set to jsx react-jsx with jsxImportSource "@", and Vite's esbuild is told to use jsx.component as the factory and to inject import { jsx } from '@/jsx-runtime' into every file. So <div className="card">Hi</div> compiles to jsx.component("div", { className: "card" }, "Hi"). React is never involved.

One 33-line function builds real DOM

jsx.component(component, props, ...children) puts the flattened children on props.children. A function component is called with those props. A tag name creates an element with document.createElement, every prop except children becomes an attribute (className is mapped to class), and each child is appended as a DOM node or as a text node. It returns a real element immediately, with no virtual DOM.

Pages are functions that return elements

Every page, such as Signin, Home or Game, is a function that returns an HTMLElement, so JSX composes with ordinary functions. The router maps route names to those functions. Links marked data-link are intercepted, history.pushState updates the URL, and the router renders the new page.

Types, and what the runtime leaves out

A global JSX.IntrinsicElements declaration accepts any tag name with any props, so TypeScript allows every element but does not check its props. The runtime has no state, no re-rendering or diffing, and no event props. Event listeners are attached after render in separate handler modules, which find the form by id and call addEventListener.

Architecture

A Vite and TypeScript single-page app sits in front of seven domain-driven Node.js microservices on Fastify: auth (2FA and OAuth), profile, relationships (friends and blocking), chat, game, dashboard and notifications. The services use SQLite databases and Redis caching, and talk to each other over RabbitMQ on the Docker network. Nginx is the reverse proxy and terminates SSL, and Docker Compose runs everything. The client renders with a custom JSX engine instead of a UI framework.

Key decisions and trade-offs

  • Split the backend into seven domain-driven microservices.

    Trade-off: Each domain, such as auth, chat or game, can change on its own. The cost is a message queue and several databases that have to stay consistent.

  • Use a small custom JSX runtime that builds DOM directly, instead of adopting a UI framework.

    Trade-off: The runtime is 33 lines with no dependencies, and JSX still compiles to real DOM nodes with TypeScript checking tag names. The cost is that there is no state, no re-rendering or diffing, and no event props, so handlers are attached by id after render and a page changes by rendering again.

Outcome

Pong with local, remote and tournament modes, chat, friends and blocking, match statistics, and login with 2FA and Google or 42 OAuth. Matchmaking is built for 500+ simultaneous players.

Stack

  • TypeScript
  • Vite
  • Tailwind CSS
  • Custom JSX
  • Chart.js
  • Node.js
  • Fastify
  • SQLite
  • Redis
  • RabbitMQ
  • WebSocket
  • Nginx
  • Docker