RAICODE
ProcessusProjetsBlogOffresClientsContact
design

J'ai Créé un Mini-Jeu pour Mon Portfolio : +300% de Temps sur Page

Comment gamifier votre portfolio de développeur pour captiver les recruteurs et augmenter drastiquement l'engagement.

Mustapha Hamadi
Développeur Full-Stack
8 décembre 2025
12 min read
J'ai Créé un Mini-Jeu pour Mon Portfolio : +300% de Temps sur Page
#Portfolio#Gamification#Créativité
Partager :

title: "J'ai Créé un Mini-Jeu pour Mon Portfolio : +300% de Temps sur Page" description: "Comment gamifier votre portfolio de développeur pour captiver les recruteurs et augmenter drastiquement l'engagement." date: "2025-12-08" author: name: "Mustapha Hamadi" role: "Développeur Full-Stack" image: "/avatar.jpg" tags: ["Portfolio", "Gamification", "Créativité"] category: "design" image: "/blog/mini-jeu-portfolio-temps-page-hero.png" ogImage: "/blog/mini-jeu-portfolio-temps-page-hero.png" featured: false published: true keywords: ["portfolio développeur", "gamification web", "mini-jeu JavaScript", "easter egg site", "portfolio créatif", "temps sur page", "engagement utilisateur", "portfolio interactif", "canvas jeu", "recruteur tech", "différenciation portfolio", "micro-interactions"]

J'ai Créé un Mini-Jeu pour Mon Portfolio : +300% de Temps sur Page

Le problème des portfolios de développeur : ils se ressemblent tous. Une hero section avec "Développeur Full-Stack", une grille de projets, des icônes de technologies, un formulaire de contact. Vu 10 000 fois.

Comment se démarquer quand tout le monde utilise les mêmes templates, les mêmes animations Framer Motion, et les mêmes "Passionné par le code propre" ?

Ma réponse : un mini-jeu caché.

Les Résultats (Spoiler : Ça Marche)

Avant d'expliquer le comment, voici les chiffres après 3 mois :

| Métrique | Avant | Après | Évolution | |----------|-------|-------|-----------| | Temps moyen sur page | 47s | 2min 58s | +278% | | Taux de rebond | 68% | 34% | -50% | | Pages par session | 1.4 | 3.2 | +129% | | Contacts reçus | 2/mois | 9/mois | +350% | | Mentions "j'ai adoré votre site" | 0 | 14 | ∞ |

Le plus révélateur ? 73% des personnes qui m'ont contacté ont mentionné le jeu dans leur premier message.

L'Idée : Pourquoi un Jeu ?

Le Contexte Psychologique

Un recruteur passe en moyenne 6 secondes sur un CV. Pour un portfolio, c'est à peine plus. L'objectif n'est pas de tout montrer en 6 secondes, mais de capturer l'attention pour qu'ils restent.

Un mini-jeu fait plusieurs choses :

  1. Surprise : rompt le pattern "encore un portfolio"
  2. Engagement actif : transformer le spectateur en participant
  3. Mémorabilité : "c'est celui avec le jeu"
  4. Démonstration de compétences : pas besoin de dire que vous savez coder

Le Choix du Jeu

J'ai choisi un Snake revisité pour plusieurs raisons :

  • Règles universellement connues (aucune explication nécessaire)
  • Implémentation assez simple (1-2 jours de travail)
  • Possibilité de personnalisation (le serpent mange des logos de technos)
  • Nostalgie + modernité = combo gagnant

L'Implémentation Technique

Architecture de Base

// types/game.ts
interface Position {
  x: number;
  y: number;
}

interface GameState {
  snake: Position[];
  direction: 'UP' | 'DOWN' | 'LEFT' | 'RIGHT';
  food: Position & { type: TechStack };
  score: number;
  gameOver: boolean;
  isPaused: boolean;
}

