문서 핵심 개념

멘탈 모델

스냅샷, 명령, 회신, 이벤트, 틱, 배치. 이 여섯 가지 개념을 이해하면 API가 왜 이런 모양인지, 어떻게 써야 빠른지 알 수 있습니다.

스냅샷: 읽기, 대기 없음

런타임은 50 ms마다 게임 스레드에서 월드 전체를 수집해 공유 메모리에 씁니다. g.snapshot()으로 얻는 것은 완전하고 일관된 월드입니다:

  • 16개 플레이어 슬롯: 금, 목재, 인구, 인구 상한, 누적 채집량, 종족
  • 최대 1024개 유닛: 종류, 소유자, 좌표, 체력 / 마나(최대치 포함), 현재 오더와 오더 대상, 실제로 공격 중인 대상(작업 대상), 영웅 레벨 / 경험치 / 스킬 포인트, 각 플레이어에 대한 가시성
  • 최대 256개 유닛 상세 정보: 스킬 12개(레벨, 남은 쿨다운), 버프 8개, 인벤토리 6칸
  • 바닥의 아이템, 나무(2초마다 갱신), 생산 테이블(훈련 / 연구 / 건설 / 업그레이드 진행도), 게임 시계, 게임 내 시각

한 번 읽는 데 약 0.4 ms(Python 파싱)가 걸리며, 게임 스레드를 기다리지 않습니다. 그러니 마음껏 읽으세요. g.units(), g.my_army(), g.cooldown(), g.inventory() 같은 API는 모두 같은 스냅샷에서 값을 꺼내므로, 한 틱에 몇 번을 호출해도 비용이 크지 않습니다.

발행 주기는 g.set_publish_period(ms)로 16 ~ 1000밀리초 사이에서 조정할 수 있습니다. 한 번 수집하는 데 게임 스레드에서 약 0.5 ~ 0.9 ms가 걸리므로 33 ms도 문제없습니다. 기기 전체가 하나의 값을 공유하며, 마지막에 쓴 값이 적용됩니다.

명령: 쓰기, 약 한 프레임

g.move / attack / gather / build / train / cast …는 게임 스레드가 실행합니다. 런타임은 게임 스레드의 이벤트 디스패치 안에서 클라이언트가 제출한 명령을 모아서 실행하므로, 명령 하나는 약 한 프레임을 기다립니다(이벤트가 몰려 있는 구간에 걸리면 약 0.1 ms, 아니면 다음 디스패치까지).

  • 명령에는 유닛 하나 또는 목록을 넘길 수 있으며, 목록의 유닛은 같은 프레임에 함께 명령을 받습니다.
  • queue='after'를 붙이면 Shift 예약입니다. 지금 하는 일을 끝낸 뒤에 실행합니다.
  • 명령에 넘기는 유닛은 스냅샷에서 얻은 객체를 그대로 쓰면 됩니다. SDK는 핸들 쌍으로 유닛을 식별합니다(주소는 새 유닛이 재사용할 수 있지만 핸들은 그렇지 않습니다).

회신: 모든 명령에 있음

r = g.build(worker, "hbar", x, y)
if r:                      # 엔진이 수락함
    ...
else:
    r.reason               # 'rejected(金不够)' = 금 부족
    r.verdict              # 8
r.exec_us                  # 이 명령이 게임 스레드에서 실행된 마이크로초

회신은 같은 프레임 안에서 읽어 옵니다. 명령 전후의 유닛 오더, 엔진 함수의 반환값, 실행 가능성 검사의 원인 코드가 담겨 있습니다. 회신은 “엔진이 이 명령을 받았는가, 왜 받지 않았는가”에 답하지만, “결국 해냈는가”에는 답하지 않습니다 — 해냈는지는 스냅샷과 이벤트로 확인합니다.

모든 상태 코드와 원인 코드는 회신과 원인 코드를 참고하세요.

이벤트: 무슨 일이 일어났는가

on_event(g, ev)는 매 틱의 on_tick 직전에, 이전 틱 이후 발생한 이벤트를 하나씩 전달합니다:

