[개발일지 #02] 같은 1단계에서 다른 모습으로 진화시키는 알고리즘 🎮

오늘은 게임의 USP를 정리했어요

요즘 퇴근하고 조금씩 방치형 로그라이크를 만들고 있는데, 지난번에는 기술 스택이랑 안티치트, 밸런싱 곡선 쪽을 정리했어요. 오늘은 그다음 단계로 이 게임이 왜 재밌어야 하는지를 잡는 작업을 했습니다.

제가 잡은 핵심은 이거예요. 같은 1단계 꿈틀이에서 시작해도, 유저 행동에 따라 9단계까지 27가지 다른 모습으로 진화한다. 말로만 보면 단순한데, 이게 실제로 굴러가려면 “유저 행동에 따라”가 정확히 뭘 의미하는지 코드로 정의돼 있어야 하더라고요.

그래서 오늘은 스킬 16종 명세랑 변이 분기 알고리즘 v1을 끝냈어요. 짤방으로 퍼질 수 있는 입소문 포인트도 결국 여기서 나온다고 봤습니다.

1. 스킬 16종부터 먼저 정리했어요

변이를 유저 행동으로 결정하려면, 먼저 유저가 어떤 행동을 할 수 있는지부터 잡아야 해요. 그래서 스킬을 액티브 9종 + 패시브 7종, 총 16개로 정리했습니다.

여기서 중요한 건 스킬 효과를 무작정 늘리지 않는 거였어요. 서버 개발자 입장에서는 효과 타입이 늘어날수록 코드 분기가 같이 폭증하거든요. 그래서 효과는 enum 15종 안에서 표현하고, 세부 차이는 effect_param_1..3 조합으로 처리하는 쪽으로 잡았습니다.

태그 스킬 수 대표 스킬
Offense 6종 강타, 충격파, 화살, 분노, 강건한 근육, 잔혹한 일격
Defense 5종 응급 회복, 강철 보호막, 흡혈 일격, 건강한 체질, 흡혈 본능
Speed 5종 가속, 황금 처치, 정밀한 눈, 탐욕, 행운

이 태그가 이번 시스템의 핵심이에요. 유저가 공격형 스킬을 많이 쓰면 공격 쪽으로, 방어형 플레이를 많이 하면 방어 쪽으로, 속도/파밍 쪽 행동이 많으면 speed 쪽으로 변이 점수가 쌓이는 구조입니다.

중간에 원거리 스킬을 빠뜨린 걸 발견했어요

살짝 부끄러운 부분도 있었어요. 1차 명세를 만들고 보니까 원거리 공격 스킬이 없더라고요. 그런데 변이 분기에는 “수상한 새” 같은 원거리 빌드가 들어가 있었어요. 즉, 결과는 있는데 그 결과를 지지하는 입력이 없는 상태였죠.

그래서 skl_arrow, 이름은 날카로운 화살을 추가했습니다. 새 enum을 만들지는 않았고, 기존 SingleDamageeffect_param_3으로 관통 적 수를 넣는 방식으로 처리했어요. 나중에 투창처럼 다관통 스킬이 생겨도 같은 구조로 확장할 수 있게요.

코드 짠 뒤에 발견했으면 리팩토링이었을 텐데, 명세서 단계에서 잡아서 그나마 다행이었습니다.

2. 변이 분기 알고리즘은 단순하게 가져갔어요

이번에 제일 오래 고민한 부분은 “어떻게 고를 것인가”였어요. 확률 기반으로 굴릴 수도 있었는데, 그렇게 하면 유저 입장에서 억울한 순간이 생길 수 있겠더라고요.

예를 들어 내가 계속 공격형으로 키웠는데 방어형이 나와버리면, 시스템이 아무리 수학적으로 자연스러워도 체감은 별로일 것 같았어요. 그래서 일반 분기는 argmax, 그러니까 가장 높은 점수의 방향을 고르는 방식으로 정했습니다.

스킬 태그와 행동 카운터 기반 변이 분기 결정 흐름도
변이 분기는 행동 카운터 → 가중치 점수 → argmax + 5% 동점 룰 순서로 결정돼요.

입력으로 쓰는 카운터는 크게 네 가지예요.

public class StatsCounters {
    public TagCounter EnemyKills   { get; set; } = new();
    public TagCounter EquipMinutes { get; set; } = new();
    public TagCounter SkillUsage   { get; set; } = new();
}

public class TagCounter {
    public float Offense { get; set; }
    public float Defense { get; set; }
    public float Speed   { get; set; }
}

public class PetCareState {
    public int FeedCount  { get; set; }
    public int WaterCount { get; set; }
    public int PlayCount  { get; set; }
}

3. 가중치는 CSV로 빼놨어요

가중치는 코드에 박지 않고 mutation_weights.csv로 분리했습니다. 출시 후 데이터를 봤는데 유저들이 공격형으로 너무 쏠린다? 그러면 코드 배포 없이 가중치만 조정할 수 있게 하려는 목적이에요.

변이 점수 가중치 그래프
적 처치를 가장 크게 보고, 장비 시간과 펫케어는 보조 입력으로 낮게 잡았어요.
카운터 가중치 이유
적 처치 1.0 자연 플레이만으로도 분기가 잡혀야 해서 가장 크게
스킬 사용 0.5 빌드 의도를 반영하되 과하지 않게
장비 시간 0.3 분 단위라 절대값이 커져서 낮게
펫케어 0.1 선택 인터랙션이라 보조 영향만

그리고 이 CSV는 지난번에 정한 클라 데이터 보호 정책에 맞춰 빌드 타임에 AES로 암호화할 예정이에요. 완벽한 보안은 아니지만, 최소한 데이터 파일을 열어 바로 조작하는 수준은 막자는 쪽입니다.

4. Rare는 5%, 일반 분기는 5% 동점 룰

Rare 변이는 행동과 무관하게 5% 독립 롤로 뺐습니다. 운 요소가 아예 없으면 도감 컬렉팅 맛이 조금 심심하고, 친구끼리 “나는 이거 떴다” 하는 비교 포인트도 약해지니까요.

대신 일반 분기는 최대한 유저 의도를 따라가게 했어요. 최고 점수와 5% 이내면 동점 후보로 보고, 그 후보 중에서만 랜덤으로 고릅니다.

var maxScore = scores.Values.Max();
var candidates = scores
    .Where(kv => kv.Value >= maxScore * 0.95f)
    .Select(kv => TagToVariant(kv.Key))
    .ToArray();

return candidates.Length == 1
    ? candidates[0]
    : rng.Choice(candidates);

또 한 가지 중요한 결정은 단계마다 카운터를 리셋하는 거예요. 1단계에서 공격형으로 갔다고 끝까지 공격형으로 고정되는 게 아니라, 2단계에서는 다시 방어형이나 속도형으로 방향을 바꿀 수 있습니다. 이게 있어야 27종 도감 컴플리트도 덜 빡빡해질 것 같았어요.

5. 안티치트는 ROI 기준으로 타협했어요

서버가 결과 검증할 때 공식을 그대로 다시 돌리지는 않습니다. 지난번에 정한 옵션 C 방향이에요. 대신 클라가 반환한 variant가 점수 최고 분기의 ±5% 범위 안에 있는지 정도만 봅니다.

이 방식이 100% 치트를 막지는 못해요. 그래도 공격형 점수가 0점인데 공격형 변이를 반환하는 식의 명백한 조작은 걸러낼 수 있습니다. 지금 규모의 인디 게임에서는 이 정도가 ROI에 맞는 보안선이라고 봤어요.

다음은 시뮬레이터 검증이에요

오늘 정한 수치는 어디까지나 출발점입니다. 진짜 답은 시뮬레이터를 돌려봐야 나와요. 일단 목표는 이렇게 잡았습니다.

  • 자연 플레이 분포: A/B/C/Rare가 너무 한쪽으로 쏠리지 않는지
  • 명시 빌드 정확도: 공격형 빌드가 A 변이로 70% 이상 가는지
  • 도감 컴플리트 비용: 27종 수집까지 윤회 5~10회 안에 들어오는지
  • 펫케어 단독 효과: 밥주기/물주기/놀아주기만 해도 의도가 조금은 반영되는지

이런 건 서버 개발자 입장에서 꽤 재밌는 작업이에요. 헤드리스로 1만 번 자동 플레이를 돌리고, 분포를 뽑아보면 감으로 정한 숫자가 얼마나 말이 되는지 바로 보이거든요.

오늘은 일단 스킬 → 태그 → 행동 카운터 → 변이 분기까지 게임의 USP 뼈대를 세웠습니다. 다음 개발일지는 아마 이 시뮬레이터 결과랑, 가중치 조정하면서 어떤 분포가 나왔는지 정리하게 될 것 같아요.

댓글 남기기