前言
我们的游戏是一款以忍者格斗为题材的ACT游戏,其主打的玩法是PVE推图及PVP 竞技。在剧情模式中,高度还原剧情再次使不少玩家泪目。而竞技场的乐趣,伴随着赛季和各种赛事相继而来,也深受玩家喜爱,从各直播平台几万到几十万的观众可见一斑。然而,在移动端推出实时PK并不是一蹴而就的,本文将向大家介绍游戏的实时PVP相关技术。
技术选型
实时PK的表现方式,是将N个玩家的行为快速同步给其它玩家展示并保持一致性的过程。这里面涉及到几个要思考的要点:
- 同步什么?可以是玩家具体操作(如移动操作),也可以是某按键操作(如方向键),这两者是有些微区别的。
- 怎么同步?可以选择方式多种,传统的C/S模式,或者是P2P形式,或者是帧同步等。
- 同步方式?载体可以是TCP/UDP。使用哪个比较靠谱?
基于以上的考量,在游戏中,使用的是基于可靠UDP的帧同步模型作为实时PVP的技术方案。你问为什么不采用TCP,为什么不用C/S,为什么不上P2P,下文分晓。
实现细则
这里讲述一些重要细节,以解决众多的Why not问题。
使用帧同步模型
为什么选择帧同步,直接原因是继承之前AI(机甲旋风)经验,对于ACT类型游戏,我们认为帧同步是不错的方案,主要是能够获得以下好处:
- 高一致性。对于格斗中的技能连招,如果不精确到帧,会出现一些诡异现象。试想某个浮空下落的角色,可能一方客户端看到已经倒地,另一方在未倒地时接上其它技能,会出现两个结果,即使将其拉扯回,同样奇怪。而帧同步的机制,保证了双方客户端的一致性。
- 服务器逻辑简化。传统的C/S 架构下,在服务器计算及校验,大量的核心逻辑需要客户端及服务器都实现一遍,使用帧同步可以大大简化及保证服务器的稳定性。
- 低流量消耗。在移动网络中,用户的流量即金钱,如果我们游戏的核心部分耗流量严重,那让45%1左右的非wifi用户情何以堪呢?
- 反作弊。讲道理来说,帧同步对于反作弊并不友好,但是有一个简单的做法可以快速反作弊,就是双方数据不一致时(上报的校验数据或者战斗结果),即检测到有人作弊。
那么,帧同步的过程是如何进行的呢?下图演示了两个客户端PlayerA/PlayerB和Server交互的过程。
对于客户端而言,不断的上报其行为(action)至服务器,并且等待服务器下发的帧包驱动其逻辑。这种方式是帧锁定同步(lockstep)的一种演化 2。
对于服务器来说,固定帧间隔(66ms)将队列中的PlayerA/PlayerB的actions放在一个Frame中,并同步给两个客户端,这似乎和BucketSync类似。
我常被问到一个问题,对方的卡顿会不会影响我的游戏体验,从以上我们的同步原理中,或许你可以找到答案。
使用UDP代替TCP
帧同步并对协议层是TCP还是UDP并无要求,但我们打一开始就没考虑TCP直接拥抱UDP,究其原因,若是对TCP的特性稍有了解,大概会背那三字经:“慢启动”,“快重传”,“拥塞避免”(三个字!)。我概括它以下几点不太适合对实时性要求高,对延迟敏感的移动网络游戏:
- 慢启动算法不适合移动网络的情景,在移动网络环境下信号时好时坏是常态,慢启动会使数据不能及时快速达到对端。
- 拥塞避免算法不适合移动网络主要原因是其考虑到网络的公平性及收敛性,并且AIMD 算法会使实时性大受影响,延迟明显提升。
还有TCP协议用于重传的RTO的指数变化及拥塞算法的实现Nagle的缓存等,都是TCP并不太适合高实时性要求的游戏玩法的原因,不再一一列举。
那么为什么UDP对比TCP更合适呢?多说无益,看一组数据:
显而易见,在各种网络情形下,UDP的表现(延迟分布)基本上都优于TCP。测试程序在相同的网络环境下,通过设置一定的延迟,丢包率,抖动等,获得TCP/UDP的RTT3 。
对于P2P的选择,我们放弃的原因是本身UDP打洞并非100%成功,而若打洞失败则仍要走服务器转发,故简化设计考虑,未选择P2P方式去同步。
自己DIY可靠UDP
上面讲了用什么(UDP)同步什么(帧数据)的问题,有同学要问了,UDP不可靠传输,丢包怎么办?当然,为了数据一致,我们不允许(或许可以允许少量)丢包,TCP的可靠性是由ack/seq+重传去保证的,世面上大多数的可靠UDP实现,也都是类似原理。
在考察了几个可靠UDP的实现(UDT,ENet等)觉得略显复杂,并且在我们开发时,公司内部的可靠UDP实现未达到可使用阶段,鉴于自己重新造个轮子并不复杂,于是挽起袖子写了起来。
用于可靠UDP的实现,其UDP协议自定义包头是这样的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
//客户端上行包
typedef
struct
UdpPkgRecvHead
{
uint16_t seq;
//start from 1
uint16_t ack;
//ack server seq
uint16_t sid;
//player uid
uint8_t action_len;
//pkg actions contain
}UdpPkgRecvHead;
//...
//服务器下行包
typedef
struct
UdpPkgSendHead
{
uint16_t seq;
//inc when frame id ++
uint16_t ack;
//ack client pkg's seq
uint8_t frame_len;
//pkg frames contain
}UdpPkgSendHead;
|
基于以上的定义,可靠性保证的过程大概如图如示:
客户端在满足以下条件之一后,发包给服务器:
- 玩家有操作时
- 发包后间隔(99ms)未收到回包时
- 收到包一定间隔(99ms)后
若玩家有操作,则确认信息随着玩家的操作上行包“捎带”至服务器 ; 如果无操作,则固定时间上报确认信息给服务器。客户端的seq每一个操作行为(action)时加1,服务器seq在每一帧数据下发时加1 ,并且双方的RTO 取值不同4。
对于可靠性的保证,可以采用请求重传,而我们使用的是冗余重传。使用冗余重传的一个好处是,简化了麻烦的时序问题,并且收到的每个包都是完整的顺序的。对于网络拥塞情况下的带宽利用优于TCP,它不足之处是流量略微增加了些。下图是冗余重传的过程:
图解如下:
Client发Action1过来,记seq=1,服务器未收到。
Client又新增了Action2,此时新包将同时包含Action1,Action2,并且seq=2。
Server确认了上一步骤的包,发给Client的包Ack=2表示确认。
Client由于某些原因(可能延迟等)尚未收到Server的Ack=2的确认,此时新增Action3,并发包seq=3。
Client再次发Action4时,发现之前已经Ack=2了,故新包将只带Action3,Action4并且seq=4。
这里演示了冗余传输的过程,服务器对于收到的包,可以根据seq/ack情况动态去除冗余或者丢弃过期包。可能你会觉得全冗余是否不太合适并且有明显优化空间?在实际现网长期运行中,全冗余的冗余率是100%左右,相比于一些可靠传输的重发最近三帧等方式,这种为可靠性付出的代价是合适的并且也提高了更多实时性。
小结:在刨除一些优化及细节外,这就是可靠UDP的机制,简单有效,开销极小5。经测试及实际线上运行来看,在弱网络环境下的表现也是不错的。
反作弊策略
实现的技术细节告一段落,接下来谈谈我们的反作弊策略。有些经验是实践下的真知:)
我们知道帧同步的一切都在客户端运算了,服务器能做的显得很有限。我们不知道玩家当前的位置,不知道玩家的技能使用情况,不知道玩家当前血量,拿什么来反作弊?
实时的检测,做了两点:
客户端固定间隔上报双方血量及技能使用情况,服务器进行记录
单局结束,上报胜负关系
基于这两点,我们可知道某一帧玩家的血量是多少,每个人都上报自己的及对方(们)的,双向校验可看出有有无作弊行为。困扰而不得其解的是,当只有两人时,判断谁是作弊者比较麻烦。当两人以上时,可以仲裁。
我们做了一点容错,当胜负结果异常时,才去进一步检查上报的记录以判断作弊者,判输。而对于上报数据并不一致但是胜负关系一致的情况,记录日志来诊断(容易发生在版本变更时)问题。
通过实时的检测,基本可以检测到单局中作弊行为(加速,无限CD,锁血等),因为他们最终都导致双方数据不一致而结算不一致或上报血量不一致。
非实时的统计学反作弊方案
当有一些漏洞可被利用但是一时无法定位的时候,统计学上的反作弊会比较有用。这里说的漏洞是指通过某种行为使对方掉线或者不发包等未知原因导致游戏异常结束的行为。
我们在游戏结算时,非正常获胜的(掉线等)都会记录下来,并且作用于一个惩罚机制。
每天通过非正常获胜的次数有上限,达到上限后,其非正常获胜都将不计。
非正常获胜的次数作用于实时检测逻辑,如果双方数据不一致,非正常获胜次数多的玩家失败。
非正常获胜次数影响玩家进入匹配,次数越高需要等越久才能开始匹配。
这个方案在线上发挥过作用,有效阻挡了少部分非正常玩家利用漏洞获益,减少了其影响面。
后话
上文介绍了游戏的实时PVP的技术实现,这里配一个架构图,看看其外围。
有两点需要说明:
-
- TGW的多通接入支持UDP四层,UDP 服务需要监听所有tunel的ip/port。
- 帧同步的原理,要求我们必须精细化匹配。游戏中是二进制版本+资源版本一致才相互匹配,可以做到更精细化的根据出战忍者双方客户端数据hash值是否一致进行匹配。