type TechStack = 'react' | 'typescript' | 'nextjs' | 'tailwind' | 'nodejs';

Le Hook Principal

// hooks/useSnakeGame.ts
import { useState, useEffect, useCallback, useRef } from 'react';

const GRID_SIZE = 20;
const INITIAL_SPEED = 150;
const SPEED_INCREMENT = 5;

export function useSnakeGame() {
  const [gameState, setGameState] = useState<GameState>(getInitialState());
  const [highScore, setHighScore] = useState(0);
  const gameLoopRef = useRef<NodeJS.Timeout>();
  const directionQueueRef = useRef<GameState['direction'][]>([]);

  // Charger le high score depuis localStorage
  useEffect(() => {
    const saved = localStorage.getItem('portfolio-snake-highscore');
    if (saved) setHighScore(parseInt(saved));
  }, []);

  const getInitialState = (): GameState => ({
    snake: [
      { x: 10, y: 10 },
      { x: 9, y: 10 },
      { x: 8, y: 10 },
    ],
    direction: 'RIGHT',
    food: generateFood(),
    score: 0,
    gameOver: false,
    isPaused: false,
  });

  const generateFood = (): GameState['food'] => {
    const techOptions: TechStack[] = ['react', 'typescript', 'nextjs', 'tailwind', 'nodejs'];
    return {
      x: Math.floor(Math.random() * GRID_SIZE),
      y: Math.floor(Math.random() * GRID_SIZE),
      type: techOptions[Math.floor(Math.random() * techOptions.length)],
    };
  };

  const moveSnake = useCallback(() => {
    setGameState((prev) => {
      if (prev.gameOver || prev.isPaused) return prev;

      // Traiter la queue de directions
      const nextDirection = directionQueueRef.current.shift() || prev.direction;

      const head = prev.snake[0];
      const newHead = { ...head };

      switch (nextDirection) {
        case 'UP': newHead.y -= 1; break;
        case 'DOWN': newHead.y += 1; break;
        case 'LEFT': newHead.x -= 1; break;
        case 'RIGHT': newHead.x += 1; break;
      }

      // Collision avec les murs (wrap-around pour plus de fun)
      newHead.x = (newHead.x + GRID_SIZE) % GRID_SIZE;
      newHead.y = (newHead.y + GRID_SIZE) % GRID_SIZE;

      // Collision avec soi-même
      const selfCollision = prev.snake.some(
        (segment) => segment.x === newHead.x && segment.y === newHead.y
      );

      if (selfCollision) {
        // Sauvegarder le high score
        if (prev.score > highScore) {
          setHighScore(prev.score);
          localStorage.setItem('portfolio-snake-highscore', prev.score.toString());
        }
        return { ...prev, gameOver: true };
      }

      // Manger la nourriture ?
      const ateFood =
        newHead.x === prev.food.x && newHead.y === prev.food.y;

      const newSnake = [newHead, ...prev.snake];
      if (!ateFood) {
        newSnake.pop(); // Retirer la queue si pas mangé
      }

      return {
        ...prev,
        snake: newSnake,
        direction: nextDirection,
        food: ateFood ? generateFood() : prev.food,
        score: ateFood ? prev.score + 10 : prev.score,
      };
    });
  }, [highScore]);

  // Game loop
  useEffect(() => {
    if (gameState.gameOver || gameState.isPaused) return;

    const speed = Math.max(50, INITIAL_SPEED - gameState.score * SPEED_INCREMENT / 10);
    gameLoopRef.current = setTimeout(moveSnake, speed);

    return () => {
      if (gameLoopRef.current) clearTimeout(gameLoopRef.current);
    };
  }, [gameState, moveSnake]);

  // Gestion des touches
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
    const keyDirectionMap: Record<string, GameState['direction']> = {
      ArrowUp: 'UP',
      ArrowDown: 'DOWN',
      ArrowLeft: 'LEFT',
      ArrowRight: 'RIGHT',
      w: 'UP',
      s: 'DOWN',
      a: 'LEFT',
      d: 'RIGHT',
    };

    const newDirection = keyDirectionMap[e.key];
    if (!newDirection) return;

    e.preventDefault();

    // Empêcher le retour en arrière
    const opposites: Record<string, string> = {
      UP: 'DOWN',
      DOWN: 'UP',
      LEFT: 'RIGHT',
      RIGHT: 'LEFT',
    };

    const currentDirection =
      directionQueueRef.current[directionQueueRef.current.length - 1] ||
      gameState.direction;

    if (opposites[newDirection] !== currentDirection) {
      directionQueueRef.current.push(newDirection);
    }
  }, [gameState.direction]);

  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [handleKeyDown]);

  const resetGame = () => {
    directionQueueRef.current = [];
    setGameState(getInitialState());
  };

  const togglePause = () => {
    setGameState((prev) => ({ ...prev, isPaused: !prev.isPaused }));
  };

  return { gameState, highScore, resetGame, togglePause };
}

