바꾼 순서

  1. AI 판 설계: 모델은 글자 없이 검정칸 좌표만 냅니다. 180° 대칭, 검정칸 40~60%, 줄무늬·긴 사다리·떨어진 섬은 피하라고 요청합니다.
  2. 판 검증·보정: 모델이 스스로 매긴 점수는 믿지 않고 프로그램이 검사합니다. 대칭인지, 흰 칸이 모두 이어져 있는지, 모든 흰 칸이 2글자 이상 단어에 속하는지, 가장 긴 칸이 제한을 넘지 않는지, 4글자 이상 칸이 충분한지 봅니다. 어긋나면 긴 줄을 자르고, 짧은 줄은 메우고, 막힌 곳은 뚫어서 보정합니다.
  3. 단어 채우기: 맞는 후보가 가장 적은 칸부터 채우고, 막히면 되돌아가 다른 단어를 넣습니다. AI 판이 안 되면 미리 그려 둔 판(크기마다 8개)을 쓰고, 그래도 안 되면 무작위 판을 만듭니다.
  4. AI 문제 작성: 판에 확정된 단어 목록만 모델에 넘깁니다. 목록에 없는 단어를 더하거나 철자를 바꾸면 버리고, 문제에 정답이 그대로 들어가도 안 됩니다.

모델은 맥의 Ollama에서 돌리는 gemma4입니다. OpenAI 호환 주소로 부르기 때문에 설정만 바꾸면 다른 모델도 쓸 수 있습니다.

뜻풀이 대신 상식과 시사

단어 뜻만 묻는 문제는 너무 쉬웠습니다. 그래서 이런 퀴즈형 문제를 넣었습니다.

  • 멀리서 보면 희극, 가까이서 보면 비극이라 한 사람 (채플린)
  • 바다 석회암 지대에 생기는 거대한 푸른 구멍 (블루홀)
  • 샌프란시스코의 명소인 이 다리의 이름 (금문)
  • 꿈을 분석해 무의식을 말한 심리학자 (프로이트)

시사 문제도 넣었습니다. 빙하가 무너져 큰 홍수가 난 나라, 미국·멕시코·캐나다가 함께 연 월드컵, 밀라노·코르티나 동계올림픽 같은 문제입니다. 판을 만들 때마다 구글 뉴스 헤드라인을 읽고, 위키백과 무작위 문서에서 단어를 더 가져옵니다.

단어 은행에는 지금 1,102개가 있습니다.

  • 길이: 2글자 568개, 3글자 291개, 4글자 168개, 5글자 74개
  • 분야: 일상 810개, 과학 86개, 역사 60개, 기술 49개, 옛말 34개, 문화 30개, 지리 21개, 시사 12개
  • 퀴즈형 문제: 147개
  • 자막 빈도 순위로 흔한 말, 보통 말, 드문 말 등급을 매깁니다.

한 기기에서 최근에 나온 단어 180개는 기억해 두었다가 다음 판에서 뺍니다. 같은 단어가 연달아 나오던 문제 때문입니다.

난이도

  • 쉬움: 8×8, 시작할 때 흰 칸 18%를 공개, 답은 5글자까지
  • 보통: 10×10, 8% 공개, 6글자까지
  • 어려움: 12×12, 공개 없음, 8글자까지

난이도마다 가로·세로 문제 수와 가운데 교차 수의 최솟값도 다르게 둡니다. 어려움은 가로 8개, 세로 6개, 가운데 교차 6개 이상입니다.

중간에 나가도 이어서

처음 대기실에는 이어하기 버튼이 있었습니다. 어려움 판을 풀다가 쉬움을 고르면 이어하기가 떠서, 새 판인지 이어 푸는 판인지 헷갈렸습니다. 그래서 대기실 개념을 바꿨습니다.

  • 대기실에서는 새 게임만 만듭니다.
  • 풀던 판이 있으면 대기실에 머물지 않고 바로 게임방으로 들어갑니다.
  • 게임방에서 대기실이나 새 게임을 누르면 지금 문제를 포기할지 묻습니다.

그다음 문제는 기록이 사라지는 것이었습니다. 마지막 입력을 하고 창을 닫았다가 대기실로 들어오면 풀던 판이 DB에서 지워졌습니다. 대기실에 들어올 때 진행 중인 판을 정리하면서 지우고 있었습니다. 지금은 한 칸이라도 입력한 판은 ‘중간 저장’으로 남기고, 아무것도 입력하지 않은 판만 지웁니다. 게임방에 저장 버튼도 있고, 기보 목록에서 다시 열면 이어서 풉니다.

휴대폰 화면

휴대폰에서는 가상 키패드가 화면 절반을 가렸습니다. 문제도 A1 같은 좌표로 표시돼 찾기 어려웠습니다. 그래서 바꿨습니다.

  • 판의 번호를 누르면 가로 문제부터 뜨고, 가로/세로 탭으로 바꿉니다.
  • 한 글자씩이 아니라 답을 통째로 쓰고 엔터를 누르면 가로 문제는 가로로, 세로 문제는 세로로 채워집니다.
  • 칸을 눌러도 키패드가 바로 뜨지 않아서 판을 먼저 볼 수 있습니다.

이 단계의 실제 코드

새 판을 만드는 API입니다. 판 → 단어 → 문제 순서가 그대로 보입니다.

app/api/puzzle/route.tstypescript · 36줄
// 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);

대기실로 나갈 때 판을 정리하는 부분입니다. 빈 판은 지우고, 입력한 판은 중간 저장으로 남깁니다.

lib/db.tstypescript · 12줄
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);
}

다음 글은 ‘3. 통계로 다시 본 출제 과정과 문제점’입니다.