diff --git a/src/App.jsx b/src/App.jsx
index 5e86718f..14858740 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -80,6 +80,7 @@ const slugToFolder = {
'language-quiz': 'LanguageQuiz',
'letter-web': 'LetterWeb',
'letter-orbit': 'LetterOrbit',
+ 'letter-triangles': 'LetterTriangles',
'lights-out': 'LightsOut',
'lightup': 'Lightup',
'lits': 'LITS',
diff --git a/src/assets/icons/letter-triangles.svg b/src/assets/icons/letter-triangles.svg
new file mode 100644
index 00000000..23a1bf41
--- /dev/null
+++ b/src/assets/icons/letter-triangles.svg
@@ -0,0 +1,14 @@
+
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 994a004f..54069389 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -752,6 +752,14 @@
"gaveUp": "Solution Revealed",
"gaveUpMessage": "Review the route and try a new seed."
},
+ "letterTriangles": {
+ "title": "Letter Triangles",
+ "instructions": "Place all triangle tiles so each line spells the target word.",
+ "selectedHint": "Tile selected: click a board cell to place it.",
+ "boardCell": "Board cell {{index}}",
+ "tileLabel": "Tile {{id}}",
+ "lineN": "Line {{num}}"
+ },
"kana": {
"rules": "Draw exactly one single closed loop by connecting centers of orthogonally adjacent cells.\n\nHow to play:\n1) Click between two neighboring cell centers to add/remove a segment.\n2) The final loop cannot branch or cross itself: every loop cell has exactly two connected segments.\n3) The loop is one continuous cycle (not multiple smaller loops).\n4) Every kana clue cell (あ, え, う, い, お) must be on the loop.\n5) If two clues show the same kana, the loop must pass straight through both in the same orientation (horizontal/vertical) and with the same distances to the next turn on each side.\n6) Different kana must have different orientation-and-distance patterns.\n\nTip: Use the Hint button to view each kana’s required orientation and turn distances for this puzzle.",
"won": "Perfect loop! You solved Kana.",
diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json
index afb65c6a..ea50ccb7 100644
--- a/src/i18n/locales/es.json
+++ b/src/i18n/locales/es.json
@@ -752,6 +752,14 @@
"gaveUp": "Solución revelada",
"gaveUpMessage": "Revisa la ruta e intenta con una nueva semilla."
},
+ "letterTriangles": {
+ "title": "Triángulos de Letras",
+ "instructions": "Coloca todas las fichas triangulares para que cada línea forme la palabra objetivo.",
+ "selectedHint": "Ficha seleccionada: haz clic en una celda para colocarla.",
+ "boardCell": "Celda {{index}}",
+ "tileLabel": "Ficha {{id}}",
+ "lineN": "Línea {{num}}"
+ },
"kana": {
"rules": "Dibuja exactamente un único bucle cerrado conectando centros de celdas ortogonalmente adyacentes.\n\nCómo jugar:\n1) Haz clic entre dos centros vecinos para añadir o quitar un segmento.\n2) El bucle final no puede ramificarse ni cruzarse: cada celda del bucle tiene exactamente dos segmentos conectados.\n3) El bucle debe ser un solo ciclo continuo (no varios bucles pequeños).\n4) Todas las celdas con pistas kana (あ, え, う, い, お) deben pertenecer al bucle.\n5) Si dos pistas muestran el mismo kana, el bucle debe pasar recto por ambas con la misma orientación (horizontal/vertical) y con las mismas distancias hasta el siguiente giro a cada lado.\n6) Kana diferentes deben tener patrones distintos de orientación y distancias.\n\nConsejo: usa el botón de Pista para ver la orientación y las distancias de giro requeridas para cada kana en este rompecabezas.",
"won": "¡Bucle perfecto! Resolviste Kana.",
diff --git a/src/packs/word-games/manifest.js b/src/packs/word-games/manifest.js
index 019c8ed2..becc8daa 100644
--- a/src/packs/word-games/manifest.js
+++ b/src/packs/word-games/manifest.js
@@ -22,6 +22,7 @@ import pyramidIcon from '../../assets/icons/pyramid.svg';
import wordtilesIcon from '../../assets/icons/wordtiles.svg';
import letterwebIcon from '../../assets/icons/letterweb.svg';
import letterorbitIcon from '../../assets/icons/letter-orbit.svg';
+import letterTrianglesIcon from '../../assets/icons/letter-triangles.svg';
import flipquotesIcon from '../../assets/icons/flipquotes.svg';
import cryptogramIcon from '../../assets/icons/cryptogram.svg';
import phraseguessIcon from '../../assets/icons/phraseguess.svg';
@@ -230,6 +231,17 @@ export const categories = [
component: () => import('../../pages/LetterOrbit'),
lastModified: 1769540742000
},
+ {
+ title: 'Letter Triangles',
+ slug: 'letter-triangles',
+ description: 'Arrange triangle tiles so every line spells its target word.',
+ icon: letterTrianglesIcon,
+ emojiIcon: '🔺',
+ colors: { primary: '#0ea5e9', secondary: '#6366f1' },
+ gradient: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)',
+ component: () => import('../../pages/LetterTriangles'),
+ lastModified: 1772400000000
+ },
{
title: 'Word Search',
slug: 'word-search',
diff --git a/src/pages/LetterTriangles/LetterTriangles.jsx b/src/pages/LetterTriangles/LetterTriangles.jsx
new file mode 100644
index 00000000..896545a6
--- /dev/null
+++ b/src/pages/LetterTriangles/LetterTriangles.jsx
@@ -0,0 +1,304 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import GameHeader from '../../components/GameHeader';
+import SeedDisplay, { useSeed } from '../../components/SeedDisplay/SeedDisplay';
+import GiveUpButton from '../../components/GiveUpButton';
+import GameResult from '../../components/GameResult';
+import {
+ CELL_COUNT,
+ generateLetterTrianglesPuzzle,
+ buildLineWordsFromPlacement,
+ isSolvedPlacement,
+ isDownCell,
+ getTileLetterForLine,
+} from './letterTrianglesLogic';
+import styles from './LetterTriangles.module.css';
+
+const BOARD_ROWS = [1, 3, 5];
+
+export default function LetterTriangles() {
+ const { t } = useTranslation();
+ const { seed, setSeed, newSeed } = useSeed('letter-triangles', () => Math.floor(Math.random() * 1000000));
+
+ const [puzzle, setPuzzle] = useState(() => generateLetterTrianglesPuzzle(seed));
+ const [placement, setPlacement] = useState(() => Array(CELL_COUNT).fill(null));
+ const [selectedTileId, setSelectedTileId] = useState(null);
+ const [gaveUp, setGaveUp] = useState(false);
+ const [rotations, setRotations] = useState(() => ({}));
+ const [draggingTileId, setDraggingTileId] = useState(null);
+
+ useEffect(() => {
+ const next = generateLetterTrianglesPuzzle(seed);
+ setPuzzle(next);
+ setPlacement(Array(CELL_COUNT).fill(null));
+ setSelectedTileId(null);
+ setGaveUp(false);
+ setRotations(next.initialRotations || {});
+ }, [seed]);
+
+ const tileById = useMemo(() => new Map(puzzle.solvedTiles.map((tile) => [tile.id, tile])), [puzzle]);
+
+ const trayTileIds = useMemo(() => {
+ const placed = new Set(placement.filter((id) => id !== null));
+ return puzzle.shuffledTiles.map((tile) => tile.id).filter((id) => !placed.has(id));
+ }, [placement, puzzle]);
+
+ const lineWords = useMemo(() => buildLineWordsFromPlacement(placement, tileById, rotations), [placement, tileById, rotations]);
+ const solved = isSolvedPlacement(placement, rotations);
+
+ const rotateTile = useCallback((tileId) => {
+ if (gaveUp || solved) return;
+ setRotations((prev) => ({
+ ...prev,
+ [tileId]: ((prev[tileId] ?? 0) + 1) % 3,
+ }));
+ }, [gaveUp, solved]);
+
+ const moveTileToCell = useCallback((tileId, targetCellIndex) => {
+ setPlacement((prev) => {
+ const next = [...prev];
+ const fromIndex = next.findIndex((id) => id === tileId);
+ const targetOccupant = next[targetCellIndex];
+
+ if (fromIndex !== -1) {
+ next[fromIndex] = targetOccupant;
+ }
+
+ next[targetCellIndex] = tileId;
+ return next;
+ });
+ }, []);
+
+ const returnTileToTray = useCallback((tileId) => {
+ setPlacement((prev) => {
+ const next = [...prev];
+ const idx = next.findIndex((id) => id === tileId);
+ if (idx !== -1) next[idx] = null;
+ return next;
+ });
+ }, []);
+
+ const handleCellClick = useCallback((cellIndex) => {
+ if (gaveUp || solved) return;
+
+ setPlacement((prev) => {
+ const next = [...prev];
+ const occupant = next[cellIndex];
+
+ if (selectedTileId === null) {
+ if (occupant !== null) {
+ next[cellIndex] = null;
+ setSelectedTileId(occupant);
+ }
+ return next;
+ }
+
+ const fromIndex = next.findIndex((id) => id === selectedTileId);
+ if (fromIndex !== -1) next[fromIndex] = occupant;
+ next[cellIndex] = selectedTileId;
+ setSelectedTileId(occupant);
+ return next;
+ });
+ }, [selectedTileId, gaveUp, solved]);
+
+ const handleTileClick = useCallback((tileId) => {
+ if (gaveUp || solved) return;
+ setSelectedTileId((current) => (current === tileId ? null : tileId));
+ }, [gaveUp, solved]);
+
+ const handleGiveUp = useCallback(() => {
+ setPlacement(Array.from({ length: CELL_COUNT }, (_, index) => index));
+ setSelectedTileId(null);
+ setGaveUp(true);
+ setRotations(() => {
+ const reset = {};
+ for (let i = 0; i < CELL_COUNT; i++) reset[i] = 0;
+ return reset;
+ });
+ }, []);
+
+ const handleDragStart = useCallback((tileId, event) => {
+ if (gaveUp || solved) return;
+ event.dataTransfer.effectAllowed = 'move';
+ event.dataTransfer.setData('text/plain', String(tileId));
+ setDraggingTileId(tileId);
+ }, [gaveUp, solved]);
+
+ const handleDragEnd = useCallback(() => {
+ setDraggingTileId(null);
+ }, []);
+
+ const handleDropOnCell = useCallback((cellIndex, event) => {
+ event.preventDefault();
+ if (gaveUp || solved) return;
+ const tileId = Number(event.dataTransfer.getData('text/plain'));
+ if (Number.isNaN(tileId)) return;
+ moveTileToCell(tileId, cellIndex);
+ setSelectedTileId(null);
+ setDraggingTileId(null);
+ }, [gaveUp, solved, moveTileToCell]);
+
+ const handleDropOnTray = useCallback((event) => {
+ event.preventDefault();
+ if (gaveUp || solved) return;
+ const tileId = Number(event.dataTransfer.getData('text/plain'));
+ if (Number.isNaN(tileId)) return;
+ returnTileToTray(tileId);
+ setSelectedTileId(null);
+ setDraggingTileId(null);
+ }, [gaveUp, solved, returnTileToTray]);
+
+ const renderTile = (tileId, cellIndex = null) => {
+ if (tileId === null || tileId === undefined) return null;
+ const tile = tileById.get(tileId);
+ const rotation = rotations[tileId] ?? 0;
+ const isDown = cellIndex !== null && isDownCell(cellIndex);
+ const a = getTileLetterForLine(tile, 'A', rotation, cellIndex);
+ const b = getTileLetterForLine(tile, 'B', rotation, cellIndex);
+ const c = getTileLetterForLine(tile, 'C', rotation, cellIndex);
+
+ return (
+
+ {a}
+ {b}
+ {c}
+
+ );
+ };
+
+ let cellIndex = 0;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {(solved || gaveUp) && (
+
+ )}
+
+
+ {BOARD_ROWS.map((rowSize, rowIdx) => (
+
+ {Array.from({ length: rowSize }).map(() => {
+ const current = cellIndex++;
+ const tileId = placement[current];
+ return (
+
event.preventDefault()}
+ onDrop={(event) => handleDropOnCell(current, event)}
+ >
+
+
+ {tileId !== null && tileId !== undefined && (
+ <>
+
+
handleDragStart(tileId, event)}
+ onDragEnd={handleDragEnd}
+ aria-label={t('letterTriangles.dragTile', { defaultValue: 'Drag tile' })}
+ title={t('letterTriangles.dragTile', { defaultValue: 'Drag tile' })}
+ >
+ ⋮⋮
+
+ >
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {selectedTileId !== null && (
+
+ {t('letterTriangles.selectedHint', { defaultValue: 'Tile selected: click a board cell to place it.' })}
+
+ )}
+
+
event.preventDefault()} onDrop={handleDropOnTray}>
+ {trayTileIds.map((tileId) => (
+
handleDragStart(tileId, event)}
+ onDragEnd={handleDragEnd}
+ >
+
+
+
+ ))}
+
+
+
+ {puzzle.targetWords.map((targetWord, idx) => {
+ const currentWord = lineWords[idx];
+ const complete = !currentWord.includes('_');
+ const correct = currentWord === targetWord;
+
+ return (
+
+ {t('letterTriangles.lineN', { defaultValue: 'Line {{num}}', num: idx + 1 })}
+ {currentWord}
+
+ {targetWord}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/pages/LetterTriangles/LetterTriangles.module.css b/src/pages/LetterTriangles/LetterTriangles.module.css
new file mode 100644
index 00000000..22e0f4df
--- /dev/null
+++ b/src/pages/LetterTriangles/LetterTriangles.module.css
@@ -0,0 +1,186 @@
+.container {
+ max-width: 980px;
+ margin: 0 auto;
+ padding: 1rem;
+ background: #ffffff;
+ border-radius: 14px;
+ border: 1px solid #dbe4ee;
+}
+
+.topBar {
+ display: flex;
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+.actionButton {
+ background: #f8fafc;
+ color: #0f172a;
+ border: 1px solid #cbd5e1;
+ border-radius: 8px;
+ padding: 0.45rem 0.8rem;
+ cursor: pointer;
+}
+
+.board {
+ margin: 1rem 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ border: 2px solid #475569;
+ border-radius: 12px;
+ padding: 0.75rem;
+ background: #0f172a;
+}
+
+.boardRow {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.cell,
+.trayTile {
+ width: 88px;
+ height: 78px;
+ border: 2px solid #64748b;
+ border-radius: 8px;
+ background: #0f172a;
+ padding: 0;
+ position: relative;
+}
+
+.cellButton,
+.trayMainButton {
+ border: none;
+ background: transparent;
+ width: 100%;
+ height: 100%;
+ padding: 0;
+ cursor: pointer;
+}
+
+.dragTarget {
+ border-color: #93c5fd;
+}
+
+.tileFace {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ background: #ffffff;
+ clip-path: polygon(50% 4%, 4% 96%, 96% 96%);
+ border: 2px solid #94a3b8;
+ transition: transform 120ms ease;
+}
+
+
+
+.down {
+ transform: rotate(180deg);
+}
+
+
+.letter {
+ position: absolute;
+ color: #0f172a;
+ font-weight: 700;
+ font-size: 1rem;
+}
+
+.top {
+ top: 10%;
+ left: 50%;
+ transform: translateX(-50%);
+}
+
+.left {
+ bottom: 14%;
+ left: 25%;
+}
+
+.right {
+ bottom: 14%;
+ right: 25%;
+}
+
+.down .top {
+ transform: translateX(-50%) rotate(180deg);
+}
+
+.down .left,
+.down .right {
+ transform: rotate(180deg);
+}
+
+.rotateButton {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 22px;
+ height: 22px;
+ border: 1px solid #94a3b8;
+ border-radius: 999px;
+ background: #e2e8f0;
+ font-size: 0.85rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.dragHandle {
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ color: #cbd5e1;
+ font-size: 0.8rem;
+ letter-spacing: -1px;
+ user-select: none;
+ cursor: grab;
+}
+
+.tray {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
+ gap: 0.4rem;
+ margin-bottom: 1rem;
+ min-height: 84px;
+ border: 1px dashed #64748b;
+ padding: 0.35rem;
+ border-radius: 8px;
+}
+
+.selected .tileFace {
+ border-color: #fbbf24;
+ box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.3);
+}
+
+.selectionHint {
+ margin-bottom: 0.75rem;
+ color: #1d4ed8;
+}
+
+.linesPanel {
+ border: 1px solid #cbd5e1;
+ border-radius: 10px;
+ padding: 0.75rem;
+ background: #f8fafc;
+}
+
+.lineRow {
+ display: grid;
+ grid-template-columns: 84px 1fr 1fr;
+ gap: 0.5rem;
+ align-items: center;
+ margin-bottom: 0.3rem;
+}
+
+.lineLabel { color: #475569; }
+.currentWord { color: #0f172a; }
+.correct { color: #22c55e; font-weight: 700; }
+.incorrect { color: #f97316; font-weight: 700; }
+.pending { color: #334155; }
+
+.cell:hover .tileFace,
+.trayTile:hover .tileFace {
+ border-color: #3b82f6;
+}
diff --git a/src/pages/LetterTriangles/LetterTriangles.test.js b/src/pages/LetterTriangles/LetterTriangles.test.js
new file mode 100644
index 00000000..d284cfd4
--- /dev/null
+++ b/src/pages/LetterTriangles/LetterTriangles.test.js
@@ -0,0 +1,89 @@
+import { describe, it, expect } from 'vitest';
+import { isValidWord, isCommonWord } from '../../data/wordUtils';
+import {
+ CELL_COUNT,
+ generateLetterTrianglesPuzzle,
+ buildLineWordsFromPlacement,
+ isSolvedPlacement,
+ getTileLetterForLine,
+} from './letterTrianglesLogic';
+
+describe('Letter Triangles generator', () => {
+ it('creates deterministic puzzles for the same seed', () => {
+ const a = generateLetterTrianglesPuzzle(123456);
+ const b = generateLetterTrianglesPuzzle(123456);
+
+ expect(a.targetWords).toEqual(b.targetWords);
+ expect(a.solvedTiles).toEqual(b.solvedTiles);
+ expect(a.shuffledTiles).toEqual(b.shuffledTiles);
+ expect(a.initialRotations).toEqual(b.initialRotations);
+ });
+
+ it('seeds each tile with a random initial rotation', () => {
+ const puzzle = generateLetterTrianglesPuzzle(9876);
+ expect(Object.keys(puzzle.initialRotations)).toHaveLength(CELL_COUNT);
+ Object.values(puzzle.initialRotations).forEach((rotation) => {
+ expect(rotation).toBeGreaterThanOrEqual(0);
+ expect(rotation).toBeLessThanOrEqual(2);
+ });
+ });
+
+ it('generates valid English words with configured line lengths', () => {
+ const puzzle = generateLetterTrianglesPuzzle(99);
+
+ const expectedLengths = [1, 2, 4, 5, 7, 8];
+ expect(puzzle.targetWords).toHaveLength(expectedLengths.length);
+ puzzle.targetWords.forEach((word, index) => {
+ expect(word).toMatch(/^[A-Z]+$/);
+ expect(word.length).toBe(expectedLengths[index]);
+
+ if (word.length >= 3) {
+ expect(isValidWord(word)).toBe(true);
+ }
+ });
+ });
+
+ it('prefers common words for most rows', () => {
+ const puzzle = generateLetterTrianglesPuzzle(314159);
+ const longRows = puzzle.targetWords.filter((word) => word.length >= 3);
+ const commonCount = longRows.filter((word) => isCommonWord(word)).length;
+
+ expect(commonCount).toBeGreaterThanOrEqual(2);
+ });
+
+ it('solved placement reconstructs exactly the target line words when rotations are reset', () => {
+ const puzzle = generateLetterTrianglesPuzzle(42);
+ const solvedPlacement = Array.from({ length: CELL_COUNT }, (_, index) => index);
+ const zeroRotations = Object.fromEntries(solvedPlacement.map((id) => [id, 0]));
+ const tileById = new Map(puzzle.solvedTiles.map((tile) => [tile.id, tile]));
+
+ const lines = buildLineWordsFromPlacement(solvedPlacement, tileById, zeroRotations);
+ expect(lines).toEqual(puzzle.targetWords);
+ expect(isSolvedPlacement(solvedPlacement, zeroRotations)).toBe(true);
+ });
+
+ it('applies upside-down cell reversal before rotation lookup', () => {
+ const tile = { letters: ['A', 'B', 'C'] };
+
+ // Cell 2 is down: display corner B maps to physical C.
+ // With one clockwise turn, display B should use previous corner -> B.
+ expect(getTileLetterForLine(tile, 'B', 1, 2)).toBe('B');
+ });
+
+ it('rotates letter positions without mutating source letter order', () => {
+ const tile = { letters: ['A', 'B', 'C'] };
+
+ expect(getTileLetterForLine(tile, 'A', 0, 0)).toBe('A');
+ expect(getTileLetterForLine(tile, 'A', 1, 0)).toBe('C');
+ expect(getTileLetterForLine(tile, 'A', 2, 0)).toBe('B');
+ expect(tile.letters).toEqual(['A', 'B', 'C']);
+ });
+
+ it('unsolved placements or rotations are never falsely considered solved', () => {
+ const placement = [0, 1, 2, 3, 4, 5, 6, 8, 7];
+ expect(isSolvedPlacement(placement)).toBe(false);
+
+ const solvedPlacement = Array.from({ length: CELL_COUNT }, (_, index) => index);
+ expect(isSolvedPlacement(solvedPlacement, { 0: 1 })).toBe(false);
+ });
+});
diff --git a/src/pages/LetterTriangles/README.md b/src/pages/LetterTriangles/README.md
new file mode 100644
index 00000000..947320c6
--- /dev/null
+++ b/src/pages/LetterTriangles/README.md
@@ -0,0 +1,19 @@
+# Letter Triangles
+
+Arrange 9 triangular letter tiles into a large triangle.
+
+Each tile has three letters. Place the tiles so that every line shown under the board spells the target word.
+
+## Rules
+
+- Place all tiles from the tray into the 9 board cells.
+- A puzzle is solved when every tile is in the correct position.
+- You can click a placed tile to pick it back up.
+- Use **Give Up** to reveal the solution.
+
+## Puzzle generation
+
+- Uses a seeded generator for reproducibility.
+- Chooses English words with lengths 1, 2, 4, 5, 7, and 8.
+- Writes those words into overlapping line definitions.
+- Builds tile letters from the solved layout and shuffles the tiles.
diff --git a/src/pages/LetterTriangles/index.js b/src/pages/LetterTriangles/index.js
new file mode 100644
index 00000000..44774133
--- /dev/null
+++ b/src/pages/LetterTriangles/index.js
@@ -0,0 +1 @@
+export { default } from './LetterTriangles';
diff --git a/src/pages/LetterTriangles/letterTrianglesLogic.js b/src/pages/LetterTriangles/letterTrianglesLogic.js
new file mode 100644
index 00000000..16fb35b7
--- /dev/null
+++ b/src/pages/LetterTriangles/letterTrianglesLogic.js
@@ -0,0 +1,153 @@
+import { createSeededRandom, seededShuffleArray, getWordsByLength, isCommonWord } from '../../data/wordUtils';
+
+export const CELL_COUNT = 9;
+export const CORNERS = ['A', 'B', 'C'];
+
+export const LINE_DEFINITIONS = [
+ [{ cell: 0, corner: 'A' }],
+ [{ cell: 0, corner: 'B' }, { cell: 0, corner: 'C' }],
+ [{ cell: 1, corner: 'A' }, { cell: 2, corner: 'B' }, { cell: 2, corner: 'C' }, { cell: 3, corner: 'A' }],
+ [{ cell: 1, corner: 'B' }, { cell: 1, corner: 'C' }, { cell: 2, corner: 'A' }, { cell: 3, corner: 'B' }, { cell: 3, corner: 'C' }],
+ [{ cell: 4, corner: 'A' }, { cell: 5, corner: 'B' }, { cell: 5, corner: 'C' }, { cell: 6, corner: 'A' }, { cell: 7, corner: 'B' }, { cell: 7, corner: 'C' }, { cell: 8, corner: 'A' }],
+ [{ cell: 4, corner: 'B' }, { cell: 4, corner: 'C' }, { cell: 5, corner: 'A' }, { cell: 6, corner: 'B' }, { cell: 6, corner: 'C' }, { cell: 7, corner: 'A' }, { cell: 8, corner: 'B' }, { cell: 8, corner: 'C' }],
+];
+
+const ONE_LETTER_WORDS = ['A', 'I'];
+const COMMON_TWO_LETTER_WORDS = [
+ 'AM', 'AN', 'AS', 'AT', 'BE', 'BY', 'DO', 'GO', 'HE', 'IF', 'IN', 'IS', 'IT', 'ME', 'MY',
+ 'NO', 'OF', 'ON', 'OR', 'OX', 'SO', 'TO', 'UP', 'US', 'WE'
+];
+
+const WORD_POOL_CACHE = new Map();
+const DOWN_CELLS = new Set([2, 5, 7]);
+
+function slotKey(cell, corner) {
+ return `${cell}:${corner}`;
+}
+
+function getWordPool(length) {
+ if (WORD_POOL_CACHE.has(length)) return WORD_POOL_CACHE.get(length);
+
+ let words;
+ if (length === 1) words = ONE_LETTER_WORDS;
+ else if (length === 2) words = COMMON_TWO_LETTER_WORDS;
+ else {
+ words = getWordsByLength(length)
+ .map((word) => word.toUpperCase())
+ .filter((word) => /^[A-Z]+$/.test(word));
+ }
+
+ WORD_POOL_CACHE.set(length, words);
+ return words;
+}
+
+function compatibleWithPattern(word, pattern) {
+ for (let i = 0; i < pattern.length; i++) {
+ if (pattern[i] && pattern[i] !== word[i]) return false;
+ }
+ return true;
+}
+
+export function isDownCell(cell) {
+ return DOWN_CELLS.has(cell);
+}
+
+export function getPhysicalCorner(cell, displayCorner) {
+ if (!isDownCell(cell)) return displayCorner;
+ if (displayCorner === 'B') return 'C';
+ if (displayCorner === 'C') return 'B';
+ return displayCorner;
+}
+
+export function getRotatedCornerIndex(displayCorner, rotation = 0) {
+ const displayIndex = CORNERS.indexOf(displayCorner);
+ const normalized = ((rotation % 3) + 3) % 3;
+ return (displayIndex - normalized + 3) % 3;
+}
+
+export function getTileLetterForLine(tile, displayCorner, rotation = 0, cellIndex = null) {
+ const effectiveCorner = cellIndex === null ? displayCorner : getPhysicalCorner(cellIndex, displayCorner);
+ const cornerIndex = getRotatedCornerIndex(effectiveCorner, rotation);
+ return tile?.letters[cornerIndex] ?? '_';
+}
+
+function generateAttempt(seed, preferCommon) {
+ const random = createSeededRandom(seed);
+ const pools = LINE_DEFINITIONS.map((line) => seededShuffleArray([...getWordPool(line.length)], random));
+
+ const assignedSlots = new Map();
+ const usedWords = new Set();
+ const chosenWords = [];
+
+ for (let lineIndex = 0; lineIndex < LINE_DEFINITIONS.length; lineIndex++) {
+ const line = LINE_DEFINITIONS[lineIndex];
+ const pattern = line.map(({ cell, corner }) => assignedSlots.get(slotKey(cell, getPhysicalCorner(cell, corner))) || null);
+
+ const matching = [];
+ const matchingCommon = [];
+
+ for (const word of pools[lineIndex]) {
+ if (usedWords.has(word) || !compatibleWithPattern(word, pattern)) continue;
+ matching.push(word);
+ if (word.length <= 2 || isCommonWord(word)) matchingCommon.push(word);
+ if (matching.length >= 1200) break;
+ }
+
+ const candidatePool = preferCommon && matchingCommon.length > 0 ? matchingCommon : matching;
+ if (candidatePool.length === 0) return null;
+
+ const chosen = candidatePool[Math.floor(random() * candidatePool.length)];
+ chosenWords.push(chosen);
+ usedWords.add(chosen);
+
+ for (let i = 0; i < line.length; i++) {
+ const key = slotKey(line[i].cell, getPhysicalCorner(line[i].cell, line[i].corner));
+ if (!assignedSlots.has(key)) assignedSlots.set(key, chosen[i]);
+ }
+ }
+
+ const solvedTiles = Array.from({ length: CELL_COUNT }, (_, cell) => ({
+ id: cell,
+ letters: CORNERS.map((corner) => assignedSlots.get(slotKey(cell, corner))),
+ }));
+
+ const initialRotations = {};
+ for (const tile of solvedTiles) {
+ initialRotations[tile.id] = Math.floor(random() * 3);
+ }
+
+ return {
+ targetWords: chosenWords,
+ solvedTiles,
+ shuffledTiles: seededShuffleArray([...solvedTiles], random),
+ initialRotations,
+ };
+}
+
+export function generateLetterTrianglesPuzzle(seed) {
+ for (let attempt = 0; attempt < 80; attempt++) {
+ const generated = generateAttempt(seed + attempt * 7919, true);
+ if (generated) return { seed, ...generated };
+ }
+
+ for (let attempt = 0; attempt < 120; attempt++) {
+ const generated = generateAttempt(seed + 500000 + attempt * 1543, false);
+ if (generated) return { seed, ...generated };
+ }
+
+ throw new Error('Failed to generate Letter Triangles puzzle using valid words');
+}
+
+export function buildLineWordsFromPlacement(placement, tileById, rotations = {}) {
+ return LINE_DEFINITIONS.map((line) => line.map(({ cell, corner }) => {
+ const tileId = placement[cell];
+ if (tileId === null || tileId === undefined) return '_';
+ const tile = tileById.get(tileId);
+ const rotation = rotations[tileId] ?? 0;
+ return getTileLetterForLine(tile, corner, rotation, cell);
+ }).join(''));
+}
+
+export function isSolvedPlacement(placement, rotations = {}) {
+ return placement.every((tileId, cellIndex) => tileId === cellIndex && ((rotations[tileId] ?? 0) % 3) === 0);
+}
diff --git a/src/tests/gameModuleImports.test.js b/src/tests/gameModuleImports.test.js
index ba98900f..5aefb4fe 100644
--- a/src/tests/gameModuleImports.test.js
+++ b/src/tests/gameModuleImports.test.js
@@ -74,6 +74,7 @@ const GAME_MODULES = {
'language-quiz': 'LanguageQuiz',
'letter-web': 'LetterWeb',
'letter-orbit': 'LetterOrbit',
+ 'letter-triangles': 'LetterTriangles',
'lights-out': 'LightsOut',
'lightup': 'Lightup',
'lits': 'LITS',
diff --git a/src/tests/gameReadmeCheck.test.js b/src/tests/gameReadmeCheck.test.js
index b528cad2..9ddfd106 100644
--- a/src/tests/gameReadmeCheck.test.js
+++ b/src/tests/gameReadmeCheck.test.js
@@ -80,6 +80,7 @@ describe('Game README Files', () => {
'language-quiz': 'LanguageQuiz',
'letter-web': 'LetterWeb',
'letter-orbit': 'LetterOrbit',
+ 'letter-triangles': 'LetterTriangles',
'lights-out': 'LightsOut',
'lightup': 'Lightup',
'lits': 'LITS',