Skip to content

Instantly share code, notes, and snippets.

@CrandellWS
Last active June 27, 2026 02:45
Show Gist options
  • Select an option

  • Save CrandellWS/5b11b732d7436e41e52908ec14989edc to your computer and use it in GitHub Desktop.

Select an option

Save CrandellWS/5b11b732d7436e41e52908ec14989edc to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canvas Word Search Maker</title>
<style>
:root {
color-scheme: light dark;
--bg: #111827;
--panel: #1f2937;
--panel-2: #111827;
--text: #f9fafb;
--muted: #cbd5e1;
--accent: #8b5cf6;
--accent-hover: #7c3aed;
--danger: #ef4444;
--grid: #475569;
--canvas-bg: #ffffff;
--canvas-text: #111827;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background:
radial-gradient(circle at top left, rgba(139, 92, 246, 0.22), transparent 35%),
var(--bg);
color: var(--text);
}
.app {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 22px;
width: min(1400px, 100%);
margin: 0 auto;
padding: 22px;
}
.panel {
background: rgba(31, 41, 55, 0.96);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px;
padding: 20px;
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.28);
}
h1 {
margin: 0 0 8px;
font-size: clamp(1.55rem, 3vw, 2.2rem);
}
.subtitle {
margin: 0 0 20px;
color: var(--muted);
line-height: 1.45;
}
label,
legend {
font-weight: 700;
}
textarea,
select,
input[type="number"],
input[type="text"] {
width: 100%;
border: 1px solid #475569;
border-radius: 10px;
background: #0f172a;
color: var(--text);
padding: 11px 12px;
font: inherit;
}
textarea {
min-height: 180px;
resize: vertical;
line-height: 1.45;
}
.field {
margin-bottom: 16px;
}
.field small {
display: block;
color: var(--muted);
margin-top: 6px;
line-height: 1.4;
}
.entry-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.entry-heading small {
margin-top: 4px;
}
.small-button {
flex: 0 0 auto;
padding: 9px 12px;
background: #334155;
color: white;
font-size: 0.9rem;
}
.small-button:hover {
background: #475569;
}
.entry-labels,
.entry-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) 42px;
gap: 8px;
align-items: stretch;
}
.entry-labels {
padding: 0 3px 6px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.entry-rows {
display: grid;
gap: 10px;
}
.entry-row {
padding: 9px;
border: 1px solid #334155;
border-radius: 12px;
background: rgba(15, 23, 42, 0.72);
}
.entry-part {
position: relative;
min-width: 0;
display: grid;
grid-template-rows: auto auto;
gap: 6px;
padding: 7px;
border: 2px solid transparent;
border-radius: 10px;
background: rgba(30, 41, 59, 0.74);
transition: border-color 120ms ease, background 120ms ease;
}
.entry-part.is-shown {
border-color: #8b5cf6;
background: rgba(91, 33, 182, 0.22);
}
.entry-part input[type="text"] {
min-width: 0;
}
.show-choice {
display: flex;
align-items: center;
gap: 7px;
min-height: 23px;
color: var(--muted);
font-size: 0.78rem;
font-weight: 800;
cursor: pointer;
user-select: none;
}
.show-choice input {
accent-color: #8b5cf6;
margin: 0;
}
.entry-part.is-shown .show-choice {
color: #ddd6fe;
}
.shown-badge {
display: none;
margin-left: auto;
padding: 2px 6px;
border-radius: 999px;
background: #8b5cf6;
color: white;
font-size: 0.65rem;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.entry-part.is-shown .shown-badge {
display: inline-block;
}
.remove-entry-button {
width: 42px;
min-height: 42px;
padding: 0;
display: grid;
place-items: center;
background: #7f1d1d;
color: white;
font-size: 1.15rem;
line-height: 1;
}
.remove-entry-button:hover {
background: #991b1b;
}
.entry-row:only-child .remove-entry-button {
opacity: 0.38;
cursor: not-allowed;
}
@media (max-width: 760px) {
.entry-heading {
align-items: stretch;
flex-direction: column;
}
.entry-labels {
display: none;
}
.entry-row {
grid-template-columns: 1fr 42px;
}
.entry-part {
grid-column: 1;
}
.entry-row .remove-entry-button {
grid-column: 2;
grid-row: 1 / span 3;
align-self: stretch;
width: 42px;
height: auto;
}
}
.entry-labels {
display: none;
}
.entry-row {
grid-template-columns: 1fr 42px;
}
.entry-row input {
grid-column: 1;
}
.entry-row .remove-entry-button {
grid-column: 2;
grid-row: 1 / span 3;
align-self: stretch;
height: auto;
}
.entry-row input::placeholder {
opacity: 1;
}
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.check-list {
display: grid;
gap: 10px;
margin: 12px 0 18px;
}
.check {
display: flex;
gap: 10px;
align-items: flex-start;
font-weight: 500;
color: var(--muted);
}
.check input {
margin-top: 3px;
accent-color: var(--accent);
}
.buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
button {
appearance: none;
border: 0;
border-radius: 11px;
padding: 12px 14px;
font: inherit;
font-weight: 800;
cursor: pointer;
transition: transform 120ms ease, background 120ms ease, opacity 120ms ease;
}
button:hover {
transform: translateY(-1px);
}
button:active {
transform: translateY(0);
}
.primary {
background: var(--accent);
color: white;
}
.primary:hover {
background: var(--accent-hover);
}
.secondary {
background: #334155;
color: white;
}
.secondary:hover {
background: #475569;
}
.canvas-panel {
display: flex;
flex-direction: column;
min-width: 0;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.status {
color: var(--muted);
font-size: 0.95rem;
}
.canvas-wrap {
display: grid;
place-items: center;
min-height: 520px;
padding: 16px;
overflow: auto;
background:
linear-gradient(45deg, rgba(255, 255, 255, 0.025) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255, 255, 255, 0.025) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255, 255, 255, 0.025) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255, 255, 255, 0.025) 75%);
background-size: 24px 24px;
background-position: 0 0, 0 12px, 12px -12px, -12px 0;
border-radius: 14px;
}
canvas {
display: block;
max-width: 100%;
height: auto;
background: var(--canvas-bg);
border-radius: 8px;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.35);
touch-action: manipulation;
}
.word-list {
display: flex;
flex-wrap: wrap;
gap: 8px 14px;
margin-top: 16px;
color: var(--muted);
}
.word-pill {
background: #0f172a;
border: 1px solid #334155;
border-radius: 999px;
padding: 6px 10px;
font-weight: 700;
letter-spacing: 0.04em;
}
.word-pill.scrambled::after {
content: attr(data-original);
display: none;
}
.answer-legend {
display: none;
flex-wrap: wrap;
gap: 12px 18px;
margin-top: 14px;
color: var(--muted);
font-size: 0.92rem;
}
.answer-legend.visible {
display: flex;
}
.legend-item {
display: inline-flex;
align-items: center;
gap: 7px;
}
.legend-swatch {
width: 28px;
height: 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.28);
}
.legend-swatch.code {
background: rgba(245, 158, 11, 0.78);
}
.legend-swatch.word {
background: rgba(239, 68, 68, 0.78);
}
.code-key {
display: grid;
gap: 9px;
margin-top: 16px;
}
.code-key[hidden] {
display: none;
}
.code-key-title {
margin: 0 0 2px;
font-size: 1rem;
}
.code-row {
display: grid;
grid-template-columns: minmax(80px, 0.7fr) auto minmax(0, 1.5fr);
align-items: center;
gap: 10px;
padding: 9px 11px;
border: 1px solid #334155;
border-radius: 10px;
background: #0f172a;
}
.code-clue {
font-weight: 800;
letter-spacing: 0.04em;
}
.code-arrow {
color: var(--muted);
}
.code-value {
display: flex;
flex-wrap: wrap;
align-items: center;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 900;
letter-spacing: 0.08em;
overflow-wrap: anywhere;
}
.code-part {
color: #fbbf24;
}
.target-part {
color: #f87171;
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
@media (max-width: 520px) {
.code-row {
grid-template-columns: 1fr;
gap: 5px;
}
.code-arrow {
display: none;
}
}
.message {
min-height: 24px;
margin-top: 12px;
color: #fca5a5;
font-weight: 700;
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
padding: 12px;
}
.canvas-wrap {
min-height: 380px;
}
}
@media print {
body {
background: white;
color: black;
}
.controls,
.toolbar,
.message {
display: none !important;
}
.app {
display: block;
padding: 0;
}
.panel {
box-shadow: none;
border: 0;
background: white;
padding: 0;
}
.canvas-wrap {
background: white;
padding: 0;
min-height: 0;
}
.word-list {
color: black;
}
.word-pill {
border-color: #999;
background: white;
color: black;
}
}
</style>
</head>
<body>
<main class="app">
<section class="panel controls">
<h1>Word Search Maker</h1>
<p class="subtitle">
Build a word search that secretly carries complete drop codes. Players see only
the required words; optional prefix and suffix characters stay hidden in the grid.
</p>
<div class="field">
<div class="entry-heading">
<div>
<label>Code entries</label>
<small>
Enter up to three parts in order, then choose which one players
will see in the public word list. The other parts remain hidden.
</small>
</div>
<button id="addEntryButton" class="small-button" type="button">
+ Add Row
</button>
</div>
<div class="entry-labels" aria-hidden="true">
<span>Part 1</span>
<span>Part 2</span>
<span>Part 3</span>
<span></span>
</div>
<div id="entryRows" class="entry-rows"></div>
</div>
<div class="row">
<div class="field">
<label for="gridSize">Grid size</label>
<input id="gridSize" type="number" min="8" max="30" value="15">
</div>
<div class="field">
<label for="seedInput">Seed</label>
<input id="seedInput" type="text" value="">
</div>
</div>
<div class="check-list">
<label class="check">
<input id="allowReverse" type="checkbox" checked>
<span>Allow words to run backwards</span>
</label>
<label class="check">
<input id="allowDiagonal" type="checkbox" checked>
<span>Allow diagonal placement</span>
</label>
<label class="check">
<input id="scrambleWords" type="checkbox">
<span>Scramble the selected public word before placing it (hard mode)</span>
</label>
<label class="check">
<input id="scrambleClues" type="checkbox">
<span>Scramble the word list shown below the puzzle</span>
</label>
</div>
<div class="buttons">
<button id="generateButton" class="primary">Generate</button>
<button id="revealButton" class="secondary">Reveal Answers &amp; Codes</button>
<button id="downloadButton" class="secondary">Download PNG</button>
<button id="printButton" class="secondary">Print</button>
</div>
<div id="message" class="message" role="status"></div>
</section>
<section class="panel canvas-panel">
<div class="toolbar">
<strong>Canvas Preview</strong>
<span id="status" class="status">Ready</span>
</div>
<div class="canvas-wrap">
<canvas id="wordCanvas" width="900" height="900"></canvas>
</div>
<div id="wordList" class="word-list" aria-label="Words to find"></div>
<div id="answerLegend" class="answer-legend" aria-label="Answer colors">
<span class="legend-item">
<span class="legend-swatch code"></span>
Complete hidden code
</span>
<span class="legend-item">
<span class="legend-swatch word"></span>
Listed search word
</span>
</div>
<section id="codeKey" class="code-key" hidden aria-label="Revealed code key">
<h2 class="code-key-title">Revealed code key</h2>
</section>
</section>
</main>
<script>
"use strict";
const canvas = document.getElementById("wordCanvas");
const ctx = canvas.getContext("2d");
const entryRowsElement = document.getElementById("entryRows");
const addEntryButton = document.getElementById("addEntryButton");
const gridSizeInput = document.getElementById("gridSize");
const seedInput = document.getElementById("seedInput");
const allowReverseInput = document.getElementById("allowReverse");
const allowDiagonalInput = document.getElementById("allowDiagonal");
const scrambleWordsInput = document.getElementById("scrambleWords");
const scrambleCluesInput = document.getElementById("scrambleClues");
const generateButton = document.getElementById("generateButton");
const revealButton = document.getElementById("revealButton");
const downloadButton = document.getElementById("downloadButton");
const printButton = document.getElementById("printButton");
const wordListElement = document.getElementById("wordList");
const answerLegendElement = document.getElementById("answerLegend");
const codeKeyElement = document.getElementById("codeKey");
const messageElement = document.getElementById("message");
const statusElement = document.getElementById("status");
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const alphaNumeric = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
let puzzle = {
grid: [],
placedWords: [],
size: 15,
revealed: false
};
let random = Math.random;
/**
* Produces a repeatable pseudo-random number generator from a string seed.
*/
function createSeededRandom(seedText) {
let hash = 2166136261;
for (let index = 0; index < seedText.length; index++) {
hash ^= seedText.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return function seededRandom() {
hash += 0x6D2B79F5;
let value = hash;
value = Math.imul(value ^ (value >>> 15), value | 1);
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
};
}
function randomInteger(maxExclusive) {
return Math.floor(random() * maxExclusive);
}
function shuffle(values) {
const copy = [...values];
for (let index = copy.length - 1; index > 0; index--) {
const swapIndex = randomInteger(index + 1);
[copy[index], copy[swapIndex]] = [copy[swapIndex], copy[index]];
}
return copy;
}
function scrambleWord(word) {
if (word.length < 2) {
return word;
}
let scrambled = word;
let attempts = 0;
while (scrambled === word && attempts < 10) {
scrambled = shuffle([...word]).join("");
attempts++;
}
return scrambled;
}
function normalizeToken(value) {
return value
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]/g, "");
}
function createPartEditor(rowId, partNumber, value, shownPart) {
const wrapper = document.createElement("div");
wrapper.className = "entry-part";
const textInput = document.createElement("input");
textInput.type = "text";
textInput.className = `part-input part-${partNumber}-input`;
textInput.placeholder = `Part ${partNumber}`;
textInput.value = value || "";
textInput.maxLength = 40;
textInput.autocomplete = "off";
textInput.spellcheck = false;
textInput.setAttribute("aria-label", `Code part ${partNumber}`);
const choiceLabel = document.createElement("label");
choiceLabel.className = "show-choice";
const radio = document.createElement("input");
radio.type = "radio";
radio.name = `shown-part-${rowId}`;
radio.value = String(partNumber);
radio.checked = partNumber === shownPart;
radio.setAttribute(
"aria-label",
`Show part ${partNumber} in the public word list`
);
const choiceText = document.createElement("span");
choiceText.textContent = "Show this word";
const badge = document.createElement("span");
badge.className = "shown-badge";
badge.textContent = "Shown";
choiceLabel.append(radio, choiceText, badge);
wrapper.append(textInput, choiceLabel);
function updateShownState() {
const row = wrapper.closest(".entry-row");
if (!row) {
return;
}
row.querySelectorAll(".entry-part").forEach(part => {
const partRadio = part.querySelector('input[type="radio"]');
part.classList.toggle("is-shown", partRadio.checked);
});
}
radio.addEventListener("change", updateShownState);
// Clicking inside a filled part makes it easy to select that part as public.
textInput.addEventListener("dblclick", () => {
radio.checked = true;
updateShownState();
});
return wrapper;
}
let entryRowCounter = 0;
function createEntryRow(entry = {}) {
const row = document.createElement("div");
row.className = "entry-row";
entryRowCounter++;
const rowId = entryRowCounter;
const shownPart = Number(entry.shownPart || 2);
const parts = entry.parts || [
entry.prefix || "",
entry.word || "",
entry.suffix || ""
];
const partOne = createPartEditor(rowId, 1, parts[0], shownPart);
const partTwo = createPartEditor(rowId, 2, parts[1], shownPart);
const partThree = createPartEditor(rowId, 3, parts[2], shownPart);
const removeButton = document.createElement("button");
removeButton.type = "button";
removeButton.className = "remove-entry-button";
removeButton.textContent = "×";
removeButton.title = "Remove this row";
removeButton.setAttribute("aria-label", "Remove code entry");
removeButton.addEventListener("click", () => {
const rows = entryRowsElement.querySelectorAll(".entry-row");
if (rows.length <= 1) {
row.querySelectorAll('.part-input').forEach(input => {
input.value = "";
});
const defaultRadio = row.querySelector(
'input[type="radio"][value="2"]'
);
defaultRadio.checked = true;
row.querySelectorAll(".entry-part").forEach(part => {
const radio = part.querySelector('input[type="radio"]');
part.classList.toggle("is-shown", radio.checked);
});
row.querySelector(".part-2-input").focus();
return;
}
row.remove();
updateRemoveButtons();
});
row.append(partOne, partTwo, partThree, removeButton);
entryRowsElement.appendChild(row);
row.querySelectorAll(".entry-part").forEach(part => {
const radio = part.querySelector('input[type="radio"]');
part.classList.toggle("is-shown", radio.checked);
});
updateRemoveButtons();
return row;
}
function updateRemoveButtons() {
const rows = [...entryRowsElement.querySelectorAll(".entry-row")];
const onlyOneRow = rows.length <= 1;
for (const row of rows) {
const button = row.querySelector(".remove-entry-button");
button.disabled = onlyOneRow;
button.title = onlyOneRow
? "At least one row is required"
: "Remove this row";
}
}
function collectCodeEntries() {
const rows = [...entryRowsElement.querySelectorAll(".entry-row")];
const entries = [];
const invalidRows = [];
const seen = new Set();
rows.forEach((row, index) => {
const parts = [1, 2, 3].map(partNumber => {
return normalizeToken(
row.querySelector(`.part-${partNumber}-input`).value
);
});
const selectedRadio = row.querySelector(
'input[type="radio"]:checked'
);
const shownPart = selectedRadio
? Number(selectedRadio.value)
: 2;
const completelyBlank = parts.every(part => !part);
if (completelyBlank) {
return;
}
const shownWord = parts[shownPart - 1];
if (!shownWord) {
invalidRows.push({
rowNumber: index + 1,
shownPart
});
return;
}
const fullText = parts.join("");
const signature = `${parts.join("|")}|shown:${shownPart}`;
if (seen.has(signature)) {
return;
}
seen.add(signature);
entries.push({
parts,
shownPart,
shownWord,
fullText,
beforeText: parts.slice(0, shownPart - 1).join(""),
afterText: parts.slice(shownPart).join("")
});
});
return {
entries,
invalidRows
};
}
function createEmptyGrid(size) {
return Array.from(
{ length: size },
() => Array.from({ length: size }, () => "")
);
}
function getDirections() {
const directions = [
{ key: "R", x: 1, y: 0, diagonal: false },
{ key: "D", x: 0, y: 1, diagonal: false }
];
if (allowDiagonalInput.checked) {
directions.push(
{ key: "DR", x: 1, y: 1, diagonal: true },
{ key: "DL", x: -1, y: 1, diagonal: true }
);
}
if (allowReverseInput.checked) {
directions.push(
{ key: "L", x: -1, y: 0, diagonal: false },
{ key: "U", x: 0, y: -1, diagonal: false }
);
if (allowDiagonalInput.checked) {
directions.push(
{ key: "UL", x: -1, y: -1, diagonal: true },
{ key: "UR", x: 1, y: -1, diagonal: true }
);
}
}
return directions;
}
function canPlaceWord(grid, word, startX, startY, direction, size) {
for (let index = 0; index < word.length; index++) {
const x = startX + direction.x * index;
const y = startY + direction.y * index;
if (x < 0 || x >= size || y < 0 || y >= size) {
return false;
}
const currentLetter = grid[y][x];
if (currentLetter !== "" && currentLetter !== word[index]) {
return false;
}
}
return true;
}
function getLineKey(startX, startY, direction) {
if (direction.y === 0) {
return `H:${startY}`;
}
if (direction.x === 0) {
return `V:${startX}`;
}
if (direction.x === direction.y) {
return `DS:${startX - startY}`;
}
return `DA:${startX + startY}`;
}
function countNearbyLetters(grid, positions, size) {
const candidateCells = new Set(
positions.map(position => `${position.x},${position.y}`)
);
let nearby = 0;
for (const position of positions) {
if (grid[position.y][position.x] !== "") {
continue;
}
for (let offsetY = -1; offsetY <= 1; offsetY++) {
for (let offsetX = -1; offsetX <= 1; offsetX++) {
if (offsetX === 0 && offsetY === 0) {
continue;
}
const x = position.x + offsetX;
const y = position.y + offsetY;
if (x < 0 || x >= size || y < 0 || y >= size) {
continue;
}
if (candidateCells.has(`${x},${y}`)) {
continue;
}
if (grid[y][x] !== "") {
nearby++;
}
}
}
}
return nearby;
}
function collectPlacementCandidates(
grid,
word,
size,
directions,
placementState,
targetDiagonalCount
) {
const candidates = [];
const directionCounts = placementState.directionCounts;
const lineCounts = placementState.lineCounts;
const currentDiagonalCount = placementState.diagonalCount;
const maximumDirectionCount = Math.max(
0,
...directions.map(direction => directionCounts.get(direction.key) || 0)
);
for (const direction of directions) {
for (let startY = 0; startY < size; startY++) {
for (let startX = 0; startX < size; startX++) {
if (!canPlaceWord(grid, word, startX, startY, direction, size)) {
continue;
}
const positions = [];
let overlapCount = 0;
for (let index = 0; index < word.length; index++) {
const x = startX + direction.x * index;
const y = startY + direction.y * index;
positions.push({ x, y });
if (grid[y][x] === word[index]) {
overlapCount++;
}
}
const lineKey = getLineKey(startX, startY, direction);
const lineUseCount = lineCounts.get(lineKey) || 0;
const directionUseCount = directionCounts.get(direction.key) || 0;
const nearbyLetters = countNearbyLetters(grid, positions, size);
const first = positions[0];
const last = positions[positions.length - 1];
const centerX = (first.x + last.x) / 2;
const centerY = (first.y + last.y) / 2;
const gridCenter = (size - 1) / 2;
const centerDistance = Math.hypot(
centerX - gridCenter,
centerY - gridCenter
);
let score = 0;
// Crossings make the puzzle less visually predictable.
score += overlapCount * 28;
// Strongly avoid repeating the same row, column, or diagonal.
score -= lineUseCount * 38;
// Avoid obvious parallel stacking beside existing words.
score -= nearbyLetters * 2.8;
// Favor directions that have been used less often.
score += (maximumDirectionCount - directionUseCount) * 13;
score += 18 / (directionUseCount + 1);
// Ensure that diagonals actually appear when enabled.
if (
direction.diagonal &&
currentDiagonalCount < targetDiagonalCount
) {
score += 55;
} else if (direction.diagonal) {
score += 10;
}
// Use more of the board instead of clustering in one strip.
score += Math.min(centerDistance, size / 2) * 1.4;
// Add controlled randomness so identical scores do not look patterned.
score += random() * 18;
candidates.push({
direction,
positions,
lineKey,
overlapCount,
score
});
}
}
}
return candidates;
}
function commitPlacement(
grid,
entry,
placedTarget,
placedText,
candidate,
placementState
) {
for (let index = 0; index < candidate.positions.length; index++) {
const position = candidate.positions[index];
grid[position.y][position.x] = placedText[index];
}
placementState.directionCounts.set(
candidate.direction.key,
(placementState.directionCounts.get(candidate.direction.key) || 0) + 1
);
placementState.lineCounts.set(
candidate.lineKey,
(placementState.lineCounts.get(candidate.lineKey) || 0) + 1
);
if (candidate.direction.diagonal) {
placementState.diagonalCount++;
}
placementState.overlapCount += candidate.overlapCount;
const beforeLength = entry.beforeText.length;
const targetLength = placedTarget.length;
const wordStart = beforeLength;
const wordEnd = wordStart + targetLength;
return {
originalWord: entry.shownWord,
parts: entry.parts,
shownPart: entry.shownPart,
beforeText: entry.beforeText,
afterText: entry.afterText,
placedTarget,
placedText,
fullCode: entry.fullText,
fullPositions: candidate.positions,
beforePositions: candidate.positions.slice(0, wordStart),
wordPositions: candidate.positions.slice(wordStart, wordEnd),
afterPositions: candidate.positions.slice(wordEnd),
positions: candidate.positions.slice(wordStart, wordEnd),
direction: candidate.direction.key,
diagonal: candidate.direction.diagonal,
overlapCount: candidate.overlapCount
};
}
function placeWord(
grid,
entry,
placedTarget,
placedText,
size,
directions,
placementState,
targetDiagonalCount
) {
const candidates = collectPlacementCandidates(
grid,
placedText,
size,
directions,
placementState,
targetDiagonalCount
);
if (candidates.length === 0) {
return null;
}
candidates.sort((a, b) => b.score - a.score);
const choicePoolSize = Math.min(12, candidates.length);
const choiceIndex = Math.floor(
Math.pow(random(), 2.4) * choicePoolSize
);
return commitPlacement(
grid,
entry,
placedTarget,
placedText,
candidates[choiceIndex],
placementState
);
}
function buildPuzzleAttempt(entries, size, seed, attemptNumber) {
random = createSeededRandom(`${seed}:layout:${attemptNumber}`);
const grid = createEmptyGrid(size);
const directions = getDirections();
const placedWords = [];
const failedEntries = [];
const placementState = {
directionCounts: new Map(),
lineCounts: new Map(),
diagonalCount: 0,
overlapCount: 0
};
const targetDiagonalCount = allowDiagonalInput.checked
? Math.max(1, Math.round(entries.length * 0.45))
: 0;
const sortedEntries = [...entries]
.map(entry => ({
entry,
tieBreaker: random()
}))
.sort((a, b) => {
if (b.entry.fullText.length !== a.entry.fullText.length) {
return b.entry.fullText.length - a.entry.fullText.length;
}
return a.tieBreaker - b.tieBreaker;
})
.map(item => item.entry);
for (const entry of sortedEntries) {
const placedTarget = scrambleWordsInput.checked
? scrambleWord(entry.shownWord)
: entry.shownWord;
const placedText =
`${entry.beforeText}${placedTarget}${entry.afterText}`;
const placed = placeWord(
grid,
entry,
placedTarget,
placedText,
size,
directions,
placementState,
targetDiagonalCount
);
if (placed) {
placedWords.push(placed);
} else {
failedEntries.push(entry);
}
}
const distinctDirectionCount = placementState.directionCounts.size;
const mostUsedDirection = Math.max(
0,
...placementState.directionCounts.values()
);
const score =
placedWords.length * 100000 +
placementState.diagonalCount * 500 +
distinctDirectionCount * 420 +
placementState.overlapCount * 260 -
failedEntries.length * 100000 -
mostUsedDirection * 90;
return {
grid,
placedWords,
failedEntries,
placementState,
targetDiagonalCount,
score
};
}
function fillEmptyCells(grid, size, characterSet) {
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (grid[y][x] === "") {
grid[y][x] =
characterSet[randomInteger(characterSet.length)];
}
}
}
}
function generatePuzzle() {
messageElement.textContent = "";
const size = Number.parseInt(gridSizeInput.value, 10);
const collected = collectCodeEntries();
const entries = collected.entries;
if (!Number.isInteger(size) || size < 8 || size > 30) {
messageElement.textContent = "Grid size must be between 8 and 30.";
return;
}
if (collected.invalidRows.length > 0) {
messageElement.textContent =
collected.invalidRows
.map(item =>
`Row ${item.rowNumber}: Part ${item.shownPart} is selected ` +
"to appear in the public list, but it is empty."
)
.join(" ");
return;
}
if (entries.length === 0) {
messageElement.textContent = "Add at least one required word.";
return;
}
const tooLong = entries.filter(entry => entry.fullText.length > size);
if (tooLong.length > 0) {
messageElement.textContent =
"These complete code strings are longer than the grid: " +
tooLong.map(entry => entry.fullText).join(", ");
return;
}
const seed = seedInput.value.trim() || String(Date.now());
seedInput.value = seed;
let bestAttempt = null;
const attemptCount = Math.min(100, Math.max(35, entries.length * 8));
for (let attemptNumber = 0; attemptNumber < attemptCount; attemptNumber++) {
const attempt = buildPuzzleAttempt(
entries,
size,
seed,
attemptNumber
);
if (!bestAttempt || attempt.score > bestAttempt.score) {
bestAttempt = attempt;
}
const enoughDirections = Math.min(
getDirections().length,
Math.max(3, Math.ceil(entries.length * 0.55))
);
if (
attempt.failedEntries.length === 0 &&
attempt.placementState.diagonalCount >= attempt.targetDiagonalCount &&
attempt.placementState.directionCounts.size >= enoughDirections
) {
bestAttempt = attempt;
break;
}
}
const containsNumbers = bestAttempt.placedWords.some(
placedWord => /\d/.test(placedWord.placedText)
);
const fillerCharacters = containsNumbers ? alphaNumeric : alphabet;
random = createSeededRandom(`${seed}:fill`);
fillEmptyCells(bestAttempt.grid, size, fillerCharacters);
puzzle = {
grid: bestAttempt.grid,
placedWords: bestAttempt.placedWords,
size,
revealed: false
};
revealButton.textContent = "Reveal Answers & Codes";
drawPuzzle();
random = createSeededRandom(`${seed}:clues`);
renderWordList();
renderCodeKey();
const diagonalCount = bestAttempt.placementState.diagonalCount;
const directionCount = bestAttempt.placementState.directionCounts.size;
const crossingCount = bestAttempt.placementState.overlapCount;
const codedCount = bestAttempt.placedWords.filter(
placedWord => placedWord.beforeText || placedWord.afterText
).length;
statusElement.textContent =
`${bestAttempt.placedWords.length} words · ` +
`${codedCount} coded · ` +
`${diagonalCount} diagonal · ` +
`${directionCount} directions · ` +
`${crossingCount} crossings · Seed ${seed}`;
if (bestAttempt.failedEntries.length > 0) {
messageElement.textContent =
"Could not place: " +
bestAttempt.failedEntries.map(entry => entry.fullText).join(", ") +
". Try a larger grid or shorter code parts.";
}
}
function drawRoundedLine(
startX,
startY,
endX,
endY,
width,
fillColor,
outlineColor
) {
ctx.save();
ctx.strokeStyle = fillColor;
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
ctx.strokeStyle = outlineColor;
ctx.lineWidth = Math.max(2, width * 0.07);
ctx.beginPath();
ctx.moveTo(startX, startY);
ctx.lineTo(endX, endY);
ctx.stroke();
ctx.restore();
}
function getLineCoordinates(positions, margin, cellSize) {
const first = positions[0];
const last = positions[positions.length - 1];
return {
startX: margin + first.x * cellSize + cellSize / 2,
startY: margin + first.y * cellSize + cellSize / 2,
endX: margin + last.x * cellSize + cellSize / 2,
endY: margin + last.y * cellSize + cellSize / 2
};
}
function drawPuzzle() {
const size = puzzle.size;
const canvasSize = 900;
const margin = 36;
const usableSize = canvasSize - margin * 2;
const cellSize = usableSize / size;
canvas.width = canvasSize;
canvas.height = canvasSize;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "#cbd5e1";
ctx.lineWidth = 1;
for (let index = 0; index <= size; index++) {
const offset = margin + index * cellSize;
ctx.beginPath();
ctx.moveTo(margin, offset);
ctx.lineTo(margin + usableSize, offset);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(offset, margin);
ctx.lineTo(offset, margin + usableSize);
ctx.stroke();
}
if (puzzle.revealed) {
for (const placedWord of puzzle.placedWords) {
// Gold marks the entire prefix + word + suffix code string.
const fullLine = getLineCoordinates(
placedWord.fullPositions,
margin,
cellSize
);
drawRoundedLine(
fullLine.startX,
fullLine.startY,
fullLine.endX,
fullLine.endY,
Math.max(17, cellSize * 0.76),
"rgba(245, 158, 11, 0.56)",
"rgba(146, 64, 14, 0.88)"
);
// Red marks only the required word shown in the clue list.
const wordLine = getLineCoordinates(
placedWord.wordPositions,
margin,
cellSize
);
drawRoundedLine(
wordLine.startX,
wordLine.startY,
wordLine.endX,
wordLine.endY,
Math.max(12, cellSize * 0.48),
"rgba(239, 68, 68, 0.62)",
"rgba(127, 29, 29, 0.9)"
);
}
}
const fontSize = Math.max(14, Math.min(38, cellSize * 0.5));
ctx.fillStyle = "#111827";
ctx.font = `800 ${fontSize}px Arial, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
ctx.fillText(
puzzle.grid[y][x],
margin + x * cellSize + cellSize / 2,
margin + y * cellSize + cellSize / 2
);
}
}
ctx.strokeStyle = "#334155";
ctx.lineWidth = 3;
ctx.strokeRect(margin, margin, usableSize, usableSize);
}
function renderWordList() {
wordListElement.replaceChildren();
for (const placedWord of puzzle.placedWords) {
const pill = document.createElement("span");
pill.className = "word-pill";
const displayedWord = scrambleCluesInput.checked
? scrambleWord(placedWord.originalWord)
: placedWord.originalWord;
pill.textContent = displayedWord;
if (scrambleCluesInput.checked) {
pill.classList.add("scrambled");
pill.dataset.original = placedWord.originalWord;
pill.title = "Scrambled clue";
}
wordListElement.appendChild(pill);
}
}
function appendCodePart(container, value, className) {
if (!value) {
return;
}
const span = document.createElement("span");
span.className = className;
span.textContent = value;
container.appendChild(span);
}
function renderCodeKey() {
codeKeyElement.replaceChildren();
const title = document.createElement("h2");
title.className = "code-key-title";
title.textContent = "Revealed code key";
codeKeyElement.appendChild(title);
for (const placedWord of puzzle.placedWords) {
const row = document.createElement("div");
row.className = "code-row";
const clue = document.createElement("span");
clue.className = "code-clue";
clue.textContent = placedWord.originalWord;
const arrow = document.createElement("span");
arrow.className = "code-arrow";
arrow.textContent = "→";
const code = document.createElement("span");
code.className = "code-value";
code.title = `Complete code: ${placedWord.fullCode}`;
placedWord.parts.forEach((part, index) => {
const className =
index + 1 === placedWord.shownPart
? "target-part"
: "code-part";
appendCodePart(code, part, className);
});
row.append(clue, arrow, code);
codeKeyElement.appendChild(row);
}
codeKeyElement.hidden = !puzzle.revealed;
answerLegendElement.classList.toggle("visible", puzzle.revealed);
}
function toggleAnswers() {
if (puzzle.placedWords.length === 0) {
messageElement.textContent = "Generate a puzzle first.";
return;
}
puzzle.revealed = !puzzle.revealed;
revealButton.textContent = puzzle.revealed
? "Hide Answers & Codes"
: "Reveal Answers & Codes";
drawPuzzle();
renderCodeKey();
}
function downloadCanvas() {
if (puzzle.grid.length === 0) {
messageElement.textContent = "Generate a puzzle first.";
return;
}
const link = document.createElement("a");
link.download = puzzle.revealed
? "word-search-code-key.png"
: "word-search-puzzle.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
generateButton.addEventListener("click", generatePuzzle);
revealButton.addEventListener("click", toggleAnswers);
downloadButton.addEventListener("click", downloadCanvas);
printButton.addEventListener("click", () => window.print());
scrambleCluesInput.addEventListener("change", () => {
if (puzzle.placedWords.length > 0) {
const seed = seedInput.value.trim() || "word-list";
random = createSeededRandom(`${seed}-clues`);
renderWordList();
}
});
addEntryButton.addEventListener("click", () => {
const row = createEntryRow({
parts: ["", "", ""],
shownPart: 2
});
row.querySelector(".part-2-input").focus();
});
const initialEntries = [
{ parts: ["X7", "APPLE", "Q2"], shownPart: 2 },
{ parts: ["BANANA", "BONUS", "VIP"], shownPart: 1 },
{ parts: ["WIN", "ORANGE", "9K"], shownPart: 2 },
{ parts: ["GRAPE", "", ""], shownPart: 1 },
{ parts: ["M", "MANGO", "Z"], shownPart: 2 },
{ parts: ["CODE", "CHERRY", "DROP"], shownPart: 2 },
{ parts: ["PEACH", "PLUS", "WIN"], shownPart: 1 }
];
for (const entry of initialEntries) {
createEntryRow(entry);
}
generatePuzzle();
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment