diff --git a/docker-compose.yml b/docker-compose.yml
index fb993e0..33f54fb 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -74,6 +74,25 @@ services:
gelf-address: "udp://127.0.0.1:12201"
tag: "{{.Name}}"
+ ###############
+ # TIC-TAC-TOE #
+ ###############
+ # tic-tac-toe:
+ # build:
+ # context: ./src/
+ # args:
+ # - SERVICE=tic-tac-toe
+ # - EXTRA_FILES=tic-tac-toe/extra
+ # container_name: tic-tac-toe
+ # restart: unless-stopped
+ # networks:
+ # - transcendance-network
+ # volumes:
+ # - sqlite-volume:/volumes/database
+ # - static-volume:/volumes/static
+ # environment:
+ # - JWT_SECRET=KRUGKIDROVUWG2ZAMJZG653OEBTG66BANJ2W24DTEBXXMZLSEB2GQZJANRQXU6JA
+ # - DATABASE_DIR=/volumes/database
###############
# CHAT #
diff --git a/frontend/index.html b/frontend/index.html
index 5163217..7ffbbff 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -28,6 +28,7 @@
π€ Login
π€ Signin
π€ Chat
+ β Tic-Tac-Toe
βοΈ Settings
πͺ Logout
diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts
index 125b99d..dfab156 100644
--- a/frontend/src/pages/index.ts
+++ b/frontend/src/pages/index.ts
@@ -3,6 +3,7 @@ import './root/root.ts'
import './chat/chat.ts'
import './login/login.ts'
import './signin/signin.ts'
+import './ttt/ttt.ts'
import './profile/profile.ts'
// ---- Initial load ----
diff --git a/frontend/src/pages/ttt/README.md b/frontend/src/pages/ttt/README.md
new file mode 100644
index 0000000..d8169ae
--- /dev/null
+++ b/frontend/src/pages/ttt/README.md
@@ -0,0 +1,29 @@
+Add another game with user history and matchmaking.
+The goal of this major module, is to introduce a new game, distinct from Pong, and
+incorporate features such as user history tracking and matchmaking. Key features
+and objectives include:
+β¦ Develop a new, engaging game to diversify the platformβs offerings and enter-
+tain users.
+β¦ Implement user history tracking to record and display individual usersβ game-
+play statistics.
+β¦ Create a matchmaking system to allow users to find opponents and participate
+in fair and balanced matches.
+β¦ Ensure that user game history and matchmaking data are stored securely and
+remain up-to-date.
+β¦ Optimize the performance and responsiveness of the new game to provide an
+enjoyable user experience. Regularly update and maintain the game to fix
+bugs, add new features, and enhance gameplay.
+This major module aims to expand your platform by introducing a new game,
+enhancing user engagement with gameplay history, and facilitating matchmaking
+for an enjoyable gaming experience.
+
+# TO-DO
+For now I am prohibited from working on the backend as per Maieul's request.
+[ ] - Implement other game
+ [X] - (Done on Dec. 7 2025) Task for December 08 or 09: Tic-tac-toe should lock up once it's finished (i.e., draw or win) and print who won, how many turns and which row/col/diag the win happened in
+
+[ ] - Implement user history
+[ ] - Implement matchmaking
+[ ] -
+[ ] -
+[ ] -
\ No newline at end of file
diff --git a/frontend/src/pages/ttt/ttt.html b/frontend/src/pages/ttt/ttt.html
new file mode 100644
index 0000000..7773eed
--- /dev/null
+++ b/frontend/src/pages/ttt/ttt.html
@@ -0,0 +1,16 @@
+
diff --git a/frontend/src/pages/ttt/ttt.ts b/frontend/src/pages/ttt/ttt.ts
new file mode 100644
index 0000000..e895914
--- /dev/null
+++ b/frontend/src/pages/ttt/ttt.ts
@@ -0,0 +1,191 @@
+import { addRoute, setTitle, type RouteHandlerReturn } from "@app/routing";
+import tttPage from "./ttt.html?raw";
+import { showError, showInfo, showSuccess } from "@app/toast";
+
+// Represents the possible states of a cell on the board.
+// `null` means that the cell is empty.
+type CellState = 'O' | 'X' | null
+
+// Encapsulates the game logic.
+class TTC {
+
+ private isGameOver: boolean;
+
+ private board: [
+ CellState, CellState, CellState,
+ CellState, CellState, CellState,
+ CellState, CellState, CellState];
+ private currentPlayer: 'O' | 'X';
+
+ constructor() {
+ this.board = [null,null,null,null,null,null,null,null,null];
+ this.isGameOver = false;
+ this.currentPlayer = 'X';
+ }
+
+ private changePlayer()
+ {
+ if (this.currentPlayer === 'X')
+ this.currentPlayer = 'O';
+ else
+ this.currentPlayer = 'X';
+ }
+
+ // Analyzes the current board to determine if the game has ended.
+ private checkState(): 'winX' | 'winO' | 'draw' | 'ongoing'
+ {
+ const checkRow = (row: number): ('X' | 'O' | null) => {
+ if (this.board[row * 3] === null)
+ return null;
+ if (this.board[row * 3] === this.board[row * 3 + 1] && this.board[row * 3 + 1] === this.board[row * 3 + 2])
+ return this.board[row * 3];
+ return null;
+ }
+
+ const checkCol = (col: number): ('X' | 'O' | null) => {
+ if (this.board[col] === null) return null;
+
+ if (this.board[col] === this.board[col + 3] && this.board[col + 3] === this.board[col + 6])
+ return this.board[col];
+ return null;
+ }
+
+ const checkDiag = (): ('X' | 'O' | null) => {
+ if (this.board[4] === null) return null
+
+ if (this.board[0] === this.board[4] && this.board[4] === this.board[8])
+ return this.board[4]
+
+ if (this.board[2] === this.board[4] && this.board[4] === this.board[6])
+ return this.board[4]
+ return null;
+ }
+
+
+ const row = (checkRow(0) ?? checkRow(1)) ?? checkRow(2);
+ const col = (checkCol(0) ?? checkCol(1)) ?? checkCol(2);
+ const diag = checkDiag();
+
+ if (row !== null) return `win${row}`;
+ if (col !== null) return `win${col}`;
+ if (diag !== null ) return `win${diag}`;
+
+ if (this.board.filter(c => c === null).length === 0)
+ return 'draw';
+ return 'ongoing';
+ }
+
+ public reset(): void {
+ this.board = [null,null,null,null,null,null,null,null,null];
+ this.currentPlayer = 'X';
+ this.isGameOver = false;
+ };
+
+ // Attempts to place the current player's mark on the specified cell.
+ // @param idx - The index of the board (0-8) to place the mark.
+ // @returns The resulting game state, or `invalidMove` if the move is illegal.
+ public makeMove(idx: number): 'winX' | 'winO' | 'draw' | 'ongoing' | 'invalidMove' {
+ if (this.isGameOver) {
+ return 'invalidMove';
+ }
+ if (idx < 0 || idx >= this.board.length) {
+ return 'invalidMove';
+ }
+ if (this.board[idx] !== null) {
+ return 'invalidMove';
+ }
+ this.board[idx] = this.currentPlayer;
+ this.changePlayer();
+
+ const result = this.checkState();
+
+ if (result !== 'ongoing') {
+ this.isGameOver = true;
+ }
+
+ return result;
+ }
+
+ public getBoard(): [
+ CellState, CellState, CellState,
+ CellState, CellState, CellState,
+ CellState, CellState, CellState]
+ {
+ return this.board;
+ }
+}
+
+// Route handler for the Tic-Tac-Toe page.
+// Instantiates the game logic and binds UI events.
+async function handleTTT(): Promise
+{
+ // Create a fresh instance for every page load.
+ let board = new TTC();
+
+ return {
+ html: tttPage,
+ postInsert: async (app) => {
+ if (!app) {
+ return;
+ }
+
+ const cells = app.querySelectorAll(".ttt-grid-cell");
+ const restartBtn = app.querySelector("#ttt-restart-btn");
+
+ const updateUI = () => {
+ const board_state = board.getBoard();
+ board_state.forEach((cell_state, cell_idx) => {
+ cells[cell_idx].innerText = cell_state !== null ? cell_state : " ";
+ });
+ };
+
+ console.log(cells);
+
+ cells?.forEach(function (c, idx) {
+ c.addEventListener('click', () => {
+ const result = board.makeMove(idx);
+ switch(result)
+ {
+ case ('draw'): {
+ showInfo('Game is a draw');
+ break;
+ }
+ case ('invalidMove'): {
+ showError('Move is invalid');
+ break;
+ }
+
+ case ('winX'): {
+ showSuccess('X won');
+ app?.querySelector('.ttt-grid')?.classList.add('pointer-events-none');
+ break;
+ }
+ case ('winO'): {
+ showSuccess('O won');
+ app?.querySelector('.ttt-grid')?.classList.add('pointer-events-none');
+ break;
+ }
+ }
+
+ // Sync UI with Game State
+ const board_state = board.getBoard();
+ board_state.forEach( function (cell_state, cell_idx) {
+ cells[cell_idx].innerText = cell_state !== null ? cell_state : " ";
+ });
+
+ updateUI();
+ });
+ });
+ restartBtn?.addEventListener('click', () => {
+ board.reset();
+ // Remove pointer-events-none to re-enable the board if it was disabled
+ app?.querySelector('.ttt-grid')?.classList.remove('pointer-events-none');
+ updateUI();
+ showInfo('Game Restarted');
+ });
+ }
+ }
+}
+
+
+addRoute('/ttt', handleTTT)
\ No newline at end of file
diff --git a/src/tic-tac-toe/.dockerignore b/src/tic-tac-toe/.dockerignore
new file mode 100644
index 0000000..246d599
--- /dev/null
+++ b/src/tic-tac-toe/.dockerignore
@@ -0,0 +1,2 @@
+/dist
+/node_modules
\ No newline at end of file
diff --git a/src/tic-tac-toe/README.md b/src/tic-tac-toe/README.md
new file mode 100644
index 0000000..e8ef203
--- /dev/null
+++ b/src/tic-tac-toe/README.md
@@ -0,0 +1,18 @@
+# Directory layout
+```plaintext
+./src/tic-tac-toe
+βββ entrypoint.sh
+βββ package.json
+βββ README.md
+βββ src
+β βββ app.ts # The microservice app file, where the major part of the backend code lives.
+β βββ run.ts # Equivalent of server.ts, it is the entrypoint for our service.
+βββ tsconfig.json
+βββ vite.config.js
+```
+
+# Anatomy of a microservice
+
+# Backend
+
+# Frontend
\ No newline at end of file
diff --git a/src/tic-tac-toe/entrypoint.sh b/src/tic-tac-toe/entrypoint.sh
new file mode 100644
index 0000000..91a963d
--- /dev/null
+++ b/src/tic-tac-toe/entrypoint.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+
+set -e
+set -x
+
+# run the CMD [ ... ] from the dockerfile
+exec "$@"
diff --git a/src/tic-tac-toe/package.json b/src/tic-tac-toe/package.json
new file mode 100644
index 0000000..2ae85bd
--- /dev/null
+++ b/src/tic-tac-toe/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "tic-tac-toe",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "start": "npm run build && node dist/run.js",
+ "build": "vite build",
+ "build:prod": "vite build --outDir=/dist --minify=true --sourcemap=false",
+ "build:openapi": "VITE_ENTRYPOINT=src/openapi.ts vite build && node dist/openapi.cjs >openapi.json",
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "packageManager": "pnpm@10.24.0"
+}
diff --git a/src/tic-tac-toe/src/app.ts b/src/tic-tac-toe/src/app.ts
new file mode 100644
index 0000000..f3aef85
--- /dev/null
+++ b/src/tic-tac-toe/src/app.ts
@@ -0,0 +1,198 @@
+// import fastify, { FastifyInstance, FastifyPluginAsync } from 'fastify';
+// // TODO: Import Fastify formbody
+// // TODO: Import Fastify multipart
+// // TODO: Import shared database
+// // TODO: Import shared auth
+// // TODO: Import shared swagger
+// // TODO: Import shared utils
+// // TODO: Import socketio
+
+// // @brief ???
+// declare const __SERVICE_NAME: string;
+
+// // TODO: Import the plugins defined for this microservice
+// // TODO: Import the routes defined for this microservice
+
+// // @brief The microservice app (as a plugin for Fastify), kinda like a main function I guess ???
+// // @param fastify
+// // @param opts
+// export const app: FastifyPluginAsync = async (fastify, opts): Promise => {
+// // Register all the fastify plugins that this app will use
+
+// // Once it is done:
+// fastify.ready((err) => {
+// if (err) {
+// throw err;
+// }
+// // TODO: Supposedly, something should be there I guess
+// });
+// };
+// // Export it as the default for this file.
+// export default app;
+
+// // TODO: Understand what is this for in /src/chat/src/app.ts
+// // declare module 'fastify' {
+// // interface FastifyInstance {
+// // io: Server<{
+// // hello: (message: string) => string;
+// // MsgObjectServer: (data: { message: ClientMessage }) => void;
+// // message: (msg: string) => void;
+// // testend: (sock_id_client: string) => void;
+// // }>;
+// // }
+// // }
+
+// // TODO: Same for this, also in /src/chat/src/app.ts
+// // async function onReady(fastify: FastifyInstance) {
+// // function connectedUser(io?: Server, target?: string): number {
+// // let count = 0;
+// // const seen = new Set();
+// // // <- only log/count unique usernames
+
+// // for (const [socketId, username] of clientChat) {
+// // // Basic sanity checks
+// // if (typeof socketId !== 'string' || socketId.length === 0) {
+// // clientChat.delete(socketId);
+// // continue;
+// // }
+// // if (typeof username !== 'string' || username.length === 0) {
+// // clientChat.delete(socketId);
+// // continue;
+// // }
+
+// // // If we have the io instance, attempt to validate the socket is still connected
+// // if (io && typeof io.sockets?.sockets?.get === 'function') {
+// // const s = io.sockets.sockets.get(socketId) as
+// // | Socket
+// // | undefined;
+// // // If socket not found or disconnected, remove from map and skip
+// // if (!s || s.disconnected) {
+// // clientChat.delete(socketId);
+// // continue;
+// // }
+
+// // // Skip duplicates (DO NOT delete them β just don't count)
+// // if (seen.has(username)) {
+// // continue;
+// // }
+// // // socket exists and is connected
+// // seen.add(username);
+// // count++;
+// // // console.log(color.green,"count: ", count);
+// // console.log(color.yellow, 'Client:', color.reset, username);
+
+// // const targetSocketId = target;
+// // io.to(targetSocketId!).emit('listObj', username);
+
+// // console.log(
+// // color.yellow,
+// // 'Chat Socket ID:',
+// // color.reset,
+// // socketId,
+// // );
+// // continue;
+// // }
+
+// // // If no io provided, assume entries in the map are valid and count them.
+// // count++;
+// // console.log(
+// // color.red,
+// // 'Client (unverified):',
+// // color.reset,
+// // username,
+// // );
+// // console.log(
+// // color.red,
+// // 'Chat Socket ID (unverified):',
+// // color.reset,
+// // socketId,
+// // );
+// // }
+
+// // return count;
+// // }
+
+// // function broadcast(data: ClientMessage, sender?: string) {
+// // fastify.io.fetchSockets().then((sockets) => {
+// // for (const s of sockets) {
+// // if (s.id !== sender) {
+// // // Send REAL JSON object
+// // const clientName = clientChat.get(s.id) || null;
+// // if (clientName !== null) {
+// // s.emit('MsgObjectServer', { message: data });
+// // }
+// // console.log(' Target window socket ID:', s.id);
+// // console.log(' Target window ID:', [...s.rooms]);
+// // console.log(' Sender window ID:', sender ? sender : 'none');
+// // }
+// // }
+// // });
+// // }
+
+// // fastify.io.on('connection', (socket: Socket) => {
+// // socket.on('message', (message: string) => {
+// // console.info(
+// // color.blue,
+// // 'Socket connected!',
+// // color.reset,
+// // socket.id,
+// // );
+// // console.log(
+// // color.blue,
+// // 'Received message from client',
+// // color.reset,
+// // message,
+// // );
+
+// // const obj: ClientMessage = JSON.parse(message) as ClientMessage;
+// // clientChat.set(socket.id, obj.user);
+// // console.log(
+// // color.green,
+// // 'Message from client',
+// // color.reset,
+// // `Sender: login name: "${obj.user}" - windowID "${obj.SenderWindowID}" - text message: "${obj.text}"`,
+// // );
+// // // Send object directly β DO NOT wrap it in a string
+// // broadcast(obj, obj.SenderWindowID);
+// // console.log(
+// // color.red,
+// // 'connected in the Chat :',
+// // connectedUser(fastify.io),
+// // color.reset,
+// // );
+// // });
+
+// // socket.on('testend', (sock_id_cl: string) => {
+// // console.log('testend received from client socket id:', sock_id_cl);
+// // });
+
+// // socket.on('list', () => {
+// // console.log(color.red, 'list activated', color.reset, socket.id);
+// // connectedUser(fastify.io, socket.id);
+// // });
+
+// // socket.on('disconnecting', (reason) => {
+// // const clientName = clientChat.get(socket.id) || null;
+// // console.log(
+// // color.green,
+// // `Client disconnecting: ${clientName} (${socket.id}) reason:`,
+// // reason,
+// // );
+// // if (reason === 'transport error') return;
+
+// // if (clientName !== null) {
+// // const obj = {
+// // type: 'chat',
+// // user: clientName,
+// // token: '',
+// // text: 'LEFT the chat',
+// // timestamp: Date.now(),
+// // SenderWindowID: socket.id,
+// // };
+
+// // broadcast(obj, obj.SenderWindowID);
+// // // clientChat.delete(obj.user);
+// // }
+// // });
+// // });
+// // }
\ No newline at end of file
diff --git a/src/tic-tac-toe/src/run.ts b/src/tic-tac-toe/src/run.ts
new file mode 100644
index 0000000..d49db36
--- /dev/null
+++ b/src/tic-tac-toe/src/run.ts
@@ -0,0 +1,31 @@
+// @file run.ts
+// @brief The entrypoint to the service.
+
+// Entry point of the microservice, ran by the Dockerfile.
+
+import fastify, { FastifyInstance } from 'fastify';
+import app from './app';
+// TODO: Import the microservice app
+
+// @brief Entrypoint for the microservice's backend.
+const start = async () => {
+ // TODO: Thingies to send to log service (if I understood that correctly from /src/chat/src/run.ts)
+
+ // TODO: Add the logging thingy to the call to fastify()
+ const fastInst: FastifyInstance = fastify();
+ try {
+ process.on('SIGTERM', () => {
+ fastInst.log.info('Requested to shutdown');
+ process.exit(143);
+ });
+ // TODO: Uncomment when app.ts will be import-able.
+ await fastInst.register(app);
+ await fastInst.listen({ port: 80, host: '0.0.0.0' });
+ }
+ catch (err) {
+ fastInst.log.error(err);
+ process.exit(1);
+ };
+};
+
+start();
\ No newline at end of file
diff --git a/src/tic-tac-toe/tsconfig.json b/src/tic-tac-toe/tsconfig.json
new file mode 100644
index 0000000..cd65905
--- /dev/null
+++ b/src/tic-tac-toe/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {},
+ "include": ["src/**/*.ts"]
+}
\ No newline at end of file
diff --git a/src/tic-tac-toe/vite.config.js b/src/tic-tac-toe/vite.config.js
new file mode 100644
index 0000000..a4b829c
--- /dev/null
+++ b/src/tic-tac-toe/vite.config.js
@@ -0,0 +1,53 @@
+import { defineConfig } from 'vite';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import nodeExternals from 'rollup-plugin-node-externals';
+import path from 'node:path';
+import fs from 'node:fs';
+
+function collectDeps(...pkgJsonPaths) {
+ const allDeps = new Set();
+ for (const pkgPath of pkgJsonPaths) {
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
+ for (const dep of Object.keys(pkg.dependencies || {})) {
+ allDeps.add(dep);
+ }
+ for (const peer of Object.keys(pkg.peerDependencies || {})) {
+ allDeps.add(peer);
+ }
+ }
+ return Array.from(allDeps);
+};
+
+const externals = collectDeps(
+ './package.json',
+ '../@shared/package.json',
+);
+
+export default defineConfig({
+ root: __dirname,
+ define: {
+ __SERVICE_NAME: '"tic-tac-toe"',
+ },
+ // service root
+ plugins: [tsconfigPaths(), nodeExternals()],
+ build: {
+ ssr: true,
+ outDir: 'dist',
+ emptyOutDir: true,
+ lib: {
+ entry: path.resolve(__dirname, process.env.VITE_ENTRYPOINT ?? 'src/run.ts'),
+ // adjust main entry
+ formats: ['cjs'],
+ // CommonJS for Node.js
+ fileName: () => 'index.js',
+ },
+ rollupOptions: {
+ external: externals,
+ },
+ target: 'node22',
+ // or whatever Node version you use
+ sourcemap: true,
+ minify: false,
+ // for easier debugging
+ },
+});