· 19 min read

엔진과 게임을 함께 개발하는 구조 — 승격 프로토콜, 파사드, 짝 worktree

게임 splanet.io는 직접 만든 엔진 OFF 위에 있다. 레포는 둘인데 변경은 대개 하나다. 무엇을 엔진에 올릴지는 규칙이 아니라 감각이지만, 어떻게 올릴지는 프로토콜이 있다. 파사드로 경계를 좁히고, worktree를 짝으로 움직이고, 어느 코딩 에이전트가 와도 같은 계약에서 출발하게 만든다.

TL;DR — 내 게임 splanet.io는 직접 만든 게임 엔진 OFF 위에 있다. 레포는 둘인데 변경은 대개 하나다. 무엇을 엔진에 올릴지는 규칙이 아니라 감각이지만, 어떻게 올릴지는 프로토콜이 있다. 엔진에 additive하게 올리고, 게임이 shim으로 갈아타고, 그다음 사본을 지운다. 경계는 재수출 파일 하나로 좁혀 CI로 봉인하고, 레포 둘을 한 변경처럼 다루기 위해 worktree를 짝으로 만든다.

엔진에 무엇을 올릴지 정하는 법

OFF는 2024년에 시작한 레포다. Unity 데모와 호스팅 골격이 먼저 있었고, 지금의 모습은 게임 두 개를 만들면서 갖춰졌다. 첫 게임은 2D 아이소메트릭 MMORPG인 Aethelgard, 두 번째가 splanet.io다.

무엇을 엔진에 넣을지 정하는 규칙은 없다. 내가 쓰는 기준은 대략 이렇다. 직관적·경험적으로 범용이면 처음부터 엔진에 만든다. 게임 도메인이거나, 범용처럼 보이지만 사실 “이 게임의 프레임워크”에 가까우면 일단 게임 레포에 만든다. 다만 게임 레포에 두더라도 엔진 아키텍처로서 올바른 구조 위에 짓는다. 그래야 나중에 옮길 수 있다. 그리고 두 번째 게임에서 같은 게 필요해지면, 그때 올린다.

Aethelgard 때 이 판단이 여러 번 갈렸다. 인벤토리와 장비 슬롯은 게임 도메인이라 게임에 직접 구현했다. 반면 렌더 설정 패널, LOD 디버그 오버레이, 지형 높이 쿼리는 엔진으로 올렸다. 어느 쪽이 맞았는지는 두 번째 게임이 알려줬다.

방향은 한쪽이 아니다. 첫 게임의 CI 리포팅 방식(TRX 멀티 리포터)이 엔진으로 역수입됐고, C#→TS 타입 생성기 TsGen도 게임에서 엔진으로 올라갔다. 반대로 cube-sphere 토폴로지는 엔진에 먼저 지었다. 4월에 WebGPU 렌더러를 토폴로지 불가지론으로 리팩터한 것도 마찬가지다. 평면 월드(Aethelgard)와 구체 월드(아직 없던 게임)를 같은 렌더러로 받으려는 선제 투자였다.

시기를 보면 방향이 뒤집힌다. 하향(엔진 먼저)은 4월에 몰려 있고, 상향(게임 → 엔진)은 6월에 몰려 있다. 4월엔 두 번째 게임이 뭘 필요로 할지 몰라서 넓게 깔았고, 6월엔 두 번째 게임이 실제로 만든 것 중 범용인 게 무엇인지 알게 됐다.

