Lettras Word Search
สร้างปริศนาค้นหาคำใน 6 ภาษา (es, en, pt, fr, de, it) พร้อมตัวอักษรที่มีเครื่องหมายกำกับเสียงตามภาษาต้นฉบับ
เอกสาร
Lettras SDK
Word searches, crosswords, sudoku and Unicode text styles in six languages, with every accent intact.
Developer tools for Lettras, the free word-puzzle platform. Generate word searches from any word list in Spanish, English, Portuguese, French, German and Italian, with native letters (Ñ, Ç, Ã, Ä, ẞ, È…) kept as one cell each, from your own code, a terminal, or an AI assistant. Besides word searches there are crosswords (crucigramas), sudoku and Unicode text styles.
| Tool | What it is | Install / connect | Source |
|---|---|---|---|
lettras on npm | JavaScript/TypeScript library and CLI. Runs locally, no network or API key | npm install lettras (npm) | npm/ |
lettras for Kotlin | Kotlin library for the JVM and Android. Same engine, runs locally | org.lettras.artificialss:lettras (Maven Central) | kotlin/ |
lettras for Java | The Kotlin library works from plain Java (JVM 17+) | org.lettras.artificialss:lettras | kotlin/ |
lettras for Go | Go module, pure Go (no cgo), every OS and architecture | go get github.com/Artificialss/lettras-sdk/go | go/ |
lettras for Swift | Swift package for Apple platforms and Linux | https://github.com/Artificialss/lettras-sdk (SwiftPM) | swift/ |
Lettras for .NET | C# library for .NET 8+ on Windows, macOS and Linux | dotnet add package Lettras | dotnet/ |
lettras for Rust | Rust crate, engine embedded and run in-process | cargo add lettras | rust/ |
lettras for Dart and Flutter | Dart package, engine embedded and run in-process | dart pub add lettras (pub.dev) | dart/ |
| Lettras for VS Code | Extension: write text in 22 Unicode styles, insert word searches and sudokus | VS Code Marketplace, artificialss.lettras | vscode/ |
| Lettras for JetBrains | Plugin for IntelliJ IDEA, PyCharm, WebStorm, Android Studio and more | JetBrains Marketplace, "Lettras" (id ai.artificialss.lettras) | jetbrains/, upload guide |
lettras for Homebrew | The command-line tool as a formula, no Node setup | brew install artificialss/lettras/lettras | homebrew/ |
| Lettras MCP server | Lets Claude and other MCP clients create puzzles. Written in Rust, runs on Vercel | https://mcp.lettras.org/mcp (MCP Registry) | mcp/ |
Published. Live today: the npm package, the Kotlin library on Maven Central (which also serves Java), and the hosted MCP server (also listed in the official MCP Registry). The Rust, Go, Swift, .NET and Dart libraries, the VS Code extension, the JetBrains plugin and the Homebrew formula are built and tested here and are being published to their own stores (crates.io, pkg.go.dev, Swift Package Index, NuGet, pub.dev, the VS Code and JetBrains marketplaces, a Homebrew tap). A documentation site is planned.
Every library on one page: docs/LIBRARIES.md.
Table of contents
- npm package
- Kotlin library
- MCP server
- Text styles
- Crossword (crucigrama)
- Blocked words
- Sudoku
- How puzzles are built
- Architecture and licensing
- Repository layout
- Development
- License
- About
npm package
npm install lettras
import { generate, render } from 'lettras';
const puzzle = generate({
words: ['gato', 'perro', 'piña', 'mono', 'cebra'],
rows: 9,
cols: 12,
position: 'mixed', // horizontal | vertical | mixed (all 8 directions)
seed: 8, // same input and seed, same grid
});
console.log(render(puzzle));
9×12 seed 8 engine 0.5.0
- - - - - - - - - - - -
- - - - - - - - - - - -
- - - - C - - - - - - -
- - - - - E - O N O M -
- - A - - - B - R - - -
O - - Ñ - - - R - - - -
T - - - I - E - A - - -
A - - - - P - - - - - -
G - - - - - - - - - - -
Words: gato, perro, piña, mono, cebra
piña takes four cells (the Ñ is one), mono is written right to left, and perro and cebra run on diagonals.
The grid is plain data: puzzle.grid is an array of rows, each an array of one-letter strings.
CLI
npx lettras --words gato,perro,piña --rows 9 --cols 12 --position mixed --seed 8
npx lettras --words sol,luna,mar --rows 8 --json # raw JSON
npx lettras --words sol,luna,mar --rows 8 --solution # only the hidden words
Options
| Option | Values |
|---|---|
words | Words to hide, in normal spelling. Accents are kept. |
rows, cols | Grid size. They can differ for a rectangular grid. |
position | horizontal (left→right), vertical (top→bottom), mixed (all 8 directions, diagonals included). |
difficulty | 1–4. Sets the directions when position is not given. |
clustering | 0 words apart · 1 words crossing · default 0.5. |
seed | Repeatable puzzles. |
lang | es (default) en pt fr de it. |
classicMode | Strip accents in the grid (ñ becomes N). |
fill | Character for empty cells. Default -. |
Fill the empty cells with random letters. generate leaves empty cells as -. Pass the result to fill, with the
language and accents on or off:
import { generate, fill } from 'lettras';
const puzzle = generate({ words: ['gato', 'perro', 'piña'], rows: 9, cols: 12, position: 'mixed', seed: 8 });
const done = fill(puzzle, { lang: 'es', accents: true }); // or: fill(puzzle.grid, { lang: 'es', accents: false })
done.grid; // the same matrix, every "-" replaced by a random letter
fill option | Meaning |
|---|---|
lang | es (default) en pt fr de it. Letters follow how common they are in that language. |
accents | true (default): include the language's accented/native letters (Ñ, Ç, Ã, Ä, ẞ…). false: plain A-Z only. |
seed | Repeatable filler. Without one, every call gives different letters. |
words | The hidden words. Added automatically when you pass the puzzle; the filler never creates an extra copy of one. |
On the CLI: lettras --words gato,piña --rows 9 --random --accents off.
Example. The same puzzle before and after fill (Spanish, seed 5):
const puzzle = generate({ words: ['gato', 'perro', 'piña', 'mono', 'cebra'], rows: 8, cols: 10, position: 'mixed', seed: 8 });
const done = fill(puzzle, { lang: 'es', accents: true, seed: 5 });
puzzle.grid has - in every empty cell; done.grid replaces them (58 cells here):
C E B R A - O - - -
- - - - - - - T O -
- - - - M - - R A -
- - - O - - R - - G
- - N - - E - - - -
- O - - P - - - - -
- - - - - - - - - -
- - - - - - A Ñ I P
C E B R A R O G G J
D R A E E R C T O D
B Ó E E M Í M R A Ñ
T S C O N R R A E G
T R N E A E R O E M
I O O E P S N P A O
O C O O A P P A U A
I R E A A S A Ñ I P
With accents: false the filler uses plain A-Z only (the one Ñ left is the hidden word piña, which keeps its spelling):
C E B R A R O G G I
D Q A E E P C T O D
B Y E E M X M R A N
S R C O N R R A E G
S R N E A E R O E M
I O O E P S N O A O
O B O O A P O A T A
H R E A A S A Ñ I P
Same grid, language, accents and seed always give the same filler on every platform. Without a seed the wrappers
pick a random one, so every call gives different letters.
Result: grid, placements (word, start r/c, step dr/dc, length), words (placed), unplaced
(words that did not fit, never dropped silently), rejected (with reasons), seed, engineVersion.
TypeScript types are included. Full notes: npm/README.md.
Kotlin library
For JVM and Android apps (and the mobile game). Same engine, same results, no network.
val lettras = Lettras() // create once, reuse
val puzzle = lettras.generate(
PuzzleRequest(words = listOf("gato", "perro", "piña"), rows = 9, cols = 12, position = Position.MIXED, seed = 8),
)
puzzle.grid // List<List<String>>, one letter per cell
puzzle.placements // where each word is hidden
puzzle.render() // text view
val done = lettras.fill(puzzle, lang = Language.ES, accents = true) // empty cells become random letters
done.grid
The engine is the same WebAssembly binary the MCP server uses, run by Chicory
(pure Java, no native libraries to build per CPU). On the JVM it is compiled to bytecode; on Android it uses the
interpreter. The tests check it returns exactly the grids the npm package returns. Install notes, API, performance
and Android details: kotlin/README.md.
MCP server
An MCP server that lets an AI assistant create puzzles for you. Ask for
"a 12×12 sopa de letras about animals, mixed directions, with random letters" and it calls generate_word_search, then fill_word_search. It can also write text in Unicode styles with stylize_text.
- Transport: streamable HTTP,
POST /mcp(JSON-RPC). Protocol versions 2025-06-18, 2025-03-26, 2024-11-05. - Runs the engine locally: the compiled engine is embedded in the server and executed in-process. It makes no outside calls and needs no credentials to start.
- Written in Rust on Vercel's Rust runtime (axum).
Connect a client (hosted endpoint: https://mcp.lettras.org/mcp):
# Claude Code
claude mcp add --transport http lettras https://mcp.lettras.org/mcp
// Claude Desktop, Cursor and other clients that take a remote server URL
{ "mcpServers": { "lettras": { "url": "https://mcp.lettras.org/mcp" } } }
Tools
| Tool | Purpose |
|---|---|
generate_word_search | Create a puzzle. Arguments match the options above (words, rows, cols, position, difficulty, clustering, seed, lang, classicMode). Returns a readable grid and the full structured result. |
fill_word_search | Complete a puzzle: takes the grid from generate_word_search (empty cells -) and fills them with random letters in the chosen lang, with accents on or off. Pass the puzzle's words so the filler never creates an extra copy. Does not use up the puzzle limit; it has its own, larger one. |
stylize_text | Text in Unicode styles (bold, italic, script, fraktur, circled, strikethrough... 22 in all) that keep accents. style is one id, several, or omitted for all; classic strips accents first. Reports in notNative and parts[].missing what a style cannot draw (ß, Æ, Œ, Ø, non-Latin scripts, accents that only exist as combining marks), so you can warn the user. No usage limit. |
list_styles | The style ids stylize_text accepts, with an English name and a category. No usage limit. |
crossword | A crossword (crucigrama) from entries ({ word, clue }) inside rows x cols: returns a puzzle matrix (- blank, word numbers, 0, and a/d such as 3/4 where an across and a down word start in the same cell), a solution matrix and placements with the clues. No usage limit. See docs/CROSSWORD.md. |
sudoku | A sudoku with exactly one solution: size 4, 6, 9, 12 or 16, difficulty 1 to 4, seed. No usage limit. |
solve_sudoku | Solves a grid (0 = empty) and says whether the solution is unique. No usage limit. See docs/SUDOKU.md. |
list_languages | The supported languages and the native letters each adds to its grid. |
Invalid arguments come back as a tool error written for the model to act on (for example
rows: an integer from 6 to 30 is required).
Free tiers
| What | Free use |
|---|---|
generate_word_search, small: up to 10×10 with at most 10 words | Unlimited. Never counted. |
generate_word_search, larger: a bigger grid (up to 30×30) or more than 10 words | 5 puzzles per client, in total, forever (there is no daily reset). |
fill_word_search | 100 calls per client, separate from the puzzles. |
stylize_text, list_styles, crossword, sudoku, solve_sudoku, list_languages | No limit. |
After a limit the tool answers with "Free limit reached… Visit https://lettras.org". Invalid requests never count.
Limits and privacy. A client is identified by a SHA-256 hash of its IP address, with the
whole IPv6 /64 treated as one client so that rotating addresses does not dodge the limit. Because the limit never
resets, the server keeps one row per client indefinitely: the hash, a count and a timestamp (no addresses, no words, no
puzzles). The request log carries the same hash prefix, never the address. A hash of an IP address can still count as
personal data in some jurisdictions, so mention it in your own privacy notice if you operate your own copy. If the
database is unreachable the request is allowed (and a usage_store_error line is logged), so a storage outage never takes
the service down. The hosted endpoint is public and unauthenticated; small puzzles, styles and languages are free to use, while bigger puzzles are a free trial of five, not a bulk
service. To run your own copy with a daily limit instead, set LETTRAS_LIMIT_WINDOW_SECS=86400.
Deploy on Vercel
- Create a Vercel project from this repository and set Root Directory to
mcp. - Optional but recommended: a Postgres database so the 5-puzzle counter is shared across instances (without it the
counter lives in each instance's memory and is not reliable). Create a Neon project, copy its
pooled connection string into the project's environment variables as
DATABASE_URL(mark it sensitive). The server creates its ownmcp_usagetable on first use; it stores a hash, a count and a timestamp per client. - Optional settings, all in
.env.example:LETTRAS_FREE_LIMIT,LETTRAS_FILL_LIMIT,LETTRAS_LIMIT_WINDOW_SECS(0 = lifetime total, the default),LETTRAS_UPGRADE_URL. - Optional: add your own domain under the project's Settings → Domains (a
CNAMEpointing tocname.vercel-dns.com; do not change the domain's nameservers). - Deploy. Every puzzle request writes one JSON line to the function logs (hashed client, count, allowed or blocked, grid size, language), which you can read in Vercel Logs or forward with a log drain.
The server is described for MCP registries in server.json.
No secret is stored in this repository. Real values belong in Vercel's project settings.
Text styles
Besides puzzles, the same engine writes text in Unicode styles that keep accents (22 styles). It is what powers Social Media Styles at lettras.org.
import { stylize, unstyle, listStyles } from 'lettras';
const out = stylize('Lettras ejemplo: piña, corazón', { style: ['bold', 'script'] });
out.results[0].text; // 𝐋𝐞𝐭𝐭𝐫𝐚𝐬 𝐞𝐣𝐞𝐦𝐩𝐥𝐨: 𝐩𝐢𝐧̃𝐚, 𝐜𝐨𝐫𝐚𝐳𝐨́𝐧 (ñ = styled n + combining tilde)
out.results[0].notNative; // ['ñ', 'ó']: accents that exist in no style as one character
unstyle(out.results[0].text); // 'Lettras ejemplo: piña, corazón'
listStyles(); // [{ id: 'bold', label: 'Bold', category: 'bold-italic' }, ...]
style is one id, an array of ids, or omitted for all 22. classic: true strips accents first. parts cuts the output into pieces,
and missing: true marks what a style cannot draw (ß, Æ, Œ, Ø, non-Latin scripts), so a UI can show an empty box like a phone without
that glyph. Text is limited to 10,000 characters. The same functions exist in Kotlin (stylize, unstyle, listStyles) and as the MCP tools
stylize_text and list_styles.
The name in every style
stylize('Lettras') (real output). The list is in display order, which is also the order of listStyles().
| Style id | Name | Lettras |
|---|---|---|
bold | Bold | 𝐋𝐞𝐭𝐭𝐫𝐚𝐬 |
italic | Italic | 𝐿𝑒𝑡𝑡𝑟𝑎𝑠 |
bold-italic | Bold italic | 𝑳𝒆𝒕𝒕𝒓𝒂𝒔 |
sans | Sans-serif | 𝖫𝖾𝗍𝗍𝗋𝖺𝗌 |
sans-bold | Sans-serif bold | 𝗟𝗲𝘁𝘁𝗿𝗮𝘀 |
sans-italic | Sans-serif italic | 𝘓𝘦𝘵𝘵𝘳𝘢𝘴 |
sans-bold-italic | Sans-serif bold italic | 𝙇𝙚𝙩𝙩𝙧𝙖𝙨 |
script | Script | ℒℯ𝓉𝓉𝓇𝒶𝓈 |
bold-script | Bold script | 𝓛𝓮𝓽𝓽𝓻𝓪𝓼 |
fraktur | Fraktur | 𝔏𝔢𝔱𝔱𝔯𝔞𝔰 |
bold-fraktur | Bold fraktur | 𝕷𝖊𝖙𝖙𝖗𝖆𝖘 |
double-struck | Double-struck | 𝕃𝕖𝕥𝕥𝕣𝕒𝕤 |
monospace | Monospace | 𝙻𝚎𝚝𝚝𝚛𝚊𝚜 |
small-caps | Small caps | ʟᴇᴛᴛʀᴀꜱ |
fullwidth | Fullwidth | Lettras |
circled | Circled | Ⓛⓔⓣⓣⓡⓐⓢ |
negative-circled | Negative circled | 🅛🅔🅣🅣🅡🅐🅢 |
squared | Squared | 🄻🄴🅃🅃🅁🄰🅂 |
negative-squared | Negative squared | 🅻🅴🆃🆃🆁🅰🆂 |
parenthesized | Parenthesized | 🄛⒠⒯⒯⒭⒜⒮ |
strikethrough | Strikethrough | L̶e̶t̶t̶r̶a̶s̶ |
underline | Underline | L̲e̲t̲t̲r̲a̲s̲ |
Examples
Every style applied to Lettras ejemplo: piña (real output of stylize). The accent in piña is the styled n plus a combining
tilde, so how it looks depends on the font; the decoration styles (strikethrough, underline) mark every character, accents included.
| Style id | Result |
|---|---|
bold | 𝐋𝐞𝐭𝐭𝐫𝐚𝐬 𝐞𝐣𝐞𝐦𝐩𝐥𝐨: 𝐩𝐢𝐧̃𝐚 |
italic | 𝐿𝑒𝑡𝑡𝑟𝑎𝑠 𝑒𝑗𝑒𝑚𝑝𝑙𝑜: 𝑝𝑖𝑛̃𝑎 |
bold-italic | 𝑳𝒆𝒕𝒕𝒓𝒂𝒔 𝒆𝒋𝒆𝒎𝒑𝒍𝒐: 𝒑𝒊𝒏̃𝒂 |
sans | 𝖫𝖾𝗍𝗍𝗋𝖺𝗌 𝖾𝗃𝖾𝗆𝗉𝗅𝗈: 𝗉𝗂𝗇̃𝖺 |
sans-bold | 𝗟𝗲𝘁𝘁𝗿𝗮𝘀 𝗲𝗷𝗲𝗺𝗽𝗹𝗼: 𝗽𝗶𝗻̃𝗮 |
sans-italic | 𝘓𝘦𝘵𝘵𝘳𝘢𝘴 𝘦𝘫𝘦𝘮𝘱𝘭𝘰: 𝘱𝘪𝘯̃𝘢 |
sans-bold-italic | 𝙇𝙚𝙩𝙩𝙧𝙖𝙨 𝙚𝙟𝙚𝙢𝙥𝙡𝙤: 𝙥𝙞𝙣̃𝙖 |
script | ℒℯ𝓉𝓉𝓇𝒶𝓈 ℯ𝒿ℯ𝓂𝓅𝓁ℴ: 𝓅𝒾𝓃̃𝒶 |
bold-script | 𝓛𝓮𝓽𝓽𝓻𝓪𝓼 𝓮𝓳𝓮𝓶𝓹𝓵𝓸: 𝓹𝓲𝓷̃𝓪 |
fraktur | 𝔏𝔢𝔱𝔱𝔯𝔞𝔰 𝔢𝔧𝔢𝔪𝔭𝔩𝔬: 𝔭𝔦𝔫̃𝔞 |
bold-fraktur | 𝕷𝖊𝖙𝖙𝖗𝖆𝖘 𝖊𝖏𝖊𝖒𝖕𝖑𝖔: 𝖕𝖎𝖓̃𝖆 |
double-struck | 𝕃𝕖𝕥𝕥𝕣𝕒𝕤 𝕖𝕛𝕖𝕞𝕡𝕝𝕠: 𝕡𝕚𝕟̃𝕒 |
monospace | 𝙻𝚎𝚝𝚝𝚛𝚊𝚜 𝚎𝚓𝚎𝚖𝚙𝚕𝚘: 𝚙𝚒𝚗̃𝚊 |
small-caps | ʟᴇᴛᴛʀᴀꜱ ᴇᴊᴇᴍᴘʟᴏ: ᴘɪɴ̃ᴀ |
fullwidth | Lettras ejemplo: piña |
circled | Ⓛⓔⓣⓣⓡⓐⓢ ⓔⓙⓔⓜⓟⓛⓞ: ⓟⓘⓝ̃ⓐ |
negative-circled | 🅛🅔🅣🅣🅡🅐🅢 🅔🅙🅔🅜🅟🅛🅞: 🅟🅘🅝̃🅐 |
squared | 🄻🄴🅃🅃🅁🄰🅂 🄴🄹🄴🄼🄿🄻🄾: 🄿🄸🄽̃🄰 |
negative-squared | 🅻🅴🆃🆃🆁🅰🆂 🅴🅹🅴🅼🅿🅻🅾: 🅿🅸🅽̃🅰 |
parenthesized | 🄛⒠⒯⒯⒭⒜⒮ ⒠⒥⒠⒨⒫⒧⒪: ⒫⒤⒩̃⒜ |
strikethrough | L̶e̶t̶t̶r̶a̶s̶ e̶j̶e̶m̶p̶l̶o̶:̶ p̶i̶ñ̶a̶ |
underline | L̲e̲t̲t̲r̲a̲s̲ e̲j̲e̲m̲p̲l̲o̲:̲ p̲i̲ñ̲a̲ |
What a style cannot draw. ñ, ó, ü... are flagged in notNative for every letter style, because the accent is a combining mark. ß, Æ, Œ, Ø and non-Latin scripts have no styled version and stay plain:
stylize('Straße', { style: 'script' }).results[0].parts;
// [ { text: '𝒮𝓉𝓇𝒶', missing: false }, { text: 'ß', missing: true }, { text: 'ℯ', missing: false } ]
// show the missing piece as an empty box, or warn the user
Classic mode strips accents first, so nothing is flagged:
stylize('piña', { style: 'bold', classic: true }).results[0];
// { id: 'bold', text: '𝐩𝐢𝐧𝐚', notNative: [], ... }
One style, several styles, or all (results is always an array):
stylize('Hola', { style: 'bold' }).results.length; // 1
stylize('Hola', { style: ['bold', 'script'] }).results.length; // 2
stylize('Hola').results.length; // 22
Reference: docs/TEXT_STYLES.md.
Kotlin and MCP. lettras.stylize("piña", style = "bold") returns the same results; in an MCP client, ask for "write 'Lettras ejemplo' in bold and script"
and the assistant calls stylize_text with { "text": "Lettras ejemplo", "style": ["bold", "script"] }.
Crossword (crucigrama)
Words with clues in, a crossword out: a puzzle matrix to play and a solution matrix to check. Every language works and accents are kept.
Use it
import { crossword } from 'lettras';
const out = crossword({
entries: [
{ word: 'gato', clue: 'Maúlla y caza ratones' },
{ word: 'perro', clue: 'El mejor amigo del hombre' },
{ word: 'mono', clue: 'Trepa por los árboles' },
{ word: 'cebra', clue: 'Rayas blancas y negras' },
{ word: 'oso', clue: 'Duerme en invierno' },
{ word: 'rata', clue: 'Roedor pequeño' },
{ word: 'toro', clue: 'Embiste al capote' },
{ word: 'piña', clue: 'Fruta tropical con corona' },
{ word: 'ñu', clue: 'Antílope africano' },
],
rows: 12, cols: 12, // the largest it may take; the result is cropped
seed: 4, // optional: same input and seed, same crossword
});
Kotlin
val c = Lettras().crossword(listOf("gato" to "Maúlla y caza ratones", "perro" to "El mejor amigo del hombre"), rows = 12, seed = 4)
c.puzzle; c.solution; c.placements; c.clues()
MCP (tool crossword, free, no usage limit): ask "make a Spanish animals crossword with these words and clues", or call it with
{ "name": "crossword", "arguments": { "entries": [ { "word": "gato", "clue": "Maúlla y caza ratones" }, { "word": "perro", "clue": "El mejor amigo del hombre" } ], "rows": 12, "seed": 4 } }
Example output
out.puzzle (7 rows x 11 columns here, what the player sees):
- - - - - 1 - 2 - - -
- - - - - 0 - 0 - - 3
- - - - - 0 - 4 0 0 0
- - - 5/6 0 0 0 0 - - 0
- - - 0 - 0 - - 7 0 0
- - - 8 0 - - - - - -
9 0 0 0 - - - - - - -
out.solution (the answers):
- - - - - C - G - - -
- - - - - E - A - - M
- - - - - B - T O R O
- - - P E R R O - - N
- - - I - A - - O S O
- - - Ñ U - - - - - -
R A T A - - - - - - -
out.placements lists every word with its number, clue, direction and first cell (r row, c column, both from 0):
{ "number": 1, "word": "cebra", "clue": "Rayas blancas y negras", "direction": "down", "r": 0, "c": 5, "length": 5 }
{ "number": 5, "word": "perro", "clue": "El mejor amigo del hombre", "direction": "across", "r": 3, "c": 3, "length": 5 }
{ "number": 6, "word": "piña", "clue": "Fruta tropical con corona", "direction": "down", "r": 3, "c": 3, "length": 4 }
The clue lists for the player come straight from placements:
Across Down
4. Embiste al capote (4) 1. Rayas blancas y negras (5)
5. El mejor amigo del hombre (5) 2. Maúlla y caza ratones (4)
7. Duerme en invierno (3) 3. Trepa por los árboles (4)
8. Antílope africano (2) 6. Fruta tropical con corona (4)
9. Roedor pequeño (4)
Other fields: rows, cols (size of the cropped grid), unplaced, rejected (words refused, with a reason), seed, engineVersion.
Options: entries, rows, cols, seed, attempts (1 to 200, default 30: more attempts, denser crossword), classicMode (strip accents),
block and open (the markers above). Every language works and accents are kept.
How it works
- You give words with clues and the biggest size the crossword may take (
rowsxcols). Offer more words than you need. - The engine tries many layouts (
attempts, default 30). Each one starts with the longest word; every next word must cross a word already on the board, and words only touch where they cross, so every run of two or more letters is a real entry. - The best layout wins: most words placed, then most crossings, then the smallest box. The grid is cropped to what it uses, and words
that did not fit come back in
unplaced. The same input andseedalways give the same crossword. - Every word gets its own number, 1 to N in reading order of its first cell. When an across word and a down word start in the same
cell, that cell shows both numbers as
across/down(5/6below: word 5 goes across, word 6 goes down). - You get two matrices of the same size:
puzzleis what the player sees,solutionhas the answers. Showpuzzlewith an empty box where a0is, and check the player's letters againstsolution.
Cell in puzzle | Meaning |
|---|---|
- | Blank: no word goes through this cell (change it with block). |
1, 2, 3... | A word starts here. Its number matches placements[].number. |
5/6 | An across word (5) and a down word (6) both start here. |
0 | A letter goes here, but no word starts here (change it with open). |
In solution every letter cell holds one grapheme (Ñ and ẞ are one cell) and blanks are -.
Reference: docs/CROSSWORD.md · Kotlin: kotlin/README.md.
Sudoku
Puzzles with exactly one solution, in five sizes, and a solver.
Use it
import { sudoku, solveSudoku } from 'lettras';
const s = sudoku({ size: 9, difficulty: 2, seed: 5 });
s.givens; // 32 clues
s.puzzle; // 9 rows of numbers, 0 = empty cell
s.solution; // the same grid solved
Kotlin
val s = Lettras().sudoku(size = 9, difficulty = 2, seed = 5)
val r = Lettras().solveSudoku(s.puzzle) // solution, solved, unique
MCP (tools sudoku and solve_sudoku, free, no usage limit): ask "give me an easy 4x4 sudoku for a child", or call them with
{ "name": "sudoku", "arguments": { "size": 9, "difficulty": 2, "seed": 5 } }
{ "name": "solve_sudoku", "arguments": { "puzzle": [[0,7,5,2,3,0,6,9,0], "... 8 more rows ..."] } }
Example output
s.puzzle (· is a 0) and s.solution:
· 7 5 2 3 · 6 9 · 8 7 5 2 3 4 6 9 1
· · · · · 7 4 2 3 1 6 9 8 5 7 4 2 3
· · · · · 1 5 · 7 3 2 4 9 6 1 5 8 7
· · · · · 3 2 5 · 9 4 8 1 7 3 2 5 6
2 · 6 · 9 8 · 1 · 2 3 6 5 9 8 7 1 4
· 1 · · · · · · · 5 1 7 6 4 2 8 3 9
· · · · 1 · 9 · 8 7 5 2 3 1 6 9 4 8
4 · 3 · · · · · 2 4 9 3 7 8 5 1 6 2
6 8 · 4 · 9 · · · 6 8 1 4 2 9 3 7 5
Solve a grid (yours or a generated one):
solveSudoku(s.puzzle); // { solution: [...], solved: true, unique: true, engineVersion: '0.5.0' }
A small one for children (size: 4, difficulty: 1, seed: 1):
{ "puzzle": [[4,1,3,0],[2,3,0,4],[0,0,0,0],[0,4,0,3]],
"solution": [[4,1,3,2],[2,3,1,4],[3,2,4,1],[1,4,2,3]], "size": 4, "boxRows": 2, "boxCols": 2, "givens": 8 }
Result fields: puzzle, solution, size, boxRows, boxCols, givens, difficulty, seed, engineVersion.
Invalid input (a size that does not exist, a number repeated in a row, column or box) throws with the reason.
How it works
- A full valid grid is built with a randomized search (every row, column and box holds each number once), so each
seedgives a different one. - Cells are taken out one at a time, in random order, and a removal is kept only if the puzzle still has exactly one solution.
difficultysets how many clues stay: 1 easy (most clues) to 4 expert (fewest). - You get
puzzle(clues as numbers,0for the empty cells) andsolution(the same grid solved). The same input andseedgive the same sudoku. solveSudokusolves any grid you give it and says whether the answer is the only one (unique).
size | Boxes | Numbers |
|---|---|---|
| 4 | 2 x 2 | 1 to 4 |
| 6 | 2 x 3 | 1 to 6 |
| 9 | 3 x 3 | 1 to 9 (the classic) |
| 12 | 3 x 4 | 1 to 12 |
| 16 | 4 x 4 | 1 to 16 |
Clues left on a 9 x 9 grid: about 41 / 32 / 28 / 24 for difficulty 1 / 2 / 3 / 4. The 12 x 12 and 16 x 16 grids stop removing clues after about half a second, so their hardest levels keep a few more.
Reference: docs/SUDOKU.md · Kotlin: kotlin/README.md.
How puzzles are built
- Normalize. Each word becomes one grapheme per cell, uppercase, with
ß→ẞ(a plain uppercase would giveSS). Words with spaces, digits or punctuation, duplicates, and words contained in another word are rejected and reported with a reason. - Place longest first. Every spot that fits is considered: inside the grid, on empty cells or on cells holding the same letter.
- Choose the spot. The least-used direction goes first, so diagonals and reversed words really appear. Each word
then either crosses existing words or sits apart from them;
clusteringsets the odds. - Verify. Every word must appear exactly once, otherwise the engine retries with the next seed. A word that cannot
be placed is returned in
unplaced. - Fill (a separate call). Empty cells get random letters, weighted by how common each letter is in the chosen language, with accents on or off. About 30% copy letters already in the grid so rare letters do not give the hidden words away, and any accidental extra copy of a hidden word is repaired.
- Check for blocked words. The finished grid is read in every row, column and diagonal, both ways, ignoring case and accents. If the filler letters spell a word from the built-in blocklist, one of them is changed and the grid is checked again. The words you hide are never touched. See Blocked words.
Deterministic: the same input and seed give the same grid everywhere, on every platform.
Architecture and licensing
Lettras engine (Rust, proprietary source, private repository)
│ compiled, stripped, size-optimised
┌───────┴──────────────────────────────┐
▼ ▼
npm/engine/ mcp/engine/ (and kotlin/ resources)
WebAssembly + loader WebAssembly (C ABI), one copy per library
used by the npm package MCP server (wasmi), Kotlin and Java (Chicory), Rust (wasmi),
Go (wazero), Swift (WasmKit), .NET (Wasmtime)
This repository contains the wrappers, CLI, MCP server, tests and documentation. The puzzle engine itself is
developed in a private repository and ships here only as compiled WebAssembly in npm/engine/, mcp/engine/,
kotlin/src/main/resources/, rust/engine/, go/engine/, swift/Sources/Lettras/Resources/, dotnet/src/Lettras/ and dart/lib/src/engine_data.dart.
All copies come from the same build and are checked against each other: the MCP server's and the Kotlin library's
tests assert that they return exactly the same grids as the npm package for the same input.
Repository layout
npm/ the `lettras` package: library, CLI, types, tests
engine/ compiled engine (proprietary, see engine/LICENSE)
kotlin/ Kotlin library (JVM and Android): API, Chicory host, tests
src/main/resources compiled engine (proprietary, see LICENSE-ENGINE)
assets/ brand kit: logos, icons and social images (see assets/README.md)
mcp/ MCP server (Rust): protocol, tools, free-tier gate, Vercel entry point
engine/ compiled engine (proprietary, see engine/LICENSE)
rust/ the `lettras` crate (crates.io): API, wasmi host, tests
go/ Go module: API, wazero host (pure Go), tests
swift/ Swift package (Package.swift is at the repository root): API, WasmKit host, tests
dotnet/ .NET package `Lettras`: API, Wasmtime host, tests
dart/ Dart and Flutter package `lettras` (pub.dev): API, wasm_run host, tests
vscode/ VS Code extension (uses the npm package): commands, tests, packaging
jetbrains/ JetBrains plugin (uses the Kotlin library): actions, tests
homebrew/ Homebrew formula for the command-line tool
.env.example every optional setting, with no values
Development
# npm package
cd npm && npm test # 25 tests
# Kotlin library (JDK 17)
cd kotlin && ./gradlew test # 33 tests, including exact parity with the npm package
# Rust crate, Go module, Swift package, .NET package
cd rust && cargo test # 6 tests + doc test
cd go && go test ./... # 3 tests; cross-build with GOOS=linux GOARCH=arm64 go build ./...
swift test # 4 tests (repository root)
cd dart && dart test # 4 tests (run `dart run wasm_run:setup` once first)
cd vscode && npm test # 5 tests; npm run package builds the .vsix
cd jetbrains && ./gradlew test # 5 tests; ./gradlew buildPlugin builds the zip
cd dotnet/tests/Lettras.Tests && dotnet test # 4 tests (.NET 8)
# MCP server (Rust 1.80+)
cd mcp && cargo test --release # 43 tests, including exact parity with the npm package
# counter against a real Postgres (optional):
# TEST_DATABASE_URL=postgres://... cargo test --release --test pg -- --ignored
The compiled engine in engine/ comes from Lettras' own build, so pull requests that change it cannot be accepted. Issues and suggestions about the wrappers, the MCP server or the docs are welcome.
Blocked words
Random filler can spell a word by accident, so the engine carries a built-in list of words that must never show up in a puzzle, for all six languages together (a puzzle in one language can spell a word of another). It covers vulgar and sexual terms, slurs and insults, drugs, alcohol, violence and weapons, death, self-harm, and other words we judged unsuitable for children. The current release holds about 30,600 words; the next one raises it to about 36,700 (plain A-Z, 3 to 12 letters, checked in every row, column and diagonal in both directions, ignoring case and accents).
- Filler is repaired automatically: a blocked word is never left in a grid you fill.
findBlocked(npm and Kotlin) returns any blocked words still readable in a grid, for example one you hid yourself; the fill result lists them inblockedLeftwhen there are any.- The list is the same one that keeps these words out of the lettras.org dictionary, where every word also has a short, child-safe description that serves as the crossword clue. The lettras.org crossword maker only uses words that have one.
Limits of the engine
The engine accepts grids up to 100×100, 500 words of up to 100 characters and cells of up to 8 characters; the MCP server and the
public examples use 6-30. The empty-cell marker (fill when generating, empty when filling) must be one character that is
not a letter or digit. Anything else returns a readable error. A fuzz test in the engine's repository throws 100,000 hostile
inputs (combining marks, emoji, right-to-left text, zero-width joiners, garbage JSON) at it with no crash.
License
The code in this repository is released under the MIT License, except the compiled engine in
npm/engine/, mcp/engine/, kotlin/src/main/resources/org/lettras/, rust/engine/, go/engine/, swift/Sources/Lettras/Resources/, dotnet/src/Lettras/ and dart/lib/src/engine_data.dart, which is proprietary and covered by its own license (engine/LICENSE): you may use it
unmodified, through this package or server, in your own products, including commercial ones, but you may not extract,
redistribute, modify or reverse-engineer it.
About
Logos and brand rules: assets/.
Lettras is built by Artificialss.
Built by Artificialss