이벤트의미
unit.appeared / unit.died / unit.removed유닛 등장, 사망, 소멸(금광에 들어감, 변환됨, 시체가 썩음도 소멸이며 사망과는 다릅니다)
unit.damaged / order.changed / owner.changed체력 감소, 오더 변경, 소유자 변경
hero.levelup영웅 레벨 업
item.appeared / item.removed바닥의 아이템 등장, 누군가 주워 가거나 사용됨
damage엔진 수준: 타격마다 발생하는 피해. 공격한 유닛, 공격 타입, 피해 타입, 실제로 깎인 체력, 방어력 적용 전 피해
killed엔진 수준: 이 타격으로 대상이 죽음. 처치한 유닛 포함
production.done훈련 / 연구 / 건설 / 업그레이드 완료. 4자 코드와 걸린 게임 초 포함. 상대의 것도 옵니다
spell.cast유닛이 스킬을 시전함: 스킬 4자 코드, 레벨, 쿨다운(초), 시전 지점
message화면 메시지 영역에 한 줄이 표시됨: 게임 안내(“농장이 더 필요합니다”), 채팅(.chat에 발신자와 내용), 시스템 메시지
selection.changed / player.left로컬 플레이어의 선택이 바뀜 / 플레이어가 나가거나 패배 판정으로 제거됨
game.started / game.ended새 게임 시작 / 게임에서 나감

캔버스 버튼 클릭, 단축키, 지면 클릭 같은 입력 이벤트는 UI와 입력을 참고하세요.

이벤트 스트림은 전역입니다. 상대의 생산 완료, 크립의 사망도 모두 들어 있습니다. ev.owner나 유닛 핸들로 필터링하세요.

틱: Bot의 리듬

on_tick은 기본적으로 초당 5번 호출됩니다(실제 시간 기준). 한 틱의 소요 시간은 사실상 여러분의 계산 시간입니다. 스냅샷은 대기가 없고, 명령은 약 한 프레임입니다. 한 틱이 주기를 넘기면 자동으로 뒤로 밀리며, 밀린 틱이 쌓이지는 않습니다.

  • 2배속에서는 실제 시간으로 기다리지 마세요. 3 게임 초를 기다리려면 g.clock()이 3만큼 늘었는지 보세요. sleep(1.5)를 쓰면 안 됩니다.
  • on_tick 안에서 sleep하지 마세요. “잠시 뒤에 하기”가 필요하면 현재 게임 시각을 기록해 두고 다음 틱에 다시 판단하세요.

배치: 명령 수십 개도 한 번만 대기

한 틱에 명령을 많이 내려야 할 때는 with g.batch():로 감쌉니다:

with g.batch():
    g.attack(melee, target_a)
    g.attack(ranged, target_b)
    g.move(wounded, home.x, home.y)
    g.cast(hero, "thunderclap")
# 블록이 끝날 때 배치 전체를 제출: 같은 프레임에 실행, 게임 스레드를 한 번만 기다림
  • 블록 안의 명령은 Pending을 반환하며, 블록이 끝난 뒤 회신이 됩니다. 블록이 끝나기 전에 읽으면 예외가 발생합니다.
  • 블록 안에서 예외가 발생하면 배치 전체가 취소됩니다(반쯤 실행된 명령은 아예 보내지 않는 것보다 위험합니다).
  • 실측 이동 명령 8개: 하나씩 보내면 68 ~ 99 ms, 배치로 보내면 6.5 ~ 10 ms.

같은 방식을 쿼리에도 쓸 수 있습니다. g.can_do_many([(u, code), ...]), g.tech_many([...])로 여러 개를 한 번에 묻습니다.

방금 쓴 것 읽기

같은 틱 안에서는 방금 내린 명령이 스냅샷에 아직 보이지 않습니다(다음 발행 때 반영됩니다). 그래서 두 로직이 같은 일꾼을 두고 다툴 수 있습니다. 한쪽은 방금 농장을 지으라고 보냈는데, 다른 쪽은 스냅샷을 보고 아직 놀고 있다고 판단하는 경우입니다.

g.order_of(u)가 이 문제를 해결합니다. 스냅샷이 따라잡기 전까지는 회신에 담긴 새 오더를 기준으로 합니다. “놀고 있는가”는 u.order가 아니라 g.order_of(u)로 판단하세요. g.idle_workers()는 “이번 틱에 방금 일을 맡은 일꾼”을 이미 제외합니다.

지연 단계

단계채널지연용도
0푸시 스냅샷 + 이벤트 스트림한 번 읽기 약 0.4 ms, 데이터는 50 ms마다 갱신모든 “보기” API
1고속 레인약 1프레임, 6개 프로세스 동시 실행 시 중앙값 0.06 ms모든 명령과 쿼리(SDK 기본값)
2제어 채널20 ~ 40 ms예비 경로, 일부 UI 성격의 작업(배속, 말풍선, 메시지)
3게이트웨이(WebSocket / JSON)1단계 + 약 1 ms모든 언어, 브라우저, LLM, 다른 컴퓨터의 프로그램

API 목록에는 API마다 어느 단계를 쓰는지 표시되어 있습니다.