경계가 애매하면 이슈 본문에 범위를 못박아 둔다. 아틀라스 추출 작업(#261)은 이렇게 적혀 있다.

generic transport/residency primitives까지만 추출한다. 실제 channel packing과 shader 의미는 game-specific으로 tidemark에 남긴다.

지금 코드 분할이 그대로다. 엔진은 희소 아틀라스의 거주(residency), 페이지 테이블 발행, 복구 우선순위를 소유하고, 게임은 자기 채널 바이트를 어디에 꽂을지만 안다.

// tidemark/src/.../planet/cell-channel-feed.ts
// OFF owns sparse atlas residency, page-table publication, repair priority,
// source metadata, and revision counters. This wrapper keeps only the Tidemark
// wire-channel contract: tile/height/water/buried/surface0/stain0/supply0
// byte routing plus the minimap/supply CPU side caches that gameplay UI uses.

게임의 페이지 테이블은 class TidemarkPageTable extends CellAtlasPageTable {} 한 줄이 본체인 25줄짜리 파일이 됐다. 나머지는 상수 재수출이다.

어떻게 올릴지는 프로토콜이 있다

무엇을 올릴지가 감각이라면, 올리는 절차는 기계적이다. 게임이 돌아가는 중에 바닥을 갈아끼워야 하기 때문이다.

6월 29일 커밋 하나가 이 절차를 그대로 보여준다.

Lower CameraInputController into OFF core

Relocate the game-agnostic pointer/wheel/pinch input accumulator out of Tidemark into OFF core (planet-camera-input-controller.ts) and publish it through the facade. It drives the shared OrbitCamera/SurfaceCamera (both now OFF-owned) via a host seam carrying only generic callbacks, so it holds no game specifics.

Additive: unconsumed until the Tidemark shim swap.

세 단계다.

1. 엔진에 추가한다. 아무도 안 쓴다. 게임 특정성을 제거한 형태로 엔진에 넣되, 게임은 아직 자기 사본을 쓴다. 이 커밋 하나로는 어떤 동작도 바뀌지 않는다. 되돌리기도 쉽다.

2. 게임이 shim으로 갈아탄다. 게임 코드가 자기 사본 대신 엔진 것을 부르기 시작한다.

3. 게임에서 사본을 지운다. 이때 비로소 diff에 큰 마이너스가 찍힌다. HDRI 환경 처리를 위임할 때 게임에서 845줄이 사라졌다. 절두체 컬링은 120줄, 손으로 관리하던 타입 미러는 동기화 스크립트 592줄과 함께 통째로 없어졌다.

이 절차를 6월 내내 반복했다. 카메라 수학, 오빗/서피스 카메라, HDRI, 절두체 컬링, 셀 생성 파이프라인, G-buffer 지형 체인, 공유 표면 도메인 베이스. 마지막에는 게임의 렌더러 본체가 뒤집혔다.

Flip TidemarkRenderer to a thin shell over OFF PlanetAtlasRenderer

2,250줄짜리 게임 렌더러가 엔진 오케스트레이터를 조립하고 구동하는 얇은 껍데기가 됐다. raw GPU 패스 호출은 0개다.

그리고 승격의 마지막 흔적은 파일 이름에 남는다. 6월 28일 커밋 제목이 “Rename the planet renderer public facade off the game name”이고, diff는 순수 리네임이다.

public/{tidemark-rendering.ts => planet-rendering.ts}

엔진의 공개 파사드 파일명이 얼마 전까지 게임 이름이었다는 뜻이다. 게임에서 코드가 올라온 직후에는 이름부터 게임 것이고, 일반화가 끝난 뒤에 리네임한다.

여기까지 오면 원칙 하나가 남는다. 엔진 코드는 자기 소비자를 몰라야 한다. 엔진에 먼저 지은 코드는 애초에 그 규칙을 따른다.

// OFF/src/Core/.../StaticPlanet/PlanetChannelGenerator.cs
// Generic channel names — no game namespace.

승격된 코드도 같은 상태로 수렴해야 한다. 파일명은 리네임으로 정리했지만, 주석에는 아직 “이건 원래 어느 게임 것이었다”가 몇 군데 남아 있다. 출신 이력은 git log --followgit blame이 이미 보존하니 주석이 중복으로 들고 있을 이유가 없고, 게임 레포가 리네임되면 엔진에 화석으로 남는다. 지우는 건 이슈로 걸어뒀다.

경계는 파사드 하나로 좁힌다

게임이 엔진의 내부 파일 경로를 직접 물기 시작하면 엔진은 파일 하나 못 옮긴다. 그래서 게임의 렌더러 코드가 엔진을 부르는 통로를 파일 하나로 강제했다. 전문이 이게 전부다.

// off-bridge.ts
// The single shim through which every OFF JavaScript import flows.
// Tidemark renderer code MUST import from `./off-bridge.js`, and this
// shim MUST import from OFF's stable public facade only. That keeps OFF's
// internal file layout out of Tidemark's consumer contract.

export * from '/_content/OpenFieldFramework.Client.Web/js/public/planet-rendering.js';

게임의 렌더러 소스 145개 파일이 전부 이 shim을 통해 엔진에 닿는다. 그리고 반대편 끝에는 엔진의 공개 파사드가 있다. 1,699줄짜리 순수 재수출 층이고, 내부 모듈을 얇게 감싸 이름만 내놓는다.

이 경계는 CI 테스트로 봉인돼 있다. off-bridge.ts가 정확히 그 파사드 URL을 재수출하는지, 그리고 렌더러 소스 어디에도 엔진의 내부 경로가 하드코딩되지 않았는지 검사한다. 현재 프로덕션 소스에 내부 경로를 직접 무는 파일은 0개다.

앞 절의 승격이 가능한 것도 이 구조 덕분이다. 엔진이 내부 파일을 어떻게 옮기든 파사드가 같은 심볼을 계속 내놓는 한 게임은 모른다. “엔진에 추가하지만 아무도 안 쓴다”는 1단계가 성립하는 이유가 여기 있다.

로컬은 소스, 남에게는 패키지

경계는 빌드에도 있다. 게임은 엔진을 두 가지 방식으로 소비하고, MSBuild 속성 하나로 갈린다.

  • UseLocalOFF=true — 형제 클론의 ProjectReference. 엔진을 고치면 즉시 반영된다. 이게 기본값이다.
  • UseLocalOFF=false — NuGet PackageReference. 33개 OFF 패키지가 단일 버전 변수 하나로 묶여 있다.

로컬에서 엔진과 게임을 함께 고칠 때는 소스 참조가 유일하게 말이 된다. 패키지를 거치면 한 줄 고칠 때마다 릴리스를 해야 한다. 반대로 릴리스 경로는 패키지가 필요하다. scripts/bump-off.sh 하나가 그 경계를 건넌다. 엔진 워킹트리가 깨끗한지 확인하고, 태그를 밀고, 릴리스 워크플로를 지켜보고, 패키지 피드에 나타날 때까지 기다린 다음, 게임의 버전 핀을 고치고, 마지막에 UseLocalOFF=false로 한 번 빌드해서 게시된 패키지가 진짜로 해석되는지 검증한다.

이 스크립트가 진실의 원천이라, 수동으로 태그를 달거나 promote PR을 만들지 않는다. (NuGet으로 .NET 코드를 쪼갠 이야기는 따로 썼다.)

작업 단위는 브랜치가 아니라 브랜치 쌍

여기까지가 코드 구조다. 이제 그 위에서 어떻게 움직이는지.

레포가 둘이고 변경이 하나면 브랜치도 둘씩 짝지어야 한다. 게임의 feature/x 브랜치만 만들고 엔진은 main에 두면 그 조합이 어디에도 기록되지 않는다. 며칠 뒤 엔진 main이 움직이는 순간 무엇이 깨졌는지 알 수 없다.

그래서 worktree를 짝으로 만든다.

/tidemark/          ↔  /OpenFieldFramework-tidemark/   (main, prune 대상 아님)
/tidemark-<name>/   ↔  /off-<name>/                    (작업 쌍)

wt.sh new palette-drift-guard 한 번이면 양쪽에 같은 이름의 브랜치와 worktree가 생기고, 첫 빌드가 경합하지 않도록 순차적으로 프로비저닝된다. 짝을 스크립트로 묶어두지 않으면 한쪽만 만들게 된다.

지우는 쪽이 더 까다로웠다. wt prune은 양쪽이 모두 깨끗하고 각자의 트렁크에 들어갔을 때만 안전하다고 판단하는데, 스쿼시 머지나 체리픽으로 들어간 브랜치는 HEAD가 트렁크의 조상이 아니다. 내용은 main에 있는데 SHA가 달라서 단순한 조상 검사로는 영영 “아직 안 머지됨”이다. patch-equivalent 판정을 추가하고 나서야 정리가 자동화됐다.

어느 에이전트가 와도 같은 계약에서 출발한다

나는 Claude Code와 Codex를 함께 쓴다. 지난 두 달 동안 splanet.io 레포에서 두 에이전트가 남긴 세션이 418개다.

둘을 쓰는 이유는 서로 검증시키려는 게 아니라 특장점이 다르고 사용량이 갈리기 때문이다. 이미지 생성처럼 한쪽만 되는 작업이 있고, 어떤 작업은 경험상 한쪽이 더 낫다. 그리고 한쪽 사용량을 다 쓰면 다른 쪽으로 넘어간다.

그래서 목표는 두 에이전트를 조합하는 법을 찾는 게 아니라, 어느 에이전트가 와도 같은 계약에서 출발하게 만드는 것이다. Claude Code든 Codex든 다음에 올 무언가든.

진입 문서는 둘로 갈라 뒀다. AGENTS.md가 범용 진입점, CLAUDE.md가 Claude Code 전용이다. 범용 쪽은 이렇게 시작한다.

This file is intentionally short so Codex, Claude Code, Cursor, Copilot, Aider, and similar agents all start from the same operating contract.

짧게 유지하는 게 목적이다. 긴 문서는 읽히지 않고, 읽혀도 컨텍스트를 먹는다. 진입점은 읽기 순서만 알려주고 실제 내용은 docs/agents/ 아래로 링크한다. 워크스페이스 규칙, 태스크 카드 색인, 프로젝트 불변식이 각자의 파일에 있다.

문서에 절대경로를 박지 않는 것도 여기서 강제한다. 에이전트는 자기 세션에서 /Users/pp/dev/private/...가 잘 동작하니 자연스럽게 쓰지만, 다른 사람이 클론하면 전부 깨진다.

병렬로 굴릴 때 프롬프트에 반드시 넣는 문장이 몇 개 있다.

  • main에서 작성하지 말 것. wt.sh new로 짝 worktree를 만들고 거기서 일한다.
  • 너 혼자가 아니다. 남의 dirty worktree를 건드리지 말고, 남의 편집을 되돌리지 말 것. 에이전트의 기본 가정은 단독 작업이라, 정리하려 드는 걸 명시적으로 막아야 한다.
  • 중단은 되돌리는 게 아니다. 작업을 멈춰야 할 때는 “되돌리지 말고 현재 상태만 보고하라”고 쓴다. 이미 머지된 이슈를 중복 착수한 적이 있는데, 그냥 “멈춰”라고만 했으면 에이전트가 자기가 만든 worktree를 지우면서 다른 작업까지 건드렸을 것이다.

첫 번째 규칙은 문서에 있어서 지켜진다. 6월의 어느 세션에서 에이전트가 스스로 멈췄다. 지금 작업트리가 main이라 규칙상 패치를 쓸 수 없으니 원인만 설명하고, 고치려면 짝 worktree에서 하자고. 시킨 게 아니라 AGENTS.md를 읽고 판단한 것이다.

남는 것

7월 9일에 렌더러 추출이 끝났다는 문서 커밋을 찍었다. 게임의 렌더러는 얇은 셸이 됐고 오케스트레이터·카메라·HDRI·지형 체인은 전부 엔진이 소유한다. 남은 건 렌더러가 아니라 세 번째 게임의 부트스트랩 — AppHost와 진입점을 clone-rename 하고, 게임 정의를 seam으로 뽑아내는 일이다.

여기 나온 장치는 넷뿐이다. worktree를 짝으로 만드는 스크립트, 재수출 파일 하나, MSBuild 속성 하나, 짧은 마크다운 문서. 이것들이 하는 일은 결국 하나다. 무엇을 엔진에 올릴지 잘못 정해도 되돌릴 수 있게 만드는 것. 파사드가 있어서 승격을 additive하게 시작할 수 있고, 짝 worktree가 있어서 두 레포를 한 변경처럼 다룰 수 있다.

세 번째 게임을 만들면 지금 게임에 남겨둔 것 중 무엇이 진짜 게임 고유의 것이었는지 알게 될 것이다.

Related Posts

View All Posts »
AI에게 게임 월드를 만들라고 시켰더니 — AI-Driven Game Engine Development #1

AI에게 게임 월드를 만들라고 시켰더니 — AI-Driven Game Engine Development #1

AI가 월드를 자율적으로 만들려면 뭘 준비해야 하는가. 도구를 설계하고, 시행착오를 거치고, 벽 없는 미로에서 교훈을 얻기까지.

#OFF #AI #Claude #GameDev
OFF에 행성을 얹다 — Topology-Agnostic 아키텍처

OFF에 행성을 얹다 — Topology-Agnostic 아키텍처

무한 평면 분산 서버 프레임워크였던 OFF에 cube-sphere 행성을 얹은 이야기. 평면도 구체도 같은 코어 위에서 도는 구조로 어떻게 바꿨나.

#OFF #Architecture #GameServer #Distributed
NuGet 프라이빗 패키지로 .NET 코드 분리하기 — 멀티 레포 9개 함정

NuGet 프라이빗 패키지로 .NET 코드 분리하기 — 멀티 레포 9개 함정

OFF를 NuGet 프라이빗 패키지로 떼어, 협업자에게 프레임워크·서버 소스를 노출하지 않고도 풀스택 dev 루프를 유지한 멀티 레포 구조와, 옮기면서 밟은 9개의 함정.

#OFF #Aethelgard #GameDev #DotNet