chess-ai

Chess vs Stockfish

브라우저 안에서만 동작하는 정적 체스 시뮬레이터입니다. Stockfish 18을 WebAssembly + Web Worker로 실행하므로 백엔드, 엔진 API, WebSocket 서버가 전혀 없습니다. Nginx는 정적 파일 서빙만 담당합니다.

핵심 UI 규칙은 일반적인 체스 UI와 반대입니다.

사용자의 진영은 항상 화면 위쪽, Stockfish의 진영은 항상 화면 아래쪽입니다. White를 선택하면 White가 위쪽, Black을 선택하면 Black이 위쪽에 표시됩니다.


실행 방법

필요한 것은 Docker뿐입니다. 프로젝트 루트에서:

docker compose up --build

접속 URL:

http://localhost:8080

컨테이너를 백그라운드로 띄우거나 정리할 때:

docker compose up --build -d     # 백그라운드 실행
docker compose logs -f chess     # 로그 확인
docker compose down              # 정리

포트를 바꾸려면 docker-compose.yml의 ports를 "9090:80" 처럼 수정하세요.

Docker 없이 확인하고 싶다면 정적 서버 아무거나로도 동작합니다. 단 file:// 로는 동작하지 않습니다(ES module, Web Worker, WASM 모두 HTTP origin이 필요합니다). 이때는 .wasm의 MIME type이 application/wasm으로 나가는지 직접 확인해야 합니다.

cd public && python3 -m http.server 8080

프로젝트 구조

chess-ai/
├── docker-compose.yml               # chess 서비스 1개, 8080 -> 80
├── Dockerfile                       # nginx:1.29-alpine + 정적 파일 + 사전 gzip
├── nginx.conf                       # /etc/nginx/conf.d/default.conf 로 복사됨
├── .dockerignore
├── README.md
├── tests/                           # 선택 사항: 브라우저 테스트 (아래 참고)
│   ├── logic.js
│   ├── integration.js
│   └── run.sh
└── public/                          # 이 디렉터리가 그대로 웹 루트
    ├── index.html
    ├── favicon.svg
    ├── css/
    │   └── style.css
    ├── js/
    │   ├── constants.js             # 상태 enum, 난이도 프리셋, 매직 넘버 모음
    │   ├── engine.js                # Stockfish Worker + UCI 통신 (DOM 의존 없음)
    │   ├── board.js                 # 8x8 렌더링, 클릭/드래그 입력 (규칙 판단 없음)
    │   ├── game.js                  # 상태 머신 + chess.js 규칙 + 진행 흐름
    │   ├── promotion.js             # 프로모션 선택 모달
    │   ├── position.js              # 사용자 지정 시작 포지션 검증/정규화
    │   ├── setup.js                 # 보드 세팅 편집기 (팔레트, 차례, 캐슬링, FEN)
    │   ├── theme.js                 # 라이트/다크/시스템 테마 전환
    │   └── app.js                   # DOM 바인딩과 부트스트랩
    ├── lib/
    │   ├── chess.js                 # chess.js 1.4.0 (ESM 빌드)
    │   ├── chess.js.map
    │   └── chess.LICENSE.txt
    ├── fonts/
    │   ├── noto-sans-symbols-2-chess.woff2      # 체스 글리프 12자만 서브셋 (2.8 KB)
    │   ├── noto-sans-symbols-2.LICENSE.txt
    │   ├── ibm-plex-sans-kr-1.1.0-{400,600,700}.woff2  # UI 글꼴, 한글 11,172자 전체 (각 370–440 KB)
    │   ├── ibm-plex-sans-kr.LICENSE.txt
    │   ├── jetbrains-mono-5.3.0-latin-{400,600}.woff2  # 기보·FEN용 고정폭 (각 21 KB)
    │   └── jetbrains-mono.LICENSE.txt
    └── stockfish/
        ├── stockfish-18-lite-single.js          # Worker 스크립트 (21 KB)
        ├── stockfish-18-lite-single.wasm        # 엔진 바이너리 (7.3 MB)
        └── COPYING.txt                          # GPL-3.0 전문

레이어 분리

파일 아는 것 모르는 것
engine.js UCI 문자열, Worker 수명주기 체스 규칙, DOM
board.js DOM, 포인터 입력, orientation 체스 규칙, 엔진
game.js 규칙(chess.js), 상태 머신 DOM 셀렉터
app.js DOM 셀렉터, 표시 문자열 규칙, UCI

board.js는 합법성을 스스로 판단하지 않고 주입된 legalTargetsFor(square) 콜백에 물어봅니다. 덕분에 규칙 판단은 game.js → chess.js 한 곳에만 존재합니다.


Stockfish WASM 구조

사용 빌드: stockfish.js 18.0.8의 stockfish-18-lite-single — 즉 단일 스레드(single-threaded), lite(NNUE 내장) 빌드입니다.

main thread                          Web Worker
-----------                          ----------
engine.js
  new Worker(".../stockfish-18-lite-single.js?v=1.0.0#<wasm URL>")
  postMessage("uci")            -->  UCI 명령 큐
                                <--  "uciok"
  postMessage("isready")        -->
                                <--  "readyok"
  postMessage("ucinewgame")     -->
  postMessage("setoption ...")  -->
  postMessage("position startpos moves e2e4 e7e5")
  postMessage("go depth 11 movetime 800")
                                <--  "info depth 9 score cp 31 ..."
                                <--  "bestmove g1f3 ponder b8c6"

왜 단일 스레드 빌드인가 (COOP/COEP를 넣지 않은 이유)

멀티스레드 Stockfish WASM은 SharedArrayBuffer를 쓰기 때문에 cross-origin isolation이 필수이고, 그러면 다음 헤더가 필요합니다.

add_header Cross-Origin-Opener-Policy   same-origin  always;
add_header Cross-Origin-Embedder-Policy require-corp always;

require-corp는 모든 하위 리소스가 CORP 헤더를 갖도록 요구하므로, iframe 임베드나 외부 리소스 추가 시 조용히 깨지기 쉽습니다. 이 프로젝트는 그 복잡도를 감수할 이유가 없어서 SharedArrayBuffer를 전혀 쓰지 않는 단일 스레드 빌드를 선택했고, 두 헤더는 적용하지 않았습니다. nginx.conf에 주석으로 남겨 두었으니 멀티스레드 빌드로 교체할 때 활성화하세요.

단일 스레드라도 Skill Level과 depth/movetime 상한 안에서는 체감 차이가 없습니다.

WASM 경로가 어긋나지 않게 하는 방법

stockfish-18-lite-single.js는 Worker 안에서 자기 위치를 기준으로 .js → .wasm 을 유추합니다. 이 방식은 쿼리 스트링이나 sub-path 호스팅에서 헷갈릴 수 있으므로, engine.js는 wasm 절대 URL을 Worker URL의 fragment로 직접 넘겨 모호함을 없앱니다.

const WORKER_SCRIPT_URL = new URL('../stockfish/stockfish-18-lite-single.js', import.meta.url);
const WORKER_WASM_URL   = new URL('../stockfish/stockfish-18-lite-single.wasm', import.meta.url);
// -> ".../stockfish-18-lite-single.js?v=1.0.0#<encoded wasm URL>"

경로가 import.meta.url 기준이므로 앱을 /chess/ 같은 sub-path에 올려도 그대로 동작합니다. ?v=<APP_VERSION>은 Worker 스크립트의 캐시 무효화용이며 constants.js의 APP_VERSION을 올리면 갱신됩니다.


White/Black orientation 처리 방법

보드는 “어떤 색이 아래쪽인가” 하나만 입력으로 받습니다.

// game.js
this.#board.setOrientation(this.engineColor);   // = oppositeColor(userColor)

사용자가 위쪽이어야 하므로 아래쪽에는 항상 Stockfish의 색이 옵니다. board.js는 그 값으로 64칸의 DOM 순서를 다시 정렬합니다.

사용자 아래쪽 색 위→아래 rank 좌→우 file 화면 위쪽
White Black 1 → 8 h → a White (사용자)
Black White 8 → 1 a → h Black (사용자)

즉 사용자가 White일 때 보드는 통상 시점에서 180° 회전한 상태입니다 (rank가 증가하는 방향으로 내려가고 file은 역순). 좌표 라벨도 같은 배열에서 계산하므로 항상 일치합니다.

새 게임을 누를 때마다 orientation이 다시 계산되고, 게임 도중 진영 버튼을 누르면 “새 게임을 눌러야 적용된다”는 안내가 표시됩니다. 아직 한 수도 두지 않았다면 즉시 새 게임으로 적용됩니다.


난이도 조절 방법

constants.js의 프리셋 한 곳에서 관리합니다.

난이도 Skill Level go depth go movetime
초급 (beginner) 1 5 300 ms
중급 (medium, 기본값) 8 11 800 ms
고급 (hard) 20 16 2000 ms

보드 세팅 (사용자 지정 시작 포지션)

보드 세팅을 누르면 진행 중인 게임이 일시정지되고 보드가 편집 모드로 바뀝니다.

시작 전에 position.js가 검증합니다. chess.js의 validateFen(킹 각 1개, 1/8랭크 폰 금지)에 더해, 차례가 아닌 쪽이 체크 상태인 포지션, 이미 체크메이트·스테일메이트인 포지션, 기물 부족, 한 색 16개 초과 / 폰 8개 초과를 거부하고, 불가능한 캐슬링 권리와 앙파상 칸은 제거합니다. Stockfish에는 position fen <FEN> moves ...로 전달됩니다.

사용자 지정 포지션으로 시작한 뒤에는 새 게임이 같은 포지션으로 다시 시작합니다. 일반 게임으로 돌아가려면 세팅에서 초기 배치로 시작하면 됩니다.


사용한 JS 라이브러리

라이브러리 버전 라이선스 용도
chess.js 1.4.0 (ESM) BSD-2-Clause 합법 수 생성, 체크/메이트/스테일메이트/캐슬링/앙파상/프로모션/반복수 판정, SAN·FEN
stockfish.js 18.0.8 (lite-single) GPL-3.0 체스 엔진 (WASM)
Noto Sans Symbols 2 서브셋 SIL OFL 1.1 체스 기물 글리프 (U+2654–U+265F)
IBM Plex Sans KR 1.1.0 SIL OFL 1.1 UI 글꼴 (400/600/700, 원본 파일 그대로)
JetBrains Mono Fontsource 5.3.0, Latin SIL OFL 1.1 기보·FEN 고정폭 글꼴

보드 UI 라이브러리는 쓰지 않았습니다. orientation 반전 요구사항을 정확히 제어하기 위해 board.js에 직접 구현했습니다. CDN 의존성은 0개이며 모든 런타임 파일이 저장소와 이미지 안에 포함되어 있습니다.

기물 렌더링

기물은 이미지가 아니라 번들된 폰트 글리프 2겹으로 그립니다.

.piece::before { content: var(--glyph-solid);   color: var(--piece-fill); }  /* 채워진 몸통 */
.piece::after  { content: var(--glyph-outline); color: var(--piece-edge); }  /* 윤곽 + 내부 디테일 */

U+265A–U+265F(채워진 글리프)와 U+2654–U+2659(윤곽 글리프)는 같은 외곽선을 공유하므로 정확히 겹칩니다. 시스템 심볼 폰트에 의존하면 플랫폼마다 기물 모양이 달라지므로, 해당 12자만 남긴 2.8 KB 서브셋 폰트를 self-host 하고 font-display: block으로 대체 글리프가 순간 노출되는 것도 막았습니다.

테마 (라이트 / 다크 / 시스템)

상단 오른쪽의 세 버튼으로 테마를 고릅니다.

색은 전부 style.css의 CSS 변수(토큰)로 정의되어 있고, <html data-theme="light|dark">가 있으면 그 테마로 고정, 없으면 OS 설정을 따릅니다. 저장된 선택은 index.html의 작은 인라인 스크립트가 첫 렌더링 전에 적용하므로 새로고침할 때 반대 테마가 번쩍이지 않습니다. UI 글꼴(IBM Plex Sans KR, JetBrains Mono)도 체스 글리프처럼 public/fonts/에 self-host 하므로 기기와 상관없이 같은 모양으로 보입니다. 파일명에 버전을 넣은 이유는 nginx가 .woff2를 immutable로 캐시하기 때문입니다. 글꼴을 교체할 때는 파일명의 버전도 바꾸세요.

Stockfish 라이선스 참고사항

Stockfish는 GPL-3.0입니다. 이 저장소는 컴파일된 stockfish-18-lite-single.wasm을 재배포하므로 다음을 지켜야 합니다.

chess.js(BSD-2-Clause)와 폰트(OFL-1.1)의 라이선스 원문도 public/lib/와 public/fonts/에 포함되어 있습니다.


Nginx 설정

nginx.conf의 핵심만 정리하면:


상태 관리와 안정성

game.js는 명시적인 상태 머신으로 동작합니다.

LOADING ──> READY ──> PLAYER_TURN <──> ENGINE_THINKING ──> GAME_OVER
   │                                                          
   └──────────────────> ERROR <───────────────────────────────

의도적으로 방어한 항목:

위험 대응
Worker 중복 생성 init()이 같은 Promise를 재사용. 동시 호출 3번에도 Worker는 1개
이전 게임의 bestmove가 새 게임에 적용 gameId 증가로 무효화 + 엔진 쪽 searchSeq 이중 검사
엔진 사고 중 사용자 입력 PLAYER_TURN이 아니면 legalTargetsFor()가 빈 배열, 보드도 board--locked
게임 종료 후 추가 수 GAME_OVER에서 입력·탐색 모두 차단, 종료 시 cancelSearch()
진영 변경 후 orientation 오류 orientation은 새 게임마다 engineColor로 재계산
WASM 경로 오류 wasm 절대 URL을 Worker fragment로 명시 전달
Worker 내부 상대 경로 오해석 위와 동일. import.meta.url 기준이라 sub-path에도 안전
브라우저 캐시의 옛 Worker JS는 no-cache + Worker URL에 ?v=APP_VERSION
bestmove (none) 누락 null로 정규화. 실제 종료면 종료 처리, 아니면 ERROR
초기화 전 go search()가 ready 이전 호출을 거부
응답 없는 탐색 movetime + 15s 타임아웃 후 stop + 에러 표시
명령/응답 경쟁 응답 대기자를 먼저 등록한 뒤 명령을 씀

Worker 로딩이 실패하면 상태 배너에 Stockfish engine failed to load. 가 표시되고 보드는 잠깁니다. 콘솔에는 [engine], [game] 접두어로 UCI 송수신과 상태 전이가 남습니다.


테스트 (선택 사항)

tests/에는 실제 브라우저에서 도는 두 개의 스위트가 있습니다. 호스트에는 Docker만 있으면 되고, Playwright는 공식 이미지 안에서 실행됩니다.

docker compose up --build -d
./tests/run.sh

이 폴더는 런타임에 관여하지 않으며 .dockerignore로 이미지에서 제외됩니다. 필요 없으면 삭제해도 앱은 그대로 동작합니다.


알려진 브라우저 제한사항


문제 해결

화면이 “Stockfish 엔진 로딩 중…“에서 멈춤 DevTools → Network에서 stockfish-18-lite-single.wasm을 확인하세요. Content-Type이 application/wasm이 아니면 Nginx 설정이 반영되지 않은 것입니다.

curl -sI http://localhost:8080/stockfish/stockfish-18-lite-single.wasm | grep -i content-type
# content-type: application/wasm

Stockfish engine failed to load. 표시 콘솔의 [engine] 로그를 확인하세요. 대개 다음 중 하나입니다.

docker compose exec chess ls -l /usr/share/nginx/html/stockfish/
docker compose exec chess nginx -t

기물이 예전 모양(시스템 글리프)으로 보임 /fonts/noto-sans-symbols-2-chess.woff2가 200으로 내려오는지, MIME이 font/woff2인지 확인하세요.

옛 코드가 계속 실행됨 JS는 no-cache지만 프록시나 서비스워커가 끼어 있으면 남을 수 있습니다. constants.js의 APP_VERSION을 올리면 Worker URL이 바뀌어 확실히 갱신됩니다. 그리고 hard reload(Ctrl+Shift+R)를 사용하세요.

엔진이 너무 느림 / 브라우저가 버벅임 초급으로 낮추거나 constants.js의 DIFFICULTIES에서 movetime을 줄이세요.

포트 8080이 이미 사용 중 docker-compose.yml의 ports를 "9090:80" 등으로 바꾸고 다시 docker compose up.

보드 방향이 이상해 보임 의도된 동작입니다. 이 앱은 사용자 진영이 항상 위쪽입니다. White를 선택하면 통상 시점의 180° 회전 상태가 정상입니다.