# crossword3d

A maker and a solver for **three-dimensional crosswords** — words running along
all three axes of a cube, interlocking at shared letters.

The grid is built on the classic British newspaper rule, generalised to three
dimensions, so **every layer and every outer face reads as an ordinary
crossword**. The solver draws them as stacked sheets you can turn with the
mouse, or flattens them into a contact sheet of plain 2-D grids.

- **Maker**: Python. Block patterns, a constraint filler, fill polishing, clues,
  exports. No third-party packages.
- **Solver**: one HTML file. No dependencies, no build step, no server needed.

```bash
py tools/fetch_words.py
```
```bash
py -m crossword3d make --size 5 --words words/english.txt -o puzzles/mine.json
```
```bash
py -m crossword3d export puzzles/mine.json -o mine.html
```

Then open `mine.html`. In this folder already:

## Putting it on the web

The whole folder is already a static site — every page is one self-contained
file, there is no build step, no server code and no network access at runtime.
`index.html` is a landing page listing the puzzles.

To publish it, push the folder and point any static host at it:

```bash
git init && git add -A && git commit -m "3-D crosswords" && git push
```

Then turn on GitHub Pages (Settings → Pages → deploy from branch), or drag the
folder onto Netlify or Cloudflare Pages. Nothing else is required. The only
thing that needs a server rather than `file://` is the **Use words/english.txt**
button in the workshop, which fetches a sibling file; loading a word list by
hand works either way.

| file | |
|---|---|
| `first-cube.html` | finished 5×5×5, 25 entries, fully clued — just open it |
| `lattice-seven.html` | a 7×7×7, 48 entries, fully clued |
| `*-print.html` | the same two on paper: flat grids plus clue lists |

Everything runs with no arguments and no download (`py -m crossword3d make
--size 5`) using the small bundled word list. Fills are luck-of-the-draw, so if
one fails, change `--seed` before changing anything else.

---

## Has this been done before?

Yes — a small but real tradition, worth knowing before you design.

