I'm FanJae.

Unity 게임 개발 캠프 개인 프로젝트 22일차(Server). 서버 기반 발판 이동 시뮬레이션 구현 본문

Projects/MyToyMapleServer

Unity 게임 개발 캠프 개인 프로젝트 22일차(Server). 서버 기반 발판 이동 시뮬레이션 구현

FanJae 2026. 10. 5. 23:13

1. 시작에 앞서

- 전날에는 Client가 요청한 좌표를 Server에서 검증하고, Map 범위와 이동 속도를 기준으로 최종 위치를 확정하도록 이동 구조를 변경했다.

- 하지만 횡스크롤 형태의 이동에서는 단순히 요청 좌표가 정상적인지 검사하는 것만으로는 부족하다.

- Player가 발판 위에 있는지, 점프 중인지, 중력이 적용되고 있는지, 벽이나 천장과 충돌했는지 등을 Server가 직접 판단할 수 있어야 한다.

- 또한 Client가 최종 위치를 계산해서 보내는 구조를 계속 사용한다면 물리 계산의 주도권이 여전히 Client에 남게 된다.

- 따라서 이번 작업에서는 Client가 좌표 대신 이동 입력만 전송하고, Server가 일정한 Tick마다 Player의 이동 상태를 직접 계산하는 구조를 구현했다.


2. Map별 이동 방식을 구분

- 모든 Map을 바로 발판 이동으로 변경하지 않고 Map별로 이동 방식을 구분하도록 했다.

enum class MovementMode : uint8_t
{
    Free = 0,
    Platformer = 1
};

- 기존 자유 이동 방식은 Free로 유지하고, 발판과 중력을 사용하는 Map은 Platformer로 구분했다.

Map 100000000
    ↓
Platformer

Map 100000001
    ↓
Free

- 이를 통해 기존 기능을 유지하면서 특정 Map부터 새로운 이동 시스템을 적용할 수 있도록 했다.


3. 발판 이동 설정을 데이터로 분리

- 발판 이동에 필요한 설정 역시 Server 코드에 직접 작성하지 않고 CSV 데이터로 분리했다.

map_movement.csv
footholds.csv
colliders.csv

- map_movement.csv에서는 다음과 같은 값을 관리한다.

Movement Mode

Player Half Width / Height

Horizontal Speed
Jump Speed

Gravity
Max Fall Speed

Spawn Foothold

- 실제 테스트 Map은 다음과 같이 설정했다.

Horizontal Speed = 80
Jump Speed       = 240
Gravity          = 480
Max Fall Speed   = 320

- Player 충돌 크기 역시 중심 좌표만 사용하는 것이 아니라 반폭과 반높이를 별도로 관리하도록 했다.


4. Foothold와 Collider 데이터 구성

- 발판은 FootholdDefinition으로 관리한다.

struct FootholdDefinition
{
    uint32_t footholdId;

    int32_t x1;
    int32_t y1;

    int32_t x2;
    int32_t y2;

    uint32_t prevId;
    uint32_t nextId;
};

- 현재 단계에서는 수평 발판만 사용한다.

Foothold 1
───────────────
              │
              │ 연결
              ▼
              ───────────────
                    Foothold 2

- 서로 연결된 발판은 prevId, nextId를 통해 이어지도록 했다.

- 벽과 천장은 발판과 별도로 사각형 Collider로 관리한다.

struct MapCollisionDefinition
{
    uint32_t colliderId;

    int32_t minX;
    int32_t minY;

    int32_t maxX;
    int32_t maxY;
};

- 일방향 발판과 모든 방향을 막는 벽의 역할을 분리한 것이다.


5. 지형 데이터 검증

- 발판과 Collider는 Server 물리 계산의 기준이 되기 때문에 잘못된 데이터로 Server가 실행되지 않도록 했다.

- 예를 들어 다음과 같은 경우를 검사한다.

중복 ID

존재하지 않는 Map

잘못된 발판 연결

겹쳐 있는 발판

Map 밖의 좌표

Spawn과 발판 높이 불일치

Spawn과 Collider 겹침

잘못된 이동 속도 / 중력 값

- 발판 연결 역시 단순히 ID가 존재하는지만 보는 것이 아니라 양쪽 참조와 실제 끝점 좌표가 맞는지 확인한다.

Foothold A.next = B

Foothold B.prev = A

A.x2 == B.x1
A.y2 == B.y1

