TECH NOTE · DESKTOP AI PET
오목 미니게임에서 수를 고르는 코드는 522줄, 반응하는 코드는 864줄이었습니다. 엔진이 이유를 돌려주는 설계와 LLM이 꺼져도 완결되는 발화 레이어를 정리했습니다.
오목을 두는 코드보다 두면서 말하는 코드가 많았다
데스크톱 펫에 오목을 넣었다. 캐릭터와 한 판 둘 수 있다.
만들기 전 예상은 이랬다. 어려운 건 수를 고르는 쪽이고, 반응은 나중에 대사 몇 줄 붙이면 된다.
다 만들고 줄 수를 세 봤다.
두는 쪽
engine.ts 316 수 고르기
session.ts 75 대국 상태
rules.ts 66 승패 판정
board.ts 65 판
─────────────────
522
반응하는 쪽
character-director.ts 349 연출
barks.ts 289 대사
banter.ts 103 LLM 연동
opponent-poses.ts 65 자세
opponent-sprites.ts 58 그림
─────────────────────────────
864

1.65배다. 이길지 질지를 정하는 코드보다, 그 상황에서 무슨 말을 하고 어떤 자세를 취할지 정하는 코드가 더 많다.
기획 단계에서는 “비슷한 분량”으로 적어 뒀는데 실제로는 그보다 컸다.
이번 편에서 공개하는 것은 그중 발화 레이어다.
https://github.com/bluecafe/pet-reference/tree/main/omok-barks
Node 22 이상이면 빌드 없이 바로 돌아간다.
수를 두는 이유가 11가지다
반응 쪽이 커진 첫 번째 이유다. 엔진이 그냥 좌표만 돌려주지 않는다. 왜 거기에 뒀는지를 같이 돌려준다.
win_five 내가 5개를 완성한다
block_five 상대의 5개를 막는다
open_four 내가 열린 4를 만든다
double_threat 두 곳을 동시에 노린다
block_open_four 상대의 열린 4를 막는다
four / block_four
open_three / block_three / three
develop 급하지 않은 수
좌표만 있으면 캐릭터는 “뒀다”밖에 못 한다. 이유가 있으면 막을 때와 노릴 때 다른 말을 할 수 있다.
대사 테이블이 이 11가지를 그대로 키로 쓴다. 엔진이 block_open_four를 돌려주면 그 상황에 맞는 대사 중에서 하나가 나온다.
이유를 돌려주는 건 엔진 쪽에 몇 줄 안 되는 변경이다. 그 몇 줄이 반응 쪽에 289줄을 열어 준다.
매번 말하면 시끄럽다
두 번째 이유다. 모든 수마다 대사가 나오면 성가시다. 그렇다고 안 하면 심심하다.
그래서 발화 확률에 등급을 뒀다. 등급을 집합 두 개로 두고, 어디에도 없으면 가장 낮은 확률이다.
const ALWAYS_SPEAK: ReadonlySet<BarkKey> = new Set<BarkKey>([
"greet", "win", "lose", "draw", "rematch", "crisis",
"win_five", "block_five", "open_four", "double_threat", "block_open_four",
]);
const MID_TIER: ReadonlySet<BarkKey> = new Set<BarkKey>([
"four", "block_four", "open_three", "block_three", "three",
]);
export function shouldSpeak(key: BarkKey, seed: number, ply: number): boolean {
if (ALWAYS_SPEAK.has(key)) return true;
const probability = MID_TIER.has(key) ? MID_TIER_PROBABILITY : LOW_TIER_PROBABILITY;
const rng = createRng((seed ^ hashKey(`speak:${key}`) ^ Math.imul(ply + 1, 0x9e3779b1)) >>> 0);
return rng() < probability;
}
말할지 말지를 정하는 것도 난수인데, 그 난수마저 시드에서 나온다. speak: 접두사를 붙여 대사 고르는 난수와 다른 값이 되게 했다. 같은 시드를 두 용도로 쓰면 “말하기로 한 판”과 “고른 대사”가 서로 얽힌다.
예제를 돌리면 실제 발화율이 나온다.
win_five 100.0% 승부가 갈리는 순간
crisis 100.0%
four 51.7% 중간 티어
open_three 49.2%
develop 19.0% 흔하고 급하지 않은 수
think 19.6%
승부가 갈리는 순간에는 반드시 말하고, 평범한 수에는 대체로 조용하다.
이 표를 만드는 것 자체는 어렵지 않다. 어려운 건 어느 수가 어느 등급인지 정하는 일이고, 그건 엔진이 이유를 돌려주기 때문에 가능하다.
LLM이 꺼져 있어도 완전한 대국이 된다
세 번째이자 이 구조에서 제일 중요한 판단이다.
캐릭터에 로컬 LLM이 붙어 있어서, 대국 상황을 넘기면 그 판에 맞는 대사를 생성할 수 있다. 그런데 로컬 LLM은 사용자가 따로 설치해야 하고, 안 깐 사람이 더 많다.
여기서 갈린다. LLM 대사를 기본으로 두고 없으면 조용해지게 만들 것인가, 아니면 반대인가.
코드는 반대를 골랐다. 원본 파일 첫 줄에 이렇게 적혀 있다.
LLM off가 1급 경험이라는 원칙에 따라 이 모듈만으로 대국 시작~종료까지 전 발화 지점이 완결된다.
미리 쓴 대사만으로 대국 전체가 돌아간다. LLM이 켜져 있으면 하는 일은 하나뿐이다 — 다음에 나갈 대사의 내용을 더 나은 것으로 바꿔치기한다. 발화의 유무와 타이밍은 언제나 기본 레이어가 정한다.
이 순서가 뒤집히면 어떻게 되는지는 쉽게 그려진다. LLM 응답이 늦으면 캐릭터가 뒀는데 3초 뒤에 말한다. 실패하면 그 수에만 조용하다. 사용자는 앱이 고장 났다고 느낀다.
부가 기능이 빠졌을 때 기본 경험에 구멍이 나면, 그건 부가 기능이 아니라 의존이다.
예제의 테스트 하나가 이걸 지킨다. 모든 발화 지점에 대사가 하나 이상 있는지 확인한다.
같은 기보를 다시 두면 같은 말을 한다
대사 고르는 데 난수를 쓴다. 그런데 Math.random()을 안 쓴다.
(대국 시드, 몇 수째, 발화 키) → PRNG → 대사 하나
셋을 섞어 만든 값으로 난수 생성기를 만들고 거기서 고른다.
export function pickBark(
table: BarkTable,
key: BarkKey,
seed: number,
ply: number,
lastLine: string | null = null,
): string | null {
const lines = table[key];
if (!lines || lines.length === 0) return null;
if (!shouldSpeak(key, seed, ply)) return null;
const rng = createRng((seed ^ hashKey(key) ^ Math.imul(ply + 1, 0x9e3779b1)) >>> 0);
const candidates = lines.length > 1 && lastLine !== null
? lines.filter((line) => line !== lastLine)
: lines;
const pool = candidates.length > 0 ? candidates : lines;
return pool[Math.floor(rng() * pool.length) % pool.length];
}
lastLine을 걸러내는 부분이 작지만 필요하다. 같은 말을 연달아 하면 미리 써 둔 목록이라는 게 그대로 드러난다. 다만 후보가 하나뿐일 때는 거르지 않는다 — 그러면 아무 말도 못 하게 된다.
그래서 같은 판을 같은 순서로 다시 두면 캐릭터가 같은 말을 한다.
예제가 한 판을 두 번 재생해서 이걸 보여준다.
[인사] 오늘은 내가 이길 것 같은데
[ 1수] develop 이쯤에
[ 2수] develop (조용)
[ 6수] crisis 막아야겠다
[ 9수] win_five 다섯 개!
[승리] 내가 이겼다!
두 번째 재생: 완전히 같다.
난수 생성기 자체도 열 줄이 안 된다.
function createRng(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
mulberry32라는 알고리즘이다. 암호용으로 쓸 물건은 아니지만 대사를 고르는 데는 충분하고, 시드 하나만 있으면 같은 수열이 나온다는 성질이 필요한 전부다.
버그를 재현할 수 있다는 게 크다. “저기서 이상한 말을 했다”는 제보를 받으면 시드와 수순만으로 그대로 재현된다. Math.random()을 썼다면 다시 볼 방법이 없다.
Date.now()도 같은 이유로 금지다. 테스트에서 시간이 흐르면 결과가 달라진다.
불순물은 한 군데로 모았다
그러면 실제 시간과 애니메이션은 어디서 처리하나. 연출 모듈 하나에 몰아 뒀다. 그 파일 첫머리에 이렇게 적혀 있다.
여기는 의도적으로 불순물 격리소다
DOM, 타이머, 프레임 루프, 실제 난수가 이 파일에만 있다. 나머지 — 규칙, 엔진, 대사 선택 — 는 순수 함수라 입력만 주면 테스트된다. 이번에 떼어낸 발화 레이어가 빌드 도구 없이 그냥 돌아가는 것도 그래서다.
정리
게임에 캐릭터를 넣는다면 분량 예측이 빗나갈 각오를 하는 게 좋다.
- 엔진이 좌표만이 아니라 이유를 돌려주게 한다. 반응의 폭이 거기서 나온다
- 모든 순간에 말하지 않는다. 승부가 갈릴 때만 반드시 말한다
- 생성 모델은 내용만 바꾸게 하고, 발화의 유무와 타이밍은 기본 레이어가 쥔다
- 대사 선택을 결정론으로 만든다. 재현할 수 없는 대사는 고칠 수도 없다
- 시간과 난수를 쓰는 곳을 한 파일로 몰아 둔다
오목 두는 법을 가르치는 것보다 오목을 두는 사람처럼 보이게 하는 것에 코드가 더 든다. 이길지 질지는 316줄로 정해지고, 그 판을 같이 둘 만한 상대로 만드는 데 864줄이 들었다.
돌아가는 코드는 pet-reference/omok-barks에 있다. 시드를 바꿔 가며 재생하면 같은 수순에서 다른 대사가 나오는 걸 볼 수 있다.
실제로 한 판 두려면 앱을 받으면 된다. 캐릭터를 우클릭하면 미니게임이 있다.
연재를 마치며
이 연재는 받아서 5분 안에 띄우기로 시작해 열 편을 왔다. 진입점 한 편을 빼면 편마다 pet-reference 폴더가 하나씩 딸려 있고, 아홉 개 전부 혼자 돌아간다.
앞의 다섯 편이 창을 띄우고 그림을 붙여 배포하는 쪽이었고, 뒤의 네 편이 말하고 듣고 기억하는 쪽이었다.
앱 저장소를 통째로 열지 않은 이유는 두 가지였다. .git이 1.3G라 코드를 보여주려고 그것을 여는 건 비용이 안 맞고, 캐릭터 에셋은 원본 디자인이 내 것이 아니라 공개할 수 없다.
대신 글에 나온 것만, 그 글을 읽은 사람이 직접 돌려볼 수 있는 형태로 다시 만들었다. 잘라낸 조각을 올리면 원본 맥락 없이는 읽히지 않는다.
다음에 무엇을 공개할지는 아직 정하지 않았다.
