Three covenants. One atomic game. Zero servers.
Complete source code for the Player, Shop, and Opponent covenants running on Kaspa Testnet-12. Every line is production code from The DAG Gate.
Browser (static HTML + WASM)
|
wRPC WebSocket
|
TN12 Kaspa Node
|
+---------+---+---+---------+
| | | |
[Player UTXO] [Shop UTXO] [Opponent UTXO]
160 bytes 56 bytes 80 bytes
| | | |
| ICC: atomic tx | |
+----[2-in, 2-out]----+ |
| |
+----[2-in, 2-out]---------+
ICC: PvP combat
Character creation deploys all three covenants + funds the wallet in one transaction (5 outputs)
directly from on-node mining rewards.
Every game action spends and recreates the relevant covenant UTXOs via
validateOutputState. Shop purchases and PvP combat use
Inter-Covenant Communication (ICC) — two covenants updated
atomically in a single tx. Combat is deterministic — seeded from the
BlockDAG's tip hash via xorshift64 PRNG, replayable and verifiable by anyone.
| Covenant | Size | State Fields | Auth | Validates |
|---|---|---|---|---|
| Player | 160 bytes | owner, hp, gold, level | Schnorr sig | output 0 |
| Shop | 56 bytes | gold_collected | None (public) | output 1 |
| Opponent | 80 bytes | hp, gold | None (public) | output 1 |
These hex strings are the compiled SilverScript output. Each is a complete Kaspa script
that enforces state transitions via validateOutputState. The placeholder
bytes (e.g. aa...aa for the owner pubkey, 14000000... for HP)
are patched at runtime with the actual state values.
20aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
08140000000000000008000000000000000008010000000000000057795479876958
795879ac69567900a269557900a269547978a269537901207c7e577958cd587c7e
577958cd587c7e577958cd587c7e7e7e7eb976c97602a00094013c937cbc7eaa02
000001aa7e01207e7c7e01877e00c3876975757575757575757551
0800000000000000007800a0697652799358cd587c7eb976c9
7601389459937cbc7eaa02000001aa7e01207e7c7e01877e51c38769757551
sell(payment) — the covenant only checks that
new_gold_collected == old_gold_collected + payment.
083200000000000000086400000000000000537900a269527900a269
537958cd587c7e537958cd587c7e7eb976c9760150940112937cbc7eaa
02000001aa7e01207e7c7e01877e51c387697575757551
fight(newHp, newGold).
The compiled scripts are templates. At runtime, we patch in the actual state values using simple hex string replacement. No recompilation, no AST manipulation — just splice the int64LE bytes at known offsets.
PLAYERbuildPlayerScript(pubkeyHex, hp, gold, level) {
let s = PLAYER_SCRIPT_HEX.replaceAll('aa'.repeat(32), pubkeyHex);
s = s.substring(0, 68) + int64LE(hp) + s.substring(84);
s = s.substring(0, 86) + int64LE(gold) + s.substring(102);
s = s.substring(0, 104) + int64LE(level) + s.substring(120);
return s;
}
SHOP
buildShopScript(goldCollected) {
let s = SHOP_SCRIPT_HEX;
s = s.substring(0, 2) + int64LE(goldCollected) + s.substring(18);
return s;
}
OPPONENT
buildOpponentScript(hp, gold) {
let s = OPPONENT_SCRIPT_HEX;
s = s.substring(0, 2) + int64LE(hp) + s.substring(18);
s = s.substring(0, 20) + int64LE(gold) + s.substring(36);
return s;
}
The int64LE helper converts a number to 8-byte little-endian hex:
function int64LE(n) {
const buf = new ArrayBuffer(8);
new DataView(buf).setBigInt64(0, BigInt(n), true);
return Array.from(new Uint8Array(buf))
.map(b => b.toString(16).padStart(2, '0')).join('');
}
Character creation deploys all three covenants + funds the player wallet in a single tx directly from on-node mining rewards. Each covenant holds 0.2 KAS to stay under the block storage mass limit (small UTXOs incur high storage mass penalties).
| Output | Covenant | Value |
|---|---|---|
| 0 | Player | 20,000,000 sompi (0.2 KAS) |
| 1 | Shop | 20,000,000 sompi (0.2 KAS) |
| 2 | Opponent | 20,000,000 sompi (0.2 KAS) |
| 3 | Wallet | 20,000,000 sompi (0.2 KAS) |
| 4 | Change | remainder to faucet |
async createPlayerAndShop(kaspa, privateKey, pubkeyHex, hp, gold, level, fundingUtxos) {
const playerScript = this.buildPlayerScript(pubkeyHex, hp, gold, level);
const playerSpk = kaspa.ScriptBuilder.fromScript(playerScript)
.createPayToScriptHashScript();
const shopScript = this.buildShopScript(0);
const shopSpk = kaspa.ScriptBuilder.fromScript(shopScript)
.createPayToScriptHashScript();
const oppScript = this.buildOpponentScript(50, 100);
const oppSpk = kaspa.ScriptBuilder.fromScript(oppScript)
.createPayToScriptHashScript();
const changeSpk = kaspa.payToAddressScript(privateKey.toAddress('testnet-12'));
// ... gather inputs, calculate totals ...
const tx = new kaspa.Transaction({
version: 0,
inputs,
outputs: [
{ value: 10000000n, scriptPublicKey: playerSpk }, // Player
{ value: 5000000n, scriptPublicKey: shopSpk }, // Shop
{ value: 5000000n, scriptPublicKey: oppSpk }, // Opponent
{ value: change, scriptPublicKey: changeSpk }, // Change
],
lockTime: 0n,
subnetworkId: '0000000000000000000000000000000000000000',
gas: 0n, payload: '',
});
const signedTx = kaspa.signTransaction(tx, [privateKey], false);
const rpc = await this.ensureRpc(kaspa);
return rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });
}
Every game action (combat, level-up, inn rest) calls updatePlayerUtxo.
This spends the current covenant UTXO and creates a new one with updated state.
The on-chain script enforces that the output script matches the expected new state
via validateOutputState.
async updatePlayerUtxo(kaspa, privateKey, pubkeyHex,
curHp, curGold, curLevel,
newHp, newGold, newLevel,
covenantUtxo
) {
const currentScript = this.buildPlayerScript(pubkeyHex, curHp, curGold, curLevel);
const newScript = this.buildPlayerScript(pubkeyHex, newHp, newGold, newLevel);
const newSpk = kaspa.ScriptBuilder.fromScript(newScript).createPayToScriptHashScript();
const currentSpk = kaspa.ScriptBuilder.fromScript(currentScript).createPayToScriptHashScript();
const covenantValue = BigInt(covenantUtxo.utxoEntry.amount);
const fee = 10000n + BigInt(Math.floor(Math.random() * 1000));
// Step 1: Build unsigned tx for signing
const unsignedTx = new kaspa.Transaction({
version: 0,
inputs: [{
previousOutpoint: covenantUtxo.outpoint,
signatureScript: '', sequence: 0n, sigOpCount: 1,
utxo: { amount: covenantValue, scriptPublicKey: currentSpk, ... },
}],
outputs: [{ value: covenantValue - fee, scriptPublicKey: newSpk }],
...
});
// Step 2: Create Schnorr signature over the tx
const sigHex = kaspa.createInputSignature(unsignedTx, 0, privateKey);
// Step 3: Build sig_script: <sig> <pubkey> <newHp> <newGold> <newLevel> <redeem>
const argSb = new kaspa.ScriptBuilder();
argSb.addData(pubBytes);
argSb.addI64(BigInt(newHp));
argSb.addI64(BigInt(newGold));
argSb.addI64(BigInt(newLevel));
const redeemSb = new kaspa.ScriptBuilder();
redeemSb.addData(currentScriptBytes);
const sigScript = sigHex + argSb.toString() + redeemSb.toString();
// Step 4: Submit with sig_script
const signedTx = new kaspa.Transaction({
inputs: [{ previousOutpoint, signatureScript: sigScript, ... }],
outputs: [{ value: covenantValue - fee, scriptPublicKey: newSpk }],
...
});
return rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });
}
<schnorr_sig> <pubkey> <arg1> <arg2> ... <redeem_script>validateOutputState to verify
that output 0 contains the new state built from the provided arguments.
When a player buys an item, both the Player and Shop covenants must update atomically.
The Player's gold decreases; the Shop's gold_collected increases by the
payment amount. One transaction, two P2SH inputs, two P2SH outputs.
Input 0: Player covenant (validates output 0)
sig_script: <sig> <pubkey> <hp> <newGold> <level> <player_redeem>
Input 1: Shop covenant (validates output 1)
sig_script: <payment> <shop_redeem>
Output 0: New Player UTXO (gold decreased)
Output 1: New Shop UTXO (gold_collected increased)
async purchaseFromShop(kaspa, privateKey, pubkeyHex,
curHp, curGold, curLevel, newGold, payment,
playerUtxo, shopUtxo, shopGoldCollected
) {
// Build current and new scripts for both covenants
const playerScript = this.buildPlayerScript(pubkeyHex, curHp, curGold, curLevel);
const newPlayerScript = this.buildPlayerScript(pubkeyHex, curHp, newGold, curLevel);
const shopScript = this.buildShopScript(shopGoldCollected);
const newShopScript = this.buildShopScript(shopGoldCollected + payment);
// Two inputs, two outputs
const unsignedTx = new kaspa.Transaction({
inputs: [
{ previousOutpoint: playerUtxo.outpoint, sigOpCount: 1, ... }, // Player
{ previousOutpoint: shopUtxo.outpoint, sigOpCount: 0, ... }, // Shop (no sig)
],
outputs: [
{ value: playerAmt - fee, scriptPublicKey: newPlayerSpk }, // output 0
{ value: shopAmt, scriptPublicKey: newShopSpk }, // output 1
],
});
// Player sig_script (input 0): signed by owner
const sigHex = kaspa.createInputSignature(unsignedTx, 0, privateKey);
const playerSigScript = sigHex
+ serialize(pubkey, curHp, newGold, curLevel)
+ serialize(playerRedeemScript);
// Shop sig_script (input 1): no signature, just args
const shopSigScript = serialize(payment) + serialize(shopRedeemScript);
// Submit atomic tx
return rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });
}
PvP combat follows the same ICC pattern. The Player covenant validates output 0 (new player HP/gold after combat). The Opponent covenant validates output 1 (new opponent HP/gold). The outcome of the fight determines the new state values.
Input 0: Player covenant (validates output 0)
sig_script: <sig> <pubkey> <newHp> <newGold> <level> <player_redeem>
Input 1: Opponent covenant (validates output 1)
sig_script: <newOppHp> <newOppGold> <opponent_redeem>
Output 0: New Player UTXO (HP/gold changed by combat)
Output 1: New Opponent UTXO (HP/gold changed by combat)
async pvpFight(kaspa, privateKey, pubkeyHex,
curHp, curGold, curLevel,
newPlayerHp, newPlayerGold,
oppHp, oppGold, newOppHp, newOppGold,
playerUtxo, oppUtxo
) {
// Player sig_script: signed, validates output 0
const sigHex = kaspa.createInputSignature(unsignedTx, 0, privateKey);
const playerSigScript = sigHex
+ serialize(pubkey, newPlayerHp, newPlayerGold, curLevel)
+ serialize(playerRedeemScript);
// Opponent sig_script: unsigned (public NPC), validates output 1
const oppSigScript = serialize(newOppHp, newOppGold)
+ serialize(oppRedeemScript);
const signedTx = new kaspa.Transaction({
inputs: [
{ previousOutpoint: playerUtxo.outpoint, signatureScript: playerSigScript },
{ previousOutpoint: oppUtxo.outpoint, signatureScript: oppSigScript },
],
outputs: [
{ value: playerAmt - fee, scriptPublicKey: newPlayerSpk },
{ value: oppAmt, scriptPublicKey: newOppSpk },
],
});
return rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });
}
The game runs in the browser. Every meaningful action calls one of three functions:
| Game Action | Chain Function | Type |
|---|---|---|
| Forest combat win/lose | syncToChain() | Player update |
| Inn rest (heal) | syncToChain() | Player update |
| Level up | syncToChain() | Player update |
| Death / respawn | syncToChain() | Player update |
| Buy weapon/armor | shopPurchase() | ICC: Player+Shop |
| PvP arena fight | pvpOnChain() | ICC: Player+Opponent |
| PvP forfeit | syncToChain() | Player update |
async function syncToChain(s, action) {
if (!Wallet._kaspa || !Wallet._privateKeyHex || !Wallet.funded) return;
const ocHp = s._onChainHp;
const ocGold = s._onChainGold;
const ocLevel = s._onChainLevel;
if (ocHp === undefined) return;
// Skip if state hasn't changed
if (s.hp === ocHp && s.gold === ocGold && s.level === ocLevel) return;
// Prevent concurrent syncs
if (syncToChain._busy) return;
syncToChain._busy = true;
try {
const kaspa = Wallet._kaspa;
const pk = new kaspa.PrivateKey(Wallet._privateKeyHex);
const pub = pk.toPublicKey().toXOnlyPublicKey().toString();
// Find the current covenant UTXO
const covAddr = Covenant.getCovenantAddress(kaspa, pub, ocHp, ocGold, ocLevel);
let covUtxo = covAddr ? await Covenant.findCovenantUtxo(covAddr) : null;
// Fallback: use cached outpoint if UTXO not indexed yet
if (!covUtxo && s._lastPlayerTxId) {
covUtxo = buildCachedUtxo(s, kaspa, pub, ocHp, ocGold, ocLevel);
}
if (!covUtxo) return;
// Spend current UTXO, create new one with updated state
const result = await Covenant.updatePlayerUtxo(
kaspa, pk, pub,
ocHp, ocGold, ocLevel, // current on-chain state
s.hp, s.gold, s.level, // new state from game
covUtxo
);
// Track the new on-chain state
s._onChainHp = s.hp;
s._onChainGold = s.gold;
s._onChainLevel = s.level;
s._lastPlayerTxId = result.transactionId;
s._lastPlayerAmount = result.playerOutputAmount;
GameState.save(s);
chainEmit('Player::update', action, result.transactionId);
} catch (err) {
console.log('Chain sync skipped:', err.message);
} finally {
syncToChain._busy = false;
}
}
No extensions. No MetaMask. The browser generates a random private key,
derives a TN12 address, and stores it in localStorage.
An on-node faucet (funded by our CPU miner's block rewards) sends 1 KAS
to deploy 3 covenants + fund the wallet — all in one transaction.
// Generate keypair
const bytes = crypto.getRandomValues(new Uint8Array(32));
const privateKeyHex = Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0')).join('');
// Derive address using @kasdk/web WASM
const pk = new kaspa.PrivateKey(privateKeyHex);
const address = pk.toAddress('testnet-12').toString();
// Fund + deploy covenants in one tx from on-node mining rewards
const result = await Covenant.createFromFaucet(kaspa, pk, pubkeyHex, hp, gold, level);
// Returns: { transactionId: "..." }
// One tx: coinbase UTXO → 3 covenants (0.2 KAS each) + wallet (0.2 KAS) + change
The game connects directly to a Kaspa TN12 node via WebSocket RPC (wRPC). No REST API, no middleware, no proxy. The same node that validates blocks serves the game's UTXO queries and transaction submissions.
const COVENANT_NODE_WS = 'wss://tn12.dagknight.xyz';
async ensureRpc(kaspa) {
if (this._rpc) return this._rpc;
const rpc = new kaspa.RpcClient({ url: COVENANT_NODE_WS });
await rpc.connect();
this._rpc = rpc;
return rpc;
}
// UTXO lookup
async findCovenantUtxo(address) {
const resp = await rpc.getUtxosByAddresses({ addresses: [address] });
const entries = resp.entries || resp || [];
return entries.length > 0 ? entries[0] : null;
}
// Transaction submission
await rpc.submitTransaction({ transaction: signedTx, allowOrphan: false });
Frontend: Static HTML/CSS/JS on GitHub Pages WASM SDK: @kasdk/web (tx building, signing, address derivation) Transport: WebSocket (wRPC) to TN12 node Node: rusty-kaspa (covpp-reset2 branch) on DigitalOcean Covenants: SilverScript → compiled bytecode → P2SH UTXOs State: UTXO chain — each spend creates next state Auth: Schnorr signatures (player actions only) ICC: Multi-input P2SH txs (shop, PvP) Faucet: On-node CPU miner block rewards Miner: Custom tn12-miner (same rusty-kaspa codebase) Registry: Node.js indexer on same droplet (player list API) Combat: Deterministic — seeded from BlockDAG tip hash (xorshift64) Backend: None (registry is optional, game works without it).