实战:构建 P2P 实时游戏 Demo
玩家位置同步、状态广播、延迟补偿——这三个词放在一起,其实就是 P2P 实时游戏最核心的骨架。我最早接触这个场景是在一个内部 hackathon 上,当时想做个双人射击小游戏,结果发现数据通道通了,但玩家位置一跳一跳的,根本没法玩。后来才明白,光会建通道远远不够,还得有一套同步策略。
这一章,我们就手把手搭一个完整的 P2P 游戏 Demo。我会把踩过的坑、试过的方案都摊开来讲。你跟着走一遍,基本就能应付大部分实时同步场景了。
整体架构:数据通道 + 游戏循环
先看整体逻辑。游戏 Demo 分为三个模块:
- 连接层:基于 WebRTC 数据通道,负责收发消息
- 同步层:处理位置、状态、延迟补偿
- 渲染层:Canvas 绘制玩家、子弹、计分板
我个人习惯把同步层单独抽出来,不跟渲染混在一起。为什么呢?因为渲染帧率可能 60fps,但同步消息不需要那么高频。分开之后,调优更方便。
核心原则:数据通道只传状态增量,不传全量快照。全量快照只在玩家加入或重连时发送一次。
玩家位置同步:增量更新 + 时间戳
位置同步最忌讳的就是「全量刷」。你想想看,如果每帧都发 {x, y, rotation, velocity},带宽很快就炸了。我建议的做法是:
- 只在位置变化超过阈值时发送
- 带上本地时间戳,方便对方做插值
- 用
Uint8Array编码,别用 JSON 字符串
// 位置消息编码(二进制)
function encodePosition(pos) {
const buf = new ArrayBuffer(13); // 1 + 4 + 4 + 4
const view = new DataView(buf);
view.setUint8(0, 0x01); // 消息类型:位置
view.setFloat32(1, pos.x, true);
view.setFloat32(5, pos.y, true);
view.setFloat32(9, pos.rotation, true);
return buf;
}
// 发送条件:位移超过 0.5 像素 或 旋转超过 2 度
const THRESHOLD_DIST = 0.5;
const THRESHOLD_ANGLE = 2 * Math.PI / 180;
function shouldSendPosition(current, last) {
const dx = current.x - last.x;
const dy = current.y - last.y;
const dist = Math.sqrt(dx*dx + dy*dy);
const angleDiff = Math.abs(current.rotation - last.rotation);
return dist > THRESHOLD_DIST || angleDiff > THRESHOLD_ANGLE;
}
小技巧:我曾经在项目里用 Float32Array 直接传,但发现不同字节序的设备会有问题。后来统一用 DataView 指定小端序,兼容性就好了。
状态广播:谁在做什么?
状态广播跟位置同步不太一样。位置是连续变化的,状态是离散的——比如开枪、换弹、受伤、死亡。这些事件需要「即时到达」,不能丢包。
我的做法是:
- 状态变更时立即发送,不等待阈值
- 每条状态消息带一个递增的序列号
- 接收方检测序列号是否连续,如果发现跳号,主动请求重传
// 状态消息结构
const StateMessage = {
type: 'state',
seq: 0, // 递增序列号
playerId: '',
health: 100,
ammo: 30,
action: 'idle', // idle | shoot | reload | hit | dead
timestamp: 0
};
// 发送状态变更
function broadcastState(newState) {
stateSeq++;
const msg = {
...newState,
seq: stateSeq,
timestamp: Date.now()
};
channel.send(JSON.stringify(msg)); // 状态消息小,JSON 够用
}
// 接收端检测丢包
let lastSeq = 0;
function onStateReceived(msg) {
if (msg.seq !== lastSeq + 1) {
console.warn(`丢包!期望 ${lastSeq + 1},收到 ${msg.seq}`);
requestRetransmit(lastSeq + 1, msg.seq);
}
lastSeq = msg.seq;
applyState(msg);
}
注意:状态广播不要用二进制编码。因为状态字段经常变,JSON 更灵活。位置消息才值得用二进制优化。
延迟补偿:让游戏「感觉」流畅
这是最头疼的部分。网络延迟是客观存在的,你没法消除它,只能「骗」过玩家。我常用的方案是:
- 客户端预测:本地操作立即生效,不等服务器确认
- 服务器校正:主机(或权威端)定期下发正确位置,客户端做插值修正
- 插值平滑:收到远端位置后,不要直接跳过去,而是用线性插值慢慢过渡
// 插值平滑实现
class Interpolator {
constructor() {
this.buffer = []; // 存储历史位置
this.interpTime = 100; // 插值窗口 100ms
}
pushPosition(pos, timestamp) {
this.buffer.push({ pos, timestamp });
// 清理太旧的数据
const cutoff = Date.now() - 200;
this.buffer = this.buffer.filter(p => p.timestamp > cutoff);
}
getInterpolatedPosition(renderTime) {
if (this.buffer.length < 2) return null;
// 找到两个包围点
const t = renderTime - this.interpTime;
let p0 = this.buffer[0];
let p1 = this.buffer[1];
for (let i = 1; i < this.buffer.length; i++) {
if (this.buffer[i].timestamp > t) {
p0 = this.buffer[i - 1];
p1 = this.buffer[i];
break;
}
}
// 线性插值
const range = p1.timestamp - p0.timestamp;
const factor = range > 0 ? (t - p0.timestamp) / range : 0;
const clamped = Math.max(0, Math.min(1, factor));
return {
x: p0.pos.x + (p1.pos.x - p0.pos.x) * clamped,
y: p0.pos.y + (p1.pos.y - p0.pos.y) * clamped,
rotation: p0.pos.rotation + (p1.pos.rotation - p0.pos.rotation) * clamped
};
}
}
延迟补偿的核心:不是消除延迟,而是让延迟「不可感知」。插值窗口一般设为 50-150ms,太短会抖动,太长会感觉「肉」。我一般从 80ms 开始调。
实战代码整合:游戏循环
把上面所有东西串起来,就是一个完整的游戏循环。我习惯用 requestAnimationFrame 驱动渲染,用独立的定时器发送同步消息。
// 游戏主循环
class P2PGame {
constructor(channel) {
this.channel = channel;
this.localPlayer = { x: 400, y: 300, rotation: 0, health: 100 };
this.remotePlayers = new Map();
this.interpolators = new Map();
this.lastSentPos = null;
// 每 50ms 检查并发送位置
setInterval(() => this.syncPosition(), 50);
// 每 1000ms 发送一次 ping
setInterval(() => this.sendPing(), 1000);
// 接收消息
channel.onmessage = (e) => this.handleMessage(e.data);
}
syncPosition() {
if (shouldSendPosition(this.localPlayer, this.lastSentPos)) {
const buf = encodePosition(this.localPlayer);
this.channel.send(buf);
this.lastSentPos = { ...this.localPlayer };
}
}
handleMessage(data) {
// 根据消息类型分发
if (data instanceof ArrayBuffer) {
const type = new DataView(data).getUint8(0);
if (type === 0x01) this.handlePosition(data);
} else {
const msg = JSON.parse(data);
if (msg.type === 'state') this.handleState(msg);
else if (msg.type === 'ping') this.handlePing(msg);
}
}
// 渲染循环
render(timestamp) {
// 对每个远端玩家,获取插值后的位置
for (const [id, interp] of this.interpolators) {
const pos = interp.getInterpolatedPosition(timestamp);
if (pos) {
drawPlayer(id, pos.x, pos.y, pos.rotation);
}
}
// 绘制本地玩家
drawPlayer('local', this.localPlayer.x, this.localPlayer.y, this.localPlayer.rotation);
requestAnimationFrame((t) => this.render(t));
}
}
避坑指南
做 P2P 游戏同步,有几个坑我反复踩过:
- 时间戳不同步:每个设备的
Date.now()不一样。我后来统一用performance.now()配合 RTT 估算,效果好了很多。 - 丢包导致位置跳变:如果连续丢包,插值器会缺数据。我加了一个「预测外推」逻辑——如果超过 200ms 没收到更新,就按上次速度继续移动。
- 状态覆盖:如果状态消息乱序到达,后到的旧状态会覆盖新状态。序列号 + 丢弃旧消息可以解决。
我曾经在一个项目里忘了处理乱序,结果玩家死了之后又复活了——因为死亡消息先到,复活消息后到,但复活消息的序列号更小。排查了整整一个下午才找到原因。
总结
P2P 实时游戏的同步,说白了就是三件事:
- 位置用增量 + 二进制 + 阈值发送
- 状态用 JSON + 序列号 + 即时发送
- 延迟用插值 + 预测 + 校正来掩盖
这套方案我用了好几个项目,从双人射击到多人赛车,基本都能跑得通。你照着这个 Demo 改一改,换成自己的游戏逻辑,很快就能看到效果。