<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Kupu Rapu - Te Reo Māori Word Game</title>
<script src="https://cdn.tailwindcss.com"></script>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap');
font-family: 'Inter', sans-serif;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
background-color: rgba(255, 255, 255, 0.1);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(10px);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
@media (min-width: 768px) {
background-color: rgba(255, 255, 255, 0.2);
text-transform: uppercase;
.word-builder .built-letter {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
animation: popIn 0.2s cubic-bezier(0.68, -0.55, 0.27, 1.55);
0% { transform: scale(0); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
grid-template-columns: repeat(6, 1fr);
background-color: rgba(255, 255, 255, 0.2);
font-size: clamp(0.9rem, 3.5vw, 1.8rem);
transition: transform 0.2s, box-shadow 0.2s, opacity 0.3s, top 0.4s ease-out;
text-transform: uppercase;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
box-shadow: 0 6px 10px rgba(0, 0, 0, 0.2);
outline: 3px solid white;
background-color: rgba(0, 0, 0, 0.3);
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
background-color: #6a1a7c;
transition: transform 0.2s, background-color 0.2s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
background-color: #7d2693;
background-color: rgba(0, 0, 0, 0.2);
justify-content: flex-start;
justify-content: space-between;
transition: background-color 0.3s;
.word-item.bg-red-400 { background-color: #f87171; }
.tile.bg-red-400 { background-color: #f87171; }
.word-item.bg-orange-400 { background-color: #fb923c; }
.tile.bg-orange-400 { background-color: #fb923c; }
.word-item.bg-yellow-400 { background-color: #facc15; }
.tile.bg-yellow-400 { background-color: #facc15; }
.word-item.bg-green-400 { background-color: #4ade80; }
.tile.bg-green-400 { background-color: #4ade80; }
.word-item.bg-blue-400 { background-color: #60a5fa; }
.tile.bg-blue-400 { background-color: #60a5fa; }
.word-item.bg-purple-400 { background-color: #a78bfa; }
.tile.bg-purple-400 { background-color: #a78bfa; }
.text-black { color: black; }
<h1 class="title">Kupu Rapu</h1>
<p class="subtitle">Drag to connect adjacent letters to find words!</p>
<div class="main-content">
<div id="game-container" class="game-section">
<div id="word-builder" class="word-builder"></div>
<div id="grid-container" class="grid"></div>
<div id="message-box" class="message-box opacity-0 transition-opacity duration-300"></div>
<div id="win-screen" class="hidden flex-col items-center justify-center">
<div class="win-message">
Ka Pai! <span>You found all the words!</span>
<button id="play-again-btn">Play Again</button>
<div class="word-list-section md:block hidden">
<div class="word-list-heading">Words to find:</div>
<div id="word-list" class="word-list"></div>
document.addEventListener('DOMContentLoaded', () => {
const gridContainer = document.getElementById('grid-container');
const messageBox = document.getElementById('message-box');
const winScreen = document.getElementById('win-screen');
const gameContainer = document.getElementById('game-container');
const playAgainBtn = document.getElementById('play-again-btn');
const wordListEl = document.getElementById('word-list');
const wordBuilderEl = document.getElementById('word-builder');
const FIND_COUNT_PER_WORD = 2;
let foundWordCounts = {};
let wordPlacementCounts = {};
const letters = ['a', 'e', 'h', 'i', 'k', 'm', 'n', 'o', 'p', 'r', 't', 'u', 'w'];
const assignedColors = ['bg-yellow-400', 'bg-red-400', 'bg-orange-400', 'bg-blue-400', 'bg-green-400'];
'hāpu': 'pregnant, sub-tribe',
'teina': 'younger sibling',
'kōrero': 'speak, story',
'tīkanga': 'customs, protocols',
'mātauranga': 'knowledge',
'karakia': 'prayer, incantation',
'whenua': 'land, placenta',
'ngākau': 'heart, feelings',
'mārama': 'moon, clarity',
'rongoā': 'medicine, remedy',
function shuffle(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
function getRandomWords(count) {
const words = Object.keys(fullWordList).filter(word => word.length <= GRID_SIZE);
return words.slice(0, count);
function createTile(row, col, letter, color) {
const tile = document.createElement('div');
tile.classList.add('tile', color, 'text-black');
tile.dataset.letter = letter;
tile.innerHTML = `<span>${letter.toUpperCase()}</span>`;
function displayWordList() {
wordListEl.innerHTML = '';
wordsToFind.forEach(word => {
const count = foundWordCounts[word] || 0;
const wordItem = document.createElement('div');
wordItem.classList.add('word-item', wordColors[word] || 'bg-purple-400');
const wordText = document.createElement('span');
wordText.classList.add('word-item-text');
wordText.textContent = word.toUpperCase();
const wordCount = document.createElement('span');
wordCount.classList.add('word-item-count');
for (let i = 0; i < count; i++) {
ticks += '<span class="tick">✓</span>';
wordCount.innerHTML = ticks;
wordItem.appendChild(wordText);
wordItem.appendChild(wordCount);
wordListEl.appendChild(wordItem);
function placeWord(word, board) {
const directions = [[0, 1], [0, -1], [1, 0], [-1, 0], [1, 1], [1, -1], [-1, 1], [-1, -1]];
while (!placed && attempts < 1000) {
const startR = Math.floor(Math.random() * GRID_SIZE);
const startC = Math.floor(Math.random() * GRID_SIZE);
const path = [{ r: startR, c: startC }];
const visited = new Set([`${startR},${startC}`]);
const findPath = (r, c, wordIndex) => {
if (wordIndex === word.length) return true;
const currentLetter = word[wordIndex];
for (const [dr, dc] of directions) {
const newCoord = `${newR},${newC}`;
if (newR >= 0 && newR < GRID_SIZE && newC >= 0 && newC < GRID_SIZE && !visited.has(newCoord)) {
if (board[newR][newC] === null || board[newR][newC].word === null || (board[newR][newC].letter === word[wordIndex] && board[newR][newC].word === word)) {
path.push({ r: newR, c: newC });
if (findPath(newR, newC, wordIndex + 1)) return true;
visited.delete(newCoord);
if (board[startR][startC] === null || board[startR][startC].word === null || (board[startR][startC].letter === word[0] && board[startR][startC].word === word)) {
const found = findPath(startR, startC, 1);
for (let i = 0; i < word.length; i++) {
const { r, c } = path[i];
board[r][c] = { letter: word[i], word: word, color: wordColors[word] || 'bg-purple-400' };
wordPlacementCounts[word] = (wordPlacementCounts[word] || 0) + 1;
function placeWordsOnGrid(words) {
const board = Array.from({ length: GRID_SIZE }, () => Array(GRID_SIZE).fill(null));
const wordsToPlace = shuffle([...words]);
for (const word of wordsToPlace) {
if (placeWord(word, board)) {
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (board[r][c] === null) {
board[r][c] = { letter: getRandomLetter(), word: null, color: 'bg-purple-400' };
return { board, placedWords };
wordPlacementCounts = {};
wordsToFind = getRandomWords(TOTAL_WORDS);
wordsToFind.forEach((word, index) => {
foundWordCounts[word] = 0;
wordPlacementCounts[word] = 0;
wordColors[word] = assignedColors[index] || 'bg-purple-400';
while (!validBoard && attempts < 200) {
const result = placeWordsOnGrid(wordsToFind);
gameBoard = result.board;
const wordsOnGrid = new Set(result.placedWords);
if (wordsOnGrid.size === TOTAL_WORDS) {
// Fallback: Accept a board with at least one word
gameBoard = placeWordsOnGrid(wordsToFind).board;
// Ensure board has no nulls before rendering
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (!gameBoard[r] || gameBoard[r][c] == null) {
// Fill any missing cell to avoid errors
if (!gameBoard[r]) gameBoard[r] = Array(GRID_SIZE).fill(null);
gameBoard[r][c] = { letter: getRandomLetter(), word: null, color: 'bg-purple-400' };
gridContainer.innerHTML = '';
gameBoard.forEach((row, r) => {
row.forEach((cell, c) => {
const tile = createTile(r, c, cell.letter, cell.color);
gridContainer.appendChild(tile);
function getRandomLetter() {
return letters[Math.floor(Math.random() * letters.length)];
function getSelectedWord() {
return selectedTiles.map(tile => gameBoard[tile.row][tile.col].letter).join('');
function isValidAdjacency(newTile) {
if (selectedTiles.length === 0) return true;
const lastTile = selectedTiles[selectedTiles.length - 1];
const dx = Math.abs(newTile.row - lastTile.row);
const dy = Math.abs(newTile.col - lastTile.col);
return (dx <= 1 && dy <= 1 && (dx > 0 || dy > 0));
function showMessage(text, duration = 1500) {
messageBox.textContent = text;
messageBox.classList.remove('opacity-0');
messageBox.classList.add('opacity-0');
function clearHighlights() {
document.querySelectorAll('.tile.selected').forEach(tile => tile.classList.remove('selected'));
wordBuilderEl.innerHTML = '';
function handleStart(e) {
const target = document.elementFromPoint(e.clientX, e.clientY);
const tile = target && target.closest('.tile');
if (tile && tile !== lastTile) {
const row = parseInt(tile.dataset.row);
const col = parseInt(tile.dataset.col);
const newTile = { row, col };
const isAlreadySelected = selectedTiles.some(t => t.row === row && t.col === col);
if (!isAlreadySelected && isValidAdjacency(newTile)) {
selectedTiles.push(newTile);
tile.classList.add('selected');
const builtLetterDiv = document.createElement('div');
builtLetterDiv.classList.add('built-letter');
builtLetterDiv.textContent = gameBoard[row][col].letter.toUpperCase();
wordBuilderEl.appendChild(builtLetterDiv);
const word = getSelectedWord().toLowerCase();
const foundWord = wordsToFind.find(w => w === word);
if (foundWord && word.length >= 4) {
foundWordCounts[foundWord] = (foundWordCounts[foundWord] || 0) + 1;
showMessage('Mīharo - you got it!');
dropLettersAndRefill(selectedTiles);
} else if (word.length > 1) {
showMessage('That is not a valid word.');
function tryToPlaceNewWord() {
const wordsOnGrid = new Set();
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (gameBoard[r][c] && gameBoard[r][c].word) {
wordsOnGrid.add(gameBoard[r][c].word);
const wordsRemaining = wordsToFind.filter(w => foundWordCounts[w] < FIND_COUNT_PER_WORD);
const wordsNotOnGrid = wordsRemaining.filter(w => !wordsOnGrid.has(w));
if (wordsNotOnGrid.length > 0) {
for (const word of wordsNotOnGrid) {
if (placeWord(word, gameBoard)) {
function replaceRandomLetters() {
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (!gameBoard[r][c].word) {
nonWordTiles.push({ r, c });
const tilesToReplace = nonWordTiles.slice(0, Math.min(10, nonWordTiles.length));
tilesToReplace.forEach(({ r, c }) => {
gameBoard[r][c] = null; // Clear the tile
// Try to place missing words on the cleared tiles
const wordsOnGrid = new Set();
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (gameBoard[r][c] && gameBoard[r][c].word) {
wordsOnGrid.add(gameBoard[r][c].word);
const wordsRemaining = wordsToFind.filter(w => foundWordCounts[w] < FIND_COUNT_PER_WORD);
const wordsNotOnGrid = wordsRemaining.filter(w => !wordsOnGrid.has(w));
for (const word of wordsNotOnGrid) {
placeWord(word, gameBoard);
// Fill any remaining null tiles with purple letters
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (gameBoard[r][c] === null) {
gameBoard[r][c] = { letter: getRandomLetter(), word: null, color: 'bg-purple-400' };
function dropLettersAndRefill(tilesToRemove) {
const newGameBoard = Array.from({ length: GRID_SIZE }, () => Array(GRID_SIZE).fill(null));
const tilesToRemoveCoords = new Set(tilesToRemove.map(t => `${t.row},${t.col}`));
// For each column, collect the remaining tiles from bottom -> top
for (let c = 0; c < GRID_SIZE; c++) {
for (let r = GRID_SIZE - 1; r >= 0; r--) {
const coord = `${r},${c}`;
if (!tilesToRemoveCoords.has(coord)) {
// push existing cell (may be object)
columnStack.push(gameBoard[r][c]);
// Fill bottom -> top in newGameBoard
let fillRow = GRID_SIZE - 1;
for (let i = 0; i < columnStack.length; i++) {
newGameBoard[fillRow][c] = columnStack[i];
// For remaining top cells, insert new random letters (purple)
newGameBoard[fillRow][c] = { letter: getRandomLetter(), word: null, color: 'bg-purple-400' };
gameBoard = newGameBoard;
// After refill, ensure required words are present or attempt to place them
const wordsOnGrid = new Set();
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (gameBoard[r][c] && gameBoard[r][c].word) {
wordsOnGrid.add(gameBoard[r][c].word);
const wordsToFindStillRemaining = wordsToFind.filter(w => foundWordCounts[w] < FIND_COUNT_PER_WORD);
if (wordsToFindStillRemaining.length > 0) {
while (wordsOnGrid.size < wordsToFindStillRemaining.length && attempts < 40) {
const successfullyPlaced = tryToPlaceNewWord();
if (!successfullyPlaced) {
replaceRandomLetters(); // fallback
for (let r = 0; r < GRID_SIZE; r++) {
for (let c = 0; c < GRID_SIZE; c++) {
if (gameBoard[r][c] && gameBoard[r][c].word) {
wordsOnGrid.add(gameBoard[r][c].word);
if (wordsOnGrid.size === 0) {
showMessage("Whakatika! - New words added!", 2000);
function checkWinCondition() {
const allFoundTwice = wordsToFind.every(word => foundWordCounts[word] >= FIND_COUNT_PER_WORD);
gameContainer.classList.add('hidden');
winScreen.classList.remove('hidden');
winScreen.classList.add('flex');
gameContainer.classList.remove('hidden');
winScreen.classList.add('hidden');
winScreen.classList.remove('flex');
function attachEventListeners() {
gridContainer.addEventListener('mousedown', handleStart);
gridContainer.addEventListener('mousemove', handleMove);
gridContainer.addEventListener('mouseup', handleEnd);
gridContainer.addEventListener('mouseleave', handleEnd);
gridContainer.addEventListener('touchstart', (e) => handleStart(e.touches[0]));
gridContainer.addEventListener('touchmove', (e) => handleMove(e.touches[0]));
gridContainer.addEventListener('touchend', handleEnd);
playAgainBtn.addEventListener('click', resetGame);
Prompt used: This is great - except i have one word to find - "wairua" the yellow letters are on the board and all the other letters are purple, but i can not make the word because of the letter placement - something in the "check a word is on the board" is not working - not only do the words need to be there - they need to have the letters adjacent - I should not have 30 (out of 36) purple letters on the board - I should have 4 to 8 of each colour at all times (6 colours 36 letters).