Une plateforme de gamification qui transforme l'étude en informatique en une habitude agréable
Funda est une plateforme d'apprentissage par gamification qui permet de continuer à apprendre l'informatique comme un jeu. C'était un projet d'équipe reliant l'apprentissage basé sur une feuille de route, différents types de quiz, des batailles en temps réel, des classements hebdomadaires et l'assistance AI dans un seul flux, et j'ai dirigé la mise en œuvre de l'UI centrée sur l'interaction dans l'ensemble du front-end.
Les valeurs clés du projet étant « une expérience d'apprentissage amusante », le front-end n'était pas simplement chargé de dessiner des données à l'écran. Tout ce que l'utilisateur ressent en résolvant un problème, la gratification reçue au moment du résultat, et la motivation à revenir le lendemain étaient tous directement liés au front-end.
Le personnage 3D de Funda était la première impression du service et servait d'objet d'interaction pour exprimer les émotions pendant l'apprentissage. Le suivi du regard, le changement d'expression et la réaction au clic devaient demeurer vivants pour que le personnage puisse avoir « une présence » et non pas simplement être une « décoration ».
Rigified Bone était une structure impossible à contrôler en temps réel dans un environnement web. En l’utilisant tel quel, le contrôle de l'animation était bloqué. À travers cinq étapes de débogage — analyse de la structure des os, mappage de poids, vérification du transfert d'animation, traçage WebGL Uniform, et vérification du rendu final — nous avons identifié que tenter de traiter des données gourmandes en calcul à l'exécution web était la cause structurelle.
Les données d'animation statiques qui se jouent en boucle, comme idle et walk, ont été précuites dans Blender et incluses dans le GLB. Plus besoin de calculer la matrice des os lors de l'exécution, ce qui a grandement réduit la charge sur le CPU.
Seules les interactions nécessitant une réaction immédiate à l'entrée de l'utilisateur, comme le suivi du regard (look-at), la réaction au clic, l'éclairage et les ombres, ont été traitées lors de l'exécution. SkeletonUtils.clone() a été utilisé pour séparer les instances de personnage et maintenir un état d'animation indépendant sur chaque écran.
Le personnage étant réutilisé sur plusieurs écrans — guide, célébration, résultats — nous avons réparti la charge GPU en utilisant une technique d'instanciation, évitant ainsi le rendu redondant de géométries identiques.
링크 정보를 불러오는 중...
Sur la base de cette structure, nous avons également mis en œuvre une page d'expérience permettant aux utilisateurs de contrôler directement l'animation et l'expression (Shape Key) du personnage en temps réel avec un contrôleur. Le personnage remplit ainsi une double mission, en tant qu'élément de branding du service et interface d'expérience d'apprentissage, plutôt qu'une simple décoration.
Un retour d'expérience plus détaillé est disponible sur le lien 링크 정보를 불러오는 중....
Funda propose divers types de quiz comme choix multiples, vrai/faux, association, analyse de code, etc. Chaque type n’énumère pas simplement des formats, mais transmet des objectifs d'apprentissage différents. Les choix multiples visent la reconnaissance des concepts clés, le vrai/faux corrige les idées fausses, l'analyse de code infère le flux d'exécution et l'association vise à comprendre les relations entre les concepts.
| Choix Multiples (MCQ) | Quiz Vrai/Faux |
|---|---|
| La méthode la plus standard pour vérifier les concepts | Corriger les idées fausses souvent confondues (Anti-pattern) |
![]() | ![]() |
| Association (Matching) | Analyse de Code |
|---|---|
| Relier les concepts et les définitions, ou les relations entre technologies | Inférer le résultat d'exécution et la logique des extraits de code réels |
![]() | ![]() |
Parmi ceux-ci, le retour d'« être incertain de ce qu'il faut faire » dans le quiz d'association s’est répétée. Lorsque l'utilisateur maintient les relations seulement dans sa tête, cela augmente la charge cognitive et interrompt rapidement le flux d'apprentissage.