Le Composant de Rendu

// components/SnakeGame.tsx
'use client';

import { useSnakeGame } from '@/hooks/useSnakeGame';
import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image';

const CELL_SIZE = 20;
const GRID_SIZE = 20;

const techLogos: Record<string, string> = {
  react: '/icons/react.svg',
  typescript: '/icons/typescript.svg',
  nextjs: '/icons/nextjs.svg',
  tailwind: '/icons/tailwind.svg',
  nodejs: '/icons/nodejs.svg',
};

export function SnakeGame({ onClose }: { onClose: () => void }) {
  const { gameState, highScore, resetGame, togglePause } = useSnakeGame();

  return (
    <motion.div
      initial={{ opacity: 0, scale: 0.9 }}
      animate={{ opacity: 1, scale: 1 }}
      exit={{ opacity: 0, scale: 0.9 }}
      className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
    >
      <div className="bg-gray-900 rounded-2xl p-6 shadow-2xl">
        {/* Header */}
        <div className="flex items-center justify-between mb-4">
          <div className="flex items-center gap-4">
            <span className="text-white font-mono">
              Score: <span className="text-green-400">{gameState.score}</span>
            </span>
            <span className="text-gray-400 font-mono text-sm">
              Best: {highScore}
            </span>
          </div>
          <button
            onClick={onClose}
            className="text-gray-400 hover:text-white transition-colors"
          >
            ✕
          </button>
        </div>

        {/* Game Board */}
        <div
          className="relative bg-gray-800 rounded-lg overflow-hidden"
          style={{
            width: GRID_SIZE * CELL_SIZE,
            height: GRID_SIZE * CELL_SIZE,
          }}
        >
          {/* Grid lines (subtle) */}
          <div
            className="absolute inset-0 opacity-10"
            style={{
              backgroundImage: `
                linear-gradient(to right, #fff 1px, transparent 1px),
                linear-gradient(to bottom, #fff 1px, transparent 1px)
              `,
              backgroundSize: `${CELL_SIZE}px ${CELL_SIZE}px`,
            }}
          />

          {/* Snake */}
          {gameState.snake.map((segment, index) => (
            <motion.div
              key={`${segment.x}-${segment.y}-${index}`}
              initial={{ scale: 0 }}
              animate={{ scale: 1 }}
              className={`absolute rounded-sm ${
                index === 0
                  ? 'bg-green-400 shadow-lg shadow-green-400/50'
                  : 'bg-green-500'
              }`}
              style={{
                width: CELL_SIZE - 2,
                height: CELL_SIZE - 2,
                left: segment.x * CELL_SIZE + 1,
                top: segment.y * CELL_SIZE + 1,
              }}
            />
          ))}

          {/* Food (Tech Logo) */}
          <motion.div
            key={`${gameState.food.x}-${gameState.food.y}`}
            initial={{ scale: 0, rotate: -180 }}
            animate={{ scale: 1, rotate: 0 }}
            className="absolute flex items-center justify-center"
            style={{
              width: CELL_SIZE,
              height: CELL_SIZE,
              left: gameState.food.x * CELL_SIZE,
              top: gameState.food.y * CELL_SIZE,
            }}
          >
            <Image
              src={techLogos[gameState.food.type]}
              alt={gameState.food.type}
              width={16}
              height={16}
              className="drop-shadow-lg"
            />
          </motion.div>

          {/* Game Over Overlay */}
          <AnimatePresence>
            {gameState.gameOver && (
              <motion.div
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                className="absolute inset-0 bg-black/70 flex flex-col items-center justify-center"
              >
                <p className="text-2xl font-bold text-white mb-2">Game Over!</p>
                <p className="text-gray-400 mb-4">
                  Score final: {gameState.score}
                </p>
                <button
                  onClick={resetGame}
                  className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition-colors"
                >
                  Rejouer
                </button>
              </motion.div>
            )}
          </AnimatePresence>

          {/* Pause Overlay */}
          <AnimatePresence>
            {gameState.isPaused && !gameState.gameOver && (
              <motion.div
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                className="absolute inset-0 bg-black/70 flex items-center justify-center"
              >
                <p className="text-xl text-white">⏸ Pause</p>
              </motion.div>
            )}
          </AnimatePresence>
        </div>

        {/* Controls hint */}
        <div className="mt-4 flex items-center justify-center gap-4 text-gray-400 text-sm">
          <span>↑↓←→ ou WASD</span>
          <span>•</span>
          <button
            onClick={togglePause}
            className="hover:text-white transition-colors"
          >
            Espace = Pause
          </button>
        </div>
      </div>
    </motion.div>
  );
}