- 전체 CSV를 임시 데이터에 먼저 로딩한 뒤 모든 검증이 성공했을 때만 실제 Map에 적용하도록 했다.

- 따라서 일부 파일만 정상적으로 읽힌 상태로 Server가 실행되지 않도록 했다.


6. 서버 고정 Tick 추가

- 기존 GameServer의 동작은 Network Event 처리가 중심이었다.

- 하지만 발판 이동은 Packet이 들어올 때만 움직이면 안 된다.

- 점프 이후에는 Client가 아무 입력을 보내지 않더라도 중력에 의해 계속 낙하해야 하기 때문이다.

- 따라서 MapManager에서 일정한 간격으로 Map을 갱신하도록 했다.

_nextTick =
    now + std::chrono::milliseconds(20);

- 물리 Tick은 20ms, 즉 약 50Hz로 동작한다.

Server Time

0ms
 │
20ms  → Tick
 │
40ms  → Tick
 │
60ms  → Tick
 │
80ms  → Tick

- 각 Tick마다 모든 Map의 Tick()을 호출한다.

for (const auto& entry : _maps)
{
    entry.second->Tick(_tick,_nextTick);
}

7. IOCP 대기 시간과 Map Tick 연결

- 기존에는 Network Event를 기다리기 위해 일정한 Timeout으로 IOCP Dispatch를 수행했다.

- 하지만 20ms Server Tick을 사용하려면 다음 Tick보다 오래 대기해서는 안 된다.

- 따라서 다음 Tick까지 남은 시간을 IOCP 대기 시간으로 사용하도록 변경했다.

현재 시간
   │
   ▼
다음 Map Tick까지 남은 시간 계산
   │
   ▼
IOCP Wait Timeout

- Network Packet이 없어도 Tick 시간이 되면 Server가 깨어나 물리를 처리한다.

- 반대로 Packet이 먼저 들어오면 Network Event를 처리한 뒤 경과한 Tick도 계산한다.


8. Tick 지연에 대한 Catch-up 제한

- Server가 일시적으로 지연될 경우 여러 Tick이 밀릴 수 있다.

- 밀린 Tick을 제한 없이 한 번에 실행하면 한 순간에 지나치게 많은 물리 연산이 몰릴 수 있다.

- 따라서 한 번에 따라잡을 수 있는 Tick 수를 최대 5회로 제한했다.

while (now >= _nextTick &&
       count < 5)
{
    ...
}

- 한도를 넘어선 경우에는 오래된 시간을 모두 재생하지 않고 다음 Tick 기준을 현재 시간 이후로 다시 맞춘다.

정상

Tick → Tick → Tick


긴 지연

Tick
Tick
Tick
Tick
Tick
──── 최대 5회

나머지 오래된 Tick은 버리고
현재 시점부터 재시작

- Server 지연 때문에 과거 입력을 지나치게 오래 재생하지 않도록 한 것이다.


9. Client는 좌표가 아니라 Input만 전달

- Platformer Map에서는 기존 MoveRequest(x, y)를 더 이상 이동 방식으로 사용하지 않는다.

- 대신 MovementInputPacket을 추가했다.

struct MovementInputPacket
{
    uint32_t mapId;

    uint64_t generation;
    uint64_t sequence;

    int8_t horizontal;
    uint8_t jumpHeld;
};

- Client가 전달하는 것은 다음 정도다.

Horizontal

-1 : Left
 0 : Stop
 1 : Right

JumpHeld
true / false

- Player의 최종 X, Y 좌표는 Client가 전달하지 않는다.


10. 잘못된 이동 Input 검증

- Server에서는 이동 입력을 바로 적용하지 않고 현재 Player 상태와 비교한다.

if (input.generation != _generation || input.sequence <= _inputSequence || input.horizontal < -1 ||
    input.horizontal > 1 || input.jumpHeld > 1)
{
    return false;
}

- 주요 검증 기준은 다음과 같다.

현재 Map Generation인가?

이전보다 큰 Sequence인가?

Horizontal이 -1 ~ 1인가?

JumpHeld가 0 또는 1인가?

- 잘못된 Input이 들어오면 해당 이동을 적용하지 않고 현재 Server 상태를 InputRejected 상태로 다시 전달한다.


11. 같은 Map 재입장을 Generation으로 구분

- Player가 Map에 들어갈 때마다 Generation을 증가시킨다.

