
Word puzzle
You form, guess or complete words from loose letters or clues, as in anagrams, crosswords or word searches. The challenge is your vocabulary and spelling.
Also known as: word game, Wordle-like
Defining mechanics
- Letters as pieces, which you pick, place on a grid or link between neighbours to form words.
- Hints about a hidden word (letters found, positions, definitions) that gradually narrow down the possibilities.
- A dictionary that decides what counts as a valid word and, often, points based on length or rare letters.
- Limits that create tension, such as a number of tries, a clock or tiles that threaten to end the game.
Gameplay loop
Look at the letters and the hints, think of a word that fits, try it and use the game's answer to rule out options on the next attempt.
Our demo
Controls. Type on the keyboard and press Enter to check; Backspace deletes. You can also tap or click the on-screen keyboard, which includes Enter and ⌫.
Open full screenYou have six tries to guess a five-letter word. After each try, every letter turns green if it is in the right place, yellow if it is in the word but somewhere else, and grey if it is not in the word. With those hints you rule words out until you find the right one.
Made with Kaplay, in about 151 lines of JavaScript.
How it's made
Setup
The game is created at a fixed size. The constants set the word length, the number of tries, the size of the squares and the three hint colours. RANK orders the hints so that a key on the keyboard always keeps the best one known.
// Played with the physical keyboard or by tapping the on-screen keyboard, // so it needs no extra touch buttons. kaplay({ width: 640, height: 360, letterbox: true, pixelDensity, background: [18, 18, 26], buttons: { start: { keyboard: ["space", "enter"] } }, }); const WORD_LENGTH = 5; const MAX_TRIES = 6; const TILE = 40; // size of a letter square const GAP = 6; const COLORS = { empty: rgb(32, 32, 42), correct: rgb(83, 141, 78), // right letter, right place present: rgb(186, 156, 52), // in the word, somewhere else absent: rgb(52, 52, 62), // not in the word }; const RANK = { absent: 1, present: 2, correct: 3 }; // a key keeps its best hintThe word lists
There is one list of everyday words per language and a keyboard layout for each (the Spanish one has Ñ). The demo's language decides which one is used.
// Small lists of everyday words. The hidden word is drawn from them; any // five letters are accepted as a guess (see the exercises). const ANSWERS = { es: ["PLATO", "LIBRO", "NUBES", "PERRO", "CAMPO", "RELOJ", "SILLA", "LLAVE", "FUEGO", "PIANO", "VERDE", "BARCO", "TIGRE", "CARTA", "PAPEL", "RUEDA", "NIEVE", "HUEVO", "CIELO", "MUNDO", "NOCHE", "PLUMA", "SUEÑO", "PUNTO"], en: ["PLANT", "CLOUD", "BREAD", "RIVER", "HOUSE", "LIGHT", "STONE", "TRAIN", "CHAIR", "MUSIC", "OCEAN", "GRAPE", "APPLE", "HORSE", "BEACH", "SMILE", "WATER", "SPOON", "CLOCK", "BRICK", "TIGER", "PAPER", "NIGHT", "GREEN"], }; const KEY_ROWS = { es: ["QWERTYUIOP", "ASDFGHJKLÑ", "ZXCVBNM"], en: ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"], }; const LETTERS = KEY_ROWS[lang].join("");Scoring a guess
This is the rule that defines the game, and it needs two passes to be fair with repeated letters. The first pass marks in green the letters that match in position and counts the answer's leftover letters. In the second, a guessed letter is yellow only if an unused copy is still left in the answer. So if the answer has one E and you type two, only one is coloured.
// The rule that defines the genre. Two passes, so repeated letters are fair: // 1) exact matches are "correct" and use up that letter of the answer; // 2) the remaining guess letters are "present" only while unused copies remain. function score(guess, answer) { const result = Array(WORD_LENGTH).fill("absent"); const unused = {}; // letters of the answer not matched exactly, with counts for (let i = 0; i < WORD_LENGTH; i++) { if (guess[i] === answer[i]) result[i] = "correct"; else unused[answer[i]] = (unused[answer[i]] ?? 0) + 1; } for (let i = 0; i < WORD_LENGTH; i++) { if (result[i] !== "correct" && unused[guess[i]] > 0) { result[i] = "present"; unused[guess[i]]--; } } return result; }The board
A grid of 6 by 5 squares. Each square is a rectangle with a child text for the letter, and a tiles[row][column] array lets you change any of them.
// A 6×5 grid of squares; each one is a rectangle with a letter on top. add([text(t({ es: "Adivina la palabra", en: "Guess the word" }), { size: 20, font: FONT }), pos(40, 12)]); const tiles = []; for (let r = 0; r < MAX_TRIES; r++) { tiles.push([]); for (let c = 0; c < WORD_LENGTH; c++) { const box = add([rect(TILE, TILE, { radius: 4 }), pos(40 + c * (TILE + GAP) + TILE / 2, 66 + r * (TILE + GAP)), anchor("center"), color(COLORS.empty), outline(2, rgb(70, 70, 90)), scale(1)]); box.add([text("", { size: 22, font: FONT }), anchor("center")]); tiles[r].push(box); } } const setLetter = (c, ch) => (tiles[row][c].children[0].text = ch);The on-screen keyboard
Each key is an object with area() and onClick(), so it works the same with a mouse or a finger. Its colour shows the best hint known for that letter, and that visual memory is what helps you plan the next try.
// The on-screen keyboard: every key is a clickable object. Its color shows // the best hint known for that letter, which helps plan the next guess. const keys = {}; const keyState = {}; function addKey(label, x, y, w, action) { const key = add([rect(w, 40, { radius: 4 }), pos(x, y), color(rgb(128, 128, 145)), area(), "key"]); key.add([text(label, { size: label.length > 1 ? 13 : 17, font: FONT }), pos(w / 2, 20), anchor("center")]); key.onClick(action); return key; } KEY_ROWS[lang].forEach((letters, r) => { const extra = r === 2 ? 2 * (48 + 3) : 0; // the last row also has Enter and Delete let x = 466 - (letters.length * 32 - 3 + extra) / 2; const y = 120 + r * 46; if (r === 2) (addKey("Enter", x, y, 48, submit), (x += 51)); for (const ch of letters) (keys[ch] = addKey(ch, x, y, 29, () => typeLetter(ch))), (x += 32); if (r === 2) addKey("⌫", x, y, 48, erase); }); function paintKey(ch, hint) { if ((RANK[hint] ?? 0) <= (RANK[keyState[ch]] ?? 0)) return; keyState[ch] = hint; keys[ch].color = COLORS[hint]; }Typing
Letters fill the current row from left to right. onCharInput receives the ones from the physical keyboard, strips accents and keeps Ñ. The on-screen keys call the same functions, so it does not matter where a letter comes from.
// Letters fill the current row from left to right; Delete removes the last // one and Enter checks the row. Accents are dropped (á → A), ñ is kept. const message = add([text("", { size: 15, font: FONT, align: "center", width: 330 }), pos(466, 300), anchor("center"), color(255, 220, 140)]); const say = (texts) => (message.text = t(texts)); function typeLetter(ch) { if (busy || guess.length >= WORD_LENGTH) return; setLetter(guess.length, ch); guess += ch; say({ es: "", en: "" }); } function erase() { if (busy || !guess.length) return; guess = guess.slice(0, -1); setLetter(guess.length, ""); } onCharInput((ch) => { const up = ch === "ñ" ? "Ñ" : ch.normalize("NFD").replace(/[̀-ͯ]/g, "").toUpperCase(); if (up.length === 1 && LETTERS.includes(up)) typeLetter(up); }); onKeyPressRepeat("backspace", erase); onKeyPress("enter", submit);Revealing the hints
Pressing Enter scores the row and the squares are coloured one by one with a little pop (wait and tween). Meanwhile, the busy variable blocks input. The pause builds suspense and gives you time to read each hint. Then the game decides whether you won, lost or move on to the next row.
// Checking a row colors its squares one by one, with a small "pop". The // short pause builds suspense and lets you read each hint. function submit() { if (busy) return; if (guess.length < WORD_LENGTH) return say({ es: "Faltan letras", en: "Not enough letters" }); busy = true; const hints = score(guess, answer); const [r, word] = [row, guess]; hints.forEach((hint, c) => wait(c * 0.18, () => { tiles[r][c].color = COLORS[hint]; tiles[r][c].scale = vec2(1.15); tween(1.15, 1, 0.15, (s) => (tiles[r][c].scale = vec2(s))); paintKey(word[c], hint); }), ); wait(WORD_LENGTH * 0.18 + 0.2, () => { if (word === answer) return wait(0.8, () => go("end", true, answer, r + 1)); if (r + 1 >= MAX_TRIES) return wait(0.8, () => go("end", false, answer, MAX_TRIES)); row++; guess = ""; busy = false; say({ es: `Intento ${row + 1} de ${MAX_TRIES}`, en: `Attempt ${row + 1} of ${MAX_TRIES}` }); }); } say({ es: `Intento 1 de ${MAX_TRIES}`, en: `Attempt 1 of ${MAX_TRIES}` });Scenes
There are three scenes (title, game and end). The final scene says how many tries you used and, if you missed, reveals the word.
function screen(title, subtitle) { add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 46)), anchor("center")]); add([text(subtitle, { size: 16, font: FONT, align: "center", width: 540, lineSpacing: 4 }), pos(center().add(0, 34)), anchor("center"), color(200, 205, 230)]); onButtonPress("start", () => go("game")); onClick(() => go("game")); } scene("title", () => screen( t({ es: "Puzles de palabras", en: "Word puzzle" }), t({ es: "Adivina la palabra de 5 letras en 6 intentos. Verde: letra en su sitio. Amarillo: está en otro sitio. Gris: no está.\nEscribe con el teclado o toca las teclas. Pulsa espacio o toca para empezar.", en: "Guess the 5-letter word in 6 tries. Green: right letter, right place. Yellow: it's somewhere else. Grey: not in the word.\nType or tap the keys. Press space or tap to start.", }), ), ); scene("end", (won, answer, tries) => screen( won ? t({ es: "¡Acertaste!", en: "You got it!" }) : t({ es: "Se acabaron los intentos", en: "Out of tries" }), (won ? t({ es: `En ${tries} de ${MAX_TRIES} intentos. `, en: `In ${tries} of ${MAX_TRIES} tries. ` }) : "") + t({ es: `La palabra era ${answer}.\nPulsa espacio para jugar otra vez.`, en: `The word was ${answer}.\nPress space to play again.` }), ), ); go("title");
What we simplified
- Any five letters are accepted as a guess. A real game only accepts words that exist, and for that it needs a dictionary of thousands of words.
- The word is picked at random from a list of 24 per language; there is no shared word of the day, no statistics and no hard mode.
- Accents are ignored (á counts as A) to keep the list and the keyboard simple; Ñ is a letter of its own.
To modify it
- Add a list of valid words and reject guesses that are not in it, with a message and a little shake of the row.
- Make the word depend on the date, so everyone who plays on the same day gets the same one.
- Implement hard mode, where every guess must use the green and yellow hints you already know.
- Change WORD_LENGTH to 6 and make a list of six-letter words. What else in the code needs to change?
Full code
// Word puzzle demo
// Engine: Kaplay 3001 (https://kaplayjs.com). MIT licence.
import kaplay from "../../vendor/kaplay.mjs";
import { lang, t, FONT, pixelDensity } from "../common.js";
// #region setup
// Played with the physical keyboard or by tapping the on-screen keyboard,
// so it needs no extra touch buttons.
kaplay({
width: 640,
height: 360,
letterbox: true, pixelDensity,
background: [18, 18, 26],
buttons: { start: { keyboard: ["space", "enter"] } },
});
const WORD_LENGTH = 5;
const MAX_TRIES = 6;
const TILE = 40; // size of a letter square
const GAP = 6;
const COLORS = {
empty: rgb(32, 32, 42),
correct: rgb(83, 141, 78), // right letter, right place
present: rgb(186, 156, 52), // in the word, somewhere else
absent: rgb(52, 52, 62), // not in the word
};
const RANK = { absent: 1, present: 2, correct: 3 }; // a key keeps its best hint
// #endregion
// #region words
// Small lists of everyday words. The hidden word is drawn from them; any
// five letters are accepted as a guess (see the exercises).
const ANSWERS = {
es: ["PLATO", "LIBRO", "NUBES", "PERRO", "CAMPO", "RELOJ", "SILLA", "LLAVE", "FUEGO", "PIANO", "VERDE", "BARCO",
"TIGRE", "CARTA", "PAPEL", "RUEDA", "NIEVE", "HUEVO", "CIELO", "MUNDO", "NOCHE", "PLUMA", "SUEÑO", "PUNTO"],
en: ["PLANT", "CLOUD", "BREAD", "RIVER", "HOUSE", "LIGHT", "STONE", "TRAIN", "CHAIR", "MUSIC", "OCEAN", "GRAPE",
"APPLE", "HORSE", "BEACH", "SMILE", "WATER", "SPOON", "CLOCK", "BRICK", "TIGER", "PAPER", "NIGHT", "GREEN"],
};
const KEY_ROWS = {
es: ["QWERTYUIOP", "ASDFGHJKLÑ", "ZXCVBNM"],
en: ["QWERTYUIOP", "ASDFGHJKL", "ZXCVBNM"],
};
const LETTERS = KEY_ROWS[lang].join("");
// #endregion
// #region score
// The rule that defines the genre. Two passes, so repeated letters are fair:
// 1) exact matches are "correct" and use up that letter of the answer;
// 2) the remaining guess letters are "present" only while unused copies remain.
function score(guess, answer) {
const result = Array(WORD_LENGTH).fill("absent");
const unused = {}; // letters of the answer not matched exactly, with counts
for (let i = 0; i < WORD_LENGTH; i++) {
if (guess[i] === answer[i]) result[i] = "correct";
else unused[answer[i]] = (unused[answer[i]] ?? 0) + 1;
}
for (let i = 0; i < WORD_LENGTH; i++) {
if (result[i] !== "correct" && unused[guess[i]] > 0) {
result[i] = "present";
unused[guess[i]]--;
}
}
return result;
}
// #endregion
scene("game", () => {
const answer = choose(ANSWERS[lang]);
let row = 0; // current attempt
let guess = ""; // letters typed in the current row
let busy = false; // true while a row is being revealed
// #region board
// A 6×5 grid of squares; each one is a rectangle with a letter on top.
add([text(t({ es: "Adivina la palabra", en: "Guess the word" }), { size: 20, font: FONT }), pos(40, 12)]);
const tiles = [];
for (let r = 0; r < MAX_TRIES; r++) {
tiles.push([]);
for (let c = 0; c < WORD_LENGTH; c++) {
const box = add([rect(TILE, TILE, { radius: 4 }), pos(40 + c * (TILE + GAP) + TILE / 2, 66 + r * (TILE + GAP)),
anchor("center"), color(COLORS.empty), outline(2, rgb(70, 70, 90)), scale(1)]);
box.add([text("", { size: 22, font: FONT }), anchor("center")]);
tiles[r].push(box);
}
}
const setLetter = (c, ch) => (tiles[row][c].children[0].text = ch);
// #endregion
// #region keyboard
// The on-screen keyboard: every key is a clickable object. Its color shows
// the best hint known for that letter, which helps plan the next guess.
const keys = {};
const keyState = {};
function addKey(label, x, y, w, action) {
const key = add([rect(w, 40, { radius: 4 }), pos(x, y), color(rgb(128, 128, 145)), area(), "key"]);
key.add([text(label, { size: label.length > 1 ? 13 : 17, font: FONT }), pos(w / 2, 20), anchor("center")]);
key.onClick(action);
return key;
}
KEY_ROWS[lang].forEach((letters, r) => {
const extra = r === 2 ? 2 * (48 + 3) : 0; // the last row also has Enter and Delete
let x = 466 - (letters.length * 32 - 3 + extra) / 2;
const y = 120 + r * 46;
if (r === 2) (addKey("Enter", x, y, 48, submit), (x += 51));
for (const ch of letters) (keys[ch] = addKey(ch, x, y, 29, () => typeLetter(ch))), (x += 32);
if (r === 2) addKey("⌫", x, y, 48, erase);
});
function paintKey(ch, hint) {
if ((RANK[hint] ?? 0) <= (RANK[keyState[ch]] ?? 0)) return;
keyState[ch] = hint;
keys[ch].color = COLORS[hint];
}
// #endregion
// #region typing
// Letters fill the current row from left to right; Delete removes the last
// one and Enter checks the row. Accents are dropped (á → A), ñ is kept.
const message = add([text("", { size: 15, font: FONT, align: "center", width: 330 }), pos(466, 300), anchor("center"), color(255, 220, 140)]);
const say = (texts) => (message.text = t(texts));
function typeLetter(ch) {
if (busy || guess.length >= WORD_LENGTH) return;
setLetter(guess.length, ch);
guess += ch;
say({ es: "", en: "" });
}
function erase() {
if (busy || !guess.length) return;
guess = guess.slice(0, -1);
setLetter(guess.length, "");
}
onCharInput((ch) => {
const up = ch === "ñ" ? "Ñ" : ch.normalize("NFD").replace(/[̀-ͯ]/g, "").toUpperCase();
if (up.length === 1 && LETTERS.includes(up)) typeLetter(up);
});
onKeyPressRepeat("backspace", erase);
onKeyPress("enter", submit);
// #endregion
// #region reveal
// Checking a row colors its squares one by one, with a small "pop". The
// short pause builds suspense and lets you read each hint.
function submit() {
if (busy) return;
if (guess.length < WORD_LENGTH) return say({ es: "Faltan letras", en: "Not enough letters" });
busy = true;
const hints = score(guess, answer);
const [r, word] = [row, guess];
hints.forEach((hint, c) =>
wait(c * 0.18, () => {
tiles[r][c].color = COLORS[hint];
tiles[r][c].scale = vec2(1.15);
tween(1.15, 1, 0.15, (s) => (tiles[r][c].scale = vec2(s)));
paintKey(word[c], hint);
}),
);
wait(WORD_LENGTH * 0.18 + 0.2, () => {
if (word === answer) return wait(0.8, () => go("end", true, answer, r + 1));
if (r + 1 >= MAX_TRIES) return wait(0.8, () => go("end", false, answer, MAX_TRIES));
row++;
guess = "";
busy = false;
say({ es: `Intento ${row + 1} de ${MAX_TRIES}`, en: `Attempt ${row + 1} of ${MAX_TRIES}` });
});
}
say({ es: `Intento 1 de ${MAX_TRIES}`, en: `Attempt 1 of ${MAX_TRIES}` });
// #endregion
});
// #region scenes
function screen(title, subtitle) {
add([text(title, { size: 32, font: FONT, align: "center" }), pos(center().sub(0, 46)), anchor("center")]);
add([text(subtitle, { size: 16, font: FONT, align: "center", width: 540, lineSpacing: 4 }), pos(center().add(0, 34)), anchor("center"), color(200, 205, 230)]);
onButtonPress("start", () => go("game"));
onClick(() => go("game"));
}
scene("title", () =>
screen(
t({ es: "Puzles de palabras", en: "Word puzzle" }),
t({
es: "Adivina la palabra de 5 letras en 6 intentos. Verde: letra en su sitio. Amarillo: está en otro sitio. Gris: no está.\nEscribe con el teclado o toca las teclas. Pulsa espacio o toca para empezar.",
en: "Guess the 5-letter word in 6 tries. Green: right letter, right place. Yellow: it's somewhere else. Grey: not in the word.\nType or tap the keys. Press space or tap to start.",
}),
),
);
scene("end", (won, answer, tries) =>
screen(
won ? t({ es: "¡Acertaste!", en: "You got it!" }) : t({ es: "Se acabaron los intentos", en: "Out of tries" }),
(won ? t({ es: `En ${tries} de ${MAX_TRIES} intentos. `, en: `In ${tries} of ${MAX_TRIES} tries. ` }) : "") +
t({ es: `La palabra era ${answer}.\nPulsa espacio para jugar otra vez.`, en: `The word was ${answer}.\nPress space to play again.` }),
),
);
go("title");
// #endregionDesign challenges
- Choosing the word lists well. Answers should be familiar words, but the dictionary of valid guesses must be broad so players are not punished.
- Every language has its own letters (ñ, accents, digraphs) and letter frequencies, so a word game is not translated but adapted.
- Making hints exact and fair, including with repeated letters.
- Balancing difficulty so the game does not hinge on the luck of the first guess.
History
- 1978OriginHangman
Atari's Hangman for the VCS brings pen-and-paper hangman to a home console.
Hangman (video game) (Wikipedia) - 1991
Wordtris, published by Spectrum HoloByte, mixes words with Tetris-style falling pieces. You form words from the letters as they drop.
Wordtris (Wikipedia) - 2003Bookworm
PopCap's Bookworm offers a grid of letters you link together to form words, with burning tiles that threaten to end the game.
Bookworm (Wikipedia) - 2009Words with Friends
Words with Friends brings Scrabble-style crosswords to phones, with asynchronous turns against friends online.
Words with Friends (Wikipedia) - 2012
Loren Brichter's Letterpress turns a 5-by-5 grid of letters into territory that two players fight over by forming words.
Letterpress (Wikipedia) - 2021Wordle
Wordle, a daily challenge to guess a five-letter word, goes viral on the web.
Wordle (Wikipedia) - 2022
The New York Times buys Wordle, and the game is quickly adapted into dozens of languages, such as Termo in Portuguese.
Wordle (Wikipedia) - 2022Absurdle
qntm's Absurdle turns Wordle on its head. The answer changes every turn to dodge you, and the puzzle becomes cornering the game.
Absurdle (Wikipedia)
Reference games
1978
Hangman
The game of hangman on a home console. You guess the letters of a hidden word and every miss adds a piece to the gallows. It shipped with 510 words of up to six letters and four difficulty levels.
2003
Bookworm
You link neighbouring letters on a grid to form words; the ones you use disappear and new ones drop from above. Burning tiles move down, destroying the letters beneath them, and if one reaches the bottom you lose.
2009
Words with Friends
A crossword-style word game on a board, in the manner of Scrabble, against friends or strangers online. Turns are asynchronous, so you can keep many games going at once and your phone tells you when it is your turn.
2021
Wordle
You have six tries to guess a five-letter word, and after each one the letters turn green, yellow or grey depending on whether they are in the right place, somewhere else or not in the word. There is a single word a day for everyone, which made it a talking point and a viral hit.
2022
Absurdle
It plays like Wordle, but the game cheats on purpose. Each turn it swaps the answer for another that fits your hints, choosing the one that rules out the fewest words. You have unlimited tries, and the challenge is to corner it until only one word is left.
Exercise
Play the demo and explain in writing why scoring needs two passes. Try a guess with two identical letters when the answer has only one. Then add a list of valid words and compare how the difficulty changes.