一、先把话说在前头:这游戏为什么卡
做 Solana 上的 Web3 游戏,很多人第一反应是“快”。确实,Solana 的区块时间只有 400 毫秒左右,理论吞吐量也高得吓人。但真正把游戏跑起来以后,你会发现“快”是链的事,跟你没啥关系。游戏该卡还是卡,该掉线还是掉线,甚至比传统游戏更容易出问题。
问题出在哪?出在实时交互和链上状态同步之间那层窗户纸。传统游戏里,你按一下W,角色往前走,客户端直接就渲染了,服务器只是帮你广播一下位置。但在 Web3 游戏里,尤其是 Solana 上,你按一下W,如果每一步都要等链上确认,那这个游戏就别玩了。你要么做个“假链上”游戏,要么就得面对一堆同步难题。
这篇文章就用大白话聊聊这些难点,并且给出可运行的代码示例。全程只用一套技术栈:TypeScript + Solana Web3.js 和 Anchor。别担心,代码注释会写得很清楚,你哪怕没写过 Solana 也能看懂大概意思。
二、实时交互的第一道坎:客户端状态和链上状态不是一回事
2.1 你的角色在客户端的“假位置”
在传统游戏里,角色位置是本地算出来的,服务器只负责验证和同步。在 Web3 游戏里,如果你把角色位置也放到链上,哪怕 Solana 再快,每次移动都发一笔交易,那也扛不住。假设你一秒走 10 步,每步一笔交易,那你的钱包和网络都会被冲爆。
所以实际做法是:客户端先用本地状态跑游戏,就是那种“我按了方向键,角色立刻移动”的体验。链上状态只记录关键节点,比如战斗结果、资源采集、成绩提交。我们可以把这种模型叫做“链上最终一致性”。
下面我们用 TypeScript 写一个简单的客户端状态类,看看怎么管理这种“假位置”。
// 技术栈:TypeScript + Solana Web3.js
// 这是客户端的一个角色控制器,管理本地坐标,不同步到链上
class LocalPlayer {
localX: number = 0;
localY: number = 0;
private speed: number = 5; // 每秒移动的像素或者格子数
// 每帧调用,输入方向键状态
updateFromInput(keysPressed: Set<string>, deltaTime: number) {
if (keysPressed.has('ArrowUp')) {
this.localY += this.speed * deltaTime;
}
if (keysPressed.has('ArrowDown')) {
this.localY -= this.speed * deltaTime;
}
if (keysPressed.has('ArrowLeft')) {
this.localX -= this.speed * deltaTime;
}
if (keysPressed.has('ArrowRight')) {
this.localX += this.speed * deltaTime;
}
// 这里是纯粹的本地计算,不经过钱包签名,也不发交易
// 所以你的角色移动起来是丝滑的,这就是实时交互的基础
}
// 在关键节点把坐标“冻结”到链上
async commitPositionToChain(connection: any, gameProgram: any, wallet: any) {
// 先拿到当前坐标,取整数,减少链上数据大小
const committedX = Math.round(this.localX);
const committedY = Math.round(this.localY);
// 这里只是演示,实际调用 Anchor 合约的方法
// 比如 gameProgram.methods.commitPosition(committedX, committedY).rpc()
console.log(`正在把位置提交到链上: (${committedX}, ${committedY})`);
// 提交完成后,本地可以继续跑动,不用等链上回调
}
}
你看,角色按一下方向键就走,这个“假位置”是实时的。链上只在你主动提交的时候才更新。这就是“最快”的做法。
2.2 链上状态到底是干嘛的
既然链上状态不实时,那它存在的意义是什么?答案是“权威性”和“资产归属”。比如你打了一个怪,掉了一把剑,这把剑必须是链上资产。如果你打到一半,链上说你没打,那你就白打了。所以链上状态要记录的是“结果”,不是“过程”。
一个常见的设计是:客户端先把整场战斗的输入序列收集起来,比如按键序列、时间戳、使用的技能,然后一并发到链上。链上智能合约会做一次确定性模拟,看你能不能打死这个怪。如果模拟通过,就给你发奖励。这个模拟过程必须有严格的一致性,不能依赖随机数,或者随机种子必须从链上获取。
下面我们用 Anchor 写一个简单的“战斗结算”状态存储。这一段是 Solana 的链上逻辑,用 Rust 写,但它是 Anchor 框架,属于我们的统一技术栈。
// 技术栈:Anchor (Solana 链上开发框架)
use anchor_lang::prelude::*;
// 声明一个战斗结果账号,存储在链上
#[account]
pub struct BattleResult {
pub player: Pubkey, // 玩家地址
pub boss_id: u32, // Boss的ID
pub won: bool, // 是否胜利
pub reward_claimed: bool, // 是否已经领取过奖励
pub timestamp: i64, // 结算时间
}
// 处理战斗结算的指令
#[derive(Accounts)]
pub struct SettleBattle<'info> {
#[account(mut)]
pub player: Signer<'info>,
#[account(
init_if_needed,
payer = player,
space = 8 + 32 + 4 + 1 + 1 + 8, // 手动算好空间
seeds = [b"battle", player.key().as_ref(), &boss_id.to_le_bytes()],
bump
)]
pub result_account: Account<'info, BattleResult>,
pub system_program: Program<'info, System>,
}
pub fn settle_battle(ctx: Context<SettleBattle>, boss_id: u32, won: bool) -> Result<()> {
let result = &mut ctx.accounts.result_account;
result.player = ctx.accounts.player.key();
result.boss_id = boss_id;
result.won = won;
result.reward_claimed = false;
result.timestamp = Clock::get()?.unix_timestamp;
msg!("战斗结算完成,玩家: {}, 胜利: {}", result.player, won);
Ok(())
}
这个合约只记录结果。你在客户端杀了十分钟的怪,最后提交的是一个布尔值。链上合约不会反问“你有没有作弊”,它只是记录“结果是赢了”。至于防作弊,那是另一套系统做验证,我们后面再聊。
三、并发冲突:一群人同时打一个怪,谁算数?
3.1 Solana 的账号模型带来的麻烦
传统服务器里,如果十个玩家同时打一个 Boss,服务器可以排队处理每次伤害。但在 Solana 上,每个玩家要改同一个链上账号,理论上这些交易如果并发执行,会发生冲突。Solana 有一个机制叫“可重性”,如果两笔交易同时改同一个账号,其中一笔会被丢弃,需要重试。
这就会造成什么情况?十个玩家同时点击“攻击”,只有一个人能成功,其他九个人收到“交易被拒绝,请重试”。这体验太糟糕了。所以我们得想办法把并发写冲突避开。
3.2 解法:把战斗过程放到客户端,让合约只认签名
如果每个玩家都在自己的本地算好伤害,然后把“结果摘要”和“签名”提交到链上,那合约始终只处理单个玩家对自己的结果账号的写入。所有人都可以写自己的账号,互不干扰。但是要防止大家乱报结果,合约里需要验证一个“可信的提交者签名”。
这个可信提交者通常是游戏服务器,它帮玩家背书。玩家打完怪,服务器验证一下流程没问题,签个名,然后玩家拿着签名去链上领奖励。这样链上合约只验证签名,就能避免并发竞争,因为每个玩家处理的是自己的奖励账号,没人抢同一个东西。
来看一个实现思路。服务器签名这个过程,我们用 TypeScript 写。
// 技术栈:TypeScript + Node.js(游戏服务端)
// 注意:这里演示的是“服务器签名”逻辑,和链上合约配合使用
import { Keypair } from '@solana/web3.js';
import nacl from 'tweetnacl';
import bs58 from 'bs58';
// 假设游戏服务器有一个用于签名的密钥对
// 在实际生产环境,私钥绝对不能暴露给客户端
const serverSigner = Keypair.fromSecretKey(
Uint8Array.from([/* 从环境变量中读取256位私钥,这里省略 */])
);
// 这是服务器验证玩家战斗过程并生成签名的地方
export function signBattleResult(
playerAddress: string,
bossId: number,
won: boolean,
damageDealt: number
): string {
// 把这几个关键信息拼接成一个 UTF-8 字符串,然后用服务器私钥签名
// 使用 Ed25519 签名算法,这是 Solana 使用的签名算法
const message = Buffer.from(
`${playerAddress}:${bossId}:${won}:${damageDealt}`,
'utf-8'
);
const signature = nacl.sign.detached(message, serverSigner.secretKey);
// 返回 base58 编码的签名,方便客户端传到链上
return bs58.encode(signature);
}
合约那边只要用服务器公钥验签,就能确认这个结果是官方认可的。这样一来,每个玩家领奖励的账号都不同,交易不会互相踩脚。
3.3 链上验证签名的完整示例
上面的签名生成之后,玩家要拿到链上去调用合约。合约需要存服务器公钥,然后验证签名。我们在 Anchor 合约里增加一个验签指令。
// 技术栈:Anchor (Solana 链上合约)
use anchor_lang::prelude::*;
use solana_program::ed25519_program;
// 因为 Solana 链上无法直接用 ed25519 验签的朴素指令,
// 实际开发中通常会使用 Secp256k1 或者调用预编译。
// 这里为了演示,展示一种“利用 Solana 原生指令验证”的简化写法。
// 真实项目中建议使用 solana_program::ed25519_program::ID 的预编译
pub fn verify_and_claim(
ctx: Context<ClaimReward>,
boss_id: u32,
won: bool,
signature: [u8; 64],
) -> Result<()> {
// 这里省略真正调用预编译的代码,因为每一版 SDK 不同
// 伪代码:ed25519_verify(signature, message_data, ctx.accounts.server_pubkey)
// 如果验签失败,直接返回错误
require!(won == true, RewardError::BattleLost);
// 如果验签通过,给玩家发奖励
let reward_account = &mut ctx.accounts.reward_account;
reward_account.claimed = true;
msg!("玩家领取奖励成功!");
Ok(())
}
#[error_code]
pub enum RewardError {
#[msg("战斗失败,不能领取奖励")]
BattleLost,
#[msg("签名验证失败")]
InvalidSignature,
}
这里有个小提醒:Solana 原生验签其实很繁琐,因为预编译是给 Ed25519 的,你得把消息、签名、公钥按特定格式写到指令数据里。实际项目里很多人反而把签名放到一条普通交易里,然后用合约调 load_instruction_at 去读取签名,这么做还比较省事。但咱这是博客,重点是把思路讲清楚,真要做的时候再翻文档。
四、状态同步的“最终一致性”:怎么让所有客户端看到一样的世界
4.1 别追求“帧同步”,要追求“关键事件同步”
有些 Web3 游戏想做成竞技对战,比如吃鸡,几十个人同一个战场。这种游戏要是纯靠链上同步,那根本不可能。Solana 再快,也没法一帧一帧地同步。所以良心建议:别做帧同步,做“松散同步”。也就是大家各自打各自的,只在重要时刻(比如开箱、死亡、交任务)上链。这样游戏体验跟传统休闲游戏差不多,但又比纯中心化游戏多了一层资产可信度。
打个比方,你在游戏里砍树。客户端自己数着砍了多少棵,每砍一棵就在本地加一根木头。等到你想把木头变成 NFT 或者卖出时,客户端把“我砍了 10 棵树”这个结果发给游戏服务器,服务器确认你的行为没问题,然后帮你上链。所有玩家看到的树量可能不一样,但只要资产上链了,就是真实的。
4.2 事件序列防作弊:让客户端提交“行为哈希”
咋才能让服务器确认你没作弊呢?最简单的方法是在客户端记录“行为日志”,比如每帧按键、每棵树出现的随机种子,然后把日志的哈希上传。服务器重新播放这个日志,看看能不能复现同样的结果。这个思路跟云游戏回放差不多。
我们用 TypeScript 写一个行为日志收集器,然后把日志的哈希发到链上做锚定。这样既能让链上存证,又不用把所有日志都放链上。
// 技术栈:TypeScript + Solana Web3.js
import { keccak256 } from 'js-sha3'; // 注意示例用 keccak,如果链上要用 sha256 也行
// 这个类用来记录玩家的行为,然后生成一个摘要哈希
class BehaviorLogger {
private events: string[] = [];
// 记录一个事件,比如 "JUMP", "MOVE_LEFT", "ATTACK_BOSS"
recordEvent(eventType: string, detail: string) {
// 把事件和时间戳拼在一起,保证唯一性
this.events.push(`${eventType}:${detail}:${Date.now()}`);
}
// 生成所有事件串的哈希,这个哈希很小,可以放到链上
computeHash(): string {
const joined = this.events.join('|');
// 实际项目中,这里应该用 keccak256 或 sha256
// 注意:Solidity 常用 keccak256,Solana 普通合约可以调用 sysvar hash
// 这里只是客户端计算,无所谓哪个
return keccak256(joined);
}
// 把哈希提交到 Solana 链上,相当于“存档”
async submitHashToChain(connection: any, gameProgram: any, wallet: any) {
const hash = this.computeHash();
// 调用合约的 submitBehaviorHash 指令
// 合约只需要存储这个哈希,不需要存完整日志
console.log(`行为哈希: ${hash} 已提交`);
// 实际调用示例:
// await gameProgram.methods
// .submitBehaviorHash(Buffer.from(hash, 'hex'))
// .accounts({ player: wallet.publicKey })
// .rpc();
}
}
链上存哈希有什么用?以后要是两个人对同一个结果产生分歧,可以拿出行为日志,算一下哈希,如果哈希对得上,就知道那确实是你的行为。这能起到“定责”的作用。
4.3 实际同步流程:一个完整的“采集-提交-确认”案例
我们把上面几个片段组合起来,形成一个完整的流程。假设玩家做了一个任务“消灭 5 只狼”,客户端先在本地把 5 只狼都杀了,然后生成一张“击杀证明”,发到链上。这里给出一个完整的简易客户端代码,展示这个流程怎么写。
// 技术栈:TypeScript + Solana Web3.js + Anchor 客户端
// 这个文件模拟一次完整的“采集战果 -> 提交链上 -> 确认返回”的过程
import { Connection, Keypair, PublicKey, Transaction } from '@solana/web3.js';
import * as anchor from '@project-serum/anchor';
async function completeQuest(
connection: Connection,
wallet: any,
program: anchor.Program
) {
// 1. 本地模拟杀5只狼
const killedWolves = 5;
const questId = 10101;
// 2. 生成行为哈希
const events = [];
for (let i = 0; i < killedWolves; i++) {
events.push(`KILL_WOLF:${i}:${Date.now()}`);
}
// 这里偷懒直接用数组长度作为哈希,真实场景需要用加密哈希
const behaviorHash = events.join(',');
// 3. 提交到链上
// 我们要创建一个“任务记录账号”,这个账号里存着哈希和完成状态
const [questRecord] = PublicKey.findProgramAddressSync(
[
Buffer.from('quest'),
wallet.publicKey.toBuffer(),
Buffer.from(questId.toString()),
],
program.programId
);
// 这行代码是调用 Anchor 合约的指令
// 假设合约里有一个 submit_quest 函数,接受一个字符串参数
const tx = await program.methods
.submitQuest(questId, behaviorHash)
.accounts({
player: wallet.publicKey,
questRecord: questRecord,
systemProgram: anchor.web3.SystemProgram.programId,
})
.transaction();
// 4. 发送交易,等待确认
const blockhashInfo = await connection.getLatestBlockhash('confirmed');
tx.recentBlockhash = blockhashInfo.blockhash;
tx.feePayer = wallet.publicKey;
// 这里真实验签需要钱包实现,咱们只打印日志
console.log('交易构造完成,待签名发送。交易签名是:', tx.signatures.length);
// 5. 等待确认后,本地继续执行后续操作
console.log(
`任务 ${questId} 已提交,杀死 ${killedWolves} 只狼。哈希为:`,
behaviorHash
);
}
// 实际调用的时候,你需要一个连接、一个钱包、一个已经部署的 Anchor 程序
// 这里就示意一下,不真跑,因为没有依赖环境
async function main() {
const connection = new Connection('https://api.devnet.solana.com', 'confirmed');
const wallet = Keypair.generate(); // 演示用随机钱包,真实环境用前置
// const program = await anchor.Program.at(programId, provider);
console.log('演示环境配置完成,玩家地址:', wallet.publicKey.toBase58());
// 因为缺少合约部署,这里不直接调用 completeQuest
}
main();
看到没有,整个流程根本不需要等链上确认你的每一步动作。先打爽了,再一次性把结果交上去。这就是 Solana 游戏的一个常见妥协方案。
五、技术选型:有些取舍你得知道
5.1 优点
这么设计的好处很明显。第一,玩家交互响应快,因为大部分逻辑发生在本地。第二,链上交易量少,不容易把区块塞满。第三,资产和结果有链上记录,玩家之间可以信任。第四,Solana 手续费极低,就算每个玩家每天提交几十笔交易,钱包也能承受。
5.2 缺点
但坏处也真实。第一,客户端防作弊能力弱,玩家可以改本地内存,伪造攻击力。第二,服务器签名是单点,服务器挂了,游戏玩不了。第三,链上状态只是“结果”,如果你游戏需要实时 PvP 竞赛,这种方案就跟现实脱节。第四,开发复杂度上升,你要写两套逻辑:一套客户端快速模拟,一套链上或服务器做验证。
5.3 啥时候不该用这种方案
如果你的游戏是纯回合制,比如卡牌,链上状态可以模拟回合过程,这种方案就合适。如果你的游戏是激烈的 FPS,建议别指望 Web3,老老实实让中心化服务器当权威,只把重要资产上链。
六、注意事项:那些坑我得提一嘴
6.1 别为了上链而牺牲手感
我们见过有些团队,为了强调“玩法上链”,强行让每次开火都发一笔交易。结果玩家打一梭子子弹,钱包确认几十次,游戏直接卡成幻灯片。这种设计就是本末倒置。记住,手感是生命,链上只是存档。
6.2 关注 Solana 网络的“交易丢弃”现象
虽然 Solana 快,但在高负载下,如果当前 block 的写入账号冲突,你的交易可能被丢弃。所以你的客户端代码必须做好重试和超时处理。一个简单的重试逻辑长这样:
// 技术栈:TypeScript + Solana Web3.js
// 发送交易的简单重试函数
async function sendTransactionWithRetry(
web3: any,
transaction: any,
wallet: any,
maxRetries: number = 5
) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
// 尝试发送交易,这里使用的是版本化事务的简化写法
const signature = await web3.sendAndConfirmTransaction(
web3,
transaction,
[wallet]
);
return signature; // 如果成功,直接返回签名
} catch (error) {
console.log(`第 ${attempt} 次发送失败:`, error.message);
// 失败就等一小会,然后循环重试
await new Promise((resolve) => setTimeout(resolve, 300 * attempt));
}
}
throw new Error('交易在多次重试后仍然失败');
}
6.3 注意状态存储的成本
Solana 上存储数据不是免费的。你每开一个账号,都要支付一笔租金押金。游戏里如果每个玩家都创建好多个状态账号,那么总租金可能很夸张。一个经济的做法是让玩家自己支付租金,同时把不必要的数据压缩掉。比如坐标用 u32 而不是 u64,任务 ID 用 u16。举个例子:
// 技术栈:Anchor (Solana 链上合约)
#[account]
pub struct PlayerProfile {
pub authority: Pubkey, // 32字节
pub level: u8, // 1字节
pub exp: u32, // 4字节
pub last_quest_id: u16, // 2字节
}
// 总大小 = 32 + 1 + 4 + 2 = 39,再加上8字节鉴别器 = 47字节
// 这样比用全 u64 省很多租金
6.4 谨防“预言机”依赖
如果游戏逻辑需要随机数,千万别自己在客户端生成随机数然后丢到链上,不然玩家可以用读档的方式刷随机结果。常见的方案是使用 Solana 上的时钟或最近区块哈希作为种子。但注意,区块哈希只有最近几个能用,太老的区间不可信。下面给一个安全的随机数获取思路:
// 技术栈:Anchor (Solana 链上合约)
// 使用 slot 和 unix_timestamp 以及一个玩家提供的 salt 做哈希
let random_seed = Clock::get()?.slot ^ Clock::get()?.unix_timestamp as u64;
let player_salt: u64 = ctx.accounts.player_salt; // 玩家输入的盐值
let result = (random_seed.wrapping_mul(player_salt)) % 100; // 0-99之间的伪随机数
不过这并不安全,因为矿工和验证者可以操作时间。在实际游戏里,更推荐用 Chainlink VRF 或者在你的服务器端生成随机数然后签名给链上验证。这里只是提个醒。
七、应用场景:哪些游戏真的适合这种模式
目前来看,以下三类游戏最适合 Solana 上的这套方案。
7.1 放置类游戏
玩家不需要实时操作,比如挖矿、修炼、建筑,每过一段时间收获一次。客户端在后台计算收益,玩家点“收获”的时候再上链。这种模式对链上状态同步的要求极低,很好做。
7.2 策略卡牌游戏
打牌的过程是回合制的,每一步都可以等待链上确认,而且需要防作弊。对局逻辑可以用确定性函数表示,大家按顺序提交动作,所有人都能验证。这种游戏特别适合 Solana 的高吞吐和低延迟。
7.3 开放世界中的“资产采集”游戏
玩家在 Solana 地图上漫游,采集资源和打怪。小怪可以离线打,然后拿着战利品上链。这种游戏的特点是“探索和战斗”是实时的,但“获取资产”是链上的,给玩家的感觉就是操作流畅,但每一件装备都有真金白银的归属感。
八、文章总结
写到这里,我们其实一直围绕两句话在说:第一,别让链上逻辑实时参与你的游戏交互;第二,用签名和哈希把客户端行为“锚定”到链上,既保证速度又保证可信度。
Solana 公链上做 Web3 游戏,本质上是两套系统配合:一套负责手感,一套负责资产。你不可能把精细的实时位置同步到区块里,但你可以把每一次战斗结果、每一次任务完成、每一个独特物品变成链上不可篡改的事实。处理好并发冲突、交易重试、签名验证、数据压缩的细节,你的游戏体验离传统游戏就不会差太远。
技术永远是在取舍中前进的。别迷信“全链上游戏”,也别因为链上同步难就完全中心化。你要做的是找到一条折中的路:让玩家觉得好玩,同时让我知道他的装备和成就是真的。这条路并不好走,但如果走通了,那就是下一代游戏的样子。
评论
围绕“Solana公链Web3游戏开发:实时交互性能优化与链上状态同步技术难点”参与讨论