void Player::BeginMap()
{
    ++_generation;
    ...
}

- 같은 Map에 다시 들어오더라도 이전 입장과 새로운 입장을 구분할 수 있다.

Map 100000000
Generation 3

      ↓ Change Map
      ↓ Return

Map 100000000
Generation 4

- 이전 Generation에서 늦게 도착한 이동 입력은 적용하지 않는다.

- 단순히 Map ID만 비교하는 것보다 Map 입장 단위까지 구분하게 된 것이다.


12. 짧은 Jump 입력 보존

- Network Packet과 Server Tick의 타이밍이 항상 일치하는 것은 아니다.

- 예를 들어 다음 두 Packet이 같은 Tick 전에 들어올 수 있다.

Jump = true
Jump = false

- 현재 상태만 저장하면 Tick 시점에는 이미 false가 되어 짧은 Jump 입력이 사라질 수 있다.

- 따라서 Jump가 눌린 순간을 별도의 Pending 상태로 저장한다.

if (input.jumpHeld != 0 &&
    !_input.jumpHeld)
{
    _jumpPending = true;
}

- 다음 Tick에서 한 번은 반드시 Jump 입력으로 처리한 뒤 Pending 값을 제거한다.

Press
  │
Release
  │
  ▼
_jumpPending = true
  │
  ▼
Next Physics Tick
  │
  ▼
Jump 적용

13. 오래된 입력 만료 처리

- Client가 이동 중 연결 문제로 Input Packet을 더 이상 보내지 않는다면 Server가 마지막 입력을 계속 유지해서는 안 된다.

- 따라서 마지막 입력을 받은 시간도 기록한다.

_inputTime = std::chrono::steady_clock::now();

- 250ms 동안 새로운 입력이 없다면 현재 입력을 중립 상태로 변경한다.

expired = now - _inputTime >= std::chrono::milliseconds(250);

- 수평 이동은 멈추지만 공중에 있는 Player의 중력 Simulation은 계속 진행된다.

마지막 Input: Right
        │
        │ 250ms 동안 갱신 없음
        ▼
Horizontal = 0
        │
        ├─ 지상 → 정지
        │
        └─ 공중 → 중력 계속 적용

14. Player 물리 상태를 Server가 직접 관리

- Platformer Player는 다음과 같은 상태를 Server에서 관리한다.

struct PlatformMovementState
{
    double x;
    double y;

    double velocityX;
    double velocityY;

    uint32_t footholdId;

    bool grounded;
    bool jumpHeld;
};

- 좌표뿐 아니라 속도와 현재 발판, 접지 상태까지 Server의 게임 상태가 된 것이다.

- 따라서 Client가 결과를 알려주지 않는다.

- Server가 Map 지형과 이동 상태를 이용해 직접 결정한다.


15. 수평 이동과 발판 연결 처리

- Player가 발판 위에 있을 때는 현재 Foothold를 기준으로 수평 이동한다.

next.velocityX = input.horizontal * horizontalSpeed;

- 현재 발판의 끝을 넘어가더라도 연결된 다음 발판이 있다면 해당 발판으로 이동한다.

Foothold 1
───────────────┐
               └──────────────
                    Foothold 2

- 연결된 발판이 더 이상 없다면 Grounded 상태를 해제한다.

state.grounded = false;
state.footholdId = 0;

- 이후부터 공중 이동으로 처리한다.


16. 점프와 중력

- 점프는 Grounded 상태에서 Jump가 새롭게 눌렸을 때만 시작한다.

if (next.grounded && jumpPressed)
{
    next.grounded = false;
    next.footholdId = 0;

    next.velocityY = jumpSpeed;
}

- 공중에서는 매 Tick 중력을 적용한다.

state.velocityY = max(-maxFallSpeed,state.velocityY - gravity * seconds);

- 최대 낙하 속도를 두어 중력 누적으로 속도가 무한히 증가하지 않도록 했다.


17. 발판 착지 처리

- 발판 착지는 단순히 이동 후 최종 좌표가 발판보다 아래인지 확인하는 방식으로 처리하지 않았다.

- 한 Tick 동안 이동하는 선분과 발판이 실제로 교차하는 시점을 계산한다.

이전 위치
    ●
     \
      \
       \
────────X────── 발판
        \
         ● 예상 최종 위치

- 하강 중 발판 높이를 통과하는 시점의 X 좌표가 실제 발판 범위 안에 있는지 확인한다.

