| | | |

404 personnalisée avec jeux arcade

Une page 404 personnalisée qui mérite qu’on se perde

La plupart des pages d’erreur 404 affichent un message laconique et un lien de retour à l’accueil. C’est fonctionnel, mais c’est une occasion manquée. Plutôt que de laisser le visiteur repartir les mains vides, pourquoi ne pas lui proposer de patienter avec un jeu ?

C’est l’idée derrière cette page d’erreur disponible à l’adresse 404.md.lu — un fichier HTML unique, autonome, sans dépendance externe, qui transforme l’erreur en expérience.

404 personnalisée avec jeux arcade

Ce que contient cette page 404 personnalisée

Deux jeux intégrés

Snake — le classique des classiques. Le serpent grandit à chaque pomme avalée, le score s’affiche en temps réel. Contrôlable au clavier (touches directionnelles + espace pour démarrer) ou au toucher sur mobile.

2048 — le jeu de fusion de tuiles. Les flèches directionnelles ou un simple swipe suffisent à déplacer les tuiles. L’objectif : atteindre la tuile 2048 en combinant les valeurs identiques.

Les deux jeux s’ouvrent dans un modal plein écran, sans quitter la page.

Détection automatique de la langue

La page détecte la langue du navigateur du visiteur et adapte tous les textes en conséquence. Quatre langues sont supportées : français, anglais, allemand et portugais. En l’absence de correspondance, l’anglais est utilisé par défaut.

Cinq thèmes visuels

Un menu de thèmes est accessible via un appui long sur le « 404 » central ou en cliquant sur l’icône ⚙ en bas à droite. Cinq ambiances sont disponibles :

  • Midnight — bleu électrique sur fond quasi-noir
  • Carbon — monochrome, contraste blanc sur noir
  • Forest — vert émeraude sur fond sombre
  • Crimson — rouge bordeaux, ambiance chaleureuse
  • Aurora — dégradé violet-cyan, inspiré des aurores boréales

Le thème choisi est mémorisé via localStorage et appliqué automatiquement à la prochaine visite.

IP du visiteur en footer

Le footer affiche discrètement l’adresse IP publique du visiteur, récupérée via l’API ipify.org. Un détail technique qui rappelle subtilement que la page sait d’où vient la requête.


Détails techniques

La page repose sur un unique fichier 404.html d’environ 20 ko. Aucune dépendance npm, aucun framework, aucun build nécessaire. Les polices sont chargées depuis Google Fonts (Space Mono et Syne).

Le déploiement utilisé ici repose sur :

  • Un container Docker avec une image nginx:alpine qui sert le fichier statique
  • Nginx Proxy Manager comme reverse proxy, avec certificat Let’s Encrypt automatique
  • Le container est défini comme Default Site dans NPM — il intercepte toutes les requêtes vers des hôtes inconnus
services:
  404-page:
    image: nginx:alpine
    container_name: 404-page
    restart: unless-stopped
    volumes:
      - ./404.html:/usr/share/nginx/html/index.html:ro
    expose:
      - "80"

Code source

