Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .clinerules
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

RPS Game is a decentralized rock-paper-scissors game on Polkadot — on-chain leaderboard and game history, with solo play and Statement Store multiplayer. (React + Vite + TypeScript). Full agent guidance is in `CLAUDE.md` at the repo root — read it before proposing changes.

- Host API (`@parity/product-sdk-signer` + `@novasamatech/host-api`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded.
- Host API (`@parity/product-sdk-signer` + `@parity/product-sdk-host`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded.
- No browser/extension fallbacks — out-of-scope on purpose. Always go through `@parity/product-sdk-*` / the Host API; never reach a chain via direct full-node RPC.
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

RPS Game is a decentralized rock-paper-scissors game on Polkadot — on-chain leaderboard and game history, with solo play and Statement Store multiplayer. (React + Vite + TypeScript). Full AI agent guidance is in `CLAUDE.md` at the repo root — read it before proposing changes.

- Host API (`@parity/product-sdk-signer` + `@novasamatech/host-api`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded.
- Host API (`@parity/product-sdk-signer` + `@parity/product-sdk-host`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded.
- No browser/extension fallbacks — out-of-scope on purpose. Always go through `@parity/product-sdk-*` / the Host API; never reach a chain via direct full-node RPC.
2 changes: 1 addition & 1 deletion .windsurfrules
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@

RPS Game is a decentralized rock-paper-scissors game on Polkadot — on-chain leaderboard and game history, with solo play and Statement Store multiplayer. (React + Vite + TypeScript). Full agent guidance is in `CLAUDE.md` at the repo root — read it before proposing changes.

- Host API (`@parity/product-sdk-signer` + `@novasamatech/host-api`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded.
- Host API (`@parity/product-sdk-signer` + `@parity/product-sdk-host`) is how the embedded dapp obtains accounts and requests signatures from the host (Polkadot Mobile, Desktop, or Web) over a postMessage transport; signing is approved on Polkadot Mobile (Desktop/Web relay to the paired phone); only works when embedded.
- No browser/extension fallbacks — out-of-scope on purpose. Always go through `@parity/product-sdk-*` / the Host API; never reach a chain via direct full-node RPC.
20 changes: 20 additions & 0 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,26 @@ cdm deploy -n paseo
(This repo also ships `npm run deploy`, which runs the same command with
the network endpoints pinned explicitly. Either works.)

> **Which network is which:** the CDM `paseo` preset targets **Paseo Next v2**
> (para 1500, a preview network), not the public Paseo testnet. To target
> **devnet** — the public products devnet on the Paseo testnet Asset Hub
> (para 1000, community-operated CDM registry) — use `cdm deploy -n devnet`
> (or `npm run deploy:devnet`; endpoints and registry come from the preset),
> and build the frontend with `VITE_NETWORK=devnet` so it connects to devnet
> and resolves the leaderboard from the devnet registry.
>
> Deploying to devnet, the whole flow uses `-n devnet` in place of `-n paseo`
> (`cdm account map -n devnet`, `cdm account bal -n devnet`, `cdm i -n devnet`),
> and the PAS faucet is the **para-1000** one:
> <https://faucet.polkadot.io/?parachain=1000>. The `-n devnet` preset needs
> **CDM v0.9.0+** (`cdm --version`); older CLIs report `unknown preset`.
>
> **Deploy the contract to devnet before shipping a `VITE_NETWORK=devnet`
> build.** The frontend resolves the leaderboard address from the devnet
> registry at startup and there is no `cdm.json` fallback, so a devnet build
> pointed at a network where `@rps/leaderboard` isn't registered fails at
> contract init with a resolution error.

*What's happening, in order:* (1) rebuilds if needed, (2) deploys the
bytecode to Paseo Asset Hub via pallet-revive, (3) publishes the contract
metadata to Bulletin, (4) registers `@<your-handle>/leaderboard → (address,
Expand Down
17 changes: 8 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,19 @@
"build": "tsc -b && vite build",
"build:contracts": "cdm build",
"deploy": "cdm deploy -n paseo --registry-address 0xf62c2ece29cd8df2e10040ecfa5a894a5c5d9cb0 --assethub-url wss://paseo-asset-hub-next-rpc.polkadot.io --bulletin-url wss://paseo-bulletin-next-rpc.polkadot.io",
"deploy:devnet": "cdm deploy -n devnet",
"preview": "vite preview"
},
"dependencies": {
"@noble/hashes": "^2.2.0",
"@novasamatech/host-api": "^0.8.10",
"@novasamatech/host-api-wrapper": "^0.8.10",
"@parity/product-sdk": "^0.15.1",
"@parity/product-sdk-address": "^0.1.1",
"@parity/product-sdk-chain-client": "^0.7.5",
"@parity/product-sdk-contracts": "^0.8.1",
"@parity/product-sdk-descriptors": "^0.6.2",
"@parity/product-sdk-signer": "^0.8.2",
"@parity/product-sdk-statement-store": "^0.4.9",
"@parity/product-sdk-tx": "^0.2.15",
"@parity/product-sdk-chain-client": "^0.9.1",
"@parity/product-sdk-contracts": "^0.9.2",
"@parity/product-sdk-descriptors": "^0.8.0",
"@parity/product-sdk-host": "^0.14.1",
"@parity/product-sdk-signer": "^0.11.1",
"@parity/product-sdk-statement-store": "^0.6.2",
"@parity/product-sdk-tx": "^0.3.2",
"multiformats": "^13.4.2",
"polkadot-api": "^2.1.6",
"react": "^19.0.0",
Expand Down
61 changes: 38 additions & 23 deletions src/pages/MultiplayerGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { Move, Round, GameData, PlayerData, RoundResult } from "../types.ts
import {
determineWinner, pointsForResult,
uploadToBulletin, ensureMapping, getContract, withTimeout,
IPFS_GATEWAY, short, asBytes20,
IPFS_GATEWAY, short, asBytes20, unwrapResult,
} from "../utils.ts";

const MOVE_EMOJI: Record<Move, string> = { rock: "✊", paper: "✋", scissors: "✂️" };
Expand Down Expand Up @@ -114,11 +114,16 @@ export default function MultiplayerGame({ account, roomCode, onDone }: {
phaseRef.current = "waiting-reveal";

if (myMoveRef.current && mySaltRef.current) {
clientRef.current.publish<GameMessage>(
{ type: "reveal", round: roundRef.current, move: myMoveRef.current,
salt: mySaltRef.current, peerId: myId, timestamp: Date.now() },
{ channel: `${roomCode}/reveal/${roundRef.current}/${myId}`, topic2: roomCode },
);
// Fire-and-forget send. publish() returns a Result since
// product-sdk 0.18 (no throw), so surface a failure rather
// than letting a dropped reveal stall the round silently.
void clientRef.current
.publish<GameMessage>(
{ type: "reveal", round: roundRef.current, move: myMoveRef.current,
salt: mySaltRef.current, peerId: myId, timestamp: Date.now() },
{ channel: `${roomCode}/reveal/${roundRef.current}/${myId}`, topic2: roomCode },
)
.then(r => { if (!r.ok) setStatusMsg("Failed to send reveal: " + r.error.message); });
}
}
}
Expand Down Expand Up @@ -161,10 +166,12 @@ export default function MultiplayerGame({ account, roomCode, onDone }: {
handleMessage(msg);
}, { topic2: roomCode });

await client.publish<GameMessage>(
// publish() returns a Result since product-sdk 0.18; unwrap so
// a failed join reaches the catch instead of vanishing.
unwrapResult(await client.publish<GameMessage>(
{ type: "join", peerId: myId, timestamp: Date.now() },
{ channel: `${roomCode}/presence/${myId}`, topic2: roomCode },
);
));

if (!destroyed) {
setPhase("pick");
Expand Down Expand Up @@ -193,21 +200,29 @@ export default function MultiplayerGame({ account, roomCode, onDone }: {
mySaltRef.current = salt;
setPickedMove(move);

await clientRef.current.publish<GameMessage>(
{ type: "commit", round: roundRef.current, hash, peerId: myId, timestamp: Date.now() },
{ channel: `${roomCode}/commit/${roundRef.current}/${myId}`, topic2: roomCode },
);

if (opponentCommitRef.current) {
setPhase("waiting-reveal");
phaseRef.current = "waiting-reveal";
await clientRef.current.publish<GameMessage>(
{ type: "reveal", round: roundRef.current, move, salt, peerId: myId, timestamp: Date.now() },
{ channel: `${roomCode}/reveal/${roundRef.current}/${myId}`, topic2: roomCode },
);
} else {
setPhase("waiting-commit");
phaseRef.current = "waiting-commit";
// publish() returns a Result since product-sdk 0.18 (no throw). Unwrap
// so a dropped commit/reveal surfaces to the player instead of leaving
// the round stuck with nothing logged.
try {
unwrapResult(await clientRef.current.publish<GameMessage>(
{ type: "commit", round: roundRef.current, hash, peerId: myId, timestamp: Date.now() },
{ channel: `${roomCode}/commit/${roundRef.current}/${myId}`, topic2: roomCode },
));

if (opponentCommitRef.current) {
setPhase("waiting-reveal");
phaseRef.current = "waiting-reveal";
unwrapResult(await clientRef.current.publish<GameMessage>(
{ type: "reveal", round: roundRef.current, move, salt, peerId: myId, timestamp: Date.now() },
{ channel: `${roomCode}/reveal/${roundRef.current}/${myId}`, topic2: roomCode },
));
} else {
setPhase("waiting-commit");
phaseRef.current = "waiting-commit";
}
} catch (err) {
console.error("[MPGame] publish failed:", err);
setStatusMsg("Failed to send move: " + (err instanceof Error ? err.message : String(err)));
}
};

Expand Down
15 changes: 10 additions & 5 deletions src/pages/MultiplayerLobby.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import type { AppAccount } from "../utils.ts";
import { StatementStoreClient } from "@parity/product-sdk-statement-store";
import { generateRoomCode } from "../utils.ts";
import { generateRoomCode, unwrapResult } from "../utils.ts";

interface JoinMessage {
type: "join";
Expand Down Expand Up @@ -56,10 +56,12 @@ export default function MultiplayerLobby({ account, onGameStart }: {
}
}, { topic2: code });

await client.publish<JoinMessage>(
// publish() returns a Result since product-sdk 0.18; unwrap so a
// failed publish reaches the catch below instead of vanishing.
unwrapResult(await client.publish<JoinMessage>(
{ type: "join", peerId: account.h160Address, timestamp: Date.now() },
{ channel: `${code}/presence/${account.h160Address}`, topic2: code },
);
));

setStatusMsg("Waiting for opponent...");
} catch (err) {
Expand Down Expand Up @@ -89,10 +91,13 @@ export default function MultiplayerLobby({ account, onGameStart }: {
accountId: account.productAccountId,
});

await client.publish<JoinMessage>(
// publish() returns a Result since product-sdk 0.18; unwrap so a
// failed join reaches the catch instead of navigating into a game
// the host never learned about.
unwrapResult(await client.publish<JoinMessage>(
{ type: "join", peerId: account.h160Address, timestamp: Date.now() },
{ channel: `${code}/presence/${account.h160Address}`, topic2: code },
);
));

setStatusMsg("Joined! Starting game...");
setTimeout(() => {
Expand Down
Loading