- 여러 발판을 한 Tick에 통과할 정도로 빠르게 떨어지더라도 처음 만나는 발판에 착지하도록 했다.

- 이를 통해 빠른 낙하로 얇은 발판을 통과하는 문제를 줄였다.


18. 벽 충돌 처리

- 벽은 사각형 Collider로 정의되어 있다.

- Player를 단순 점으로 보는 것이 아니라 Player의 반폭과 반높이를 Collider에 반영한다.

Player

┌─────┐
│     │  →→→
└─────┘

             █████
             █████
             █████

- 이동 시작점과 이동량을 이용해 Collider와 가장 먼저 충돌하는 시간을 계산한다.

- 벽에 부딪히면 수평 속도를 0으로 만든다.

if (collision.blockX)
{
    state.velocityX = 0.0;
}

- 천장에 부딪힌 경우에는 수직 속도를 막는다.

- 또한 벽에 붙어 있는 상태에서도 반대 방향으로 움직이거나 아래로 떨어지는 것은 가능하도록 처리했다.


19. Map 경계와 Respawn

- 좌우와 위쪽 경계는 Player 충돌 영역 전체가 Map 내부에 있도록 제한한다.

- 반면 아래쪽 경계를 벗어나면 단순히 Y를 경계값에 고정하지 않고 Spawn 위치로 복귀시킨다.

Player Fall
    │
    ▼
Map Bottom
    │
    ▼
Respawn
    │
    ▼
Spawn Foothold

- 이 경우 Server 상태에는 Respawned 이유를 포함시켜 Client가 일반 이동과 구분할 수 있도록 했다.


20. Map Tick에서 실제 Player 상태 계산

- Server의 Map::Tick()에서 각 Player의 현재 입력을 가져와 이동 Simulation을 수행한다.

auto input = player.ConsumeInput(now,expired);

SimulationResult result = _simulation->Step(state,input);

- 이후 Simulation 결과를 Server의 Player 위치에 반영한다.

player.SetPosition(round(state.x),round(state.y));

- 즉 실제 Player 위치 변화는 Network Request Handler가 아니라 Map Tick에서 발생한다.

MovementInput
      │
      ▼
Player Input State 저장
      │
      ▼
20ms Map Tick
      │
      ▼
MovementSimulation
      │
      ▼
Server Player State 갱신

21. MovementState 전파

- Server가 계산한 결과는 MovementState 패킷으로 전달한다.

struct MovementStatePacket
{
    uint32_t mapId;

    uint64_t generation;
    uint32_t characterId;

    uint64_t serverTick;
    uint64_t sequence;

    int64_t x;
    int64_t y;

    int64_t velocityX;
    int64_t velocityY;

    uint32_t footholdId;

    uint8_t grounded;
    MovementStateReason reason;
};

- 단순 X, Y만 전달하는 것이 아니라 이동 Simulation에 필요한 상태를 함께 전달한다.

Position

Velocity

Foothold

Grounded

Server Tick

Applied Input Sequence

- 이를 통해 Client가 Server의 현재 이동 상태를 기준으로 자신의 예측을 보정할 수 있다.


22. 이동 상태 전파 주기

- 물리 Simulation 자체는 20ms마다 실행하지만 모든 Tick마다 무조건 Packet을 보내는 것은 아니다.

- 일반 상태에서는 3 Tick마다 한 번 정도 상태를 전송한다.

if (tick % 3 != 0 && grounded == state.grounded && reason == MovementStateReason::Normal)
{
    continue;
}

- 약 60ms 주기로 상태를 전달하면서도 다음과 같은 중요한 변화가 발생하면 즉시 전송한다.

Grounded 상태 변경

Respawn

Input Expired

- Network Packet 수와 상태 반영 속도 사이에서 기본적인 균형을 잡은 것이다.


23. 같은 Map의 모든 Player에게 상태 전파

- MovementState는 이동한 본인뿐 아니라 같은 Map의 다른 Player에게도 전달한다.

Player A Input
      │
      ▼
Server Simulation
      │
      ▼
Player A MovementState
      │
      ├────────► Player A
      │
      ├────────► Player B
      │
      └────────► Player C

- 따라서 모든 Client가 같은 Server 상태를 기준으로 Player를 표시할 수 있다.


24. 지형 데이터도 Server가 전달

- Client가 Server와 같은 방식으로 이동을 예측하려면 동일한 지형 데이터가 필요하다.