Le code complet est disponible dans l’accordéons ci-dessous. Il suffit de le placer dans un dossier servi par n’importe quel serveur web, Apache, Nginx, ou même directement ouvert dans un navigateur pour tester en local.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404</title>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@400;700;800&display=swap" rel="stylesheet">
<style>
:root {
  --bg: #080c10;
  --surface: #0d1320;
  --border: #1a2535;
  --accent: #4f9cf9;
  --accent2: #7b61ff;
  --text: #e8edf5;
  --muted: #4a5568;
  --glow: rgba(79,156,249,0.15);
}
[data-theme="carbon"] {
  --bg: #090909;
  --surface: #111;
  --border: #222;
  --accent: #e8e8e8;
  --accent2: #888;
  --text: #f0f0f0;
  --muted: #444;
  --glow: rgba(255,255,255,0.07);
}
[data-theme="forest"] {
  --bg: #060f0b;
  --surface: #0a1a12;
  --border: #102818;
  --accent: #2ecc71;
  --accent2: #1abc9c;
  --text: #d4f0e0;
  --muted: #2d5040;
  --glow: rgba(46,204,113,0.12);
}
[data-theme="crimson"] {
  --bg: #0a0507;
  --surface: #150a0d;
  --border: #2a1018;
  --accent: #e84060;
  --accent2: #c0392b;
  --text: #f0d5da;
  --muted: #4a2030;
  --glow: rgba(232,64,96,0.12);
}
[data-theme="aurora"] {
  --bg: #07080f;
  --surface: #0d0e1f;
  --border: #151830;
  --accent: #a855f7;
  --accent2: #06b6d4;
  --text: #e8e0f8;
  --muted: #2d2a50;
  --glow: rgba(168,85,247,0.12);
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

body {
  background: var(--bg);
  color: var(--text);
  font-family: 'Syne', sans-serif;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  overflow-x: hidden;
  transition: background 0.4s, color 0.4s;
  cursor: default;
  position: relative;
}

/* Grid background */
body::before {
  content: '';
  position: fixed;
  inset: 0;
  background-image:
    linear-gradient(var(--border) 1px, transparent 1px),
    linear-gradient(90deg, var(--border) 1px, transparent 1px);
  background-size: 40px 40px;
  opacity: 0.4;
  pointer-events: none;
  z-index: 0;
}

/* Radial glow */
body::after {
  content: '';
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 600px;
  height: 600px;
  background: radial-gradient(circle, var(--glow) 0%, transparent 70%);
  pointer-events: none;
  z-index: 0;
  transition: background 0.4s;
}

.container {
  position: relative;
  z-index: 1;
  text-align: center;
  padding: 2rem;
  max-width: 600px;
  width: 100%;
}

/* Big 404 */
.error-code {
  font-family: 'Space Mono', monospace;
  font-size: clamp(6rem, 20vw, 12rem);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.05em;
  color: transparent;
  -webkit-text-stroke: 1px var(--accent);
  position: relative;
  cursor: pointer;
  user-select: none;
  transition: -webkit-text-stroke 0.3s;
  animation: fadeUp 0.8s ease both;
}
.error-code:hover { -webkit-text-stroke-width: 2px; }
.error-code::after {
  content: '404';
  position: absolute;
  inset: 0;
  color: var(--accent);
  opacity: 0.06;
  filter: blur(20px);
  pointer-events: none;
}

.tagline {
  font-size: clamp(0.85rem, 2.5vw, 1rem);
  color: var(--muted);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  margin-top: 0.5rem;
  animation: fadeUp 0.8s 0.15s ease both;
}

.message {
  font-size: clamp(1rem, 3vw, 1.2rem);
  color: var(--text);
  opacity: 0.7;
  margin: 1.5rem 0 2.5rem;
  line-height: 1.6;
  animation: fadeUp 0.8s 0.25s ease both;
}

/* Game cards */
.games {
  display: flex;
  gap: 1rem;
  justify-content: center;
  animation: fadeUp 0.8s 0.35s ease both;
}

.game-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 1.2rem 2rem;
  cursor: pointer;
  transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
  flex: 1;
  max-width: 200px;
}
.game-card:hover {
  border-color: var(--accent);
  transform: translateY(-3px);
  box-shadow: 0 8px 30px var(--glow);
}
.game-card .icon { font-size: 1.8rem; margin-bottom: 0.5rem; }
.game-card .name {
  font-size: 0.9rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--accent);
}
.game-card .desc {
  font-size: 0.7rem;
  color: var(--muted);
  margin-top: 0.2rem;
}

/* Footer */
footer {
  position: fixed;
  bottom: 1rem;
  left: 0; right: 0;
  text-align: center;
  font-family: 'Space Mono', monospace;
  font-size: 0.65rem;
  color: var(--muted);
  z-index: 1;
  animation: fadeUp 0.8s 0.5s ease both;
}
footer a { color: var(--accent); text-decoration: none; }
footer a:hover { text-decoration: underline; }

/* Theme hint */
.theme-hint {
  position: fixed;
  bottom: 2.5rem;
  right: 1.5rem;
  font-family: 'Space Mono', monospace;
  font-size: 0.6rem;
  color: var(--muted);
  opacity: 0.5;
  z-index: 1;
  cursor: pointer;
}