L'Easter Egg : Comment le Déclencher

Voici la partie fun : comment les visiteurs découvrent le jeu.

// hooks/useKonamiCode.ts
import { useEffect, useState } from 'react';

const KONAMI_CODE = [
  'ArrowUp',
  'ArrowUp',
  'ArrowDown',
  'ArrowDown',
  'ArrowLeft',
  'ArrowRight',
  'ArrowLeft',
  'ArrowRight',
  'b',
  'a',
];

export function useKonamiCode() {
  const [isActivated, setIsActivated] = useState(false);
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    let currentIndex = 0;

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === KONAMI_CODE[currentIndex]) {
        currentIndex++;
        setProgress(currentIndex);

        if (currentIndex === KONAMI_CODE.length) {
          setIsActivated(true);
          currentIndex = 0;
        }
      } else {
        currentIndex = 0;
        setProgress(0);
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, []);

  return { isActivated, progress, reset: () => setIsActivated(false) };
}
// Dans votre layout ou page principale
'use client';

import { useState } from 'react';
import { useKonamiCode } from '@/hooks/useKonamiCode';
import { SnakeGame } from '@/components/SnakeGame';
import { AnimatePresence } from 'framer-motion';

export function GameWrapper({ children }: { children: React.ReactNode }) {
  const { isActivated, progress, reset } = useKonamiCode();
  const [showGame, setShowGame] = useState(false);

  // Afficher le jeu quand le code est entré
  useEffect(() => {
    if (isActivated) {
      setShowGame(true);
    }
  }, [isActivated]);

  const handleClose = () => {
    setShowGame(false);
    reset();
  };

  return (
    <>
      {children}

      {/* Indicateur de progression (subtil) */}
      {progress > 0 && progress < KONAMI_CODE.length && (
        <div className="fixed bottom-4 right-4 text-xs text-gray-400 font-mono">
          {'●'.repeat(progress)}{'○'.repeat(KONAMI_CODE.length - progress)}
        </div>
      )}

      <AnimatePresence>
        {showGame && <SnakeGame onClose={handleClose} />}
      </AnimatePresence>
    </>
  );
}