- **Patents go back decades.** US 2,886,325 (Long) covers a 3-D crossword of
  lettered cubical blocks, with 1970s follow-ups such as
  [US 3,930,651](https://image-ppubs.uspto.gov/dirsearch-public/print/downloadPdf/3930651)
  and [US 4,009,882](https://image-ppubs.uspto.gov/dirsearch-public/print/downloadPdf/4009882)
  for 3×3×3 word games, plus a more recent
  [cube word/puzzle game system](https://patents.google.com/patent/WO2011119212A2/en).
- **The third direction is conventionally called AWAY.** Published puzzles use
  Across / Down / Away — see
  [Crossword Unclued](https://www.crosswordunclued.com/2009/01/3-dimensional-crosswords.html).
  This project follows that convention.
- **There is a live setting scene.** Eric Westbrook and John Henderson
  (Enigmatist) have run 3-D cryptic series for the RNIB and BBC CiNA, including
  an [All England 3D Crosswords Cup](http://bigdave44.com/2011/08/21/the-first-all-england-rnib-3d-crosswords-cup/)
  and recurring [3-D calendar puzzles](https://times-xwd-times.livejournal.com/1436833.html).

The puzzle form is established. What is thin on the ground is open tooling — a
constructor that can actually fill a cube, and a solver you can turn.

---

## Which language?

- **Python for the maker.** Filling is a search problem, and search code gets
  rewritten twenty times before it works. The inner loop is fast anyway because
  candidate lookup uses *big-integer bitsets* — one bit per word, so "which
  words match `.A..E`?" is a single `AND` of precomputed masks. CPython runs
  that at memory speed; the hot path is barely Python. ~10–20k search nodes/sec.
- **One HTML file for the solver.** Turning a cube with the mouse means either a
  3-D library or 200 lines of matrix maths. This does the maths: yaw/pitch
  orbit, perspective divide, painter's-algorithm sort, canvas 2-D, with glyphs
  drawn through the projected basis of each square so letters lie *in* the sheet
  rather than floating over it. No Three.js, no WebGL, no CDN — so it works
  offline, opens from `file://`, and will still run in five years.

C++ would buy 20–50× on the filler. It is not the bottleneck — grid design is,
and no amount of speed fixes an unfillable cube.

---

## The grid: a British crossword in three dimensions

A newspaper cryptic grid follows one rule: **a square is black when both its
row and its column are odd.** That leaves alternate rows and columns running
the full width as entries, and the squares between them checked in only one
direction — which is why roughly half the letters in a British cryptic are
unchecked.

The three-dimensional version of the same rule is:

> **blocked ⟺ at least two of (x, y, z) are odd**

Slice that along any axis and it collapses back to the flat rule. Layers at an
even coordinate become exactly the newspaper pattern; the layers between them
keep only the pillars where an Away entry threads through the depth. All six
outer faces, and every interior slice, read as a proper crossword.

A 7-cube built this way is perfectly balanced — 16 Across, 16 Down, 16 Away, all
seven letters long, at 1.62 words per letter. `--breaks` then chops some
full-length entries into shorter ones the way a real grid does:

```bash
py -m crossword3d grid --size 7 --style lattice --breaks 0.3 -o grids/g7.json
```

Use an **odd** size. With an even edge the far faces land on an odd coordinate
and come out sparse instead of as full grids.

### Why this matters: the fillability wall

In 2-D a fully checked letter is in 2 words. In 3-D it can be in 3, and that one
extra constraint is brutal. Measured here against a 153k-word list:

| mean words per letter | result |
|---|---|
| ≤ 2.2 | fills in well under a second |
| 2.4 – 2.55 | fills in seconds |
| 2.6 – 2.7 | minutes, often fails |
| ≥ 2.75 | effectively impossible |

A solid cube sits at exactly 3.0 — a 3-D word square, where order-5 examples are
rare and order-7 essentially do not exist. An early version of the layered
pattern here accidentally made every layer a *blockless* square, and the filler
stalled at **5 of 72 entries**; blocking those same layers got it to 72 of 72 in
seconds. The British lattice sits at ~1.6, which is why it fills instantly and
scores 88/90 on word quality.

The second surprise: **you cannot block much in 3-D.** One block cuts three
lines at once, and a line left with a run of exactly 2 is illegal. With
`--min-checks 2` the generator cannot get a 5-cube below ~73% open no matter
what you ask — and 73% open is already unfillable. Hence `--min-checks 1`, which
is not a compromise but the British convention.

### Grid styles

| style | what it does |
|---|---|
| `lattice` *(default)* | The British rule above. Every face and slice a proper crossword. Fills instantly. |
| `stack` | Stacked 2-D crossword grids threaded by full-length pillars. More varied entry lengths. |
| `scatter` | Blocks grown greedily anywhere, kept valid at every step. Organic, but heavy on 3-letter entries. |
| `shell` / `core` | Blocks biased toward the faces / toward the middle. |

All validate the same way: no runs of 2, every open square in at least
`--min-checks` words, open squares all connected.

---

## Commands

```
py -m crossword3d grid    --size 5 -o grids/g.json       # design a grid
py -m crossword3d fill    grids/g.json -o puzzles/p.json  # fill it with words
py -m crossword3d make    --size 5 -o puzzles/p.json      # both at once
py -m crossword3d show    puzzles/p.json                  # print it in the terminal
py -m crossword3d clue    puzzles/p.json                  # write clues interactively
py -m crossword3d clue    puzzles/p.json --import c.tsv   # or bulk-import them
py -m crossword3d export  puzzles/p.json -o p.html        # standalone playable HTML
py -m crossword3d print   puzzles/p.json -o p-print.html  # paper version
py -m crossword3d serve   puzzles/p.json                  # play at localhost
py -m crossword3d words   --pattern .A..E                 # word list lookups
```

Useful flags for `fill`:

- `--time 120` — total budget. The filler does not stop at the first answer: it
  re-rolls for a better-scoring one, then spends the last half (`--polish`)
  tearing out weak entries and re-solving just those pockets.
- `--quality 3.0` — how hard to push for words a solver will recognise.
- `--pin 3x=LASER` — force an entry (`<number><axis>`).
- `--seed 7` — reproducible, and the first thing to change on an ugly fill.

`make` keeps the intermediate grid on failure so you can retry the fill alone
with a different seed instead of regenerating everything.

---

## Word lists

`tools/fetch_words.py` merges three public lists into `words/english.txt` as
`WORD;score`:

| source | score |
|---|---|
| ENABLE1 (~152k Scrabble-legal) | 40 |
| dolph/dictionary "popular" (~25k everyday) | 70 |
| google-10000-english | **+20 boost, never a source on its own** |

That last row matters. Used as a source in its own right, the frequency list
fills grids with USPS, DNS, PCI and ASN — technically frequent, useless in a
crossword. It is only allowed to *promote* words a dictionary already vouches
for.

Prefer `--quality` over `--min-score`: a hard score cutoff shrinks the 3-letter
pool so far that tight grids stop filling, whereas the quality weight uses
common words where it can and reaches into the long tail only where a corner
demands it.

A ~5k starter list ships in `crossword3d/data/` so everything runs before you
download anything.

---

## The solver

Open the HTML file. Drag to turn the stack, scroll to zoom, shift-drag to pan.

### A cube is not a stack of slices — it is twelve crosswords

This is the thing worth getting right. A 7-cube on the lattice rule holds
**twelve crossword grids: four facing each way.** They sit at the even
coordinates; the odd slices between them hold no crossword at all, only the
pillars threading one grid into the next. Each grid carries 8 entries, and each
entry belongs to two grids at once — 48 × 2 = 12 × 8.

The solver is built on that, not on "slices along z". A **Facing** control picks
X, Y or Z, and the stepper walks the four crosswords facing that way. The top
bar tells you what a puzzle contains: *12 crosswords (4/4/4 facing X/Y/Z)*.
Which grids count is worked out structurally — by asking which entries lie
wholly inside each slice — so it stays right for hand-drawn grids and the other
block styles, not just the lattice.

Twelve chips in the view bar — `X 1 2 3 4  Y 1 2 3 4  Z 1 2 3 4` — pick one
directly. No hunting for a plane by clicking into a stack of overlapping ones.

Five ways to look at it:

| Show | |
|---|---|
| **All** *(default)* | every crossword at once: the chosen one crisp, the other eleven ghosted behind it. Click any ghost to switch to it. Ghosts *between* you and the chosen grid fade almost away and lose their letters, so nothing ever obscures what you are reading. |
| **Cube** | the closed solid. Every square is a box, so **all six outer faces are real crossword grids** — front, sideways, top and bottom, black squares and all. Faces pressed against another box are never drawn. |
| **Stack** | the four crosswords facing one way, pulled apart. |
| **One** | a single crossword, camera turning to face it square on. |
| **Flat** | every grid laid out as ordinary 2-D crosswords. |

**X-ray** makes the closed cube see-through. Picking an entry that lies on none
of the grids currently shown swings the planes round to it.

Two things this went through before landing: a *Trio* view showing one grid per
direction (not enough — you could not see or reach the other nine), and fanning
the slices out diagonally like a deck of cards (disorienting, and it still only
ever showed one of the three directions).

### The look

Warm and muted, deliberately: newsprint squares on a dark desk, serif letters,
a single ochre accent for whatever is selected. Nothing blue and nothing
saturated — the grids are the only bright thing on screen, which is where your
eye should be.

| key | |
|---|---|
| click | select a square; click again to change direction |
| A–Z | type; the cursor advances along the entry |
| <kbd>←</kbd> <kbd>→</kbd> <kbd>↑</kbd> <kbd>↓</kbd> | move **by what you can see** — see below |
| <kbd>Enter</kbd> / <kbd>Shift</kbd>+<kbd>Enter</kbd> | next / previous entry |
| <kbd>Space</kbd> / <kbd>Tab</kbd> | cycle Across → Down → Away |
| <kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd> | jump straight to Across / Down / Away |
| <kbd>.</kbd> | show: all → cube → stack → one → flat |
| <kbd>/</kbd> | flat view |
| <kbd>\\</kbd> | x-ray |
| <kbd>,</kbd> | pencil mode (tentative letters, drawn lighter) |
| <kbd>[</kbd> <kbd>]</kbd> | previous / next crossword |
| <kbd>;</kbd> <kbd>'</kbd> | change which way you are facing (X / Y / Z) |
| <kbd>0</kbd> | reset the camera |
| <kbd>Ctrl</kbd>+<kbd>Z</kbd> / <kbd>Shift</kbd> | undo / redo |

**Arrows follow the picture, not the axes.** Press Right and you move to the
square that *looks* right, whatever the cube is turned to: a one-square step
along each of the six lattice directions is projected to the screen, and the
one that best matches the arrow wins. Turn the puzzle 135° and Right still
goes right. Blocked and hidden squares are stepped over, and typing carries on
in whatever direction you just moved.

**Which way am I facing?** A labelled pin sticks out of the far face along each
axis — ACROSS, DOWN, AWAY — so the directions are always named and always
pointing the way they actually run. An axis that runs *behind* the puzzle is
drawn as a dashed hidden line rather than vanishing; an axis pointing straight
at you projects to nothing and is dropped, since a dot parked in the middle of
the grid is clutter and the other two pins fix the orientation anyway.

**Every shortcut is remappable.** Hit **Keys**, click a shortcut, press the key
you want; it saves to `localStorage`. Binding a key that is already taken swaps
the two rather than refusing, so you never have to hunt for a free one.
Letters are rejected — A–Z all have to reach the grid. An early build bound
<kbd>R</kbd>/<kbd>V</kbd>/<kbd>F</kbd>/<kbd>P</kbd> to view controls and
quietly made POLAR unenterable, which is why the defaults are all punctuation.

Two things make the third dimension survivable:

- **Auto-turn.** Pick an entry pointing straight at the camera and it is a
  single square with four letters hiding behind it. When that happens the stack
  eases round to an angle where the entry reads — and only then, so the view
  stays put while you work down a clue list. Toggle it off in the view bar.
- **Crossing chips.** The clue card lists the other entries running through the
  square you are on. In 2-D that is obvious from the grid; in 3-D it is the
  whole point and otherwise invisible.

Progress, pencil marks and the timer autosave to `localStorage` per puzzle.

### Solving aids

**Autocheck** marks a letter wrong the moment you type it — off by default,
because for some solvers it removes the point. **Reveal letter** gives up just
the square you are on. **Pause** stops the clock. Finishing records the time,
so a second attempt has something to beat.

### On a phone

One finger turns the cube, two pinch to zoom and slide to pan. Tapping a square
raises the keyboard — there is a real input behind the canvas, which is also
what gives assistive technology something to attach to. Controls grow to
thumb size and the view bar becomes a single scrolling strip so it never eats
the grid.

### Screen readers

A canvas tells a screen reader nothing, so the state is mirrored into a live
region: which entry you are on, its clue, which letter of how many, the letters
so far, and whether the square is marked wrong. There is a **Contrast** switch
(which repaints the canvas too, not just the chrome), and camera moves become
instant jumps when the system asks for reduced motion.

### Share a cube as a link

**Share link** puts the entire puzzle in the URL fragment — gzipped where the
browser offers it, about **2 kB for a 5-cube**. Nothing is uploaded anywhere
and the link works from a file on disk. Past 32 kB it refuses and tells you to
send the `.json` instead, rather than handing over a link that silently
truncates.

### The maker in the browser

Hit **Design**.

On an *empty* grid you get the block designer: click squares to block or open
them (mirrored through the centre unless you turn symmetry off), with a live
readout of entry count, length distribution and words-per-letter — the number
that decides whether it can be filled at all. **Export grid**, then:

```bash
py -m crossword3d fill grid5.json --words words/english.txt -o puzzles/mine.json
```

On a *filled* puzzle it opens straight into the **clue editor** instead, so
clicking cannot wreck the fill. Write clues against the cube you can see and
turn, set title and author, then **Export puzzle** for the finished JSON (or
**Export clues** for a TSV to feed back to the CLI).

**The filler runs in the browser too.** Load a word list (or fetch
`words/english.txt` when served) and press **Fill grid**. The whole loop —
design, fill, clue, publish — now happens in one file with nothing installed.

The search is the same idea as the Python one: per length, a bitmap per
position and letter, so *"what still fits `.A..E`?"* is a few `AND`s rather
than a scan. Two things make it usable in a page:

- It picks the most-constrained slot by **counting** matches, and only builds a
  word list for the slot it actually commits to. Building a list for every slot
  just to compare sizes meant sorting most of the dictionary at every step —
  the 7-cube managed 12 attempts in 20 seconds. Counting instead fills the same
  cube in **0.1 s**.
- It **yields the thread** every 12 ms. A synchronous solve with a 30-second
  budget freezes the page for 30 seconds, with no progress and no way out; on a
  phone the browser offers to kill the tab. Now it reports progress and the
  button turns into **Stop**.

And a **look-up** box: pattern (`.A..E`), anagram, or contains — plus a live
count of how many words still fit the tightest entry in the grid, which is the
one number that tells you whether a grid is fillable before you spend an hour
on it.

The designer runs the same structural checks as `grid.py`, so if it says the
grid is valid, the Python side agrees.

---

## Nothing from outside is trusted

Everything that crosses into the program from elsewhere is checked, because
the failure mode is silence rather than a crash:

- **Puzzle files.** A coordinate one past the edge does not fail loudly — it
  indexes past the block array and every later assumption is quietly wrong. So
  size, every block, every entry square, the axis, and the answer length are
  all validated on load, in both the Python and the browser reader, and a bad
  file is rejected with a sentence saying what is wrong with it.
- **Saved progress.** A puzzle can be re-cut and republished under the same id;
  yesterday's letters then land on squares that are now black. Restored
  progress is filtered against the current grid.
- **Saved keybindings.** A keymap edited by hand, or left from an older build,
  must not be able to bind a letter and silently steal it from the grid. Both
  the rebinding UI and the loader reject letters and reserved keys, and refuse
  to let two actions share one key.
- **CLI arguments.** Bad sizes, missing files and malformed JSON print one
  line — `error: no such file: nope.json` — not a stack trace.

## File format

One JSON file per puzzle, `format: "crossword3d"`. Blocks as `[x,y,z]` triples;
entries carry number, axis, start, cells, answer and clue. Answers are base64 by
default — obfuscation so the solution is not readable at a glance, not security,
since the solver must know the answers to check them. `--plain` turns it off.

## Layout

```
crossword3d/
  grid.py       cube geometry, runs, slots, numbering, validation
  patterns.py   block patterns: the British 3-D lattice and the rest
  wordlist.py   bitset-indexed word lookup
  filler.py     constraint solver, restarts, local-search polishing
  puzzle.py     puzzle model and JSON format
  printable.py  paper rendering
  cli.py        commands
web/index.html  solver, flat view, grid designer and clue editor - one file
tools/          word list downloader
```

Requires Python 3.9+.