/* Theme panel */
.theme-panel {
  position: fixed;
  bottom: -100px;
  left: 50%;
  transform: translateX(-50%);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 16px 16px 0 0;
  padding: 1.2rem 2rem;
  z-index: 100;
  transition: bottom 0.4s cubic-bezier(0.34,1.56,0.64,1);
  display: flex;
  align-items: center;
  gap: 1rem;
}
.theme-panel.open { bottom: 0; }
.theme-panel span {
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--muted);
  font-family: 'Space Mono', monospace;
}
.theme-dots { display: flex; gap: 0.6rem; }
.theme-dot {
  width: 22px; height: 22px;
  border-radius: 50%;
  cursor: pointer;
  border: 2px solid transparent;
  transition: transform 0.2s, border-color 0.2s;
}
.theme-dot:hover { transform: scale(1.2); }
.theme-dot.active { border-color: var(--text); }
.theme-dot[data-t="midnight"] { background: linear-gradient(135deg, #080c10, #4f9cf9); }
.theme-dot[data-t="carbon"]   { background: linear-gradient(135deg, #111, #e8e8e8); }
.theme-dot[data-t="forest"]   { background: linear-gradient(135deg, #060f0b, #2ecc71); }
.theme-dot[data-t="crimson"]  { background: linear-gradient(135deg, #0a0507, #e84060); }
.theme-dot[data-t="aurora"]   { background: linear-gradient(135deg, #07080f, #a855f7, #06b6d4); }

/* Modal */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.92);
  z-index: 200;
  display: none;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  backdrop-filter: blur(8px);
}
.modal-overlay.open { display: flex; }
.modal-box {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 0;
  overflow: hidden;
  position: relative;
  width: min(95vw, 520px);
}
.modal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem 1.2rem;
  border-bottom: 1px solid var(--border);
}
.modal-title {
  font-size: 0.85rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--accent);
  font-family: 'Space Mono', monospace;
}
.modal-close {
  background: none;
  border: none;
  color: var(--muted);
  font-size: 1.2rem;
  cursor: pointer;
  line-height: 1;
  transition: color 0.2s;
}
.modal-close:hover { color: var(--text); }
.modal-content { padding: 1.2rem; }

/* Snake game */
#snake-canvas {
  display: block;
  margin: 0 auto;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg);
}
.game-controls {
  text-align: center;
  margin-top: 0.8rem;
  font-family: 'Space Mono', monospace;
  font-size: 0.7rem;
  color: var(--muted);
}
.game-score {
  text-align: center;
  font-family: 'Space Mono', monospace;
  font-size: 0.85rem;
  color: var(--accent);
  margin-bottom: 0.6rem;
}
.game-btn {
  display: inline-block;
  background: var(--accent);
  color: var(--bg);
  border: none;
  border-radius: 6px;
  padding: 0.4rem 1rem;
  font-family: 'Space Mono', monospace;
  font-size: 0.75rem;
  cursor: pointer;
  font-weight: 700;
  margin-top: 0.5rem;
  transition: opacity 0.2s;
}
.game-btn:hover { opacity: 0.85; }

/* 2048 */
#g2048-board {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
  background: var(--border);
  border-radius: 10px;
  padding: 8px;
  max-width: 360px;
  margin: 0 auto;
  touch-action: none;
}
.tile {
  aspect-ratio: 1;
  border-radius: 6px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: 'Space Mono', monospace;
  font-weight: 700;
  font-size: clamp(0.8rem, 2.5vw, 1.1rem);
  transition: background 0.1s;
  min-height: 60px;
}

@keyframes fadeUp {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body data-theme="midnight">

<div class="container">
  <div class="error-code" id="err404" title="Hold to change theme">404</div>
  <div class="tagline" id="tagline">Page not found</div>
  <p class="message" id="message">This page doesn't exist.<br>Since you're here anyway…</p>
  <div class="games">
    <div class="game-card" onclick="openGame('snake')">
      <div class="icon">🐍</div>
      <div class="name">Snake</div>
      <div class="desc" id="desc-snake">Classic arcade</div>
    </div>
    <div class="game-card" onclick="openGame('2048')">
      <div class="icon">🔢</div>
      <div class="name">2048</div>
      <div class="desc" id="desc-2048">Merge tiles</div>
    </div>
  </div>
</div>

<div class="theme-hint" onclick="toggleTheme()">⚙ theme</div>

<div class="theme-panel" id="themePanel">
  <span>theme</span>
  <div class="theme-dots">
    <div class="theme-dot" data-t="midnight" onclick="setTheme('midnight')" title="Midnight"></div>
    <div class="theme-dot" data-t="carbon"   onclick="setTheme('carbon')"   title="Carbon"></div>
    <div class="theme-dot" data-t="forest"   onclick="setTheme('forest')"   title="Forest"></div>
    <div class="theme-dot" data-t="crimson"  onclick="setTheme('crimson')"  title="Crimson"></div>
    <div class="theme-dot" data-t="aurora"   onclick="setTheme('aurora')"   title="Aurora"></div>
  </div>
</div>

<!-- Snake Modal -->
<div class="modal-overlay" id="modal-snake">
  <div class="modal-box">
    <div class="modal-header">
      <span class="modal-title">🐍 Snake</span>
      <button class="modal-close" onclick="closeGame('snake')">✕</button>
    </div>
    <div class="modal-content">
      <div class="game-score">Score: <span id="snake-score">0</span></div>
      <canvas id="snake-canvas" width="400" height="400"></canvas>
      <div class="game-controls" id="snake-status">Press Space or tap to start</div>
      <div style="text-align:center"><button class="game-btn" onclick="startSnake()">New Game</button></div>
    </div>
  </div>
</div>

<!-- 2048 Modal -->
<div class="modal-overlay" id="modal-2048">
  <div class="modal-box">
    <div class="modal-header">
      <span class="modal-title">🔢 2048</span>
      <button class="modal-close" onclick="closeGame('2048')">✕</button>
    </div>
    <div class="modal-content">
      <div class="game-score">Score: <span id="g2048-score">0</span></div>
      <div id="g2048-board"></div>
      <div style="text-align:center"><button class="game-btn" onclick="init2048()">New Game</button></div>
      <div class="game-controls">Arrow keys or swipe to move</div>
    </div>
  </div>
</div>

<footer>
  © 2026 <a href="https://iav.lu" target="_blank">iav s.à r.l.</a> — your ip: <span id="visitor-ip">...</span>
</footer>

<script>
// ─── i18n ────────────────────────────────────────────────
const i18n = {
  en: { tagline: 'Page not found', message: "This page doesn't exist.<br>Since you're here anyway...", snake: 'Classic arcade', g2048: 'Merge tiles', start: 'Press Space or tap to start', gameover: 'Game Over - Space to restart' },
  fr: { tagline: 'Page introuvable', message: "Cette page n'existe pas.<br>Puisque vous êtes là...", snake: 'Arcade classique', g2048: 'Fusionnez les tuiles', start: 'Espace ou tap pour démarrer', gameover: 'Game Over - Espace pour rejouer' },
  de: { tagline: 'Seite nicht gefunden', message: "Diese Seite existiert nicht.<br>Da Sie schon hier sind...", snake: 'Klassisches Arcade', g2048: 'Kacheln zusammenführen', start: 'Leertaste oder tippen', gameover: 'Game Over - Leertaste zum Neustart' },
  pt: { tagline: 'Página não encontrada', message: "Esta página não existe.<br>Já que está aqui...", snake: 'Arcade clássico', g2048: 'Mesclar peças', start: 'Espaço ou toque para iniciar', gameover: 'Game Over - Espaço para reiniciar' }
};
const lang = (navigator.language || 'en').slice(0,2).toLowerCase();
const t = i18n[lang] || i18n.en;
document.getElementById('tagline').textContent = t.tagline;
document.getElementById('message').innerHTML = t.message;
document.getElementById('desc-snake').textContent = t.snake;
document.getElementById('desc-2048').textContent = t.g2048;

// ─── IP ─────────────────────────────────────────────────
fetch('https://api.ipify.org?format=json')
  .then(r=>r.json()).then(d=>{ document.getElementById('visitor-ip').textContent = d.ip; })
  .catch(()=>{ document.getElementById('visitor-ip').textContent = '—'; });

// ─── Theme ───────────────────────────────────────────────
let themePanelOpen = false;
let holdTimer = null;

function setTheme(name) {
  document.body.setAttribute('data-theme', name);
  localStorage.setItem('theme', name);
  document.querySelectorAll('.theme-dot').forEach(d => d.classList.toggle('active', d.dataset.t === name));
  setTimeout(() => { themePanelOpen = false; document.getElementById('themePanel').classList.remove('open'); }, 800);
}
function toggleTheme() {
  themePanelOpen = !themePanelOpen;
  document.getElementById('themePanel').classList.toggle('open', themePanelOpen);
}

// Hold on 404 to open theme
document.getElementById('err404').addEventListener('mousedown', () => { holdTimer = setTimeout(() => toggleTheme(), 600); });
document.getElementById('err404').addEventListener('mouseup', () => clearTimeout(holdTimer));
document.getElementById('err404').addEventListener('touchstart', () => { holdTimer = setTimeout(() => toggleTheme(), 600); });
document.getElementById('err404').addEventListener('touchend', () => clearTimeout(holdTimer));

// Init theme
const saved = localStorage.getItem('theme') || 'midnight';
setTheme(saved);

// ─── Modal ───────────────────────────────────────────────
function openGame(g) {
  document.getElementById('modal-'+g).classList.add('open');
  if (g==='snake') startSnake();
  if (g==='2048') init2048();
}
function closeGame(g) {
  document.getElementById('modal-'+g).classList.remove('open');
  if (g==='snake') { snakeRunning = false; if(snakeTimer) clearInterval(snakeTimer); }
}
document.querySelectorAll('.modal-overlay').forEach(m => {
  m.addEventListener('click', e => { if(e.target===m) closeGame(m.id.replace('modal-','')); });
});

// ─── SNAKE ───────────────────────────────────────────────
const canvas = document.getElementById('snake-canvas');
const ctx = canvas.getContext('2d');
const GRID = 20, COLS = 20, ROWS = 20;
let snake, dir, nextDir, food, snakeScore, snakeRunning, snakeTimer, snakeStarted;

function startSnake() {
  snake = [{x:10,y:10},{x:9,y:10},{x:8,y:10}];
  dir = {x:1,y:0}; nextDir = {x:1,y:0};
  snakeScore = 0; snakeRunning = false; snakeStarted = false;
  document.getElementById('snake-score').textContent = 0;
  document.getElementById('snake-status').textContent = t.start;
  placeFood();
  if(snakeTimer) clearInterval(snakeTimer);
  drawSnake();
}
function placeFood() {
  do { food={x:Math.floor(Math.random()*COLS),y:Math.floor(Math.random()*ROWS)}; }
  while(snake.some(s=>s.x===food.x&&s.y===food.y));
}
function drawSnake() {
  const style = getComputedStyle(document.body);
  const accent = style.getPropertyValue('--accent').trim();
  const bg = style.getPropertyValue('--bg').trim();
  const muted = style.getPropertyValue('--muted').trim();
  ctx.fillStyle = bg; ctx.fillRect(0,0,400,400);
  snake.forEach((s,i) => {
    ctx.fillStyle = i===0 ? accent : accent+'99';
    ctx.beginPath(); ctx.roundRect(s.x*GRID+1,s.y*GRID+1,GRID-2,GRID-2,3); ctx.fill();
  });
  ctx.fillStyle = '#ff6b6b';
  ctx.beginPath(); ctx.arc(food.x*GRID+GRID/2,food.y*GRID+GRID/2,GRID/2-2,0,Math.PI*2); ctx.fill();
}
function stepSnake() {
  dir = nextDir;
  const head = {x:snake[0].x+dir.x, y:snake[0].y+dir.y};
  if(head.x<0||head.x>=COLS||head.y<0||head.y>=ROWS||snake.some(s=>s.x===head.x&&s.y===head.y)) {
    snakeRunning = false; clearInterval(snakeTimer);
    document.getElementById('snake-status').textContent = t.gameover;
    return;
  }
  snake.unshift(head);
  if(head.x===food.x&&head.y===food.y) { snakeScore+=10; document.getElementById('snake-score').textContent=snakeScore; placeFood(); }
  else snake.pop();
  drawSnake();
}
document.addEventListener('keydown', e => {
  const map = {ArrowUp:{x:0,y:-1},ArrowDown:{x:0,y:1},ArrowLeft:{x:-1,y:0},ArrowRight:{x:1,y:0}};
  if(map[e.key]) { e.preventDefault(); const d=map[e.key]; if(d.x!=-dir.x||d.y!=-dir.y) nextDir=d; }
  if(e.code==='Space') {
    if(!snakeStarted||!snakeRunning) { snakeStarted=true; snakeRunning=true; document.getElementById('snake-status').textContent=''; if(snakeTimer) clearInterval(snakeTimer); snakeTimer=setInterval(stepSnake,120); }
  }
});
// Touch controls for snake
let touchStartX, touchStartY;
canvas.addEventListener('touchstart', e => { touchStartX=e.touches[0].clientX; touchStartY=e.touches[0].clientY; e.preventDefault();
  if(!snakeStarted||!snakeRunning){ snakeStarted=true; snakeRunning=true; document.getElementById('snake-status').textContent=''; if(snakeTimer) clearInterval(snakeTimer); snakeTimer=setInterval(stepSnake,120); }
},{passive:false});
canvas.addEventListener('touchend', e => {
  const dx=e.changedTouches[0].clientX-touchStartX, dy=e.changedTouches[0].clientY-touchStartY;
  if(Math.abs(dx)>Math.abs(dy)){nextDir=dx>0?{x:1,y:0}:{x:-1,y:0};}else{nextDir=dy>0?{x:0,y:1}:{x:0,y:-1};}
},{passive:false});

// ─── 2048 ────────────────────────────────────────────────
let board2048, score2048;
const COLORS = {0:'#1a2535',2:'#2d3748',4:'#374151',8:'#7c3aed',16:'#6d28d9',32:'#4f46e5',64:'#2563eb',128:'#0891b2',256:'#059669',512:'#d97706',1024:'#dc2626',2048:'#db2777'};

function init2048() {
  board2048 = Array(4).fill(null).map(()=>Array(4).fill(0));
  score2048 = 0; document.getElementById('g2048-score').textContent = 0;
  add2048Tile(); add2048Tile(); render2048();
}
function add2048Tile() {
  const empty = [];
  for(let r=0;r<4;r++) for(let c=0;c<4;c++) if(!board2048[r][c]) empty.push([r,c]);
  if(!empty.length) return;
  const [r,c] = empty[Math.floor(Math.random()*empty.length)];
  board2048[r][c] = Math.random()<0.9?2:4;
}
function render2048() {
  const bd = document.getElementById('g2048-board');
  bd.innerHTML = '';
  for(let r=0;r<4;r++) for(let c=0;c<4;c++) {
    const v = board2048[r][c];
    const tile = document.createElement('div');
    tile.className = 'tile';
    tile.style.background = COLORS[Math.min(v,2048)] || '#581c87';
    tile.style.color = v>4 ? '#fff' : '#9ca3af';
    tile.textContent = v||'';
    bd.appendChild(tile);
  }
}
function move2048(dir) {
  let moved = false;
  const rotate = m => m[0].map((_,i)=>m.map(r=>r[i]).reverse());
  const slideRow = row => {
    let r = row.filter(x=>x);
    for(let i=0;i<r.length-1;i++) { if(r[i]===r[i+1]){ r[i]*=2; score2048+=r[i]; r.splice(i+1,1); i++; } }
    while(r.length<4) r.push(0);
    return r;
  };
  let b = board2048.map(r=>[...r]);
  if(dir==='up') b=rotate(rotate(rotate(b)));
  if(dir==='right') b=b.map(r=>[...r].reverse());
  if(dir==='down') b=rotate(b);
  const nb = b.map(row=>{ const s=slideRow(row); if(s.join()!==row.join()) moved=true; return s; });
  let fb = nb;
  if(dir==='up') fb=rotate(fb);
  if(dir==='right') fb=fb.map(r=>[...r].reverse());
  if(dir==='down') fb=rotate(rotate(rotate(fb)));
  if(moved){ board2048=fb; add2048Tile(); document.getElementById('g2048-score').textContent=score2048; render2048(); }
}
document.addEventListener('keydown', e => {
  if(!document.getElementById('modal-2048').classList.contains('open')) return;
  const map = {ArrowUp:'up',ArrowDown:'down',ArrowLeft:'left',ArrowRight:'right'};
  if(map[e.key]) { e.preventDefault(); move2048(map[e.key]); }
});
// Touch for 2048
let t2x,t2y;
document.getElementById('g2048-board').addEventListener('touchstart',e=>{t2x=e.touches[0].clientX;t2y=e.touches[0].clientY;},{passive:true});
document.getElementById('g2048-board').addEventListener('touchend',e=>{
  const dx=e.changedTouches[0].clientX-t2x, dy=e.changedTouches[0].clientY-t2y;
  if(Math.abs(dx)>Math.abs(dy)){move2048(dx>0?'right':'left');}else{move2048(dy>0?'down':'up');}
},{passive:true});
</script>
</body>
</html>

Personnalisation rapide

Quelques lignes à modifier pour adapter la page à un autre contexte :

  • Lien footer : remplacer https://iav.lu par l’URL souhaitée
  • Couleurs des thèmes : modifier les variables CSS dans les blocs [data-theme="..."]
  • Langues : ajouter une entrée dans l’objet i18n avec le code ISO de la langue cible
  • Vitesse du Snake : modifier la valeur 120 (en millisecondes) dans setInterval(stepSnake, 120)