Rust로 롤백 기반 멀티플레이 게임 엔진을 처음부터 만들며 겪은 함정과 해결책: 결정성, 난수, 신뢰적 UDP, 성능, 복제 최적화.
Rust로 멀티플레이 엔진을 만들며 맞닥뜨린 함정들과 그 탄생 과정을 읽어보세요
소프트웨어 엔지니어, CEO
2025년 9월 28일 수정: 2025년 10월 1일
2018년, 한 남자가 꿈을 꾸었습니다. 협동에 초점을 둔 멀티플레이어 게임을 만드는 것. 막 공학학교를 졸업한 그는 회사를 창업하고 혼자 프로젝트를 시작하기로 했습니다.
몇 년이 지나 충분한 경험이 쌓였고, 그것을 정리해 블로그 글로 남기게 됐습니다. 지금 이 글이 바로 그 이야기입니다.
제로부터 게임 엔진을 만들었던 경험, 그리고 Rust가 아니었다면 이 엔진이 태어나지 못했을 이유를 들려드리겠습니다.
이 글은 그저 날것의 경험담이기도 하지만, 동시에 “왜 Unity / Unreal / Godot / 기타를 쓰지 않았나?”라는 영원한 질문에 대한 답이기도 합니다.
명확히 해두고 싶은 점이 있습니다. 이것은 다른 게임 엔진에 대한 비판이 아닙니다. “내 것이 더 낫고 너희 건 나쁘다”가 아니라, “기존 엔진들이 내 요구에 딱 맞지 않았기에 내 길을 갔다”에 가깝습니다. 사람도, 시점도 다르면 결과가 달라지는 건 당연하고, 그건 전혀 문제없습니다.
기존에 있는 게임에 멀티플레이를 나중에 얹는 건 대규모 리팩터링 없이는 불가능하고, 운이 나쁘면 큰 단점까지 감수해야 한다는 건 널리 알려져 있습니다. “Don’t Starve Together”가 “Don’t Starve”의 단순한 확장이 아닌 이유가 바로 이것이죠. 멀티플레이를 넣으려면 아예 멀티플레이를 전제로 처음부터 다시 만들어야 했습니다. 그래서 게임 엔진을 만들 때 어떤 네트워크 동기화 전략을 쓸지부터 고민해야 했습니다.
멀티플레이 상태를 동기화하는 기법은 매우 많지만, 완벽한 것은 없습니다. 심지어 여러 아이디어를 섞은 하이브리드도 있죠. Glenn Fiedler의 자세한 글 같은 자료가 많고, 그 외에도 훌륭한 글이 수십 개나 있습니다.
참고로, 직접 만들고 싶다면 Gaffer on Games는 꼭 북마크하세요. 물론 대부분은 직접 알아내거나 변형할 수 있지만, 이미 누군가 바퀴를 만들어뒀다면 굳이 재발명할 필요는 없잖아요?
저는 2명 이상의 플레이어가 반응적으로 액션하는 게임, 예를 들어 액션이나 플랫폼 게임을 원했고, 턴제 게임으로 제한되고 싶지 않았습니다.
또한 상호작용이 매우 높은 게임을 원했기 때문에 권한을 분산한다거나 병렬 시뮬레이션을 하는 방식은 배제됐습니다. 마법사가 파이어볼을 쏘는 순간 적이 모두를 기절시키는 스킬을 쓰면, 플레이어 상태를 어떻게 동기화하겠습니까?
그리고 마지막으로, 장거리에서도 플레이가 가능해야 했습니다. 같은 대륙에 살지 않으면 함께 할 게임을 찾기 어려운데, 보통은 낮은 핑이 필요하기 때문입니다. 특히 COVID 시기에는 수요가 매우 컸죠. 팬데믹 한가운데서 Valheim이 일주일 만에 100만 장을 판매했을 때를 기억하시나요?
정리하면 이렇습니다.
짐작하셨겠지만, 결국 롤백 기반 멀티플레이만이 남았습니다.
롤백이 어떻게 동작하는지는 여기서 설명하지 않겠습니다. 이미 관련 자료가 매우 많습니다. GGPO 팀의 글부터, Core-A Gaming의 유튜브 개론 영상까지요. 이 글의 상당 부분은 여러분이 롤백을 알고 있다는 전제하에 진행합니다. 경고 드렸습니다.
덧붙이면, 롤백은 보통 P2P 대전 격투게임에서 언급되지만, 여기서는 서버 권위 기반이고 클라이언트가 자신의 화면에서 롤백을 사용합니다.
그다음은 사용 스택을 고르는 일이었습니다. 당시 롤백 넷코드의 사실상 표준인 GGPO는 오픈소스가 아니었고, 게다가 P2P 게임만을 대상으로 했습니다. 롤백 구현을 내장하거나 오픈소스로 제공하는 게임 엔진도 없었기 때문에, 스스로 만들어야 한다는 걸 알았습니다.
C++와 C# 쪽 라이브러리를 찾아봤지만, 제 요구에 맞는 것을 찾지 못했습니다. 그때쯤 저는 개인적으로 몇 년 동안 Rust를 써오고 있었고, Rust로 직접 시작해보기로 했습니다. 지금까지 내린 결정 중 단연 최고였습니다.
롤백 기반 멀티플레이 엔진을 만들려면 필요한 것이 아주 많습니다. 그리고 그중 몇몇은 Rust였기에 훨씬 수월했습니다. 볼 게 많으니 꽉 잡으세요.
롤백을 가능케 하는 핵심은 크로스 플랫폼에서의 결정성입니다. 플레이어들이 같은 화면을 봐야 할 뿐만 아니라, 롤백 때문에 같은 프레임에서 같은 입력을 여러 번 재생할 수 있어야 하기 때문입니다.
몇 가지 중요한 고려사항이 있었습니다.
이 주제는 논쟁이 치열하고, 다행히 Gaffer on Games가 훌륭한 정리를 해두었습니다. 요약하면, 부동소수점 연산은 거의 항상 결정적이지만, “항상”은 아닙니다. 몇 가지 플래그로 달성 가능하다는 주장도 있고, 아키텍처가 다르면 불가능하다는 주장도 있죠.
명확히 하자면, 부동소수점으로 결정적 엔진을 만든 사례는 존재합니다. rapier가 좋은 예입니다. 하지만 위 글의 일부 댓글에서 언급되는 것과 달리 그들은 단순히 플래그 몇 개만 켠 게 아니라, 예제에서 비-표준 라이브러리 함수를 사용합니다.
의존 트리를 거슬러 올라가 보면, 결국 sin 같은 libm 함수로 귀결되고, 구현은 기본 연산만으로 구성되어 있습니다.
요컨대 꽤나 복잡합니다. 아마 방법이야 있겠지만, 저는 그 길을 택하지 않았습니다.
Rust에는 훌륭한 고정소수점 산술 크레이트가 있고, 미래에 부동소수점으로 인한 문제가 생길 위험을 감수하느니 이것을 쓰기로 했습니다. 덜 효율적일까요? 아마도요. 다만 실제로 측정하진 않았습니다.
cos, acos를 위한 룩업 테이블을 만들어야 했느냐고요? 네.
계산 정확도가 떨어지느냐고요? 그것도 맞습니다.
하지만 얻는 이점이 있습니다.
NaN, Infinity 같은 묵시적 실패가 없습니다. 고정소수점을 0으로 나누면 즉시 패닉이 나고, 제가 원하는 바 그대로입니다. 오버플로/언더플로 등도 마찬가지죠.Ord)를 가지므로, std::cmp::{max, min}을 쓸 수 있고, std만으로 Vec<FixedPoint>를 정렬할 수 있습니다.libm::sin 대신 f32.cos()를 써서, 희귀 CPU에서 보름에 한 번 결정성이 깨지는 일을 만들 일은 없겠죠.그리고 완벽히 정확하지 않은 단점이요? 이건 게임입니다. 투사체 속도가 5.56945가 아니라 5.56950이라 해도, 모두의 화면에서 같은 결과만 보인다면 현실적으로 아무도 신경 쓰지 않습니다.
덧붙여, 고정소수점이 Rust의 표준 라이브러리뿐 아니라 다른 언어에서도 더 널리 쓰였으면 합니다. 항상 3등 시민처럼 취급받지만, 부동소수점만큼이나 분명한 쓰임새가 있습니다.
이 부분에서 Rust가 빛납니다. 대부분의 언어에서 표준 라이브러리의 난수는 OS 기반이고, 결정적이지 않습니다.
다른 난수 생성기를 찾으려 하면 결정성이 필요한 요구를 만족하는 좋은 구현을 찾기 어렵습니다. Rust에서는 rand_pcg가 완벽합니다. ThreadRng나 OsRng와 같은 API를 쓰고, 부동소수점을 사용하지 않으며, 단지 두 개의 u128 워드(따라서 손쉽게 복제 가능하고 크로스 플랫폼에서 결정성 보장)로 이루어져 있고, Cargo.toml에 한 줄만 추가하면 됩니다.
게임 시뮬레이션의 가장 중요한 요소 중 하나가 단 한 줄로 해결됩니다. 제 기준에서는 이보다 좋은 건 없습니다.
불행히도 시뮬레이션 내부에서는 스레딩을 포기해야 했습니다. 일부 ECS 라이브러리는 시스템 1이 컴포넌트 A를 필요로 하고 시스템 2가 컴포넌트 B를 필요로 하면 두 시스템을 병렬로 실행할 수 있도록 의존 트리를 계산하는 복잡한 메커니즘이 있습니다. 하지만 1인 개발자가 작은 프로젝트에서 그런 복잡도를 감수할 가치는 없었고, 훨씬 단순한 엔티티-컴포넌트 라이브러리를 설계했습니다.
대략 이런 모습입니다.
for (entity_id, entity, gravity) in world.entities.iter_single::<Gravity>() {
if !gravity.enabled.get() {
continue;
}
let collision_body = entity.get::<CollisionBody>();
let stepping_on = collision_body
.map(|c| c.stepping_on.get().is_some())
.unwrap_or(false);
// 무언가 위에 서 있지 않다면 중력을 적용한다
if !stepping_on {
let mut tmp = entity.speed.get();
tmp.y -= gravity.fallaccel.get() * world.steps.playtime_step / F_UPS;
tmp.y = std::cmp::max(tmp.y, -gravity.max_fallspeed.get());
entity.speed.set(tmp);
}
}
EntityId로 엔티티를 랜덤 접근할 수 있고, Entity로부터 임의의 컴포넌트를 랜덤 접근할 수 있습니다. 트레이드오프는 모든 컴포넌트가 내부 가변성(interior mutability)을 가져야 한다는 점입니다. 하지만 병렬이 아니므로 대부분을 Cell과 RefCell로 감쌀 수 있습니다. 이 시점에서 저를 비효율적인 돼지라고 생각하는 분들도 있겠지만, 수만 줄을 이 아키텍처로 작성해 본 결과, 여기저기서 한 번씩 get과 set이 있는 것이 코드 어디서든 컴포넌트를 랜덤 접근하지 못하는 것보다 훨씬 낫습니다.
제가 시작할 당시 ECS 라이브러리의 상태는 아주 좋지 않았습니다. 지금은 꽤 나아졌고, 여전히 제 요구에 딱 맞는 것은 없지만 hecs가 꽤 근접합니다.
자세한 내용은 다음 글에서 다루겠습니다.
“인터넷으로 데이터를 어떻게 보내지?” 범주의 이야기입니다. 이쪽을 파본 분께 새롭진 않겠지만, 참고로 포함합니다.
또 한 번 신뢰적 순서 보장 메시징에 관한 훌륭한 글을 강력 추천합니다.
기본 논리는 간단합니다. TCP처럼 연속 스트림이 아니라, 시작과 끝이 있는 “메시지” 단위로 생각합니다. UDP의 MTU가 대략 1500바이트이므로, 1500바이트보다 긴 메시지를 보내려면(자주 그럴 겁니다) 메시지를 1500바이트 정도로 조각내고, 수신 측이 이를 재조합할 수 있게 해야 합니다.
Fragment 구조체를 정의해 봅시다.
pub struct Fragment<T: AsRef<[u8]>> {
pub sequence_id: u32,
pub fragment_id: u8,
pub fragment_total: u8,
pub fragment_meta: FragmentMeta,
pub data: T
}
아이디어는 단순합니다. sequence_id는 메시지 번호, fragment_id는 메시지의 몇 번째 조각인지 나타냅니다.
조각을 어떤 순서로 받든, 특정 sequence_id의 조각 개수가 fragment_total에 도달할 때까지 저장합니다. 그리고 각 조각의 data를 붙여 원래 메시지를 복원합니다.
fragment_total이 u8임에 유의하세요. 즉 메시지 최대 크기는 약 380kB 정도입니다. 그 이상이면 초과 분할이나 스트리밍이 필요합니다. 아니면 그런 메시지에 한해 TCP로 돌아가도 됩니다.
fragment_id와 fragment_total을 u16으로 키우는 건 추천하지 않습니다. 최대치가 수백 MB가 되겠지만, 현실적으로 한 번에 수천 개의 UDP 패킷을 보내면 오늘날의 인터넷에서도 어딘가에서 혼잡이 날 가능성이 큽니다.
struct FragmentMeta {
kind: FragmentKind,
// 기타 메타 정보
}
#[repr(u16)]
enum struct FragmentKind {
Forgettable = 0,
Key = 1,
}
FragmentMeta는 간단한 메타 정보 구조체입니다. 가장 명백한 건 패킷 종류겠죠.
Forgettable은 패킷 손실로 모든 조각을 받지 못하면 그 메시지를 그냥 버리라는 뜻입니다. Key는 언젠가 전체 메시지가 도착하도록 Ack를 보내 재전송을 보장해야 함을 의미합니다.
하지만 조각(fragment)만이 UDP로 보낼 수 있는 메시지 타입의 전부는 아니니, 메시지 타입이 더 필요합니다.
pub enum Packet<P: AsRef<[u8]>> {
Fragment(Fragment<P>),
Ack(u32, u8),
Syn,
SynAck,
Heartbeat,
End,
}
Ack는 “(sequence_id, fragment_id)의 데이터를 받았으니, 다음부터는 재전송하지 않아도 된다”는 의미입니다. Syn과 SynAck은 TCP와 유사하게 연결을 시작/확인합니다.
Heartbeat는 UDP에서 어쩔 수 없이 필요한 존재입니다. 일정 시간 메시지가 오가지 않으면 일부 라우터는 연결이 끝났다고 판단해 이후 수신 패킷을 차단하기도 합니다. 몇 초에 한 번 간단한 하트비트를 보내 연결을 유지합니다.
각 패킷은 인코딩되고, 예를 들어 Fragment는 대략 다음과 같습니다.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| CRC32 | Message Type |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| Sequence ID | Frag ID |FragTot| Frag Meta |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
: :
: Payload :
: :
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
바이트 4부터 끝까지 CRC32 검사를 포함합니다. Rust에는 이를 위한 크레이트가 여럿 있으니, 수신 시 검사를 잊지 마세요!
물론 이는 매우 상위 수준의 개요에 불과하고, 실제로는 좀 더 복잡합니다. 하지만 약간의 경험이 있다면 며칠 안에 이런 라이브러리를 직접 만들 수 있습니다. Rust는 그만큼 작업하기 쉽습니다.
어디에 둘지 애매해 여기 적는 팁 하나: Windows에서 UDP 소켓은 다른 플랫폼에서는 절대 발생하지 않는 ConnectionReset을 반환할 수 있는 짜증나는 동작이 있습니다.
이는 Windows의 Virtual UDP circuit 때문인데, 이 기능을 끄면 다른 플랫폼과 더 일관되게 동작합니다. 리눅스에서 개발하다가 테스터 손에 넘어가서야 마주친 문제라, 온라인에서 이 코드를 본 기억이 없어 여기 남깁니다. 정말 재미있는 디버깅 세션이었죠.
use std::net::UdpSocket;
use windows::Win32::Networking::WinSock::{WSAIoctl, SOCKET, SIO_UDP_CONNRESET, WSAGetLastError};
use windows::Win32::Foundation::{BOOL};
/// Windows에서 Virtual UDP Circuit을 비활성화한다
///
/// 실패 시 `WSA_ERROR`를 반환한다.
pub fn disable_virtual_udp_circuit(udp_socket: &UdpSocket) -> Result<(), i32> {
let socket = udp_socket.as_raw_socket();
let mut bytes_returned = 0;
let enable = BOOL::from(&false);
unsafe {
let r = WSAIoctl(
SOCKET(socket as usize),
SIO_UDP_CONNRESET,
Some(&enable as *const _ as *const core::ffi::c_void),
std::mem::size_of_val(&enable) as u32,
None,
0,
&mut bytes_returned,
None,
None
);
if r == -1 {
let e = WSAGetLastError();
Err(e.0)
} else {
Ok(())
}
}
}
이제 바이트를 받아 네트워크로 전송하고, 다른 컴퓨터에서 동일한 바이트를 받는 API를 대략 갖췄습니다. 이걸로 무엇을 할 수 있을까요?
Rust를 이미 쓰는 분들에겐 너무 자명하겠지만, 그렇지 않다면 serde와 bincode 조합이 정말 훌륭합니다.
두 구조체를 정의해 봅시다.
#[derive(Debug, Serialize, Deserialize)]
pub enum ClientMessage {
InGameMessage(ClientInGameMessage),
Quit { err: Option<String> },
Handshake { name: String }
}
#[derive(Debug, Serialize, Deserialize)]
pub enum ServerMessage {
InGameMessage(ServerInGameMessage),
Quit { reason: Result <(), String> },
HandshakeResponse(Result<(), HandshakeError>),
}
직렬화와 역직렬화는 사소해집니다.
let result: Vec<u8> = bincode::encode_to_vec(
input_client_message,
bincode::config::standard()
).unwrap();
// result를 네트워크로 보냈다고 가정...
let (decoded_client_message, _): (ClientMessage, usize) = bincode::decode_from_slice(
&result,
bincode::config::standard()
).unwrap();
별것 아닌 것처럼 보이지만, C++였다면 직접 포맷을 짜거나 템플릿을 며칠씩 만지작거렸을 일입니다. Rust에서는 몇 분이면 끝납니다.
Rust에서 롤백 로직이 어떻게 생겼는지 설명해 보겠습니다.
struct Game {
/// 화면에 표시되는 월드
played_world: World,
/// 서버로부터 받은 입력이 예측 입력과 다르면
/// 롤백해야 하는 마지막으로 유효한 월드
last_valid_world: World
}
struct World {
tick: u32,
// 나머지 데이터는 여기... 우리의 논의에는 중요하지 않음
}
두 개의 World를 상상해 봅시다. 하나는 화면에 표시되는 플레이 월드, 다른 하나는 우리가 참이라고 아는 마지막 유효 월드입니다. 두 색을 부여하겠습니다. 파랑과 빨강. 이렇게 하면 이후 설명이 더 명확해집니다. 단, 파랑이 항상 플레이드 월드이고 빨강이 항상 마지막 유효 월드인 것은 아닙니다. 곧 이유를 보게 됩니다.
이제 이런 상황을 가정해 봅시다. 서버로부터 31~33 틱의 입력을 받았고, 현재 34 틱을 플레이 중입니다. 롤백을 시작해야 합니다.
먼저, 플레이드 월드와 마지막 유효 월드를 스왑해 플레이드가 과거 시점으로 돌아가게 합니다.
std::mem::swap(&mut self.last_valid_world, &mut self.played_world);
그다음, 방금 서버에서 받은 진짜 입력을 적용합니다.
/// 진짜 입력으로 플레이드 상태를 전진시킨다
fn true_advance(&mut self, true_inputs: &[WorldInput]) {
for true_input in true_inputs {
self.played.update(&true_input);
}
}
진짜 입력이 더 이상 없다면, clone_from으로 플레이드 월드를 마지막 유효 월드에 덮어씁니다.
self.last_valid_world.clone_from(&self.played_world);
마지막으로 롤백을 시작했던 시점으로 플레이드 위치를 복구합니다.
/// 예측 입력으로 플레이드 상태를 원하는 틱까지 전진시킨다
fn advance_to_played(&mut self, played_tick: WorldTick, predicted_inputs: &HashMap<WorldTick, WorldInput>) {
while self.played_world.tick < played_tick {
let predicted_input = predicted_inputs.get(self.played_world.tick).unwrap();
self.played_world.update(&predicted_input)
}
}
첫 번째 다이어그램과 비교해 보면 상태는 대략 같지만, 파랑과 빨강이 서로 변수를 바꿨습니다. 이 롤백 방식에서는 롤백이 일어날 때마다 두 변수가 메모리에서 스왑됩니다. 마지막 유효 → 플레이드로 clone, 플레이드 → 업데이트된 마지막 유효로 clone처럼 두 번 복제하는 것 대신 스왑 + 한 번의 clone만 수행하면, 프레임당 복제 횟수가 한 번으로 줄어듭니다. 매우 많은 객체가 있는 월드에서는 게임 체인저가 될 수 있습니다.
위 다이어그램 때문에 update 함수는 프레임마다 한 번만 호출되는 게 아니라, 매 프레임 최대 max_rollback + 1번까지 호출될 수 있습니다. 따라서 update 시간은 가능한 한 낮아야 합니다.
다른 롤백 엔진은 보통 처리할 것이 많지 않습니다. 대부분 P2P 격투게임이라 캐릭터 2명과 몇 개의 투사체 정도는 느린 엔진에서도 계산 비용이 크지 않죠.
하지만 우리의 경우는 다릅니다. 2명 이상의 플레이어와 월드의 복잡한 상호작용이 있는 경우 update가 공짜가 아닙니다. 해답은 간단합니다. Rust는 애초에 빠릅니다. 특별히 성능을 위해 할 일이 많지 않습니다. 성능은 기본으로 따라옵니다.
위 다이어그램에서 보셨듯, 우리는 깊은 복제를 위해 clone_from을 사용합니다.
Rust만이 깊은 복제를 제공하는 건 아니지만, Rust가 가장 쉽습니다.
C++에는 복사 대입이 있지만, 사용하는 모든 클래스/구조체에 구현되어 있다는 보장은 없고(특히 외부 것들), 자동 생성도 없습니다. 코드의 모든 구조체마다 이 연산자를 직접 오버로드해야 합니다. 가능은 하지만요.
Rust에는 #[derive(Clone)]이 있지만, clone_from까지 자동 유도되지는 않습니다. 대부분의 경우 컴파일러가 많은 최적화를 해주니 괜찮습니다. 단, 구조체가 어떤 식으로든 할당을 포함하는 경우는 예외입니다. derivative를 쓰면 clone_from과 clone 메서드를 자동으로 생성할 수 있습니다. 자식 멤버에 어떤 형태로든 할당이 있는 모든 구조체에 쓰세요. 숫자, 불리언 등 단순 필드만 있는 구조체라면 Clone만 유지하고 clone_from은 생략해도 충분합니다.
사소한 코드 차이지만, 영향은 클 수 있습니다. World와 그 하위 대부분에 clone_from을 구현했더니, 월드의 개체 수에 따라 복제 시간이 약 30~40% 줄었습니다.
각 항목만 떼어내면 대수롭지 않아 보일 수 있지만, 모두 합치면 설득력 있는 얘기가 됩니다. 저처럼 시간이 제한된 1인 개발자가 Rust가 아니었다면 이렇게 빠르게 엔진을 만들 수는 없었을 겁니다.
이 텍스트의 결과물이 무엇인지, 특히 핑을 어떻게 처리하는지 보여드리고 싶습니다.
아래 비교는 모두 netns로 핑을 시뮬레이션했고, 패킷 재정렬을 크게 주었으며(약 20%), 패킷 손실 0.1%를 적용했습니다.
20ms에서는 롤백이 거의 눈에 띄지 않습니다. 입력 지연이 한 프레임이라면 1~2 프레임 정도의 롤백만 필요합니다.
70ms에서는 롤백이 확실히 보이기 시작합니다. 대략 3프레임 정도의 완전한 롤백에 약간의 입력 지연이 더해집니다. 그렇다고 해도 이 정도 핑에서는 랙에 대한 불만이 거의 없었습니다.
140ms에서는 롤백이 확실히 심해집니다. 이번에는 약 6~7프레임의 롤백이 발생합니다. 플레이어가 지연을 알아차리고 불평하기 시작할 한계에 가까워졌다고 볼 수 있지만, 여전히 플레이 가능합니다.
북미 서해안과 유럽 서해안 사이의 RTT가 약 150ms라는 점을 고려하면, 이 거리에서 실시간 멀티플레이 게임이 플레이 가능하다는 것만으로도 작은 승리입니다.
2018년에 시작된 프로젝트는 현재 보류 중이지만 여전히 출시를 계획하고 있습니다. 올해는 같은 엔진과 동일한 멀티플레이 로직을 사용한 더 작은 프로젝트를 새로 만들었고, 2025년 말 출시되면 확인해 보실 수 있습니다.
그때까지 이곳에 몇 편의 글을 더 올릴 예정입니다. 지금 읽어주셔서 감사하고, 그때도 즐겁게 읽어주시길 바랍니다.
Andres Franco 소프트웨어 엔지니어, CEO
2025년 9월 28일 수정: 2025년 10월 1일