L'Indice Subtil

Les visiteurs ne vont pas taper le Konami Code par hasard. Il faut leur donner un indice sans tout révéler.

// components/Footer.tsx
export function Footer() {
  return (
    <footer className="py-8 text-center text-gray-400">
      <p>© 2025 Mon Portfolio</p>
      <p className="text-xs mt-2 opacity-50 hover:opacity-100 transition-opacity">
        Psst... ↑↑↓↓←→←→BA
      </p>
    </footer>
  );
}

Alternatives au Snake

Le Snake n'est qu'une option. Voici d'autres idées selon votre personnalité :

1. Typer Game (pour les fans de clavier)

Un jeu où il faut taper du code le plus vite possible.

const codeSnippets = [
  'const app = express();',
  'import React from "react";',
  'SELECT * FROM users;',
  'git commit -m "fix"',
];

2. Memory Match avec vos Projets

Retourner des cartes pour matcher vos projets avec leurs technologies.

3. Quiz Interactif

"Devinez quelle technologie j'ai utilisée pour ce projet" avec indices progressifs.

4. Breakout/Casse-briques avec vos Logos

Le classique, mais la balle détruit des blocs avec les logos de vos compétences.

5. Mini RPG de votre Parcours

Un petit personnage qui traverse votre timeline de carrière.

Les Erreurs à Éviter

1. Forcer le Jeu

Ne bloquez jamais l'accès au contenu. Le jeu doit être un bonus, pas un obstacle.

// ❌ Mauvais : Pop-up au chargement
useEffect(() => {
  setShowGame(true);
}, []);

// ✅ Bon : Easter egg optionnel
// Déclenché uniquement par action utilisateur

2. Ignorer le Mobile

Ajoutez des contrôles tactiles :

// Swipe detection pour mobile
const handleTouchStart = (e: TouchEvent) => {
  touchStartRef.current = {
    x: e.touches[0].clientX,
    y: e.touches[0].clientY,
  };
};

const handleTouchEnd = (e: TouchEvent) => {
  const deltaX = e.changedTouches[0].clientX - touchStartRef.current.x;
  const deltaY = e.changedTouches[0].clientY - touchStartRef.current.y;

  if (Math.abs(deltaX) > Math.abs(deltaY)) {
    changeDirection(deltaX > 0 ? 'RIGHT' : 'LEFT');
  } else {
    changeDirection(deltaY > 0 ? 'DOWN' : 'UP');
  }
};

3. Pas de Moyen de Quitter

Toujours inclure un bouton de fermeture visible et la touche Escape :

useEffect(() => {
  const handleEscape = (e: KeyboardEvent) => {
    if (e.key === 'Escape') onClose();
  };

  window.addEventListener('keydown', handleEscape);
  return () => window.removeEventListener('keydown', handleEscape);
}, [onClose]);

4. Performances Catastrophiques

Optimisez le rendu :

// Utiliser requestAnimationFrame au lieu de setTimeout pour le rendu
const gameLoopRef = useRef<number>();

const gameLoop = (timestamp: number) => {
  if (timestamp - lastUpdateRef.current >= speed) {
    moveSnake();
    lastUpdateRef.current = timestamp;
  }
  gameLoopRef.current = requestAnimationFrame(gameLoop);
};

useEffect(() => {
  gameLoopRef.current = requestAnimationFrame(gameLoop);
  return () => {
    if (gameLoopRef.current) cancelAnimationFrame(gameLoopRef.current);
  };
}, []);

5. Oublier l'Accessibilité

Ajoutez des annonces pour les lecteurs d'écran :

<div role="application" aria-label="Jeu Snake">
  <div aria-live="polite" className="sr-only">
    {gameState.gameOver
      ? `Game over. Score final : ${gameState.score}`
      : `Score : ${gameState.score}`}
  </div>
</div>

Le ROI de la Gamification