En cliquant sur les choix des deux côtés, un chemin SVG est généré dynamiquement pour visualiser immédiatement la connexion. Avec ResizeObserver, même si la taille de l'écran change, les lignes de connexion sont recalculées, et après la soumission des réponses, la couleur et le style des lignes distinguent immédiatement les réponses correctes/incorrectes.
Clic utilisateur → mise à jour de l'état des paires
→ Lecture de la position DOM (getBoundingClientRect)
Après l'amélioration, le retour sur l'intuitivité a convergé vers zéro, éliminant ainsi les facteurs de déviation du flux d'apprentissage principal.
Dans un service d'apprentissage, l'écran de résultat n'est pas simplement un tableau de résumé. C'est un dispositif qui transmet brièvement la sensation que « cet apprentissage a été enregistré et qu'un accomplissement significatif a été réalisé ».
Nous avons conçu différentes expériences de récompense selon les situations.
![]() | ![]() | ![]() |
|---|
Les cartes de XP, de taux de réussite et de temps nécessaire apparaissent de gauche à droite. Avec Framer Motion, nous synchronisons le moment d'apparition, et nous renforçons le sentiment de récompense en diffusant un son de ding à chaque apparition.
Si c'est la première résolution de la journée, nous passons à l'écran Série. Composé de grands chiffres, d'une icône de flamme, du statut de vérification hebdomadaire et d'un texte incitant à poursuivre l'apprentissage le lendemain, cela transmet le contexte de « continuer l'enregistrement d'apprentissage en continu ».
Lors de la fin du dernier problème en mode révision, nous passons à un écran à effet séparé plutôt qu'au résultat général. Un badge de vérification, des vagues, et des particules d'étoiles apparaissent brièvement avant de retourner à l'écran d'apprentissage.
Étant donné que les effets étaient utilisés de manière brève et précise au moment des résultats, cela n’a pas perturbé l’utilisateur.
| Lobby de Bataille | Résolution de Problème |
|---|---|
| Vérification des Réponses | Écran de Résultats |
|---|---|
La bataille en temps réel n'était pas simplement une fonctionnalité de synchronisation des scores. Le lobby, le lien d'invitation, le décompte de début, le score en temps réel et l'affichage des résultats devaient tous être reflétés simultanément dans les routes et l'UI, et il fallait traiter les entrées incorrectes (pas de jeton d'invitation, salle déjà commencée).
Nous avons conçu une structure qui sépare l'infrastructure Socket du flux de domaine de la bataille. Afin que les changements d'état en temps réel ne soient pas dispersés dans les composants, nous avons divisé en quatre niveaux.
L'état global lié à la bataille est séparé dans battleStore pour réduire le couplage entre les événements Socket et le rendu de l'UI. Même si les règles de la bataille changent ou que des transitions d'état sont ajoutées, la responsabilité de chaque couche est claire, ce qui permet de prévoir la portée des modifications nécessaires.
Pour le lancement du service, nous avions besoin d'un ensemble de données de quiz de 6,000 questions, mais sa création manuelle aurait pris plusieurs jours.
Nous avons directement conçu un pipeline d'automatisation basé sur n8n auto-hébergé (Docker) + Google Sheets (source de vérité unique) + Gemini. Avec une sortie parallèle en JSON/JSONL/CSV et une exécution parallèle via Docker, nous avons maximisé la vitesse de traitement.

Lors de l'appel en boucle continue de LLM, la limite de débit de Gemini et les erreurs de parsing de schéma ont considérablement augmenté. Nous avons constitué une logique de délai entre les lots et de sauvegarde intermédiaire pour sécuriser la fiabilité.
En utilisant trois clés d'API en rotation, nous avons triplé la capacité de traitement par minute, optimisant ainsi le temps de traitement.
En conséquence, nous avons généré 6,000 quiz en 30 minutes, automatisant ainsi plusieurs jours de travail manuel. Les ressources de l'équipe ont pu se concentrer sur le développement des fonctionnalités principales.
Funda a choisi une structure où les utilisateurs non connectés peuvent d'abord expérimenter l'apprentissage et se connecter ensuite, de manière fluide, lorsqu'ils en ont besoin. Nous avons contrôlé ce flux entier sur la base d'un routage basé sur le code.
En combinant , , avec et , nous avons clairement défini les chemins d'accès selon l'état de connexion et les autorisations. La raison pour laquelle nous avons explicitement défini les gardes et les loaders dans la définition des routes est que, à mesure que le service grandit, les politiques d'accès doivent être compréhensibles même en dehors du code de l'interface utilisateur pour assurer la maintenance.
La séparation entre le stockage navigateur et le stockage temporaire serveur est due à leurs rôles différents.
Le stockage navigateur est un dispositif pour éviter que le contexte d'apprentissage de l'utilisateur ne soit interrompu même s'il n'est pas connecté. En gardant la dernière position vue, l'unité complétée par champ et l'état d'avancement des solutions des invités, l'utilisateur peut reprendre l'apprentissage immédiatement après un rafraîchissement ou une reconnexion. L'avantage perçu par l'utilisateur est l'instantanéité de ne pas « devoir tout recommencer ».
D'autre part, Redis est une couche pour garder temporairement les enregistrements de progression et l'état des cœurs du serveur, même sans compte. Ainsi, au moment de la connexion, les données basées sur le client_id peuvent être fusionnées avec le compte utilisateur, ce qui permet de reprendre des valeurs connectées aux règles du service, et non seulement de l'état de l'UI. L'avantage perçu par l'utilisateur est la continuité des progrès accumulés lors de l'expérimentation qui ne disparaissent pas avec la connexion.
En résumé, le navigateur saisit le contexte visuel, tandis que Redis saisit les données de progression juste avant le changement de compte. C'est similaire à laisser le cadre et les notes dans un carnet de croquis, tandis que les matériaux nécessaires au travail principal sont organisés séparément sur la table de l'atelier.
![]() | ![]() | ![]() |
|---|
Si le SRS basé sur SM-2 calcule le timing des révisions, les e-mails de rappel d'apprentissage régulier sont l'outil qui relie ce moment à l'action réelle. Le consentement à la réception sur l'écran de connexion → commutateur sur l'écran des paramètres → page de désabonnement dans le corps de l'e-mail, complètent ensemble le flux.
Les e-mails ne sont pas envoyés en masse mais filtrés par requêtes selon les conditions: '5 jours après inscription', 'interruption du Streak', 'connexion dans les 14 derniers jours', '48 heures après le dernier envoi' pour garantir que les mails arrivent seulement aux moments opportuns.
Pour éviter les problèmes liés à la limite d'envoi par seconde du serveur SMTP Gmail, une logique de délai a été ajoutée dans la boucle batch, limitant la vitesse d'envoi à moins de 5 courriels par seconde au niveau de l'application. Afin d'éviter l'impression mécanique du format fixe, le nom de l'utilisateur (displayName) et des messages diversifiés dans le corps du texte sont combinés de manière aléatoire pour augmenter le taux d'ouverture.
Enfin, pour empêcher la résiliation d'abonnement malintentionnée via la manipulation d'URL, un JWT à usage unique de 7 jours est généré et inclus dans le lien, et l'API est protégée contre les attaques de force brute par une limite de 5 appels par minute avec @Throttle.

La page de profil ne se contente pas d'afficher les informations de l'utilisateur, elle sert de tableau de bord personnel qui retraduit les enregistrements de quiz en motivation d'apprentissage. Avec le graphique annuel des temps d'apprentissage, le graphique des heures d'apprentissage des 7 derniers jours et le graphique des résolutions de problèmes par domaine des 7 derniers jours, l'objectif était de permettre de voir non seulement « combien » a été fait, mais aussi « quand » et « dans quel domaine » cela a été fait.
Les enregistrements d'apprentissage doivent s'afficher au niveau journalier, mais si la date du serveur et celle du local de l'utilisateur ne coïncident pas, l'erreur où « les problèmes résolus hier sont affichés pour aujourd'hui » apparaît immédiatement. Le fuseau horaire de l'utilisateur est lu avec et transmis au serveur via l'en-tête ; la date retournée par le serveur est ensuite systématiquement liée via , , aux herbes, infobulles et étiquettes d'axes.

Conçu non pas comme une simple grille div, mais comme une carte de chaleur d'apprentissage avec des étiquettes mensuelles, étiquettes journalières, intensité des couleurs basée sur le niveau, et infobulles au survol/en focus. Les cellules sans enregistrement ne réagissent pas à l'interaction, seules les cellules avec un enregistrement réel fonctionnent comme des boutons, ce qui augmente la densité d'information tout en évitant une interaction excessive.

Les graphiques de temps d'apprentissage et de quantité d'apprentissage par domaine ont tous deux été implémentés directement en SVG avec des graduations en Y, des lignes de grille, des chemins courbes doux, des dégradés de zone, des points de survol, et des popovers. Ceci a permis un contrôle finement adapté au ton du produit, bien supérieur à celui des bibliothèques de graphiques.
Grâce à Funda, j'ai compris que l'interaction n'est pas une décoration visuelle mais une partie de la conception qui incite l'utilisateur à continuer à utiliser le service.
Bien que nous ayons traité des implémentations visibles comme des personnages 3D, des quiz de correspondance et des batailles en temps réel, nous avons surtout mis l'accent sur la raison pour laquelle chaque interaction devait être conçue de cette manière et sur les problèmes qu'elle devait résoudre. Une bonne interaction ne se limite pas à une animation spectaculaire, elle repose sur un flux qui permet à l'utilisateur de comprendre naturellement la prochaine action à entreprendre et de revenir par la suite.
GuestGuardLoginGuardAdminGuardguestLoaderprotectedLoaderGuestGuard → Chemin autorisé pour non-connectés
LoginGuard → Redirection vers /login si non authentifié
AdminGuard → Vérification des droits administrateur
guestLoader → Redirection des utilisateurs connectés vers /learn
protectedLoader → Redirection des utilisateurs non connectés vers /loginIntl.DateTimeFormat().resolvedOptions().timeZonex-time-zonenormalizeDateKeyformatDateKeyLocalformatDateDisplayNameLocalconst resolveTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC';
async getProfileStreaks(userId: number): Promise<ProfileStreakDay[]> {
return apiFetch.get(`/profiles/${userId}/streaks`, {
headers: { 'x-time-zone': resolveTimeZone() },
});
}const resolveLevel = (count: number) => {
if (count <= 0) return 0;
if (count <= 10) return 1;
if (count <= 30) return 2;
if (count <= 60) return 3;
return 4;
};
// Seules les cellules avec un enregistrement fonctionnent comme un bouton
const isInteractive = !isPlaceholder && solvedCount > 0;<span
role={isInteractive ? 'button' : undefined}
tabIndex={isInteractive ? 0 : undefined}
aria-label={
isInteractive
? `${formatDateDisplayNameLocal(dayDate)} ${solvedCount}개`
: undefined
}
/>const createSmoothPath = (points: { x: number; y: number }[]): string => {
if (points.length === 0) return '';
if (points.length === 1) return `M ${points[0].x} ${points[0].y}`;
return points.reduce((acc, point, index, array) => {
if (index === 0) return `M ${point.x} ${point.y}`;
const previous = array[index - 1];
const cp1 = controlPoint(previous, array[index - 2], point, false);
const cp2 = controlPoint(point, previous, array[index + 1], true);
return `${acc} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${point.x} ${point.y}`;
}, '');
};<path d={group.areaPath} fill={`url(#gradient-${index})`} />
<path d={group.linePath} fill="none" stroke={group.color} strokeWidth="2.5" />
<circle
cx={point.x} cy={point.y} r="10" fill="transparent"
role="button" tabIndex={0}
aria-label={`${point.label}: ${point.tooltipFormatter(point.value)}`}
/>Portfolio personnel · Site d'archivage d'articles
Une plateforme de gamification qui transforme l'étude en informatique en une habitude agréable
Site d'interaction pour les lecteurs du livre pour enfants 'Au-delà des Nuages'
Un package d'éditeur Markdown combiné que l'application hôte peut contrôler directement
마지막 프로젝트까지 모두 확인했습니다.