- 따라서 Map 입장 시 Server에서 다음 순서로 지형 정보를 전달한다.

MapInfo
   │
   ▼
MapGeometry
   │
   ├─ Foothold
   ├─ Foothold
   ├─ Collider
   │
   ▼
GeometryEnd

- 발판 전체를 하나의 거대한 Packet으로 만들지 않고 객체별 Packet으로 나눠 전송한다.

- 현재 최대 Packet 크기를 넘지 않도록 하기 위한 방식이다.


25. Platformer Map에서 기존 MoveRequest 차단

- 새 이동 방식이 생겼더라도 기존 절대 좌표 요청을 그대로 허용하면 발판 Simulation을 우회할 수 있다.

- 따라서 Platformer Map에서는 기존 좌표 기반 이동을 거절한다.

if (IsPlatformer())
    return MoveResult::WrongMovementMode;

- 반대로 Free Map에서는 기존 이동 방식을 그대로 사용한다.

Platformer
    → MovementInput

Free
    → MoveRequest

26. Protocol Version 추가

- 이번 작업으로 GameServer와 Client 사이의 Packet 구조가 크게 변경됐다.

- 이전 Client가 새로운 Server에 접속하면 같은 Opcode를 다른 구조로 해석할 위험이 있다.

- 따라서 Game 입장 요청에 Protocol Version을 추가했다.

constexpr uint32_t
GAME_PROTOCOL_VERSION = 2;
struct EnterGameRequest
{
    uint64_t authKey;
    uint32_t protocolVersion;
};

- Version이 다르면 게임 입장을 명시적으로 거절한다.

Old Client
Protocol 1
     │
     ▼
GameServer Protocol 2
     │
     ▼
ProtocolMismatch

- 호환되지 않는 Client가 잘못된 상태로 게임에 들어가는 것을 막도록 했다.


27. 테스트

- 발판 이동은 별도의 테스트 프로젝트를 만들어 Network와 분리된 상태에서도 검증할 수 있도록 했다.

- 주요 테스트 항목은 다음과 같다.

발판 데이터 로딩

잘못된 발판 연결

Spawn 충돌

중복 Collider

지상 이동

연결 발판 통과

점프

중력

착지

빠른 낙하

벽 충돌

천장 충돌

모서리 충돌

벽을 따라 낙하

Map 경계

낙하 Respawn

잘못된 Input

잘못된 State

동일 입력의 결정성

- 특히 동일한 초기 상태와 입력을 두 Simulation에 적용했을 때 같은 결과가 나오는지도 확인했다.

- Client C# Prediction과 비교하기 위한 C++ Physics Trace 생성 기능도 추가했다.


28. 정리

- 이번 작업에서는 기존 좌표 요청 기반 이동에서 벗어나 Server가 Player의 입력을 받아 직접 물리 상태를 계산하는 발판 이동 구조를 구현했다.

- Map별로 Free와 Platformer 이동 방식을 구분하고, 발판·Collider·점프·중력·Player 충돌 크기를 CSV 데이터로 분리했다.

- Server 시작 시 모든 지형 데이터를 검증하고, 잘못된 발판 연결이나 Spawn 충돌 등이 있으면 Server 실행 자체를 중단하도록 했다.

- Platformer Map은 20ms 고정 Tick으로 갱신하며 Player의 좌우 입력과 Jump 상태를 바탕으로 이동, 점프, 중력, 착지, 벽 및 천장 충돌을 계산한다.

- Client는 최종 좌표가 아니라 MovementInput만 전달하며, Map Generation과 Sequence를 검사하여 오래되거나 잘못된 입력이 현재 Player 상태에 적용되지 않도록 했다.

- 일정 시간 동안 새로운 입력이 들어오지 않으면 마지막 입력을 만료시켜 계속 이동하는 상황도 방지했다.

- Server가 계산한 위치, 속도, 현재 발판, 접지 여부와 Server Tick은 MovementState를 통해 같은 Map의 Client들에게 전달하도록 했다.

- 또한 Platformer Map에서는 기존 절대 좌표 이동을 차단하고, Protocol Version을 추가해 이전 Client와 새로운 Server의 패킷 구조가 섞이지 않도록 했다.

- Player 이동의 최종 판단뿐 아니라 실제 이동 Simulation 자체를 Server가 담당하는 구조로 확장했다.

Comments