The new order

  1. AI grid design: the model outputs only black-square coordinates, without letters. We ask for 180° symmetry, 40–60% black squares, and no stripes, long ladders or detached islands.
  2. Grid validation and correction: the model's self-assigned score is not trusted; the program checks. It checks symmetry, whether all white squares are connected, whether every white square belongs to a word of two or more syllables, whether the longest slot stays within the limit, and whether there are enough slots of four or more syllables. If something is off, it corrects the grid by cutting long runs, filling in short ones and opening blocked spots.
  3. Word filling: fill the slot with the fewest matching candidates first, and backtrack to try another word when stuck. If the AI grid does not work, use a pre-drawn grid (8 per size), and if that fails too, generate a random grid.
  4. AI clue writing: only the list of words confirmed on the grid is passed to the model. If it adds words not on the list or changes the spelling, the result is discarded, and the answer must not appear verbatim in the clue.

The model is gemma4 running on Ollama on the Mac. It is called through an OpenAI-compatible address, so other models can be used just by changing the settings.

General knowledge and current affairs instead of definitions

Clues that only asked for a word's meaning were too easy. So we added quiz-style clues like these.

  • The person who said life is a comedy in long-shot and a tragedy in close-up (채플린, Chaplin)
  • A huge blue hole formed in marine limestone areas (블루홀, blue hole)
  • The name of this famous bridge in San Francisco (금문, Golden Gate)
  • The psychologist who analysed dreams to talk about the unconscious (프로이트, Freud)

We added current-affairs clues too: the country where a collapsing glacier caused a huge flood, the World Cup co-hosted by the US, Mexico and Canada, the Milano Cortina Winter Olympics, and so on. Every time a grid is made, it reads Google News headlines and pulls extra words from random Wikipedia articles.

The word bank currently holds 1,102 words.

  • Length: 568 of two syllables, 291 of three, 168 of four, 74 of five
  • Field: everyday 810, science 86, history 60, technology 49, old words 34, culture 30, geography 21, current affairs 12
  • Quiz-style clues: 147
  • Words are graded as common, ordinary or rare by subtitle frequency rank.

The 180 words that appeared most recently on a device are remembered and excluded from the next grid. This is because of the earlier problem of the same words appearing again and again.

Difficulty

  • Easy: 8×8, 18% of white squares revealed at the start, answers up to 5 syllables
  • Medium: 10×10, 8% revealed, up to 6 syllables
  • Hard: 12×12, nothing revealed, up to 8 syllables

Each difficulty also has different minimums for the number of across and down clues and for middle crossings. Hard requires at least 8 across, 6 down and 6 middle crossings.

Picking up where you left off

At first the lobby had a continue button. If you chose easy while partway through a hard grid, the continue button appeared, and it was confusing whether you were getting a new grid or resuming one. So we changed the concept of the lobby.

  • The lobby only creates new games.
  • If there is a grid in progress, you go straight into the game room instead of staying in the lobby.
  • Pressing lobby or new game in the game room asks whether to give up the current puzzle.

The next problem was records disappearing. If you made your last input, closed the window and then entered the lobby, the grid you were solving was deleted from the DB. It was being deleted while tidying up in-progress grids on entering the lobby. Now any grid with even one square filled in is kept as a "partial save", and only grids with no input at all are deleted. The game room also has a save button, and reopening a grid from the record list lets you continue solving.

The phone screen

On phones, the virtual keypad covered half the screen. Clues were also shown with coordinates like A1, which made them hard to find. So we changed things.

  • Tapping a number on the grid shows the across clue first, and the across/down tabs switch between them.
  • Instead of one syllable at a time, you type the whole answer and press enter, and across clues fill across while down clues fill down.
  • Tapping a square no longer pops up the keypad right away, so you can look at the grid first.

The actual code for this step

This is the API that makes a new grid. The grid → words → clues order is plain to see.

app/api/puzzle/route.tstypescript · 36 lines
// 1. 모델이 글자 없는 판 구조를 먼저 제안한다.
const patternCandidates = await generateAiPatternCandidates(difficulty);
// 2. 판의 슬롯 길이가 정해진 뒤 후보 단어를 모아 실제로 채운다.
const dynamicBank = await loadDynamicBank(difficulty, avoidWords, { useLlm: false });
let puzzle: Puzzle | null = null;
let patternSource: "ai" | "curated" = "curated";
// AI의 판 초안을 목표 모양으로 삼고, 연결형 배치기가 실제 단어를 고른다.
for (const rows of patternCandidates) {
  puzzle = generatePuzzleFromRows(difficulty, rows, {
    avoidWords,
    extraBank: dynamicBank.entries,
  });
  if (puzzle) {
    patternSource = "ai";
    break;
  }
}
if (!puzzle) {
  puzzle = generatePuzzle(difficulty, {
    avoidWords,
    extraBank: dynamicBank.entries,
  });
}
if (!puzzleMeets(puzzle, difficulty)) {
  puzzle = generatePuzzle(difficulty, { extraBank: dynamicBank.entries });
}
if (!puzzleMeets(puzzle, difficulty)) {
  puzzle = generatePuzzle(difficulty);
}

// 3. 최종 선택된 정답만 모델에 전달해 문제를 쓴다.
const selected = puzzle.across
  .concat(puzzle.down)
  .map(({ word, clue }) => ({ word, clue }));
const generatedClues = await generateCluesForWords(difficulty, selected);
puzzle = applyGeneratedClues(puzzle, generatedClues);

This is the part that tidies up grids when going out to the lobby. Empty grids are deleted, and grids with input are kept as partial saves.

lib/db.tstypescript · 12 lines
for (const row of rows) {
  if (isEmptyBoard(row)) {
    d.prepare(`DELETE FROM moves WHERE code = ?`).run(row.code);
    d.prepare(`DELETE FROM games WHERE code = ?`).run(row.code);
    continue;
  }
  d.prepare(
    `UPDATE games
     SET status = 'abandoned', end_text = COALESCE(NULLIF(end_text, ''), '중간 저장'), updated_at = ?
     WHERE code = ?`,
  ).run(now, row.code);
}

The next post is "3. The setting process and its problems, revisited with statistics".