[设计]
Kabletop是一款基于CKB的通用回合制对战游戏框架,其完全基于点对点的网络架构设计,节点与节点之间的交互以状态通道技术实现,基于该框架开发出来的游戏将同时实现游戏客户端逻辑和CKB智能合约逻辑,开发语言为Lua,框架本身基于Rust和C实现。
文章后面将从以下几个方面详细介绍Kabletop的设计内容:
- 基于CKB的合约设计:NFT、Wallet、Payment和Kabletop合约
- 基于Godot游戏引擎的游戏节点设计
#1 基于CKB的合约设计说明
NFT合约的Cell(nft_cell)结构:
Data:
blake160 | blake160 | ...
Lock:
Any
Type:
code_hash = nft_contract
hash_type = data
args = nft_wallet_lockhash
每个NFT的数据由其原始数据在Blake2b哈希后的前20字节(blake160)表示,NFT合约Cell的Data部分则以20字节为单位存放NFT数据集合,此集合在该合约规定的协议里可拆解、可组合、可销毁,但不能增发,增发操作由Wallet和Payment合约共同完成。
和sUDT相似,NFT合约的Type_args字段存放的是Wallet合约的Cell中Lock部分的Blake2b哈希值,Wallet合约的Cell由该系列NFT的创作者创建。
随着拥有的NFT数量的增多,用户需要质押更多的CKB用于储存这些NFT,虽然可以通过SMT(稀疏默克尔树)技术来解决储存需求增多的问题,但这种设计是故意为之,因为类似于Defi领域里的TVL,如果一个系列的NFT因为有很好的使用场景而有更多的市场需求,那相应的用于存储这些NFT所质押的CKB就可以理解成对应的锁仓价值,而该锁仓价值的高低也能较为直接的反应该系列NFT所对应的使用场景的优劣程度。
Wallet合约的Cell结构:
Data:
Any (but not an integer multiple of 20 bytes)
Lock:
code_hash = nft_wallet_contract
hash_type = data
args = pubkey_blake160
Type:
Any
Wallet合约实现的逻辑和ACP(AnyoneCanPay)合约类似,只不过刨除了对sUDT资产的增减判断只保留了对CKB数量的增减判断:任何用户往Wallet合约支付CKB时都不需要提供该Cell所有者的签名数据,反之则需要。
这种设计主要用来配合Payment合约实现用户通过CKB向NFT创作者购买NFT卡包的增发操作,Cell的Data部分限制了不能为20字节的整数倍,目的是为了限制创作者不能随意构造Cell来指向性地增发NFT,所有的增发操作都必须符合NFT配置表中预先设定的随机模型。
Payment合约用于创建NFT配置表的Cell(config_cell)结构:
Data:
ckb_per_package(u64) | nft_count_per_package(u8) | [blake160|rate(u16)] | [blake160|rate(u16)] | ...
Lock:
code_hash = nft_wallet_contract
hash_type = data
args = composer_pubkey_blake160
Type:
code_hash = nft_payment_contract
hash_type = data
args = composer_pubkey_blake160
该Cell由系列NFT的创作者创建,Lock_args和Type_args都为创作者的公钥哈希,Data包含NFT的增发规则,包括单个卡包的价格、单个卡包包含的NFT数量和系列所有NFT及对应抽取概率的NFT配置表。
配置表的协议格式表示NFT以NFT卡包的形式增发,而每个卡包能开出来的NFT则由每个NFT对应的概率数值决定,创作者可以通过消耗旧config_cell和创建新的config_cell来更新NFT配置表。
Payment合约用于创建支付钱包的Cell(wallet_cell)结构:
CellDeps:
config_cell
Data:
0 (uint8)
Lock:
code_hash = nft_wallet_contract
hash_type = data
args = composer_pubkey_blake160
Type:
code_hash = nft_payment_contract
hash_type = data
args = user_pubkey_blake160
Capacity:
Any
该Cell由用户自己创建,Lock_args与Type_args不一致代表该Payment合约创建的Cell用于支付CKB来购买NFT卡包,有多少用户就要创建多少这样的wallet_cell,一方面可以规避单一ACP合约Cell极端情况下无法处理并发支付请求的Cell竞争问题,另一方面可以让创作者更简单的知道有多少CKB地址在支持自己的NFT艺术创作。
Payment合约用于支付购买NFT卡包的Cell(payment_cell)结构:
// INPUT_CELL
on-chain wallet_cell
// OUTPUT_CELL
CellDeps:
config_cell
Data:
nft_package_count (uint8)
Lock:
code_hash = nft_wallet_contract
hash_type = data
args = composer_pubkey_blake160
Type:
code_hash = nft_payment_contract
hash_type = data
args = user_pubkey_blake160
Capacity:
Any /*(must be greator than or equal to wallet_cell's)*/
用户通过消耗wallet_cell来创建payment_cell,代表已经向NFT创作者钱包支付了足够多的CKB去购买等额的NFT卡包,创作者可以随时提取所有与自己相关的payment_cell中的CKB到自己的账户里。
Payment合约用于开启NFT卡包的组合Cell结构:
// INPUT_CELL
on-chain matured payment_cell
// OUTPUT_CELL_1 (same as wallet_cell)
CellDeps:
config_cell
Data:
0 (uint8)
Lock:
code_hash = nft_wallet_contract
hash_type = data
args = composer_pubkey_blake160
Type:
code_hash = nft_payment_contract
hash_type = data
args = user_pubkey_blake160
Capacity:
Any /*(must be greator than or equal to payment_cell's)*/
// OUTPUT_CELL_2 (same as nft_cell)
HeaderDeps:
blockheader from payment_cell
Data:
blake160 | blake160 | ...
Lock:
Any
Type:
code_hash = nft_contract
hash_type = data
args = nft_wallet_lockhash
用户通过消耗成熟的payment_cell来恢复wallet_cell和给自己创建新的nft_cell,随机开出的NFT列表的随机数种子来源于打包payment_cell的区块头数据,nft_cell判断到payment_cell中Lock部分的哈希值与自身的Type_args相同,则认为这是一个由Owner操作的NFT增发操作,于是直接跳过对该交易的验证。
由于CKB主网规定一个区块从打包到成熟需要16小时,所以一个从支付到撕包的操作至少需要等待16个小时才能完成,这是目前Kabletop合约架构里最大的短板,不过目前CKB上还没有更好的随机数发生器,所以暂时没有更好的解决办法。
Kabletop合约用于创建状态通道的Cell(channel_cell)结构:
Data:
Any
Lock:
code_hash = kabletop_contract
hash_type = data
args = staking_ckb(u64) | deck_size(u8) | begin_blocknumber(u64) | lock_code_hash(blake256)
| user1_pkhash(blake160) | user1_nfts(vec<blake160>) | user2_pkhash(blake160) | user2_nfts(vec<blake160>)
Type:
Any
该Cell需要对战双方同时签名,因为需要同时消耗双方提供的Cell来创建状态通道,双方提供的CKB中包含对战赌注和质押两部分,其中单边质押数额为staking_ckb。对战双方提供的NFT集合容量相同,均为deck_size,对战开始时的区块号为begin_blocknumber,双方也需提供各自的公钥哈希,并且和lock_code_hash组合在一起后与各自消耗的Cell中的Lock数据一致,即code_hash = lock_code_hash,args = 公钥哈希。
这种设计有个弊端是通过技术手段可以在对战开始的时候达到明牌的效果,因为对战双方提供的NFT集合已经公示在通道Cell中,但这种明牌也只是知道对方使用了哪些NFT与自己对战,并不知道后续对手能抽到的NFT和具体操作。
提前明牌是一种偏保守的设计,一方面如果确认操作都发生在链上,那明牌行为都很难规避,另一方面提前明牌也能为后面的挑战操作提供更多便利,提高一局对战在极端情况下也能正常结束的概率。
Kabletop合约用于正常关闭状态通道的Cell(settlement_cell,from origin)结构:
// INPUT_CELL
on-chain channel_cell
// OUTPUT_CELL_1
Data:
Any
Lock:
code_hash = lock_code_hash /*(from channel_cell)*/
hash_type = data
args = user1_pkhash /*(from channel_cell)*/
Type:
Any
Capacity:
Any /*(calculate via channel_cell)*/
// OUTPUT_CELL_2
Data:
Any
Lock:
code_hash = lock_code_hash /*(from channel_cell)*/
hash_type = data
args = user2_pkhash /*(from channel_cell)*/
Type:
Any
Capacity:
Any /*(calculate via channel_cell)*/
// WITNESSES
[
lock: user1_or_user2_input_signature
lock: user1_round_signature, input_type: user2_type (uint8) | operations (vec<string>)
lock: user2_round_signature, input_type: user1_type (uint8) | operations (vec<string>)
...
]
对战的状态通道可由对战的任意一方通过消耗掉通道Cell关闭,将对战的回合数据和对应的签名数据放入Witnesses中,创建结算用的Cell并计算好各自应得的CKB,甲方的回合操作数据需由乙方签名确认,签好名后乙方会将签名数据和乙方的回合操作数据一同发回甲方进行确认,如此往复进行对战直到出现胜利者,如果在对战过程中有一方迟迟不完成签名确认,那另一方可发起挑战。
Kabletop合约用于发起挑战的Cell(challenge_cell)结构:
// INPUT_CELL
on-chain channel_cell
// OUTPUT_CELL
Data:
round_count (uint8) | user_round_signature | user_type (uint8) | operations (vec<string>)
Lock:
same as open_cell
Type:
same as open_cell
Capacity:
same as open_cell
// WITNESSES
[
lock: user1_or_user2_input_signature
lock: user1_round_signature, input_type: user2_type (uint8) | operations (vec<string>)
lock: user2_round_signature, input_type: user1_type (uint8) | operations (vec<string>)
...
]
如果存在一方故意拖延游戏的情况,另一方可以向其发起挑战,challenge_cell与channel_cell除了Data字段不一样以外其余完成一致,Date部分主要包含了挑战发起方所在回合的相关数据,包括回合数、挑战方签名(确认对方的回合数据)、挑战方用户类别和挑战方回合操作数据。
如果被挑战方因掉线而延误游戏,在其上线与对手客户端重新建立连接后,可从挑战Cell中获取挑战方最新的回合数据,也可向对手客户端索要最新的回合数据,游戏继续进行。如果被挑战方迟迟未做出任何响应,则挑战方在经过一段区块时间后可强制关闭状态通道并赢得比赛。
Kabletop合约用于异常关闭状态通道的Cell(settlement_cell,from challenge)结构:
// INPUT_CELL
Data:
same as challenge_cell
Lock:
same as challenge_cell
Type:
same as challenge_cell
Capacity:
same as challenge_cell
Since:
target blocknumber (uint64)
// OUTPUT_CELL_1
Data:
Any
Lock:
code_hash = lock_code_hash /*(from open_cell)*/
hash_type = data
args = user1_pkhash /*(from open_cell)*/
Type:
Any
Capacity:
Any /*(calculate via open_cell)*/
// OUTPUT_CELL_2
Data:
Any
Lock:
code_hash = lock_code_hash /*(from open_cell)*/
hash_type = data
args = user2_pkhash /*(from open_cell)*/
Type:
Any
Capacity:
Any /*(calculate via open_cell)*/
// WITNESSES
[
lock: challenger_input_signature
lock: user1_round_signature, input_type: user2_type (uint8) | operations (vec<string>)
lock: user2_round_signature, input_type: user1_type (uint8) | operations (vec<string>)
...
]
两个settlement_cell的结构基本一致,区别在于从challenge_cell到settlement_cell的过程中,输入端的Cell需要填写Since字段,内容为挑战期结束时的区块号,kabletop合约将会验证该字段与begin_blocknumber的差值与当前回合数的关系是否符合合约预先设定的计算公式,只有在符合的情况下合约才会认定挑战期结束并允许强制关闭状态通道。
#2 基于Godot游戏引擎的游戏节点设计说明
在游戏引擎选型的过程中考虑了多款市面上流行的引擎,包括CocosCreator、Unity3D、Godot、Amethyst和Bevy。从面向设计师而不是工程师的角度考虑,合适的引擎只有CocosCreator、Unity3D和Godot,而再从Rust支持度和社区活跃度的角度考虑,Godot就是唯一的选择。
目前基于Godot游戏引擎的Kabletop游戏节点的框架还在设计当中,待基本成型后再专门撰写一篇文章来说明,不过不管框架如何设计,最终要达到的效果是用户在该框架下制作出来的游戏同时包含客户端和服务端功能,游戏节点之间完全对等连接、自由进出,并会运用一定的共识算法完成全局范围内的游戏匹配功能。
[总览]
注:并未包含对Challenge情况的描述,不过大致流程也差不多