返回文章列表

用 Rust 从零写一个 PVP 游戏服务器:先别急着上 ECS

595·5 分钟阅读
Rust游戏开发PVP服务端架构

我以前写游戏后端的时候,最怕听到一句话:"我们先做个简单 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 说了算。

如果你准备继续往下做,我建议下一步不要急着优化协议,而是先加三个东西:

  1. 一个简单 HTML 客户端,用键盘发输入,把快照画出来。
  2. 输入序号和延迟统计,看真实网络下 tick 抖动有多大。
  3. 房间容量和匹配队列,让玩家不再全挤进一个房间。

等这三件事跑通,你就不再是在写 demo 了。你已经有一个能继续演进的 PVP 游戏服务器骨架。