前阵子有个做后端的朋友半夜给我发消息,说他刷到一条帖子,标题写着"咸鱼之王私服搭建 + 附源码下载链接",底下还挂着网盘链接,问我有没有兴趣一起搞一个,说流量肯定好。我回了他四个字:别碰这个。不是我不爱折腾,而是这条路从第一天起就站在一个很尴尬的位置上——那套服务端是别人打包好的二进制,你既不知道里面塞了什么,也不知道哪天它会反手把你自己的数据端走;更现实的是,拿别人的美术、数值、玩法去做一套对外运营的东西,性质上根本说不清楚。所以后来我们换了个思路:既然喜欢的是那套放置卡牌的玩法结构,那就自己写一套原创的放置卡牌服务端。下面这篇就是我这两个多月从零搭起来的过程,包括数据模型、通信协议、反作弊、部署压测,尽量把每个"为什么这么设计"讲清楚,适合有点编程基础、想自己动手做一套小规模游戏服务端的人参考。
1. 先想清楚"自研"和"拿来主义"的分界线在哪
很多人搜"私服搭建""源码下载"的时候,脑子里想的是"省事"——弄一份现成的包,改改数值,租台机器就能跑。我完全理解这种心态,但从工程角度看,这笔账其实算不过来,而且问题的核心不在技术难度,在于你根本没法掌控那套东西。
1.1 拿来主义省下的时间,后面会加倍还回去
一份来路不明的服务端包,最典型的特征是:没有文档、没有构建脚本、没有数据库迁移记录。你拿到的是一个能跑的目录,里面可能有.exe、可能有加密过的.dll、可能有一堆没有注释的脚本文件。这种项目一旦出问题,排查成本是灾难级的。
我见过最典型的情况是数据库表结构改了但没人知道怎么改回去,最后只能删档重开。放置类游戏最值钱的就是玩家存档和累计收益,删一次档,玩家的信任就没了。相比之下,自己写的代码哪怕粗糙,至少每一行你都知道它为什么在那里。
还有一层是安全。别人打包的程序里带一个定时上报或者后门接口,从外部看是完全无感的,日志里看不出任何异常。你的服务器配置、你的数据库账号密码、你的支付回调密钥,全都在它手边。这不是危言耸听,而是这类"免费包"最常见的商业模式——前期免费给你用,等你有量了再谈"授权"。
1.2 玩法结构可以学,素材和代码不能抄
这条线必须划清楚。放置卡牌的核心循环——挂机产出资源、资源换养成、养成分阶段解锁新关卡——这是品类层面的通用设计思路,属于玩法框架,学它没有问题。但具体到:
- 每一个武将/角色的立绘、名字、技能文案
- 具体的数值曲线和关卡配置
- 客户端和服务端的实际代码
这三样都属于别人的创作成果,不能直接搬。所以我的做法是:框架参考品类共性,内容全部原创。数值曲线自己推,美术用可商用授权的素材或者干脆先用色块占位,技能文案自己写。这样即使后面想商业化,路也是通的。
1.3 一个人做,MVP 到底能砍到什么程度
"自己写一套游戏服务端"听起来很大,但砍完之后其实没那么夸张。我的第一版 MVP 只做了四件事:
- 玩家注册登录,拿到一个初始存档
- 挂机产出每分钟结算一次,离线后按时间戳补发
- 关卡战斗走服务端结算,返回战报
- 养成操作(升级、升星)改存档并回写数据库
就这四件事,跑通之后游戏的骨架就立起来了。后面加排行榜、加活动、加内购,都是在骨架上挂东西。所以第一章我想强调的是:难度不在总量,在于顺序。先做能闭环的最小版本,再谈扩展,这比一上来就设计一套"完美架构"务实得多。
2. 数据模型:把"配置"和"存档"彻底分开
这是我踩的第一个坑,也是我后来觉得收益最大的一个设计决定。一开始我把武将的攻击力、血量这些数值直接写在代码里,改一次数值就要重新部署一次,测试同学天天来找我。后来改成配置表驱动,整个开发节奏完全变了。
2.1 配置表与代码分离,是数值迭代的前提
核心思路很简单:代码只负责"怎么算",配置负责"算多少"。所有能调的数值——角色属性、关卡掉落、升级消耗、收益速率——全部抽到独立的配置文件里。
我用的是一张 CSV 表配合一个加载脚本,启动时读进内存,用不可变字典存着。格式大概是这样:
{ "hero_id": 1001, "name": "青石卫", "base_atk": 120, "base_hp": 1800, "atk_growth": 12.5, "hp_growth": 210, "skill_id": 20001, "rarity": 3 }这里有个细节值得一提:atk_growth我用的是小数,而不是整数。原因是我后来发现,如果成长值只能取整数,后期等级拉高之后不同角色之间的强度差会变得非常粗糙,调平衡的时候手感很差。用浮点存成长值、最终结算时再取整,调起来细腻多了。
配置加载的时候一定要做校验,这是很多人会漏的一步。我写了一个检查函数,专门盯这几件事:
| 校验项 | 检查内容 | 出问题的后果 |
|---|---|---|
| 唯一性 | hero_id 是否重复 | 后加载的覆盖先加载的,数值莫名其妙 |
| 引用完整性 | skill_id 是否存在于技能表 | 战斗时找不到技能,直接崩 |
| 范围 | 数值是否为正数、是否超过上限 | 溢出甚至负数,玩家白嫖属性 |
| 枚举 | rarity 是否在允许集合内 | 稀有度筛选、排序逻辑出异常 |
这张表我建议每个做配置驱动的项目都对照检查一遍,因为配置错误往往是那种"上线三天后才被玩家发现"的隐蔽问题。
2.2 战斗结算必须放在服务端,而且要固定随机种子
放置类游戏的战斗通常是自动推演,几十秒内打完一场,客户端只负责播放。这里有个很关键的点:战斗过程必须在服务端跑,客户端拿到的只是一份战报。如果让客户端算伤害,玩家改个内存就能一刀秒 Boss,你所有的数值设计全部作废。
服务端结算的伪代码大致是:
def settle_battle(player, stage_id, seed): rng = Random(seed) # 种子由服务端生成,客户端无法预测 heroes = build_team(player) enemies = load_stage_enemies(stage_id) log = [] tick = 0 while tick < MAX_TICK: for unit in alive_units(heroes + enemies): target = pick_target(unit, rng) damage = calc_damage(unit, target, rng) target.hp -= damage log.append({"t": tick, "src": unit.id, "dst": target.id, "dmg": damage}) tick += 1 if battle_over(heroes, enemies): break return {"win": all_dead(enemies), "log": log, "seed": seed}注意pick_target和calc_damage都吃 rng,也就是说相同的种子必然产生相同的结果。这一点非常有用:玩家如果对结果有异议,你可以用同一个种子重新跑一遍,结果完全一致,客服成本直接降下来。同时它也方便你做回放功能,只需要存种子和初始阵容,几十 KB 的战报就变成几百字节。
提示:种子一定要用服务端的高熵随机源生成,不要用时间戳的秒级值。秒级时间戳可以被枚举,玩家只要试几十次就能刷到对自己有利的战报。
2.3 存档模型:我为什么最后选了分表而不是单文档
一开始我的存档就是一个大 JSON,整个塞进一个字段里。简单是真简单,但很快撞墙了:
- 并发写:挂机结算、升级、领奖可能同时改同一个 JSON,后写的覆盖先写的,玩家会发现领的奖励消失了
- 查询:想做全服排行榜,得把所有人的 JSON 都读出来解析一遍,几十万玩家直接卡死
- 体积:背包物品越堆越多,单条记录涨到几百 KB,读写都变慢
后来改成"热点字段分表 + 冷数据 JSON"的混合方案:
CREATE TABLE player_base ( uid BIGINT PRIMARY KEY, level INT NOT NULL DEFAULT 1, exp BIGINT NOT NULL DEFAULT 0, last_settle BIGINT NOT NULL DEFAULT 0, power INT NOT NULL DEFAULT 0, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ); CREATE TABLE player_hero ( uid BIGINT NOT NULL, hero_id INT NOT NULL, star TINYINT NOT NULL DEFAULT 1, level INT NOT NULL DEFAULT 1, PRIMARY KEY (uid, hero_id) );power(战力)这个字段是冗余出来的,专门给排行榜用,它不需要绝对精确,只需要排序正确。每次养成操作后异步更新一次,排行榜查询走覆盖索引,几十万行也是毫秒级。用一点点数据一致性,换查询性能,在排行榜这种场景里非常划算。
2.4 离线收益:时间戳要签名,否则就是提款机
离线收益是放置游戏的灵魂,也是最容易被薅的地方。基础逻辑是:玩家下线时间t1,上线时间t2,按(t2 - t1) * rate补发收益。问题在于t1如果存在客户端,玩家把手机时间往回调,就能反复领。
我的做法是:
- 服务端在每次结算后把
last_settle写进数据库,并下发一个带 HMAC 签名的令牌给客户端 - 客户端下次请求带上令牌,服务端先验签,再比对
last_settle - 收益上限用服务端时间卡死,超过 12 小时的部分按 12 小时算
def calc_offline_gain(player, token): if not verify_hmac(token, SECRET): raise CheatDetected("bad token") now = server_now() elapsed = min(now - player.last_settle, MAX_OFFLINE_SECONDS) if elapsed < 0: raise CheatDetected("clock rollback") gain = int(elapsed * player.rate) player.last_settle = now return gainserver_now()必须取服务端时间,绝对不能信客户端传上来的时间。这几行代码看着不起眼,但它挡住的是最常见的作弊方式,我实测下来,加了校验之后异常收益请求量直接掉了九成以上。
3. 通信协议:HTTP 和 WebSocket 各管一摊
协议选型上我纠结过一阵子要不要全用长连接,最后选了双通道方案。做完之后我觉得这是这个项目里最值得分享的一个决定。
3.1 什么时候用 HTTP,什么时候用长连接
判断标准其实就一条:这次交互需不需要服务端主动推。
| 场景 | 通道 | 原因 |
|---|---|---|
| 登录、拉配置 | HTTP | 一次性,可缓存,走 CDN 都行 |
| 升级、升星、领奖 | HTTP | 请求-响应模型天然契合,失败直接重试 |
| 推图战斗 | HTTP | 有明确结果,一次返回战报 |
| 排行榜刷新、活动开启、聊天 | WebSocket | 需要服务端主动推 |
| 挂机收益结算提醒 | WebSocket | 定时推送,不需要客户端轮询 |
全部塞进长连接的问题是:长连接天然要处理断线、重连、心跳、消息乱序,而这些复杂度只在少数场景里才需要。HTTP 那部分请求天然是幂等的、可重试的,写起来省心太多。
3.2 协议结构:消息号 + 请求 ID + 时间戳
我的协议格式很朴素,JSON 为主(内部接口用 Protobuf 压了一下流量):
{ "cmd": 1003, "rid": "a7f3e1c9-8b2d-4f01", "ts": 1735689600, "body": { "stage_id": 42, "hero_ids": [1001, 1002, 1003] } }其中rid是客户端生成的唯一请求 ID,这个字段是整个协议里最重要的一个,下面细说。cmd用整数编号而不是字符串,是为了省流量和方便做路由表;ts用于服务端做时间窗口校验,偏差超过 300 秒的请求直接拒掉。
3.3 断线重连和消息幂等,靠 rid 兜底
移动端网络环境很糟糕,地铁里切个隧道就断一次。如果玩家点"升级",请求发出去了但响应没回来,他大概率会再点一次,结果就是扣了两次资源。
我的处理方式是用 rid 做幂等键:
async function handle(client, msg) { const key = `idem:${client.uid}:${msg.rid}`; const cached = await redis.get(key); if (cached) return JSON.parse(cached); // 直接返回上次的结果 const result = await dispatch(msg.cmd, client, msg.body); await redis.set(key, JSON.stringify(result), 'EX', 300); return result; }Redis 里存 5 分钟就够了,正常人不会隔五分钟才重试。这样即使网络抖动导致重发,玩家也只会收到同一份结果,资源不会重复扣。这个改法上线之后,"资源凭空少了"这类客诉基本消失了。
重连部分我额外做了两件事:一是连接建立后把玩家在线的这段时间内产生的推送消息补发一次(存一个最近 50 条的环形缓冲);二是重连成功后客户端主动拉一次全量关键状态,避免本地缓存和服务端不一致。
3.4 为什么我没做全 Protobuf
有人会问,既然对流量敏感,为什么不全部上 Protobuf。我的考虑是:开发期的可调试性比流量更重要。JSON 抓包一眼就能看懂,调试接口的时候节省的时间远超那点流量成本。我的做法是分层——对外的主接口用 JSON,内部的战斗日志、排行榜批量数据用 Protobuf 压。等游戏真的到需要抠流量的规模,再整体替换也不迟,协议层抽象好了,换起来就是改一个编解码器。
4. 反作弊:放置游戏最容易被薅的四个口子
放置类游戏的特点是"数值增长快、链条长",所以作弊的收益点特别多。这一章我把实际遇到过的四类问题整理出来,每一条都附上我最终的处置方式。
4.1 服务端权威原则,一条都不能破
这条是底线:任何影响玩家资产或进度的计算,必须在服务端完成。包括伤害结算、掉落判定、收益计算、抽卡随机。客户端能做的只有展示和输入。
我在代码 review 的时候会专门搜一下有没有if (client_result.win)这种写法——只要出现"信任客户端传上来的结果",基本就是漏洞。正确写法永远是客户端传意图(我要打第 42 关),服务端返回结果(你赢了,掉落这些)。
4.2 时间与随机数,是最容易被打的两个点
时间相关的问题前面讲离线收益提过了,这里补充一个更隐蔽的:关卡限时次数。如果每日挑战次数用本地日期重置,玩家改时区就能天天刷新。我的做法是统一用服务端 UTC+8 的自然日做 key,跨天判断也在服务端做。
随机数方面,除了战斗种子,抽卡也是重灾区。一定不要用Math.random()这种客户端可预测的源,服务端这边我用的是带种子的 CSPRNG,并且每次抽卡都记录一条审计日志:谁、什么时候、消耗了什么、抽到了什么。日志落盘之后,出现"同一个人连抽十个顶级角色"的情况,你至少能查。
4.3 数值溢出和负数,是批量刷道具的入口
这类问题的典型形态是:某个操作的参数是玩家可控的,比如"使用 N 个经验丹",服务端没做上下界校验,玩家传个负数-99999,结果背包里反而多出来一堆。
我的防御是三层的:
- 入参层:所有数值参数做范围校验,数量类参数必须
1 <= n <= 背包持有量 - 计算层:所有资源变更用整数,加减后立刻检查
>= 0,越界直接抛异常并回滚事务 - 审计层:单次资源变更超过阈值的操作,单独打一条 warning 日志
def consume(player, item_id, count): assert 1 <= count <= 9999, "invalid count" owned = player.bag.get(item_id, 0) if owned < count: raise NotEnough() player.bag[item_id] = owned - count assert player.bag[item_id] >= 0 if count > 1000: logger.warning("large consume uid=%s item=%s n=%s", player.uid, item_id, count)注意:整数溢出在不同语言里行为不一样。Python 会自动转大整数,Go 的 int64 会回绕。如果你的核心逻辑用 Go 写,一定要显式检查边界,别指望它报错。
4.4 内购回调和发卡类逻辑,验签和幂等一个都不能少
如果游戏里接了内购、月卡、礼包码这类东西,回调接口的安全等级要拉到最高。我总结的三个必做项:
- 验签:用 HMAC-SHA256 对回调原始报文验签,密钥只存在服务端环境变量里,不落代码仓库
- 幂等:以订单号为唯一键,处理过的订单直接返回成功,绝不重复发货
- 金额校对:回调里的金额、商品 ID 必须和本地订单记录一致,不一致的订单直接挂起人工审核
def on_pay_callback(raw_body, signature, headers): if not hmac_compare(signature, sign(raw_body, PAY_SECRET)): return {"code": 401} order = parse(raw_body) if redis.set(f"pay:{order.order_id}", 1, nx=True, ex=86400) is None: return {"code": 0} # 已处理过,直接确认 local = db.get_order(order.order_id) if local is None or local.amount != order.amount: alert(order) # 金额不符,人工介入 return {"code": 402} grant_items(local.uid, local.goods_id) return {"code": 0}这里set nx=True是关键,它把"判断是否处理过"和"标记为已处理"合并成一个原子操作,避免了并发回调下的重复发货。这个模式在很多业务里都通用,值得记住。
5. 从本机到一台小服务器:部署、压测和上线检查
代码能跑和能给人玩,中间隔着一段距离。这一章讲我怎么把它从localhost挪到一台普通云主机上,并且扛住第一批玩家。
5.1 选型:我最后用的组合和理由
| 层 | 选择 | 理由 |
|---|---|---|
| 接入 | Nginx | 反代 + WebSocket 升级 + 静态资源,够用且稳 |
| 业务 | Python (FastAPI) | 开发快,异步 IO 处理长连接比较顺手 |
| 缓存 | Redis | 幂等键、排行榜 ZSet、在线状态,三合一 |
| 存储 | MySQL | 事务保证,存档一致性比纯 KV 好管 |
| 部署 | Docker Compose | 单机多容器,配置即代码,迁移方便 |
选 Python 的原因很实在:这个项目的瓶颈在数据库和网络,不在 CPU 计算。用 Python 写得快,遇到问题排查也快。真到了性能瓶颈,先加 Redis 缓存、再拆服务,比一开始就上 Go 更划算。
5.2 容器编排和 WebSocket 转发,两个容易踩的配置
docker-compose.yml里我做了三件事:给业务容器限制内存(防止内存泄漏拖垮整机)、把日志输出到 stdout 交给宿主机统一收集、数据库和 Redis 只开内网端口。
Nginx 这边最关键的是长连接的转发配置:
location /ws { proxy_pass http://game:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; proxy_send_timeout 300s; } location /api { proxy_pass http://game:8000; proxy_read_timeout 30s; }proxy_read_timeout我给到了 300 秒,配合客户端 60 秒一次的心跳,连接基本不会被中途掐断。这两个超时如果不设,默认 60 秒就会断,你会看到日志里大量"莫名其妙的重连",排查半天才发现是网关干的。
5.3 压测:先搞清楚瓶颈在哪一层
我用 k6 写了脚本,模拟三件事:登录拉配置、推图战斗、挂机结算。指标只盯四个:
- P99 延迟:平均值骗人,尾延迟决定玩家体感
- 错误率:超过 0.5% 就要停下来看
- 数据库连接数:这个最容易成为第一瓶颈
- Redis 命中率:命中率掉下来,说明缓存策略有问题
第一次压测结果很难看,200 并发的时候 P99 就冲到 800ms。查下来是两个问题:一是每次战斗都要读一遍配置表,二是排行榜每次请求都全量查 MySQL。修完这两处(配置表加进程内缓存 + 排行榜搬 Redis ZSet),同样的并发下 P99 降到 60ms 左右。
5.4 上线前我会过一遍的检查清单
| 检查项 | 为什么重要 |
|---|---|
| 数据库账号是否为最小权限 | 业务账号不该有 DROP 权限 |
| 密钥是否都在环境变量里 | 代码仓库一旦泄露,密钥跟着走 |
| 是否有关键字段的备份策略 | 存档丢了就是灭顶之灾 |
| 日志是否带 uid 和 rid | 出了问题能精准定位到玩家和请求 |
| 是否有资源变更审计日志 | 事后追责和数据回滚的依据 |
| 限流是否生效 | 单 IP 高频请求要有兜底 |
这张表我建议直接抄走,每次上线前照着打勾。里面任何一条出问题,带来的都不是"体验差一点",而是"玩家资产受损"。
这两个多月下来,我最大的感受是:自研这条路前期确实比拿现成的包慢,但它的收益曲线是完全不同的。前两周你觉得处处都在造轮子,第三周开始,改数值不用重新部署了,出问题能定位到具体请求了,玩家反馈的异常两小时就能修完。这种"掌控感"不是省钱换来的,是省心换来的。另外分享一个小技巧:把战斗结算函数单独抽成一个纯函数,不依赖任何数据库和网络,然后用固定的种子写一批单元测试。我后面每次调数值,跑一遍测试就知道有没有把战斗逻辑改坏,比手动点十遍强太多。