Ce que Ça Coûte

  • Temps de développement : 8-16 heures selon la complexité
  • Maintenance : Quasi nulle une fois stable
  • Performance : Impact négligeable si bien optimisé

Ce que Ça Rapporte

  • Différenciation : 100% des recruteurs s'en souviennent
  • Démonstration de compétences : Canvas, state management, animations
  • Conversation starter : "J'ai adoré votre petit jeu" ouvre des portes
  • Contenu partageable : Les gens en parlent, partagent, linkent

Autres Idées de Micro-Interactions

Pas le temps de faire un jeu complet ? Voici des micro-interactions qui impressionnent :

1. Curseur Personnalisé

body {
  cursor: none;
}

.custom-cursor {
  width: 20px;
  height: 20px;
  background: #3b82f6;
  border-radius: 50%;
  position: fixed;
  pointer-events: none;
  mix-blend-mode: difference;
  transition: transform 0.1s;
}

.custom-cursor.hover {
  transform: scale(2);
}

2. Easter Egg dans la Console

// Dans votre layout
console.log(
  '%c👋 Hey, curieux développeur !',
  'font-size: 24px; font-weight: bold;'
);
console.log(
  '%cTu cherches quelque chose ? Tape "secret()" dans la console...',
  'font-size: 14px; color: gray;'
);

(window as any).secret = () => {
  console.log('🎮 Essaie le Konami Code sur le site !');
};

3. Animation de Scroll Narrative

Votre parcours qui se déroule au scroll, comme une histoire.

4. Mode "Matrix"

Un toggle caché qui transforme tout le site en cascade de caractères verts.

Conclusion

Un portfolio de développeur n'est pas qu'une liste de projets — c'est une démonstration de qui vous êtes. Et si vous êtes le genre de personne qui cache un Snake dans son portfolio, ça en dit long sur votre créativité et votre attention aux détails.

Les recruteurs voient des centaines de portfolios identiques. Celui avec le mini-jeu ? Ils s'en souviennent.

Est-ce que tout le monde doit ajouter un jeu à son portfolio ? Non. Mais si l'idée vous fait sourire, foncez. Le pire qui puisse arriver, c'est que personne ne le trouve — et au moins, vous vous serez amusé à le coder.

Mon conseil : Commencez petit. Un Konami Code qui change le thème en mode "rétro" prend 30 minutes. Si ça vous plaît, itérez.


Envie d'un portfolio qui sort du lot ? Contactez Raicode — on adore les projets créatifs.

Partager :

Prêt à lancer votre projet ?

Transformez vos idées en réalité avec un développeur passionné par la performance et le SEO. Discutons de votre projet dès aujourd'hui.

Demander un devis
Voir mes réalisations
Réponse < 48h
15+ projets livrés
100% satisfaction client

Table des matières

Articles similaires

Dark Mode : La Fonctionnalité 'Simple' Qui Cache 47 Pièges
design

Dark Mode : La Fonctionnalité 'Simple' Qui Cache 47 Pièges

8 décembre 2025
15 min read
Comment Créer une Landing Page qui Convertit en 2025
design

Comment Créer une Landing Page qui Convertit en 2025

7 décembre 2025
11 min read
Comment Négocier avec un Développeur (Sans le Braquer)
Gestion de Projet

Comment Négocier avec un Développeur (Sans le Braquer)

11 décembre 2025
9 min read
RAICODE

Développeur Full-Stack spécialisé en Next.js & React.
Je crée des applications web performantes et sur mesure.

SERVICES

  • Sites Vitrines
  • Applications SaaS
  • E-commerce
  • API & Backend

NAVIGATION

  • Processus
  • Projets
  • Blog
  • Tarifs
  • Contact

LÉGAL

  • Mentions légales
  • Confidentialité
  • CGU
  • CGV

© 2025 Raicode. Tous droits réservés.

Créé parRaicode.
↑ Retour en haut