用 Rust 从零写一个 PVP 游戏服务器:先别急着上 ECS
我以前写游戏后端的时候,最怕听到一句话:"我们先做个简单 PVP,同步一下玩家位置就行。"
这句话听起来像半天能搞定,实际里面全是坑:连接怎么管,房间怎么分,玩家掉线怎么办,客户端乱发包怎么办,两个人同时攻击谁先结算,服务器 tick 慢了会不会把整个房间拖死。
所以这篇不走"先安装 Rust,然后 Hello World"那套。我想按真实开发的顺序,从一个最小可玩的 PVP 服务器开始:TCP/WebSocket 接入,房间 Actor,固定 tick,输入上行,状态广播,服务端权威判定。
我的观点也先放在前面:PVP 服务器最核心的不是网络库,而是你能不能把游戏状态收口到一个确定的地方。 Rust 很适合干这件事,因为所有权会逼你别把状态到处借来借去。
我们先定一个很小的目标
别一上来就想 MMO、帧同步、分布式房间、热更新脚本。那些都很性感,但第一版 PVP 服务器不需要。
我建议先做这个版本:
- 两个玩家进入同一个房间
- 客户端每 50ms 发送一次输入:移动方向、是否攻击
- 服务器每 50ms 跑一次 tick
- 服务器更新玩家位置、处理攻击命中
- 服务器把房间快照广播给两个客户端
这已经能覆盖 PVP 服务器最关键的骨架了。
依赖我会控制得很少。下面代码基于:
# Rust 2024 edition
[package]
name = "pvp-server"
version = "0.1.0"
edition = "2024"
[dependencies]
tokio = { version = "1.48", features = ["full"] }
tokio-tungstenite = "0.28"
futures-util = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.18", features = ["v4", "serde"] }如果你第一次写游戏服务器,我建议先用 WebSocket,而不是裸 TCP。不是 WebSocket 更高级,而是它方便你用浏览器做一个临时客户端调试。等协议稳定了,再考虑 TCP、QUIC 或 UDP。
第一刀:协议要小,但边界要清楚
很多人写游戏服务器第一步就写 Player,我不太建议。先写协议更好,因为协议会逼你想清楚:客户端到底能控制什么,服务器到底返回什么。
这里有个原则:客户端只能发输入,不能发结果。
也就是说,客户端可以说"我想往右走",但不能说"我的坐标是 x=100";客户端可以说"我按了攻击",但不能说"我打中了对方"。
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct Vec2 {
pub x: f32,
pub y: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum ClientMsg {
Join { name: String },
Input { seq: u64, movement: Vec2, attack: bool },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
pub enum ServerMsg {
Joined { player_id: Uuid },
Snapshot { tick: u64, players: Vec<PlayerView> },
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlayerView {
pub id: Uuid,
pub name: String,
pub position: Vec2,
pub hp: i32,
}注意这个协议里没有 SetPosition,也没有 HitPlayer。这是有意的。
PVP 游戏里,客户端永远是不可信的。你可以为了手感做客户端预测,但最终结算必须在服务器。否则外挂甚至不需要逆向你的客户端,只要自己连 WebSocket 发 JSON 就行。
房间不要共享锁,直接做成 Actor
Rust 新手写多人房间,很容易写出这种结构:
// 别急着这么写
Arc<Mutex<HashMap<RoomId, Room>>>不是说它一定错,而是这个方向很容易越写越痛苦。连接任务要拿锁,tick 任务要拿锁,广播也要拿锁,最后你会在异步代码里到处跟 borrow checker 和锁粒度纠缠。
我更喜欢的做法是:一个房间一个 task,房间状态只活在这个 task 里,外面通过 channel 发命令。
这就是 Actor 模型,不神秘,也不需要引入 Actix。
use tokio::sync::mpsc;
use uuid::Uuid;
pub type ClientTx = mpsc::UnboundedSender<ServerMsg>;
#[derive(Debug)]
pub enum RoomCommand {
Join {
player_id: Uuid,
name: String,
tx: ClientTx,
},
Input {
player_id: Uuid,
seq: u64,
movement: Vec2,
attack: bool,
},
Leave {
player_id: Uuid,
},
}这段代码的重点不是 enum,而是状态边界:连接层不直接改房间,房间也不关心 WebSocket 怎么收包。连接层只负责把消息翻译成 RoomCommand。
这个边界一旦立住,后面代码会顺很多。
房间状态:把不确定性关进一个结构体
现在可以写 Room 了。第一版别复杂,玩家、输入、tick 计数器就够。
use std::collections::HashMap;
pub struct Room {
tick: u64,
players: HashMap<Uuid, Player>,
clients: HashMap<Uuid, ClientTx>,
latest_inputs: HashMap<Uuid, PlayerInput>,
}
pub struct Player {
id: Uuid,
name: String,
position: Vec2,
hp: i32,
attack_cooldown: u32,
}
#[derive(Debug, Clone, Copy)]
pub struct PlayerInput {
seq: u64,
movement: Vec2,
attack: bool,
}我个人很喜欢这种写法:Room 拥有所有状态。没有 Arc<Player>,没有 Mutex<Player>,没有某个系统偷偷拿着玩家引用。
Rust 的所有权在这里不是负担,反而像一个架构审查员。它会提醒你:这个状态到底归谁?谁能改?什么时候改?
固定 tick:PVP 服务器的心跳
PVP 服务器不能收到一条输入就立刻改一次世界。不同玩家网络延迟不一样,如果你按消息到达时间直接结算,谁网快谁就天然占便宜。
更常见的做法是固定 tick:服务器每隔一段时间消费最新输入,推进一次世界。
use tokio::time::{self, Duration, MissedTickBehavior};
impl Room {
pub async fn run(mut self, mut rx: mpsc::UnboundedReceiver<RoomCommand>) {
let mut ticker = time::interval(Duration::from_millis(50));
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
loop {
tokio::select! {
Some(command) = rx.recv() => self.handle_command(command),
_ = ticker.tick() => self.step(),
}
}
}
}这里我特意用了 MissedTickBehavior::Skip。如果服务器某一帧卡住了,我宁愿跳过追帧,也不想它疯狂补 tick 把延迟越滚越大。
这是游戏服务器和普通业务服务不太一样的地方:业务服务经常追求"每个任务都处理完",实时游戏更在意"别让现在继续恶化"。
输入处理:只收最新,不堆历史
第一版 PVP 小游戏,不要一上来做完整输入队列和回滚。可以先只保留每个玩家的最新输入。
impl Room {
fn handle_command(&mut self, command: RoomCommand) {
match command {
RoomCommand::Join { player_id, name, tx } => {
self.players.insert(player_id, Player {
id: player_id,
name,
position: Vec2 { x: 0.0, y: 0.0 },
hp: 100,
attack_cooldown: 0,
});
self.clients.insert(player_id, tx.clone());
let _ = tx.send(ServerMsg::Joined { player_id });
}
RoomCommand::Input { player_id, seq, movement, attack } => {
self.latest_inputs.insert(player_id, PlayerInput {
seq,
movement,
attack,
});
}
RoomCommand::Leave { player_id } => {
self.players.remove(&player_id);
self.clients.remove(&player_id);
self.latest_inputs.remove(&player_id);
}
}
}
}这里有个隐藏坑:seq 现在还没用。
为什么还要传?因为你很快就会需要它。客户端预测、丢包检测、重放保护、延迟统计都会用到输入序号。第一版可以不做复杂逻辑,但协议上最好先把这个字段留出来。
世界推进:移动和攻击都在服务器算
接下来是最像"游戏逻辑"的部分。我们每个 tick 做三件事:移动玩家,处理攻击冷却,广播快照。
impl Room {
fn step(&mut self) {
self.tick += 1;
self.apply_movement();
self.apply_attacks();
self.broadcast_snapshot();
}
fn apply_movement(&mut self) {
const SPEED_PER_TICK: f32 = 0.2;
for (player_id, player) in &mut self.players {
let input = self.latest_inputs.get(player_id).copied().unwrap_or(PlayerInput {
seq: 0,
movement: Vec2 { x: 0.0, y: 0.0 },
attack: false,
});
player.position.x += input.movement.x.clamp(-1.0, 1.0) * SPEED_PER_TICK;
player.position.y += input.movement.y.clamp(-1.0, 1.0) * SPEED_PER_TICK;
}
}
}clamp 这行很重要。客户端可以发 { x: 9999, y: 9999 },服务器不能傻乎乎照单全收。
这就是我说的服务端权威:客户端输入可以很脏,服务器状态必须干净。
攻击逻辑稍微麻烦一点,因为你不能一边遍历玩家,一边修改另一个玩家的血量。Rust 在这里会拦你一下。
impl Room {
fn apply_attacks(&mut self) {
let mut hits = Vec::new();
for (attacker_id, attacker) in &mut self.players {
attacker.attack_cooldown = attacker.attack_cooldown.saturating_sub(1);
let Some(input) = self.latest_inputs.get(attacker_id) else {
continue;
};
if !input.attack || attacker.attack_cooldown > 0 {
continue;
}
attacker.attack_cooldown = 10;
hits.push((*attacker_id, attacker.position));
}
for (attacker_id, attacker_position) in hits {
if let Some(target) = self.find_target(attacker_id, attacker_position) {
if let Some(player) = self.players.get_mut(&target) {
player.hp = (player.hp - 10).max(0);
}
}
}
}
fn find_target(&self, attacker_id: Uuid, attacker_position: Vec2) -> Option<Uuid> {
self.players
.iter()
.filter(|(id, player)| **id != attacker_id && player.hp > 0)
.find(|(_, player)| distance(attacker_position, player.position) < 1.5)
.map(|(id, _)| *id)
}
}
fn distance(a: Vec2, b: Vec2) -> f32 {
let dx = a.x - b.x;
let dy = a.y - b.y;
(dx * dx + dy * dy).sqrt()
}这段是 Rust 很典型的"先收集意图,再统一提交"。
一开始你可能觉得 borrow checker 烦:为什么我不能直接遍历的时候扣别人血?但写多了会发现,它逼你把逻辑拆成两个阶段,反而更接近游戏服务器真正需要的确定性。
广播快照:先能跑,再谈压缩
第一版直接广播完整快照,不要急着做 delta compression。
impl Room {
fn broadcast_snapshot(&self) {
let players = self.players
.values()
.map(|player| PlayerView {
id: player.id,
name: player.name.clone(),
position: player.position,
hp: player.hp,
})
.collect();
let message = ServerMsg::Snapshot {
tick: self.tick,
players,
};
for tx in self.clients.values() {
let _ = tx.send(message.clone());
}
}
}这里会 clone,JSON 也不省流量。说实话,这在第一版完全可以接受。
我见过太多人在还没跑通 PVP 闭环之前,就开始设计二进制协议、差量同步、兴趣管理。最后网络层看起来很专业,游戏却连两个人互相打一下都不稳定。
我的建议是:先用完整快照把语义跑通,再用指标告诉你该优化哪里。
连接层:它只负责翻译消息
现在补上 WebSocket 接入层。连接层的职责很窄:收客户端 JSON,转成 RoomCommand;收房间消息,发回客户端。
use futures_util::{SinkExt, StreamExt};
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::Message;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (room_tx, room_rx) = mpsc::unbounded_channel();
let room = Room::new();
tokio::spawn(room.run(room_rx));
let listener = TcpListener::bind("127.0.0.1:9001").await?;
println!("PVP server listening on ws://127.0.0.1:9001");
loop {
let (stream, _) = listener.accept().await?;
let room_tx = room_tx.clone();
tokio::spawn(async move {
if let Err(error) = handle_connection(stream, room_tx).await {
eprintln!("connection error: {error}");
}
});
}
}上面这段少了一个依赖:anyhow = "1.0"。如果你不想引入,也可以把 main 的错误类型写完整。我写应用层代码时会用 anyhow,写库的时候才会认真定义错误枚举。
连接处理稍微长一点,但逻辑很直:拆分 WebSocket,给房间发 Join,两个 task 分别处理上行和下行。
use tokio::net::TcpStream;
async fn handle_connection(
stream: TcpStream,
room_tx: mpsc::UnboundedSender<RoomCommand>,
) -> anyhow::Result<()> {
let ws = tokio_tungstenite::accept_async(stream).await?;
let (mut writer, mut reader) = ws.split();
let player_id = Uuid::new_v4();
let (client_tx, mut client_rx) = mpsc::unbounded_channel();
room_tx.send(RoomCommand::Join {
player_id,
name: "player".to_string(),
tx: client_tx,
})?;
let write_task = tokio::spawn(async move {
while let Some(message) = client_rx.recv().await {
let text = serde_json::to_string(&message)?;
writer.send(Message::Text(text.into())).await?;
}
anyhow::Ok(())
});
while let Some(message) = reader.next().await {
let text = message?.into_text()?;
let ClientMsg::Input { seq, movement, attack } = serde_json::from_str(&text)? else {
continue;
};
room_tx.send(RoomCommand::Input {
player_id,
seq,
movement,
attack,
})?;
}
let _ = room_tx.send(RoomCommand::Leave { player_id });
write_task.abort();
Ok(())
}这里为了文章长度,我把 Join { name } 简化掉了,真实项目里你会在第一条消息里解析玩家名、token、匹配参数。
不过架构重点已经出来了:WebSocket task 不碰 Room,它只发命令。房间状态没有锁,只有房间自己的事件循环能改。
这个版本能跑,但还不能上线
到这里,一个最小 PVP 服务器就有了:能接连接,能进房间,能收输入,能 tick,能广播状态。
但我会很明确地说:这不是生产级服务器。
它还缺这些东西:
| 问题 | 第一版做法 | 真正上线前要补什么 |
|---|---|---|
| 鉴权 | 直接生成 UUID | 登录态、token 校验、重连恢复 |
| 匹配 | 所有人进一个房间 | 匹配队列、房间容量、段位规则 |
| 协议 | JSON | 二进制协议、版本号、兼容策略 |
| 同步 | 完整快照 | delta、兴趣管理、客户端插值 |
| 反作弊 | clamp 输入 | 速度校验、攻击频率校验、行为审计 |
| 容灾 | 单进程单房间 | 房间调度、进程监控、状态转移 |
但这些不是推翻重来,而是在这个骨架上继续长。
这就是我喜欢先写 Actor 房间的原因:你后面想把房间迁到别的进程,想加匹配服务,想把快照换成二进制协议,核心边界都还在。
最后说点真实感受
用 Rust 写 PVP 服务器,最爽的地方不是性能,而是它会逼你把状态所有权想清楚。
游戏服务器最怕的不是代码慢一点,而是状态乱:这个 task 改一点,那个连接改一点,某个定时器再补一刀。线上 bug 出来以后,你根本不知道一次攻击到底经过了几条路径。
Rust 的 borrow checker 有时候确实烦,尤其是你刚开始写 HashMap<Uuid, Player> 的时候。但它烦得很有价值:它会不断问你,"这个世界到底谁说了算?"
对 PVP 游戏服务器来说,这个问题的答案必须是:服务器说了算,而且最好只有房间 Actor 说了算。
如果你准备继续往下做,我建议下一步不要急着优化协议,而是先加三个东西:
- 一个简单 HTML 客户端,用键盘发输入,把快照画出来。
- 输入序号和延迟统计,看真实网络下 tick 抖动有多大。
- 房间容量和匹配队列,让玩家不再全挤进一个房间。
等这三件事跑通,你就不再是在写 demo 了。你已经有一个能继续演进的 PVP 游戏服务器骨架。