From 655148fa7aadae0a46368b1ee79e6601f8a611ed Mon Sep 17 00:00:00 2001 From: nearnshaw Date: Thu, 26 Mar 2026 13:51:42 -0300 Subject: [PATCH 01/14] original Pravus draft --- ai-sdk-context/README.md | 2 - ai-sdk-context/dcl-crypto/SKILL.md | 707 +++++++++++++++ .../references/blockchain-basics.md | 361 ++++++++ .../dcl-crypto/references/crypto-toolkit.md | 600 +++++++++++++ ai-sdk-context/dcl-game-design/SKILL.md | 237 +++++ .../references/design-patterns.md | 151 ++++ .../references/scene-limitations.md | 391 ++++++++ ai-sdk-context/dcl-multiplayer/SKILL.md | 558 ++++++++++++ .../references/networking-sync.md | 570 ++++++++++++ ai-sdk-context/dcl-npc/SKILL.md | 519 +++++++++++ .../dcl-npc/references/npc-toolkit.md | 712 +++++++++++++++ ai-sdk-context/dcl-scene/SKILL.md | 436 +++++++++ .../dcl-scene/references/3d-essentials.md | 257 ++++++ .../dcl-scene/references/camera-system.md | 139 +++ .../dcl-scene/references/ecs-architecture.md | 204 +++++ .../dcl-scene/references/interactivity.md | 314 +++++++ .../dcl-scene/references/media-audio-video.md | 202 +++++ .../references/movement-animation.md | 283 ++++++ .../references/patterns-and-systems.md | 309 +++++++ .../references/player-avatar-runtime.md | 336 +++++++ .../setup-debugging-optimization.md | 259 ++++++ .../dcl-scene/references/utils-library.md | 347 +++++++ ai-sdk-context/dcl-ui/SKILL.md | 265 ++++++ .../dcl-ui/references/react-ecs-ui.md | 696 ++++++++++++++ .../dcl-ui/references/ui-toolkit.md | 848 ++++++++++++++++++ ai-sdk-context/dcl-wearables/SKILL.md | 318 +++++++ .../references/wearables-portables.md | 415 +++++++++ .../{ => overview}/sdk7-complete-reference.md | 0 .../{ => overview}/sdk7-examples.mdc | 0 .../{ => overview}/sdk7-scripts.mdc | 0 .../sdk7-updates-since-2026-01-21.mdc | 0 31 files changed, 10434 insertions(+), 2 deletions(-) delete mode 100644 ai-sdk-context/README.md create mode 100644 ai-sdk-context/dcl-crypto/SKILL.md create mode 100644 ai-sdk-context/dcl-crypto/references/blockchain-basics.md create mode 100644 ai-sdk-context/dcl-crypto/references/crypto-toolkit.md create mode 100644 ai-sdk-context/dcl-game-design/SKILL.md create mode 100644 ai-sdk-context/dcl-game-design/references/design-patterns.md create mode 100644 ai-sdk-context/dcl-game-design/references/scene-limitations.md create mode 100644 ai-sdk-context/dcl-multiplayer/SKILL.md create mode 100644 ai-sdk-context/dcl-multiplayer/references/networking-sync.md create mode 100644 ai-sdk-context/dcl-npc/SKILL.md create mode 100644 ai-sdk-context/dcl-npc/references/npc-toolkit.md create mode 100644 ai-sdk-context/dcl-scene/SKILL.md create mode 100644 ai-sdk-context/dcl-scene/references/3d-essentials.md create mode 100644 ai-sdk-context/dcl-scene/references/camera-system.md create mode 100644 ai-sdk-context/dcl-scene/references/ecs-architecture.md create mode 100644 ai-sdk-context/dcl-scene/references/interactivity.md create mode 100644 ai-sdk-context/dcl-scene/references/media-audio-video.md create mode 100644 ai-sdk-context/dcl-scene/references/movement-animation.md create mode 100644 ai-sdk-context/dcl-scene/references/patterns-and-systems.md create mode 100644 ai-sdk-context/dcl-scene/references/player-avatar-runtime.md create mode 100644 ai-sdk-context/dcl-scene/references/setup-debugging-optimization.md create mode 100644 ai-sdk-context/dcl-scene/references/utils-library.md create mode 100644 ai-sdk-context/dcl-ui/SKILL.md create mode 100644 ai-sdk-context/dcl-ui/references/react-ecs-ui.md create mode 100644 ai-sdk-context/dcl-ui/references/ui-toolkit.md create mode 100644 ai-sdk-context/dcl-wearables/SKILL.md create mode 100644 ai-sdk-context/dcl-wearables/references/wearables-portables.md rename ai-sdk-context/{ => overview}/sdk7-complete-reference.md (100%) rename ai-sdk-context/{ => overview}/sdk7-examples.mdc (100%) rename ai-sdk-context/{ => overview}/sdk7-scripts.mdc (100%) rename ai-sdk-context/{ => overview}/sdk7-updates-since-2026-01-21.mdc (100%) diff --git a/ai-sdk-context/README.md b/ai-sdk-context/README.md deleted file mode 100644 index 3c9bcb9..0000000 --- a/ai-sdk-context/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# ai-sdk-context - diff --git a/ai-sdk-context/dcl-crypto/SKILL.md b/ai-sdk-context/dcl-crypto/SKILL.md new file mode 100644 index 0000000..977cea8 --- /dev/null +++ b/ai-sdk-context/dcl-crypto/SKILL.md @@ -0,0 +1,707 @@ +--- +name: dcl-crypto +description: "Assists with Decentraland SDK7 blockchain integration when the user mentions MANA, ERC20, ERC721, NFTs, blockchain, crypto, dcl-crypto-toolkit, wallets, transactions, smart contracts, marketplace, token gating, or message signing." +--- + +# Decentraland SDK7 Blockchain Integration + +This skill covers all blockchain-related functionality in Decentraland SDK7 scenes, including the `dcl-crypto-toolkit` library, raw Ethereum provider access, signed fetch, NFT display, and common patterns like token gating and tip jars. + +## 1. Safety-First Guidance + +All blockchain operations are asynchronous and interact with real wallets and real tokens. Follow these rules strictly: + +- **Always wrap blockchain calls in `executeTask()`** — blockchain operations cannot run synchronously in the scene lifecycle. +- **Always use try/catch** — network failures, user rejections, and insufficient funds are all common. +- **Always check if the user is a guest** before any wallet operation. Guests have no wallet and all crypto calls will fail. +- **Never hardcode private keys or secrets** in scene code. Scenes run client-side and all code is visible. +- **Validate all addresses** with a regex check (`/^0x[a-fA-F0-9]{40}$/`) before passing them to contract calls. +- **Validate amounts** — ensure they are positive, finite numbers before sending transactions. +- **Handle user rejection gracefully** — the player can decline any transaction prompt in their wallet. + +```typescript +import { executeTask } from '@dcl/sdk/ecs' +import { getPlayer } from '@dcl/sdk/src/players' + +// Safety pattern: always check guest status first +executeTask(async () => { + try { + const player = getPlayer() + if (!player || player.isGuest) { + console.log('Player has no wallet — cannot perform blockchain operations') + return + } + // Safe to proceed with blockchain calls + } catch (error) { + console.error('Blockchain operation failed:', error) + } +}) +``` + +## 2. Setup + +### Install the Crypto Toolkit + +```bash +npm install dcl-crypto-toolkit +``` + +For raw contract interaction (without the toolkit), you also need: + +```bash +npm install eth-connect +``` + +### Imports + +```typescript +// Crypto toolkit (high-level API) +import * as crypto from 'dcl-crypto-toolkit' + +// Required for async blockchain calls +import { executeTask } from '@dcl/sdk/ecs' + +// For raw contract interaction +import { RequestManager, ContractFactory } from 'eth-connect' +import { createEthereumProvider } from '@dcl/sdk/ethereum-provider' + +// For signed HTTP requests +import { signedFetch } from '@dcl/sdk/network' + +// For player/wallet info +import { getPlayer } from '@dcl/sdk/src/players' + +// For NFT display +import { NftShape } from '@dcl/sdk/ecs' +``` + +### executeTask Wrapper + +Every blockchain operation must be wrapped in `executeTask`: + +```typescript +executeTask(async () => { + // All blockchain operations go here +}) +``` + +## 3. Player Identity + +### Get Wallet Address + +```typescript +executeTask(async () => { + const player = getPlayer() + if (player && !player.isGuest) { + console.log('Wallet address:', player.userId) + } else { + console.log('Player is a guest (no wallet)') + } +}) +``` + +### Check If Guest + +```typescript +function playerHasWallet(): boolean { + const player = getPlayer() + return player !== undefined && !player.isGuest +} +``` + +## 4. MANA Operations + +MANA is Decentraland's native ERC20 token. The crypto toolkit provides dedicated MANA functions. + +### Send MANA + +```typescript +executeTask(async () => { + await crypto.mana.send( + '0xRecipientAddress', // toAddress + 10, // amount in MANA + true // waitConfirm (optional, default: false) + ) +}) +``` + +### Check MANA Balance + +```typescript +executeTask(async () => { + // Current player's balance + const myBalance = await crypto.mana.getBalance() + + // Specific address balance + const otherBalance = await crypto.mana.getBalance('0xSomeAddress') +}) +``` + +### Approve MANA Spending + +```typescript +executeTask(async () => { + await crypto.currency.setApproval( + crypto.contract.mainnet.MANAToken, // MANA contract + '0xSpenderContract', // who can spend + true, // waitConfirm + '1000000000000000000000' // amount in wei (optional, defaults to max) + ) +}) +``` + +## 5. ERC20 Operations (Generic Tokens) + +### Send Any ERC20 Token + +```typescript +executeTask(async () => { + await crypto.currency.send( + '0xTokenContractAddress', // contractAddress + '0xRecipientAddress', // toAddress + 1000000000000000000, // amount in wei + true // waitConfirm + ) +}) +``` + +### Check Token Balance + +```typescript +executeTask(async () => { + const balance = await crypto.currency.getBalance( + '0xTokenContractAddress', // contractAddress + '0xOptionalAddress' // defaults to current player + ) +}) +``` + +### Check Allowance + +```typescript +executeTask(async () => { + const allowance = await crypto.currency.allowance( + '0xTokenContractAddress', // contractAddress + '0xOwnerAddress', // owner + '0xSpenderAddress' // spender + ) +}) +``` + +### Set Approval + +```typescript +executeTask(async () => { + await crypto.currency.setApproval( + '0xTokenContractAddress', // contractAddress + '0xSpenderAddress', // spender + true, // waitConfirm + '1000000000000000000000' // amount in wei (optional) + ) +}) +``` + +### Check Approval Status + +```typescript +executeTask(async () => { + const isApproved = await crypto.currency.isApproved( + '0xTokenContractAddress', + '0xOwnerAddress', + '0xSpenderAddress' + ) +}) +``` + +## 6. ERC721/NFT Operations + +### Check NFT Ownership + +```typescript +executeTask(async () => { + const balance = await crypto.nft.getBalance( + '0xNFTContractAddress', // contractAddress + 123, // tokenId + '0xOptionalAddress' // defaults to current player + ) + const ownsNFT = balance > 0 +}) +``` + +### Transfer NFT + +```typescript +executeTask(async () => { + await crypto.nft.transfer( + '0xNFTContractAddress', // contractAddress + '0xRecipientAddress', // toAddress + 123, // tokenId + true // waitConfirm + ) +}) +``` + +### NFT Approval for All + +```typescript +executeTask(async () => { + // Check + const approved = await crypto.nft.isApprovedForAll( + '0xNFTContractAddress', + '0xAssetHolder', + '0xOperator' + ) + + // Grant + await crypto.nft.setApprovalForAll( + '0xNFTContractAddress', + '0xOperator', + true, // approved + true // waitConfirm + ) +}) +``` + +### Display NFT in Scene with NftShape + +```typescript +import { engine, Entity, NftShape, NftFrameType, Transform } from '@dcl/sdk/ecs' +import { Vector3, Color4 } from '@dcl/sdk/math' + +const nftFrame = engine.addEntity() + +Transform.create(nftFrame, { + position: Vector3.create(8, 2, 8) +}) + +NftShape.create(nftFrame, { + urn: 'urn:decentraland:ethereum:erc721:0x06012c8cf97bead5deae237070f9587f8e7a266d:558536', + color: Color4.White(), + style: NftFrameType.NFT_CLASSIC +}) + +// Available frame styles: +// NFT_CLASSIC, NFT_BAROQUE_ORNAMENT, NFT_DIAMOND_ORNAMENT, +// NFT_MINIMAL_WIDE, NFT_MINIMAL_GREY, NFT_BLOCKY, +// NFT_GOLD_EDGES, NFT_GOLD_CARVED, NFT_GOLD_WIDE, NFT_GOLD_ROUNDED, +// NFT_METAL_MEDIUM, NFT_METAL_WIDE, NFT_METAL_SLIM, NFT_METAL_ROUNDED, +// NFT_PINS, NFT_MINIMAL_BLACK, NFT_MINIMAL_WHITE, +// NFT_TAPE, NFT_WOOD_SLIM, NFT_WOOD_WIDE, NFT_WOOD_TWIGS, +// NFT_CANVAS, NFT_NONE +``` + +## 7. Smart Contract Interaction (Raw) + +For contracts not covered by the crypto toolkit, use `eth-connect` directly. + +### Provider Setup + +```typescript +import { RequestManager, ContractFactory } from 'eth-connect' +import { createEthereumProvider } from '@dcl/sdk/ethereum-provider' + +executeTask(async () => { + const provider = createEthereumProvider() + const requestManager = new RequestManager(provider) + + // Check gas price + const gasPrice = await requestManager.eth_gasPrice() + + // Check ETH balance + const balance = await requestManager.eth_getBalance('0xAddress', 'latest') +}) +``` + +### Create Contract Instance + +```typescript +import { RequestManager, ContractFactory } from 'eth-connect' +import { createEthereumProvider } from '@dcl/sdk/ethereum-provider' +import { abi } from '../contracts/myContract' + +executeTask(async () => { + const provider = createEthereumProvider() + const requestManager = new RequestManager(provider) + const factory = new ContractFactory(requestManager, abi) + const contract = (await factory.at('0xContractAddress')) as any +}) +``` + +### ABI Format + +Store ABIs in separate files (e.g., `src/contracts/mana.ts`): + +```typescript +export const abi = [ + { + constant: true, + inputs: [{ name: '_owner', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: '', type: 'uint256' }], + type: 'function' + } + // ... more functions/events +] +``` + +### Read vs Write Methods + +```typescript +executeTask(async () => { + const player = getPlayer() + if (!player || player.isGuest) return + + // Read operation (free, no gas) + const balance = await contract.balanceOf(player.userId) + + // Write operation (costs gas, prompts wallet) + const txHash = await contract.transfer( + '0xRecipient', + 100, + { + from: player.userId, + gas: 100000, + gasPrice: await requestManager.eth_gasPrice() + } + ) +}) +``` + +### Send Custom RPC Messages + +```typescript +import { sendAsync } from '~system/EthereumController' + +await sendAsync({ + id: 1, + method: 'myMethod', + jsonParams: '{ myParam: myValue }', +}) +``` + +## 8. Marketplace Integration + +### Buy from Marketplace + +```typescript +executeTask(async () => { + await crypto.marketplace.buyOrder( + '0xNFTAddress', + 123, // assetId + '1000000000000000000' // price in wei + ) +}) +``` + +### Sell on Marketplace + +```typescript +executeTask(async () => { + // Ensure marketplace is authorized to handle NFTs + const isAuthorized = await crypto.marketplace.isAuthorized() + if (!isAuthorized) { + await crypto.nft.setApprovalForAll( + '0xNFTContractAddress', + crypto.contract.mainnet.Marketplace, + true, + true + ) + } + + const expiresAt = Date.now() + (7 * 24 * 60 * 60 * 1000) // 7 days + await crypto.marketplace.sellOrder( + '0xNFTAddress', + 123, + '1000000000000000000', // price in wei + expiresAt.toString() + ) +}) +``` + +### Cancel Marketplace Order + +```typescript +executeTask(async () => { + await crypto.marketplace.cancelOrder('0xNFTAddress', 123) +}) +``` + +### Check Marketplace Authorization + +```typescript +executeTask(async () => { + const isAuthorized = await crypto.marketplace.isAuthorized() +}) +``` + +### Open Marketplace in Browser + +Use `openExternalUrl` to send the player to the marketplace: + +```typescript +import { openExternalUrl } from '~system/RestrictedActions' + +openExternalUrl({ url: 'https://market.decentraland.org' }) +``` + +## 9. Message Signing + +### Sign a Message + +```typescript +executeTask(async () => { + const signature = await crypto.signMessage('Hello Decentraland!') + console.log('Signature:', signature) +}) +``` + +### signedFetch for Authenticated API Calls + +`signedFetch` automatically includes the player's identity and a cryptographic signature in the request headers, allowing your backend to verify the request came from the actual player. + +```typescript +import { signedFetch } from '@dcl/sdk/network' + +executeTask(async () => { + try { + // GET request + const getResponse = await signedFetch('https://api.example.com/data') + const getData = await getResponse.json() + + // POST request + const postResponse = await signedFetch('https://api.example.com/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'claimReward', amount: 100 }) + }) + const postData = await postResponse.json() + } catch (error) { + console.error('signedFetch failed:', error) + } +}) +``` + +## 10. Token Gating Pattern + +Check NFT/token ownership to grant or deny access to areas or features. + +```typescript +import * as crypto from 'dcl-crypto-toolkit' +import { executeTask } from '@dcl/sdk/ecs' +import { getPlayer } from '@dcl/sdk/src/players' + +const REQUIRED_NFT_CONTRACT = '0xYourNFTContract' +const REQUIRED_TOKEN_ID = 1 + +executeTask(async () => { + try { + const player = getPlayer() + if (!player || player.isGuest) { + console.log('Connect a wallet to access this area') + return + } + + const balance = await crypto.nft.getBalance( + REQUIRED_NFT_CONTRACT, + REQUIRED_TOKEN_ID + ) + + if (balance > 0) { + // Player owns the NFT — grant access + openGatedDoor() + } else { + // Player does not own the NFT — deny access + showAccessDeniedMessage() + } + } catch (error) { + console.error('Token gate check failed:', error) + } +}) +``` + +### Token Gate by ERC20 Balance + +```typescript +executeTask(async () => { + const player = getPlayer() + if (!player || player.isGuest) return + + const manaBalance = await crypto.mana.getBalance() + if (manaBalance >= 100) { + // Player has at least 100 MANA — grant VIP access + grantVIPAccess() + } +}) +``` + +## 11. Decision Tree + +| I want to... | Use this | +|---|---| +| Send MANA to another player | `crypto.mana.send()` | +| Check MANA balance | `crypto.mana.getBalance()` | +| Send any ERC20 token | `crypto.currency.send()` | +| Check ERC20 balance | `crypto.currency.getBalance()` | +| Transfer an NFT | `crypto.nft.transfer()` | +| Check if player owns an NFT | `crypto.nft.getBalance()` | +| Display an NFT in scene | `NftShape.create()` | +| Buy from marketplace | `crypto.marketplace.buyOrder()` | +| List NFT for sale | `crypto.marketplace.sellOrder()` | +| Sign a message | `crypto.signMessage()` | +| Make authenticated API call | `signedFetch()` | +| Call a custom smart contract (read) | `contract.methodName()` via `eth-connect` | +| Call a custom smart contract (write) | `contract.methodName({ from, gas, gasPrice })` via `eth-connect` | +| Check gas price | `requestManager.eth_gasPrice()` | +| Get player's wallet address | `getPlayer().userId` | +| Check if player is guest | `getPlayer().isGuest` | +| Open marketplace in browser | `openExternalUrl()` | +| Get wearable data | `crypto.wearable.getListOfWearables()` | + +## 12. Common Recipes + +### Tip Jar + +```typescript +import * as crypto from 'dcl-crypto-toolkit' +import { executeTask } from '@dcl/sdk/ecs' +import { getPlayer } from '@dcl/sdk/src/players' + +const CREATOR_WALLET = '0xYourWalletAddress' +const TIP_AMOUNTS = [1, 5, 10] // MANA + +function sendTip(amount: number) { + executeTask(async () => { + try { + const player = getPlayer() + if (!player || player.isGuest) { + console.log('Connect wallet to send tips') + return + } + + const balance = await crypto.mana.getBalance() + if (balance < amount) { + console.log('Insufficient MANA balance') + return + } + + await crypto.mana.send(CREATOR_WALLET, amount, true) + console.log(`Tip of ${amount} MANA sent!`) + } catch (error) { + console.error('Tip failed:', error) + } + }) +} +``` + +### NFT Gallery + +```typescript +import { engine, NftShape, NftFrameType, Transform } from '@dcl/sdk/ecs' +import { Vector3, Color4 } from '@dcl/sdk/math' + +interface NFTDisplay { + urn: string + position: { x: number; y: number; z: number } + style?: number +} + +function createNFTGallery(nfts: NFTDisplay[]) { + for (const nft of nfts) { + const entity = engine.addEntity() + Transform.create(entity, { + position: Vector3.create(nft.position.x, nft.position.y, nft.position.z) + }) + NftShape.create(entity, { + urn: nft.urn, + color: Color4.White(), + style: nft.style ?? NftFrameType.NFT_CLASSIC + }) + } +} + +// Usage +createNFTGallery([ + { + urn: 'urn:decentraland:ethereum:erc721:0x06012c8cf97bead5deae237070f9587f8e7a266d:558536', + position: { x: 4, y: 2, z: 1 }, + style: NftFrameType.NFT_GOLD_EDGES + }, + { + urn: 'urn:decentraland:ethereum:erc721:0x06012c8cf97bead5deae237070f9587f8e7a266d:558537', + position: { x: 8, y: 2, z: 1 }, + style: NftFrameType.NFT_BAROQUE_ORNAMENT + } +]) +``` + +### Token-Gated Door + +```typescript +import * as crypto from 'dcl-crypto-toolkit' +import { engine, Transform, GltfContainer, Entity } from '@dcl/sdk/ecs' +import { Vector3, Quaternion } from '@dcl/sdk/math' +import { executeTask } from '@dcl/sdk/ecs' +import { getPlayer } from '@dcl/sdk/src/players' + +const REQUIRED_NFT_CONTRACT = '0xYourNFTContract' +const REQUIRED_TOKEN_ID = 1 + +let doorEntity: Entity +let doorOpen = false + +function setupDoor() { + doorEntity = engine.addEntity() + Transform.create(doorEntity, { + position: Vector3.create(8, 1, 8), + rotation: Quaternion.fromEulerDegrees(0, 0, 0) + }) + GltfContainer.create(doorEntity, { src: 'models/door.glb' }) +} + +function checkAccess() { + executeTask(async () => { + try { + const player = getPlayer() + if (!player || player.isGuest) { + console.log('Connect wallet to enter') + return + } + + const balance = await crypto.nft.getBalance( + REQUIRED_NFT_CONTRACT, + REQUIRED_TOKEN_ID + ) + + if (balance > 0) { + // Open the door + const transform = Transform.getMutable(doorEntity) + transform.rotation = Quaternion.fromEulerDegrees(0, 90, 0) + doorOpen = true + } else { + console.log('You need the required NFT to enter') + } + } catch (error) { + console.error('Access check failed:', error) + } + }) +} +``` + +### Marketplace Link Button + +```typescript +import { openExternalUrl } from '~system/RestrictedActions' + +function openMarketplaceForItem(contractAddress: string, tokenId: string) { + openExternalUrl({ + url: `https://market.decentraland.org/contracts/${contractAddress}/tokens/${tokenId}` + }) +} +``` + +## 13. Reference Files + +For detailed API documentation and code examples, see the reference files in this skill: + +- `references/crypto-toolkit.md` — Full dcl-crypto-toolkit API reference including MANA, currency, NFT, marketplace, wearable, contract, and message signing operations with complete code examples. +- `references/blockchain-basics.md` — Core blockchain patterns for SDK7: executeTask usage, eth-connect provider/contract setup, signedFetch, getUserData for wallet info, and raw smart contract interaction examples. diff --git a/ai-sdk-context/dcl-crypto/references/blockchain-basics.md b/ai-sdk-context/dcl-crypto/references/blockchain-basics.md new file mode 100644 index 0000000..2ec64b8 --- /dev/null +++ b/ai-sdk-context/dcl-crypto/references/blockchain-basics.md @@ -0,0 +1,361 @@ +# Blockchain Basics for Decentraland SDK7 + +## executeTask Usage and Patterns + +All blockchain operations are asynchronous and must be wrapped in `executeTask`. This function is provided by the SDK runtime and ensures async operations run correctly within the scene lifecycle. + +```typescript +import { executeTask } from '@dcl/sdk/ecs' + +// Basic pattern +executeTask(async () => { + // All async blockchain operations go here +}) + +// With error handling (recommended) +executeTask(async () => { + try { + // Blockchain operations + } catch (error) { + console.error('Operation failed:', error) + } +}) + +// Named function variant +executeTask(async function checkBlockchain() { + // Operations here +}) +``` + +### Why executeTask Is Required + +Decentraland scenes run in a synchronous game loop. `executeTask` creates an async context that the runtime manages, ensuring blockchain calls (which involve network requests and wallet prompts) do not block the scene. + +## Player Identity and Wallet Info + +### getUserData / getPlayer + +```typescript +import { getPlayer } from '@dcl/sdk/src/players' + +function checkWallet() { + const player = getPlayer() + if (player && !player.isGuest) { + console.log('Player wallet address:', player.userId) + } else { + console.log('Player is guest (no wallet)') + } +} +``` + +Key fields: +- `player.userId` — the player's Ethereum wallet address (when connected) +- `player.isGuest` — `true` if the player has no wallet connected + +Always check `isGuest` before any blockchain operation. Guest players cannot sign transactions, and all crypto calls will fail for them. + +## Ethereum Provider and Contract Interaction + +### Creating a Provider + +The Ethereum provider bridges the scene to the player's wallet (MetaMask or equivalent). + +```typescript +import { RequestManager, ContractFactory } from 'eth-connect' +import { createEthereumProvider } from '@dcl/sdk/ethereum-provider' + +executeTask(async () => { + // Create the web3 provider interface + const provider = createEthereumProvider() + + // Create request manager for sending/receiving RPC messages + const requestManager = new RequestManager(provider) +}) +``` + +### Checking Gas Price + +```typescript +import { RequestManager } from 'eth-connect' +import { createEthereumProvider } from '@dcl/sdk/ethereum-provider' + +executeTask(async function () { + const provider = createEthereumProvider() + const requestManager = new RequestManager(provider) + + // Check current gas price on the Ethereum network + const gasPrice = await requestManager.eth_gasPrice() + console.log({ gasPrice }) +}) +``` + +### Checking ETH Balance + +```typescript +executeTask(async () => { + const provider = createEthereumProvider() + const requestManager = new RequestManager(provider) + + const balance = await requestManager.eth_getBalance('0x123...abc', 'latest') + console.log('Account balance:', balance) +}) +``` + +## Smart Contract Interaction + +### ABI Format + +Store contract ABIs in separate files (e.g., `src/contracts/mana.ts`): + +```typescript +// Example of one function in the MANA ABI +export const abi = [ + { + anonymous: false, + inputs: [ + { + indexed: true, + name: 'burner', + type: 'address' + }, + { + indexed: false, + name: 'value', + type: 'uint256' + } + ], + name: 'Burn', + type: 'event' + } + // ... rest of ABI +] +``` + +### Instancing a Contract + +```typescript +import { RequestManager, ContractFactory } from 'eth-connect' +import { createEthereumProvider } from '@dcl/sdk/ethereum-provider' +import { abi } from '../contracts/mana' + +executeTask(async () => { + const provider = createEthereumProvider() + const requestManager = new RequestManager(provider) + + // Create a factory based on the ABI + const factory = new ContractFactory(requestManager, abi) + + // Instance the contract at a specific address + const contract = (await factory.at( + '0x2a8fd99c19271f4f04b1b7b9c4f7cf264b626edb' + )) as any +}) +``` + +### Calling Contract Methods + +```typescript +import { getPlayer } from '@dcl/sdk/src/players' +import { createEthereumProvider } from '@dcl/sdk/ethereum-provider' +import { RequestManager, ContractFactory } from 'eth-connect' +import { abi } from '../contracts/mana' + +executeTask(async () => { + try { + const provider = createEthereumProvider() + const requestManager = new RequestManager(provider) + const factory = new ContractFactory(requestManager, abi) + const contract = (await factory.at( + '0x2a8fd99c19271f4f04b1b7b9c4f7cf264b626edb' + )) as any + + let userData = getPlayer() + if (userData.isGuest) { + return + } + + // Write operation (costs gas, prompts wallet) + const res = await contract.setBalance( + '0xaFA48Fad27C7cAB28dC6E970E4BFda7F7c8D60Fb', + 100, + { + from: userData.userId, + } + ) + console.log(res) + } catch (error: any) { + console.log(error.toString()) + } +}) +``` + +### Read vs Write Operations + +```typescript +executeTask(async () => { + try { + const userData = getPlayer() + if (userData.isGuest) return + + // Read operation — free, no gas, no wallet prompt + const balance = await contract.balanceOf(userData.userId) + console.log('Current balance:', balance) + + // Write operation — costs gas, prompts wallet confirmation + const writeResult = await contract.transfer( + '0xRecipientAddress', + 100, + { + from: userData.userId, + gas: 100000, + gasPrice: await requestManager.eth_gasPrice() + } + ) + console.log('Transaction hash:', writeResult) + } catch (error) { + console.log('Transaction failed:', error) + } +}) +``` + +### Send Custom RPC Messages + +```typescript +import { sendAsync } from '~system/EthereumController' + +await sendAsync({ + id: 1, + method: 'myMethod', + jsonParams: '{ myParam: myValue }', +}) +``` + +## signedFetch + +`signedFetch` makes HTTP requests that include the player's identity and a cryptographic signature in the headers. This allows your backend server to verify the request genuinely came from the player who claims to be making it. + +### Basic GET Request + +```typescript +import { executeTask } from '@dcl/sdk/ecs' +import { signedFetch } from '@dcl/sdk/network' + +executeTask(async () => { + try { + const response = await signedFetch('https://api.example.com/data') + const json = await response.json() + console.log('Response:', json) + } catch (error) { + console.error('Failed to fetch:', error) + } +}) +``` + +### POST Request with Body + +```typescript +executeTask(async () => { + try { + const response = await signedFetch('https://api.example.com/data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + key: 'value' + }) + }) + const json = await response.json() + console.log('Response:', json) + } catch (error) { + console.error('Failed to fetch:', error) + } +}) +``` + +### Authenticated Action (Claim Reward Example) + +```typescript +import { signedFetch } from '@dcl/sdk/signed-fetch' + +executeTask(async () => { + try { + const response = await signedFetch('https://example.com/api/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'claimReward', + amount: 100 + }) + }) + + const result = await response.json() + console.log('Transaction result:', result) + } catch (error) { + console.log('Transaction failed:', error) + } +}) +``` + +## Using Test Networks + +```typescript +// For Sepolia testnet testing +// Set Metamask to Sepolia network +// Use test URLs for preview: +// decentraland://realm=http://127.0.0.1:8000&local-scene=true&debug=true&dclenv=zone&position=0,0 + +// Contract addresses differ between networks +const CONTRACT_ADDRESSES = { + mainnet: '0x0f5d2fb29fb7d3cfee444a200298f468908cc942', + sepolia: '0x...' // Test contract address +} + +const currentNetwork = 'sepolia' // or determine dynamically +const contractAddress = CONTRACT_ADDRESSES[currentNetwork] +``` + +## NFT Display with NftShape + +Display certified NFTs as framed pictures in your scene: + +```typescript +import { NftShape, NftFrameType } from '@dcl/sdk/ecs' + +NftShape.create(entity, { + urn: 'urn:decentraland:ethereum:erc721:0x06012c8cf97bead5deae237070f9587f8e7a266d:558536', + color: Color4.White(), + style: NftFrameType.NFT_CLASSIC +}) + +// Available frame styles: +// NFT_CLASSIC, NFT_BAROQUE_ORNAMENT, NFT_DIAMOND_ORNAMENT, +// NFT_MINIMAL_WIDE, NFT_MINIMAL_GREY, NFT_BLOCKY, +// NFT_GOLD_EDGES, NFT_GOLD_CARVED, NFT_GOLD_WIDE, NFT_GOLD_ROUNDED, +// NFT_METAL_MEDIUM, NFT_METAL_WIDE, NFT_METAL_SLIM, NFT_METAL_ROUNDED, +// NFT_PINS, NFT_MINIMAL_BLACK, NFT_MINIMAL_WHITE, +// NFT_TAPE, NFT_WOOD_SLIM, NFT_WOOD_WIDE, NFT_WOOD_TWIGS, +// NFT_CANVAS, NFT_NONE +``` + +## MANA Transactions (SDK Native) + +The SDK also provides a native MANA interface without the crypto toolkit: + +```typescript +import { manaUser } from '@dcl/sdk/ethereum' + +executeTask(async () => { + try { + // Check MANA balance + const balance = await manaUser.balance() + console.log('MANA balance:', balance) + + // Send MANA + const result = await manaUser.send('0x123...abc', 100) // 100 MANA + console.log('MANA sent:', result) + } catch (error) { + console.log('MANA transaction failed:', error) + } +}) +``` diff --git a/ai-sdk-context/dcl-crypto/references/crypto-toolkit.md b/ai-sdk-context/dcl-crypto/references/crypto-toolkit.md new file mode 100644 index 0000000..2127016 --- /dev/null +++ b/ai-sdk-context/dcl-crypto/references/crypto-toolkit.md @@ -0,0 +1,600 @@ +# DCL Crypto Toolkit Reference + +## Installation & Import +```typescript +// Install via npm +npm install dcl-crypto-toolkit + +// Import in your code +import * as crypto from 'dcl-crypto-toolkit' +import { executeTask } from '@dcl/sdk/ecs' +``` + +## Basic Setup + +### Initialize Crypto Toolkit +```typescript +// The crypto toolkit is ready to use after import +// All functions are async and should be wrapped in executeTask +executeTask(async () => { + // Crypto operations here +}) +``` + +## MANA Operations + +### Send MANA to Address +```typescript +// Send MANA to a specific address +executeTask(async () => { + await crypto.mana.send( + '0x1234567890123456789012345678901234567890', // toAddress + 10, // amount in MANA + true // waitConfirm (optional, default: false) + ) +}) + +// Send without waiting for confirmation +executeTask(async () => { + await crypto.mana.send( + '0x1234567890123456789012345678901234567890', + 5, + false + ) +}) +``` + +### Get Player's MANA Balance +```typescript +// Get current player's MANA balance +executeTask(async () => { + const balance = await crypto.mana.getBalance() + console.log('MANA Balance:', balance) +}) + +// Get balance for specific address +executeTask(async () => { + const balance = await crypto.mana.getBalance( + '0x1234567890123456789012345678901234567890' + ) + console.log('Address MANA Balance:', balance) +}) +``` + +## Currency Operations + +### Send Currency +```typescript +// Send any ERC20 token +executeTask(async () => { + await crypto.currency.send( + '0x1234567890123456789012345678901234567890', // contractAddress + '0x0987654321098765432109876543210987654321', // toAddress + 1000000000000000000, // amount in wei + true // waitConfirm (optional, default: false) + ) +}) +``` + +### Check Currency Balance +```typescript +// Check balance of any ERC20 token +executeTask(async () => { + const balance = await crypto.currency.getBalance( + '0x1234567890123456789012345678901234567890', // contractAddress + '0x0987654321098765432109876543210987654321' // address (optional, defaults to current player) + ) + console.log('Token Balance:', balance) +}) +``` + +### Check Currency Allowance +```typescript +// Check how much a contract can spend on behalf of a player +executeTask(async () => { + const allowance = await crypto.currency.allowance( + '0x1234567890123456789012345678901234567890', // contractAddress + '0x0987654321098765432109876543210987654321', // owner + '0x1111111111111111111111111111111111111111' // spender + ) + console.log('Allowance:', allowance) +}) +``` + +### Set Currency Approval +```typescript +// Grant permission for a contract to spend tokens +executeTask(async () => { + await crypto.currency.setApproval( + '0x1234567890123456789012345678901234567890', // contractAddress + '0x0987654321098765432109876543210987654321', // spender + true, // waitConfirm (optional, default: false) + '1000000000000000000000' // amount in wei (optional, defaults to max) + ) +}) +``` + +### Check Currency Approval Status +```typescript +// Check if a contract is approved to spend tokens +executeTask(async () => { + const isApproved = await crypto.currency.isApproved( + '0x1234567890123456789012345678901234567890', // contractAddress + '0x0987654321098765432109876543210987654321', // owner + '0x1111111111111111111111111111111111111111' // spender + ) + console.log('Is Approved:', isApproved) +}) +``` + +## NFT Operations + +### Transfer NFT +```typescript +// Transfer an NFT to another address +executeTask(async () => { + await crypto.nft.transfer( + '0x1234567890123456789012345678901234567890', // contractAddress + '0x0987654321098765432109876543210987654321', // toAddress + 123, // tokenId + true // waitConfirm (optional, default: false) + ) +}) +``` + +### Check NFT Ownership +```typescript +// Check if a player owns a specific NFT +executeTask(async () => { + const balance = await crypto.nft.getBalance( + '0x1234567890123456789012345678901234567890', // contractAddress + 123, // tokenId + '0x0987654321098765432109876543210987654321' // address (optional, defaults to current player) + ) + console.log('NFT Balance:', balance) +}) +``` + +### Check NFT Approval for All +```typescript +// Check if a contract is approved to handle all NFTs of a collection +executeTask(async () => { + const isApproved = await crypto.nft.isApprovedForAll( + '0x1234567890123456789012345678901234567890', // contractAddress + '0x0987654321098765432109876543210987654321', // assetHolder + '0x1111111111111111111111111111111111111111' // operator + ) + console.log('Is Approved For All:', isApproved) +}) +``` + +### Set NFT Approval for All +```typescript +// Grant permission for a contract to handle all NFTs of a collection +executeTask(async () => { + await crypto.nft.setApprovalForAll( + '0x1234567890123456789012345678901234567890', // contractAddress + '0x0987654321098765432109876543210987654321', // operator + true, // approved (optional, default: true) + true // waitConfirm (optional, default: false) + ) +}) +``` + +## Message Signing + +### Sign Message +```typescript +// Sign a message with the player's wallet +executeTask(async () => { + const signature = await crypto.signMessage('Hello Decentraland!') + console.log('Signature:', signature) +}) + +// Sign with custom message +executeTask(async () => { + const message = 'I agree to the terms and conditions' + const signature = await crypto.signMessage(message) + console.log('Signed message:', signature) +}) +``` + +## Decentraland Contracts + +### Access Predefined Contracts +```typescript +// Use predefined Decentraland contract addresses +console.log('MANA Token:', crypto.contract.mainnet.MANAToken) +console.log('Marketplace:', crypto.contract.mainnet.Marketplace) +console.log('LAND Registry:', crypto.contract.mainnet.LANDRegistry) +``` + +### Available Contract Addresses +```typescript +// Key mainnet contracts +crypto.contract.mainnet.MANAToken +crypto.contract.mainnet.Marketplace +crypto.contract.mainnet.LANDRegistry +crypto.contract.mainnet.EstateRegistry +crypto.contract.mainnet.Halloween2019Collection +crypto.contract.mainnet.Xmas2019Collection +crypto.contract.mainnet.MCHCollection +crypto.contract.mainnet.CommunityContestCollection +crypto.contract.mainnet.DappcraftMoonminerCollection +crypto.contract.mainnet.DCLLaunchCollection +crypto.contract.mainnet.DCGCollection +crypto.contract.mainnet.DCNSCollection +// ... plus many seasonal DG collections (DGSummer2020Collection, DGFall2020Collection, etc.) +``` + +## Marketplace Operations + +### Buy Item from Marketplace +```typescript +// Buy an item from the Decentraland marketplace +executeTask(async () => { + await crypto.marketplace.buyOrder( + '0x1234567890123456789012345678901234567890', // nftAddress + 123, // assetId + '1000000000000000000' // price in wei + ) +}) +``` + +### Check Player's Marketplace Authorizations +```typescript +// Check if player has authorized marketplace to handle their tokens +executeTask(async () => { + const isAuthorized = await crypto.marketplace.isAuthorized() + console.log('Marketplace Authorized:', isAuthorized) +}) +``` + +### Sell Item from Scene +```typescript +// List an item for sale on the marketplace +executeTask(async () => { + await crypto.marketplace.sellOrder( + '0x1234567890123456789012345678901234567890', // nftAddress + 123, // assetId + '1000000000000000000', // price in wei + '1000000000000000000' // expiresAt timestamp + ) +}) +``` + +### Cancel Marketplace Order +```typescript +// Cancel a marketplace listing +executeTask(async () => { + await crypto.marketplace.cancelOrder( + '0x1234567890123456789012345678901234567890', // nftAddress + 123 // assetId + ) +}) +``` + +## Third Party Contract Operations + +### Check Currency Approval for Third Party +```typescript +// Check if a third-party contract can spend player's tokens +executeTask(async () => { + const isApproved = await crypto.currency.isApproved( + crypto.contract.mainnet.MANAToken, + '0x1234567890123456789012345678901234567890' // thirdPartyContract + ) + console.log('Third Party Approved:', isApproved) +}) +``` + +### Grant Currency Approval to Third Party +```typescript +// Allow a third-party contract to spend player's tokens +executeTask(async () => { + await crypto.currency.setApproval( + crypto.contract.mainnet.MANAToken, + '0x1234567890123456789012345678901234567890', // thirdPartyContract + true, // waitConfirm + '1000000000000000000000' // amount in wei + ) +}) +``` + +### Check NFT Approval for Third Party +```typescript +// Check if a third-party contract can handle player's NFTs +executeTask(async () => { + const isApproved = await crypto.nft.isApprovedForAll( + crypto.contract.mainnet.Halloween2019Collection, + '0x1234567890123456789012345678901234567890' // thirdPartyContract + ) + console.log('Third Party NFT Approved:', isApproved) +}) +``` + +### Grant NFT Approval to Third Party +```typescript +// Allow a third-party contract to handle player's NFTs +executeTask(async () => { + await crypto.nft.setApprovalForAll( + crypto.contract.mainnet.Halloween2019Collection, + '0x1234567890123456789012345678901234567890', // thirdPartyContract + true, // approved + true // waitConfirm + ) +}) +``` + +## Custom Contract Interactions + +### Call Any Contract Function +```typescript +// Define contract ABI (simplified example) +const LANDAbi = [ + { + "constant": true, + "inputs": [{"name": "_owner", "type": "address"}], + "name": "balanceOf", + "outputs": [{"name": "", "type": "uint256"}], + "type": "function" + } +] + +// Get contract instance +executeTask(async () => { + const contract = await crypto.contract.getContract( + '0xF87E31492Faf9A91B02Ee0dEAAd50d51d56D5d4d', // contractAddress + LANDAbi + ) + + // Call contract function + const balance = await contract.balanceOf('0x1234567890123456789012345678901234567890') + console.log('LAND Balance:', balance) +}) +``` + +### Get Contract with Request Manager +```typescript +// Get contract with additional request manager for more control +executeTask(async () => { + const { contract, requestManager } = await crypto.contract.getContract( + crypto.contract.mainnet.MANAToken + ) + + // Use request manager for custom transaction handling + const balance = await contract.balanceOf('0x1234567890123456789012345678901234567890') + console.log('MANA Balance:', balance) +}) +``` + +## Wearable Data + +### Get List of Wearables +```typescript +// Get all wearables +executeTask(async () => { + const wearables = await crypto.wearable.getListOfWearables() + console.log('All Wearables:', wearables) +}) + +// Get wearables with filters +executeTask(async () => { + const filteredWearables = await crypto.wearable.getListOfWearables({ + collectionIds: ['urn:decentraland:ethereum:collections-v1:mf_sammichgamer'], + wearableIds: ['urn:decentraland:ethereum:collections-v1:mf_sammichgamer:mf_sammichgamer'], + textSearch: 'sammich' + }) + console.log('Filtered Wearables:', filteredWearables) +}) +``` + +### Wearable Filter Options +```typescript +interface WearableFilters { + collectionIds?: string[] // Filter by collection URNs + wearableIds?: string[] // Filter by specific wearable URNs + textSearch?: string // Search by text in wearable name/description +} +``` + +## Common Crypto Patterns + +### Complete Payment Flow +```typescript +// Complete payment flow with approval checks +async function processPayment(amount: number, recipient: string) { + try { + // Check MANA balance + const balance = await crypto.mana.getBalance() + if (balance < amount) { + throw new Error('Insufficient MANA balance') + } + + // Send MANA + await crypto.mana.send(recipient, amount, true) + + console.log(`Payment of ${amount} MANA sent to ${recipient}`) + return true + } catch (error) { + console.error('Payment failed:', error) + return false + } +} + +// Usage +executeTask(async () => { + const success = await processPayment(10, '0x1234567890123456789012345678901234567890') + if (success) { + console.log('Payment successful!') + } +}) +``` + +### NFT Trading System +```typescript +// Complete NFT trading system +class NFTTradingSystem { + async checkOwnership(contractAddress: string, tokenId: number): Promise { + const balance = await crypto.nft.getBalance(contractAddress, tokenId) + return balance > 0 + } + + async transferNFT(contractAddress: string, toAddress: string, tokenId: number): Promise { + try { + const ownsNFT = await this.checkOwnership(contractAddress, tokenId) + if (!ownsNFT) { + throw new Error('Player does not own this NFT') + } + + await crypto.nft.transfer(contractAddress, toAddress, tokenId, true) + console.log(`NFT ${tokenId} transferred to ${toAddress}`) + return true + } catch (error) { + console.error('NFT transfer failed:', error) + return false + } + } + + async listForSale(contractAddress: string, tokenId: number, price: string): Promise { + try { + // Check marketplace authorization + const isAuthorized = await crypto.marketplace.isAuthorized() + if (!isAuthorized) { + // Grant authorization + await crypto.nft.setApprovalForAll(contractAddress, crypto.contract.mainnet.Marketplace, true, true) + } + + // List for sale + const expiresAt = Date.now() + (7 * 24 * 60 * 60 * 1000) // 7 days from now + await crypto.marketplace.sellOrder(contractAddress, tokenId, price, expiresAt.toString()) + + console.log(`NFT ${tokenId} listed for sale at ${price} wei`) + return true + } catch (error) { + console.error('Listing failed:', error) + return false + } + } +} +``` + +### Marketplace Integration +```typescript +// Complete marketplace integration +class MarketplaceIntegration { + async buyItem(nftAddress: string, assetId: number, price: string): Promise { + try { + // Check if player has enough MANA + const balance = await crypto.mana.getBalance() + const priceInMana = parseFloat(price) / 1e18 // Convert from wei to MANA + + if (balance < priceInMana) { + throw new Error('Insufficient MANA balance') + } + + // Check marketplace authorization + const isAuthorized = await crypto.marketplace.isAuthorized() + if (!isAuthorized) { + await crypto.mana.setApproval(crypto.contract.mainnet.Marketplace, true, price) + } + + // Buy the item + await crypto.marketplace.buyOrder(nftAddress, assetId, price) + + console.log(`Successfully purchased NFT ${assetId} for ${priceInMana} MANA`) + return true + } catch (error) { + console.error('Purchase failed:', error) + return false + } + } + + async cancelListing(nftAddress: string, assetId: number): Promise { + try { + await crypto.marketplace.cancelOrder(nftAddress, assetId) + console.log(`Cancelled listing for NFT ${assetId}`) + return true + } catch (error) { + console.error('Cancellation failed:', error) + return false + } + } +} +``` + +## Error Handling + +### Common Error Patterns +```typescript +// Handle common crypto operation errors +async function safeCryptoOperation(operation: () => Promise) { + try { + const result = await operation() + return { success: true, result } + } catch (error: any) { + if (error.message.includes('insufficient funds')) { + console.error('Insufficient balance for transaction') + return { success: false, error: 'INSUFFICIENT_BALANCE' } + } else if (error.message.includes('user rejected')) { + console.error('User rejected the transaction') + return { success: false, error: 'USER_REJECTED' } + } else if (error.message.includes('network error')) { + console.error('Network error occurred') + return { success: false, error: 'NETWORK_ERROR' } + } else { + console.error('Unknown error:', error) + return { success: false, error: 'UNKNOWN_ERROR' } + } + } +} + +// Usage +executeTask(async () => { + const result = await safeCryptoOperation(async () => { + return await crypto.mana.send('0x1234567890123456789012345678901234567890', 1, true) + }) + + if (result.success) { + console.log('Operation successful:', result.result) + } else { + console.log('Operation failed:', result.error) + } +}) +``` + +## Best Practices + +### Security Guidelines +```typescript +// 1. Always validate addresses +function isValidAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address) +} + +// 2. Always validate amounts +function isValidAmount(amount: number): boolean { + return amount > 0 && Number.isFinite(amount) +} + +// 3. Use proper error handling +async function secureCryptoOperation(operation: () => Promise) { + try { + return await operation() + } catch (error: any) { + console.error('Crypto operation failed:', error) + throw new Error(`Operation failed: ${error.message}`) + } +} + +// 4. Validate user input +function validateTransactionInput(toAddress: string, amount: number): boolean { + if (!isValidAddress(toAddress)) { + throw new Error('Invalid recipient address') + } + if (!isValidAmount(amount)) { + throw new Error('Invalid amount') + } + return true +} +``` diff --git a/ai-sdk-context/dcl-game-design/SKILL.md b/ai-sdk-context/dcl-game-design/SKILL.md new file mode 100644 index 0000000..3423e33 --- /dev/null +++ b/ai-sdk-context/dcl-game-design/SKILL.md @@ -0,0 +1,237 @@ +--- +name: dcl-game-design +description: "Assists with Decentraland game design and scene optimization when the user mentions game design, scene optimization, performance, scene limits, parcel limits, triangle counts, entity limits, draw calls, pre-loading, asset loading, UX design, MVP planning, game loop design, or continuous world design." +--- + +# Decentraland Game Design & Scene Optimization + +## 1. DCL Game Design Philosophy + +Decentraland is a **continuous, shared 3D world**. Design around these constraints: + +- **No startup screen**: The scene is always live. Players walk in from adjacent parcels — there is no splash screen, no "press start." Your scene must be meaningful the instant a player arrives. +- **No forced endings**: You cannot force a "game over" state. Players can leave at any time by walking away or teleporting. Design loops that accommodate drop-in / drop-out naturally. +- **Cannot remove players**: There is no API to eject a player from a scene. You can teleport a player, but only with their consent (they must accept the prompt). Design around misbehaving players with game mechanics, not eviction. +- **Boundary awareness**: Players standing outside your parcel can see into it. Your scene is always on display. Neighboring scenes are visible too — consider visual harmony. +- **Shared space**: Multiple players are always potentially present. Even a "single-player" puzzle is witnessed by others. Embrace or account for this. + +## 2. Scene Limitation Formulas + +All limits scale with parcel count `n`. Know these formulas and design within them. + +| Resource | Formula | 1 parcel | 2 parcels | 4 parcels | 9 parcels | 16 parcels | +|---|---|---|---|---|---|---| +| **Triangles** | n x 10,000 | 10,000 | 20,000 | 40,000 | 90,000 | 160,000 | +| **Entities** | n x 200 | 200 | 400 | 800 | 1,800 | 3,200 | +| **Physics bodies** | n x 300 | 300 | 600 | 1,200 | 2,700 | 4,800 | +| **Materials** | log2(n+1) x 20 | 20 | 31 | 46 | 66 | 81 | +| **Textures** | log2(n+1) x 10 | 10 | 15 | 23 | 33 | 40 | +| **Height limit** | log2(n+1) x 20m | 20m | 31m | 46m | 66m | 81m | +| **Draw calls** | n x 300 (target) | 300 | 600 | 1,200 | 2,700 | 4,800 | + +**File limits:** +- 15 MB per parcel, 300 MB max total +- 200 files per parcel +- 50 MB max per individual file + +## 3. Texture Requirements + +- **Dimensions must be power-of-two**: 256, 512, 1024, 2048. +- **Recommended sizes**: 1024x1024 for scene objects, 512x512 for wearables. +- **Avoid textures over 2048x2048** — they consume excessive memory and often exceed limits. +- **Use texture atlases** to combine multiple small textures into one, reducing draw calls and material count. +- Prefer compressed formats (WebP) over raw PNG where possible. +- Share texture references across materials — do not duplicate texture files. + +## 4. Asset Preloading (AssetLoad Component) + +For large assets that would cause visible pop-in, use `AssetLoad` to pre-download before rendering: + +```typescript +import { engine, Entity } from '@dcl/sdk/ecs' +import { AssetLoad, LoadingState, GltfContainer, Transform } from '@dcl/sdk/ecs' +import { Vector3 } from '@dcl/sdk/math' + +// Create a preload entity at scene startup +const preloadEntity = engine.addEntity() +AssetLoad.create(preloadEntity, { src: 'models/large-model.glb' }) + +// System to track loading progress +function assetLoadingSystem(dt: number) { + for (const [entity] of engine.getEntitiesWith(AssetLoad)) { + const state = AssetLoad.get(entity) + if (state.loadingState === LoadingState.FINISHED) { + // Asset is cached — now safe to create the visible entity + GltfContainer.create(entity, { src: 'models/large-model.glb' }) + Transform.create(entity, { + position: Vector3.create(8, 0, 8) + }) + AssetLoad.deleteFrom(entity) // Remove preload component + } + } +} +engine.addSystem(assetLoadingSystem) +``` + +Use this pattern for any model over ~1 MB or for assets that should be ready before a game phase begins. + +## 5. Performance Patterns + +### Entity Pooling +Reuse entities instead of creating and destroying them. Move unused entities off-screen or toggle visibility: + +```typescript +// Hide: move underground +Transform.getMutable(entity).position = Vector3.create(0, -100, 0) +// Show: move to target position +Transform.getMutable(entity).position = targetPosition +``` + +### LOD (Level of Detail) +Swap models or hide entities based on distance from the player: + +```typescript +function lodSystem() { + const playerPos = Transform.get(engine.PlayerEntity).position + for (const [entity, transform] of engine.getEntitiesWith(Transform, GltfContainer)) { + const distance = Vector3.distance(playerPos, transform.position) + if (distance > 30) { + VisibilityComponent.createOrReplace(entity, { visible: false }) + } else { + VisibilityComponent.createOrReplace(entity, { visible: true }) + } + } +} +engine.addSystem(lodSystem) +``` + +### Draw Call Reduction +- Merge meshes in Blender before export. +- Use texture atlases (one material for many objects). +- Limit unique materials — reuse them across entities. +- Avoid transparency when possible (transparent objects cost extra draw calls). + +### System Optimization +- Do NOT run heavy logic every frame. Use timers: + ```typescript + let timer = 0 + function heavySystem(dt: number) { + timer += dt + if (timer < 0.5) return // Run every 500ms, not every frame + timer = 0 + // ... expensive work here + } + ``` +- Minimize `engine.getEntitiesWith()` queries — cache results when entity sets are stable. +- Avoid allocating new objects (Vector3.create, arrays) inside systems that run every frame. + +### Disable Unused Colliders +Remove collision meshes from decorative objects that players never interact with. This reduces physics body count significantly. + +## 6. Input System Design + +### Available Inputs +| Input | Action | Notes | +|---|---|---| +| **E key** | Primary action (`IA_PRIMARY`) | Main interaction | +| **F key** | Secondary action (`IA_SECONDARY`) | Alternate interaction | +| **Pointer click** | `IA_POINTER` | Left mouse click / tap | +| **Pointer down/up** | `IA_POINTER` | For drag-like behavior | +| **Keys 1-4** | `IA_ACTION_3` through `IA_ACTION_6` | Action bar slots | + +### Design Considerations +- Mouse wheel is **not available** as an input. +- Always design for both **desktop and mobile**. Mobile has no keyboard — rely on pointer and on-screen buttons. +- Set `maxDistance` on pointer events (8-10 meters typical) to prevent interactions from across the scene. +- Use `hoverText` to communicate what an interaction does before the player commits. + +## 7. State Management Patterns + +### Module-Level State (Simple Games) +```typescript +// game-state.ts +export let score = 0 +export let gamePhase: 'waiting' | 'playing' | 'ended' = 'waiting' +export function addScore(points: number) { score += points } +``` + +### Component-Based State (Complex Games) +Use custom components as structured data containers: +```typescript +import { engine, Schemas } from '@dcl/sdk/ecs' + +const EnemyState = engine.defineComponent('EnemyState', { + health: Schemas.Number, + speed: Schemas.Number, + target: Schemas.Entity +}) +``` + +### State Machines +Model game phases as explicit states with clear transitions: +```typescript +type GameState = 'lobby' | 'countdown' | 'active' | 'cooldown' +let currentState: GameState = 'lobby' + +function gameStateSystem(dt: number) { + switch (currentState) { + case 'lobby': handleLobby(dt); break + case 'countdown': handleCountdown(dt); break + case 'active': handleActive(dt); break + case 'cooldown': handleCooldown(dt); break + } +} +``` + +### Multiplayer State Sync +For networked state, see the **dcl-multiplayer** skill. Use server-authoritative state for competitive games and optimistic sync for cooperative experiences. + +## 8. UX/UI Guidelines + +- **Keep UI minimal**: The metaverse is about 3D presence, not 2D overlays. Avoid large HUDs that obscure the world. +- **Prefer spatial UI**: Use `TextShape` on entities and 3D signs over screen-space UI whenever the information is tied to a place or object. +- **Clear affordances**: Interactive objects should look interactive. Use glow effects, outlines, floating indicators, or subtle animations to signal "you can click this." +- **Sound feedback**: Every significant player action should produce audio feedback. It confirms the action registered and adds polish. +- **Progressive disclosure**: Do not dump all information at once. Reveal mechanics and story as the player engages. Start simple, layer complexity. +- **Immediate feedback**: When a player interacts, respond within the same frame. Use tweens, sounds, or UI popups so the player never wonders "did that work?" +- **Accessibility**: Use high-contrast text, readable font sizes (fontSize >= 16 for screen UI), and audio cues alongside visual ones. + +## 9. MVP Planning + +### Start with the Core Loop +Ask: **What does the player DO?** The answer should be a single sentence: +- "The player explores rooms and finds hidden objects." +- "The player races other players through an obstacle course." +- "The player collects resources and builds structures." + +### Prototype Fast +- Build in **1-2 parcels** first, even if the final scene will be larger. +- Use primitive shapes (boxes, spheres) as placeholders — do not wait for final art. +- Get the core loop working before adding any secondary features. + +### Test Early +- Deploy to a test world and walk through it yourself. +- Invite 2-3 real players and watch them (do not explain the game — see if it is self-explanatory). +- Measure: Do players understand what to do within 30 seconds? + +### Iterate on Fun +- Polish comes last. If the core loop is not fun with placeholder art, better art will not fix it. +- Cut features aggressively. A tight, small experience beats a sprawling, unfinished one. +- Replay value matters more than content volume in DCL (players return to scenes they enjoy). + +### MVP Checklist +- [ ] Core loop is playable in under 60 seconds +- [ ] Works with 1 player and with 5+ players simultaneously +- [ ] Fits within scene limits for target parcel count +- [ ] Has clear visual/audio feedback for all interactions +- [ ] Player understands the goal without external instructions + +## 10. Cross-References + +| Topic | Skill | When to Use | +|---|---|---| +| ECS architecture, components, systems | **dcl-scene** | Implementing game mechanics in code | +| Multiplayer sync, server communication | **dcl-multiplayer** | Networked game state, real-time sync | +| Screen UI, React-ECS, HUD elements | **dcl-ui** | Building menus, scoreboards, dialogs | + +When the user needs implementation details for a game mechanic, point them to **dcl-scene**. When they need networking, point to **dcl-multiplayer**. When they need UI layout, point to **dcl-ui**. This skill focuses on the **design decisions and optimization constraints** that shape those implementations. diff --git a/ai-sdk-context/dcl-game-design/references/design-patterns.md b/ai-sdk-context/dcl-game-design/references/design-patterns.md new file mode 100644 index 0000000..0fae878 --- /dev/null +++ b/ai-sdk-context/dcl-game-design/references/design-patterns.md @@ -0,0 +1,151 @@ +# Decentraland Game Design Patterns + +## Continuous World Design Rules + +These are non-negotiable constraints of the Decentraland platform. Every design must account for them. + +1. **Always-live scene**: There is no loading screen, no start button. The scene runs the moment a player's avatar enters the parcel. Design the default state to be visually interesting and functionally coherent. + +2. **Drop-in / drop-out**: Players arrive and leave without warning. Never require a fixed player count to start. Never assume a player will stay for a set duration. Save progress incrementally — not at the end. + +3. **No ejection**: You cannot remove a player from a scene. You can teleport them with consent (they must accept a prompt). Design around griefing with mechanics (invulnerability zones, cooldowns, ignore lists) rather than moderation tools. + +4. **Visible boundaries**: Your scene is visible from neighboring parcels. The "idle state" of your scene is your storefront. Make it attractive even when no game is active. + +5. **Shared presence**: Other players are always potentially visible and present. A "single-player" experience in DCL is really a "solo experience in a public space." Decide whether other players are spectators, collaborators, or competitors — but never pretend they do not exist. + +6. **Persistent world**: The scene resets when the last player leaves (or on redeploy). There is no built-in persistence. If you need saved state, use an external server or blockchain storage. + +## Game Loop Archetypes + +### Exploration +- **Core loop**: Discover locations, find hidden items, unlock areas. +- **DCL fit**: Excellent. The 3D world and spatial navigation are strengths. +- **Design tips**: Use landmarks for wayfinding. Reward curiosity with hidden content. Use lighting and sound to guide attention. + +### Collection +- **Core loop**: Gather items, complete sets, earn rewards. +- **DCL fit**: Strong. Combines well with exploration and daily engagement. +- **Design tips**: Use entity pooling for collectibles. Scatter items spatially. Tie collections to visual progress (display cases, counters). + +### Puzzle +- **Core loop**: Solve spatial or logic challenges to progress. +- **DCL fit**: Good. Spatial puzzles (move objects, find paths, activate sequences) work well. +- **Design tips**: Provide clear feedback on progress. Avoid puzzles that require typing (input is limited). Use 3D interactions (click, proximity triggers) as puzzle inputs. + +### Social +- **Core loop**: Interact with other players, attend events, roleplay. +- **DCL fit**: Excellent. This is the platform's native strength. +- **Design tips**: Create gathering spaces (seating, stages, open areas). Provide conversation starters (interactive objects, games). Design for groups of 5-20. + +### Competitive +- **Core loop**: Race, fight, or outscore other players. +- **DCL fit**: Moderate. Latency and input limitations constrain fast-paced action. +- **Design tips**: Prefer turn-based or timing-based competition over twitch reflexes. Use server-authoritative state to prevent cheating. Keep rounds short (2-5 minutes). + +## Engagement Patterns + +### Daily Rewards +- Offer small rewards for daily visits (collectibles, progress points). +- Track visits via external server (DCL has no built-in daily tracking). +- Display streak counters in-scene to motivate return visits. + +### Progression Systems +- Levels, ranks, or unlockable content tied to cumulative play. +- Store progress on a server or use NFT-based progression. +- Show progression visually (leaderboards, badges, evolving scene elements). + +### Achievements +- Define clear milestones (first win, 100 collectibles, visited all rooms). +- Announce achievements with sound and visual effects. +- Display achievement history in-scene (trophy room, wall of fame). + +## Spatial Design + +### Landmarks +- Place a tall, visible landmark at the center or entrance of your scene. Players use it to orient themselves. +- Every distinct area should have a unique visual identity (color, shape, lighting). + +### Pathfinding +- Guide players with visible paths (floor patterns, lighting, railings). +- Avoid dead ends that require backtracking — use loops. +- Place interactive elements along paths to maintain engagement during traversal. + +### Sightlines +- Use open sightlines to draw players toward objectives. +- Block sightlines strategically to create mystery and discovery. +- Ensure the scene looks inviting from the parcel boundary (this is your "shop window"). + +### Parcel Transitions +- If your scene spans multiple parcels, ensure smooth visual transitions. +- Do not place critical interactive elements right at parcel boundaries (loading edge cases). +- Use open space at parcel edges as buffer zones. + +## Monetization Approaches + +### In-Scene Purchases +- Sell virtual items or abilities via MANA transactions. +- Use the `signedFetch` flow for secure server-verified purchases. +- Always provide free gameplay alongside paid upgrades — pay-to-win drives players away. + +### Wearable Sales +- Create and sell wearables that complement your scene's theme. +- Display wearables on mannequins in-scene as advertisements. +- Offer wearables as prizes for game achievements. + +### Entry Fees +- Charge MANA to enter a premium area or participate in a competition. +- Always have a free area that showcases what the paid area offers. +- Use token-gating (require ownership of a specific NFT) as an alternative to direct payment. + +## Social Mechanics + +### Cooperative Tasks +- Design objectives that require multiple players (two switches that must be pressed simultaneously, items too heavy for one player). +- Reward cooperation with shared benefits. +- Scale difficulty or rewards with player count. + +### Shared Spaces +- Create common areas where players naturally congregate (lobbies, plazas, markets). +- Add ambient interactive objects that encourage casual interaction (musical instruments, ball toss, dance floors). + +### Events +- Design scenes that can host scheduled events (concerts, launches, competitions). +- Include a "stage" area with good sightlines for audiences. +- Provide event host controls (start/stop game, reset scene, broadcast messages). + +## Tutorial and Onboarding Patterns + +### In-World Signs +- Place `TextShape` entities with short instructions at key locations. +- Use arrows, glowing outlines, or animated indicators to point to interactive objects. +- Keep text under 10 words per sign. + +### NPC Guides +- Use an animated NPC at the scene entrance to greet and instruct. +- Deliver instructions through a dialog system (one message at a time, player advances). +- NPC dialog should be skippable for returning players. + +### Progressive Complexity +- Introduce one mechanic at a time. The first interaction should be obvious (a big, glowing button). +- After the player succeeds at the simple task, introduce the next layer. +- Gate advanced mechanics behind early accomplishments (unlock new areas after completing the tutorial). + +### Zero-Explanation Test +- If a new player cannot figure out the first action within 30 seconds without any text or instructions, the design needs work. +- Watch real players attempt your scene cold. Their confusion is your design feedback. + +## MVP Checklist + +Before expanding scope, verify these fundamentals: + +- [ ] **Core loop defined**: One sentence describing what the player does. +- [ ] **First action obvious**: A new player knows what to do within 30 seconds. +- [ ] **Feedback present**: Every interaction produces visible and/or audible feedback. +- [ ] **Win/progress condition clear**: The player understands when they are succeeding. +- [ ] **Lose/fail condition fair**: If there is failure, the player understands why and can retry quickly. +- [ ] **Replay value exists**: There is a reason to play again (score improvement, new content, social competition). +- [ ] **Multiplayer compatible**: Works correctly with 1 player and with 5+ simultaneous players. +- [ ] **Within scene limits**: Triangle count, entity count, texture count, and file size all within budget for the target parcel count. +- [ ] **Performance acceptable**: Maintains 30+ FPS during gameplay with target entity/triangle counts. +- [ ] **Mobile compatible**: Core interactions work without a keyboard (pointer-only inputs available). diff --git a/ai-sdk-context/dcl-game-design/references/scene-limitations.md b/ai-sdk-context/dcl-game-design/references/scene-limitations.md new file mode 100644 index 0000000..29eae2c --- /dev/null +++ b/ai-sdk-context/dcl-game-design/references/scene-limitations.md @@ -0,0 +1,391 @@ +# Decentraland Scene Limitations & Optimization Reference + +## Full Scene Limits Table + +All limits are based on parcel count `n`. Each parcel is 16m x 16m. + +### Linear Scaling (n x multiplier) + +| Parcels | Triangles (n x 10,000) | Entities (n x 200) | Physics Bodies (n x 300) | Draw Calls (n x 300) | +|---|---|---|---|---| +| 1 | 10,000 | 200 | 300 | 300 | +| 2 | 20,000 | 400 | 600 | 600 | +| 3 | 30,000 | 600 | 900 | 900 | +| 4 | 40,000 | 800 | 1,200 | 1,200 | +| 5 | 50,000 | 1,000 | 1,500 | 1,500 | +| 6 | 60,000 | 1,200 | 1,800 | 1,800 | +| 8 | 80,000 | 1,600 | 2,400 | 2,400 | +| 9 | 90,000 | 1,800 | 2,700 | 2,700 | +| 10 | 100,000 | 2,000 | 3,000 | 3,000 | +| 12 | 120,000 | 2,400 | 3,600 | 3,600 | +| 16 | 160,000 | 3,200 | 4,800 | 4,800 | +| 20 | 200,000 | 4,000 | 6,000 | 6,000 | + +### Logarithmic Scaling (log2(n+1) x multiplier) + +| Parcels | log2(n+1) | Materials (x 20) | Textures (x 10) | Height Limit (x 20m) | +|---|---|---|---|---| +| 1 | 1.00 | 20 | 10 | 20m | +| 2 | 1.58 | 31 | 15 | 31m | +| 3 | 2.00 | 40 | 20 | 40m | +| 4 | 2.32 | 46 | 23 | 46m | +| 5 | 2.58 | 51 | 25 | 51m | +| 6 | 2.81 | 56 | 28 | 56m | +| 8 | 3.17 | 63 | 31 | 63m | +| 9 | 3.32 | 66 | 33 | 66m | +| 10 | 3.46 | 69 | 34 | 69m | +| 12 | 3.70 | 74 | 37 | 74m | +| 16 | 4.09 | 81 | 40 | 81m | +| 20 | 4.39 | 87 | 43 | 87m | + +### File Limits + +| Constraint | Limit | +|---|---| +| File size per parcel | 15 MB | +| Maximum total file size | 300 MB | +| Files per parcel | 200 | +| Maximum individual file size | 50 MB | + +## Texture Specifications + +### Dimension Requirements +- All texture dimensions **must be powers of two**: 64, 128, 256, 512, 1024, 2048. +- Non-power-of-two textures are resized at runtime, wasting memory and causing artifacts. +- Textures do not need to be square (512x1024 is valid). + +### Recommended Sizes +| Use Case | Recommended Size | Maximum | +|---|---|---| +| Scene objects (walls, floors) | 1024x1024 | 2048x2048 | +| Props and furniture | 512x512 | 1024x1024 | +| Wearables | 512x512 | 1024x1024 | +| UI elements / icons | 256x256 | 512x512 | +| Skybox / environment | 1024x1024 | 2048x2048 | + +### Texture Optimization Tips +- Prefer WebP format over PNG for smaller file size. +- Use texture atlases: combine multiple small textures into one larger texture and adjust UVs. This reduces both texture count and material count. +- Avoid alpha transparency when possible. Transparent textures require additional draw calls. +- Reuse texture references across materials to avoid duplicate downloads. + +## Model Optimization Guidelines + +### Triangle Budget Planning +Reserve your triangle budget across categories: + +| Category | Suggested Budget Share | +|---|---| +| Environment (terrain, walls, floors) | 40% | +| Props and decorations | 25% | +| Interactive objects | 20% | +| Characters / NPCs | 10% | +| Particle effects / UI meshes | 5% | + +### Mesh Optimization +- **Merge static meshes** in Blender before export. 10 merged objects with 1 material are far cheaper than 10 separate objects with 10 materials. +- **Remove hidden faces**: Delete faces players will never see (bottoms of objects sitting on floors, backs against walls). +- **Use Blender's Decimate modifier** to reduce triangle count on detailed models. Target 30-50% reduction for background props. +- **Avoid n-gons**: Ensure all faces are triangulated or quad-based before export. + +### Export Settings (Blender to glTF) +- Export as `.glb` (binary glTF) for smaller files. +- Enable Draco compression when available. +- Apply all transforms before export (Ctrl+A in Blender). +- Ensure scale is correct (1 Blender unit = 1 meter in DCL). + +## Draw Call Budget and Reduction + +### What Counts as a Draw Call +Each unique combination of (mesh + material) rendered in a frame is one draw call. An object with 3 materials = 3 draw calls. + +### Reduction Techniques + +1. **Merge meshes sharing the same material** in Blender. This is the single most effective optimization. +2. **Use texture atlases**: One atlas material for many objects instead of unique materials per object. +3. **Limit unique materials**: Target under 20 unique materials for a 1-parcel scene. +4. **Minimize transparency**: Transparent materials require separate rendering passes. +5. **Use instancing**: Multiple copies of the same GLB with the same material share instanced draw calls (the engine handles this automatically for identical GltfContainer references). +6. **Reduce light sources**: Each additional light increases draw calls. Use baked lighting where possible. + +## AssetLoad API Reference + +### Purpose +Pre-download assets before they are rendered to prevent visible pop-in during gameplay. + +### Import +```typescript +import { AssetLoad, LoadingState } from '@dcl/sdk/ecs' +``` + +### Creating a Preload Request +```typescript +const preloadEntity = engine.addEntity() +AssetLoad.create(preloadEntity, { + src: 'models/my-model.glb' +}) +``` + +### Loading States +| State | Meaning | +|---|---| +| `LoadingState.LOADING` | Asset is being downloaded | +| `LoadingState.FINISHED` | Asset is cached and ready | +| `LoadingState.FINISHED_WITH_ERROR` | Download failed | + +### Tracking Pattern +```typescript +function assetLoadingSystem(dt: number) { + for (const [entity] of engine.getEntitiesWith(AssetLoad)) { + const state = AssetLoad.get(entity) + switch (state.loadingState) { + case LoadingState.FINISHED: + // Safe to render — create GltfContainer, AudioSource, etc. + GltfContainer.create(entity, { src: state.src }) + AssetLoad.deleteFrom(entity) + break + case LoadingState.FINISHED_WITH_ERROR: + console.error(`Failed to load: ${state.src}`) + AssetLoad.deleteFrom(entity) + break + // LoadingState.LOADING — still waiting, do nothing + } + } +} +engine.addSystem(assetLoadingSystem) +``` + +### When to Use AssetLoad +- Models over 1 MB +- Audio files used in gameplay (preload so they play without delay) +- Any asset needed at the start of a game phase (preload during the lobby/countdown) + +## Entity Pooling Implementation + +Entity creation and destruction are expensive. For objects that appear and disappear frequently (projectiles, collectibles, effects), use a pool. + +```typescript +import { engine, Entity } from '@dcl/sdk/ecs' +import { Transform, MeshRenderer, VisibilityComponent } from '@dcl/sdk/ecs' +import { Vector3 } from '@dcl/sdk/math' + +class EntityPool { + private available: Entity[] = [] + private active: Set = new Set() + + constructor( + private size: number, + private setup: (entity: Entity) => void + ) { + for (let i = 0; i < size; i++) { + const entity = engine.addEntity() + setup(entity) + this.hide(entity) + this.available.push(entity) + } + } + + acquire(): Entity | undefined { + const entity = this.available.pop() + if (!entity) return undefined // Pool exhausted + this.active.add(entity) + VisibilityComponent.createOrReplace(entity, { visible: true }) + return entity + } + + release(entity: Entity): void { + if (!this.active.has(entity)) return + this.active.delete(entity) + this.hide(entity) + this.available.push(entity) + } + + private hide(entity: Entity): void { + Transform.getMutable(entity).position = Vector3.create(0, -100, 0) + VisibilityComponent.createOrReplace(entity, { visible: false }) + } + + get activeCount(): number { return this.active.size } + get availableCount(): number { return this.available.length } +} + +// Usage +const bulletPool = new EntityPool(20, (entity) => { + MeshRenderer.setSphere(entity) + Transform.create(entity, { position: Vector3.create(0, -100, 0) }) +}) + +// Acquire when firing +const bullet = bulletPool.acquire() +if (bullet) { + Transform.getMutable(bullet).position = playerPosition +} + +// Release when bullet hits or expires +bulletPool.release(bullet) +``` + +## LOD Implementation Pattern + +Swap model detail levels based on player distance to save triangles and draw calls. + +```typescript +import { engine, Entity } from '@dcl/sdk/ecs' +import { Transform, GltfContainer, VisibilityComponent } from '@dcl/sdk/ecs' +import { Vector3 } from '@dcl/sdk/math' + +// Define LOD thresholds +const LOD_NEAR = 10 // Full detail within 10m +const LOD_MID = 25 // Medium detail 10-25m +const LOD_FAR = 50 // Low detail 25-50m — beyond 50m, hide + +// Custom component to track LOD state +const LodObject = engine.defineComponent('LodObject', { + modelHigh: Schemas.String, + modelMid: Schemas.String, + modelLow: Schemas.String, + currentLod: Schemas.Number // 0=high, 1=mid, 2=low, 3=hidden +}) + +function lodSystem(dt: number) { + const playerPos = Transform.get(engine.PlayerEntity).position + + for (const [entity] of engine.getEntitiesWith(LodObject, Transform)) { + const lod = LodObject.getMutable(entity) + const pos = Transform.get(entity).position + const distance = Vector3.distance(playerPos, pos) + + let targetLod: number + if (distance < LOD_NEAR) targetLod = 0 + else if (distance < LOD_MID) targetLod = 1 + else if (distance < LOD_FAR) targetLod = 2 + else targetLod = 3 + + if (targetLod !== lod.currentLod) { + lod.currentLod = targetLod + switch (targetLod) { + case 0: + GltfContainer.createOrReplace(entity, { src: lod.modelHigh }) + VisibilityComponent.createOrReplace(entity, { visible: true }) + break + case 1: + GltfContainer.createOrReplace(entity, { src: lod.modelMid }) + VisibilityComponent.createOrReplace(entity, { visible: true }) + break + case 2: + GltfContainer.createOrReplace(entity, { src: lod.modelLow }) + VisibilityComponent.createOrReplace(entity, { visible: true }) + break + case 3: + VisibilityComponent.createOrReplace(entity, { visible: false }) + break + } + } + } +} +engine.addSystem(lodSystem) +``` + +**Important**: Run the LOD system at a reduced frequency (every 0.5s) using a timer, not every frame. + +## System Performance Best Practices + +### Reduce System Frequency +```typescript +let elapsed = 0 +function expensiveSystem(dt: number) { + elapsed += dt + if (elapsed < 0.25) return // Run 4 times per second, not 30+ + elapsed = 0 + // ... do work +} +``` + +### Minimize Allocations +```typescript +// BAD: allocates a new Vector3 every frame +function moveSystem(dt: number) { + const direction = Vector3.create(1, 0, 0) // New object each frame +} + +// GOOD: reuse a pre-allocated vector +const direction = Vector3.create(1, 0, 0) +function moveSystem(dt: number) { + // Use 'direction' without reallocating +} +``` + +### Cache Entity Queries +```typescript +// BAD: queries all entities every frame +function systemBad(dt: number) { + for (const [entity] of engine.getEntitiesWith(MyComponent, Transform)) { + // ... + } +} + +// The engine already optimizes getEntitiesWith internally, but avoid +// adding unnecessary extra component filters that force wider scans. +``` + +### Limit Active Systems +- Remove systems when they are not needed: `engine.removeSystem(mySystem)` +- Add them back when needed: `engine.addSystem(mySystem)` +- Idle scenes should have minimal active systems. + +## Common Performance Pitfalls and Fixes + +| Pitfall | Symptom | Fix | +|---|---|---| +| Too many unique materials | High draw calls, low FPS | Merge into texture atlases, reuse materials | +| Non-power-of-two textures | Memory bloat, visual artifacts | Resize all textures to 256/512/1024/2048 | +| Creating/destroying entities rapidly | Frame stutters | Use entity pooling | +| Heavy computation every frame | Consistent low FPS | Add timer guards, reduce frequency | +| Unused colliders on decorations | Physics body limit exceeded | Remove MeshCollider from non-interactive objects | +| Large uncompressed textures | Slow loading, file size exceeded | Use WebP, reduce resolution, use atlases | +| Too many transparent materials | Extra draw calls, sorting issues | Minimize transparency, use alpha cutoff instead of blend | +| Unbounded entity queries | CPU spike | Filter with specific components, cache results | +| All detail loaded at all distances | Triangle budget blown | Implement LOD system | +| No asset preloading | Pop-in during gameplay | Use AssetLoad for large models and audio | + +## Scene Statistics Monitoring + +### In Preview Mode +When running the scene locally with `npm run start`, open the debug panel to view live stats: +- Press **P** to toggle the performance panel. +- Monitor: FPS, draw calls, triangles, entities, materials, textures, memory. +- Scene limits are shown alongside current usage with green/yellow/red indicators. + +### Programmatic Checking +There is no runtime API to query scene stats from within scene code. Use the preview debug panel or the Creator Hub scene inspector for monitoring. + +### What to Watch +- **FPS below 30**: Something is too expensive. Check draw calls and system execution time. +- **Triangle count approaching limit**: Enable LOD, reduce model detail, remove hidden faces. +- **Entity count climbing**: Likely a leak — entities being created but never destroyed. Implement pooling. +- **Draw calls above budget**: Too many materials. Merge, atlas, and reduce transparency. + +## Recommended Tools for Asset Optimization + +### Blender (3D Models) +- **Decimate modifier**: Reduce triangle count on imported models. Use "Collapse" mode for best results. +- **Limited Dissolve**: Remove unnecessary vertices from flat surfaces (Edit Mode > Mesh > Clean Up > Limited Dissolve). +- **Material consolidation**: Merge materials that use the same texture. Reduces draw calls. +- **UV packing**: Combine UVs from multiple objects onto one atlas texture. +- Export as `.glb` with Draco compression enabled. + +### Texture Compression Tools +- **Squoosh** (squoosh.app): Browser-based tool for converting images to WebP and resizing to power-of-two dimensions. +- **TexturePacker**: Creates texture atlases from multiple source images. Outputs atlas image + UV coordinate mappings. +- **GIMP / Photoshop**: Manual resizing and format conversion. Use "Image > Canvas Size" to pad to power-of-two if needed. + +### glTF Tools +- **gltf-transform** (CLI): Optimize glTF/GLB files — compress textures, merge meshes, strip unused data. + ```bash + npx @gltf-transform/cli optimize input.glb output.glb --compress draco + ``` +- **glTF Validator** (github.khronos.org/glTF-Validator): Check for spec compliance issues before importing into DCL. + +### In-Engine Tools +- **Creator Hub Scene Inspector**: Visual tool for checking entity counts, triangle counts, and placement within parcel boundaries. +- **Preview Debug Panel**: Press P during `npm run start` to see live performance metrics. diff --git a/ai-sdk-context/dcl-multiplayer/SKILL.md b/ai-sdk-context/dcl-multiplayer/SKILL.md new file mode 100644 index 0000000..490283c --- /dev/null +++ b/ai-sdk-context/dcl-multiplayer/SKILL.md @@ -0,0 +1,558 @@ +--- +name: dcl-multiplayer +description: "Assists with Decentraland SDK7 multiplayer and networking when the user mentions multiplayer, syncEntity, MessageBus, state sync, networked entities, WebSocket connections, REST API calls, fetch, signedFetch, authoritative servers, player sync, or shared state." +--- + +# Decentraland SDK7 Multiplayer and Networking + +## Sync Strategy Decision Tree + +Choose the right networking approach based on what you need: + +| Strategy | Use When | Persistence | Example | +|----------|----------|-------------|---------| +| `syncEntity` | Shared state that all players see and that persists for new arrivals | Yes — state survives player join/leave | Doors, switches, scoreboards, elevators | +| `MessageBus` | Ephemeral events that only matter in the moment | No — late joiners miss past messages | Chat messages, sound effects, particle triggers, notifications | +| `fetch` / REST API | Reading or writing data to an external server | Server-dependent | Leaderboards, inventory, external game state | +| `signedFetch` | Authenticated requests that prove player identity | Server-dependent | Claiming rewards, submitting verified scores | +| `WebSocket` | Real-time bidirectional communication with a server | Connection-dependent | Live game servers, real-time chat, authoritative multiplayer | + +**Decision flow:** +1. Does every player need to see the same state, including late joiners? --> `syncEntity` +2. Is it a fire-and-forget event only for players currently in the scene? --> `MessageBus` +3. Do you need to talk to an external server? --> `fetch` or `signedFetch` +4. Do you need continuous real-time server communication? --> `WebSocket` +5. Combine approaches freely: use `syncEntity` for world state, `MessageBus` for effects, and `fetch` for persistence to your own backend. + +--- + +## syncEntity Essentials + +### Import and Basic Usage + +```typescript +import { syncEntity } from '@dcl/sdk/network' +``` + +Signature: `syncEntity(entity, componentIds[], syncId?)` + +- `entity` — the entity to synchronize +- `componentIds[]` — array of component IDs to keep in sync (e.g., `[Transform.componentId]`) +- `syncId` — unique numeric identifier (required for predefined entities, optional for player-spawned entities) + +### Enum Sync IDs (Predefined Entities) + +Every predefined synced entity MUST have a unique numeric ID. Use an enum to avoid collisions: + +```typescript +enum SyncIds { + DOOR = 1, + ELEVATOR = 2, + DRAWBRIDGE = 3, + SCOREBOARD = 4 +} + +const door = engine.addEntity() +Transform.create(door, { position: Vector3.create(8, 1, 8) }) +MeshRenderer.setBox(door) +syncEntity(door, [Transform.componentId, MeshRenderer.componentId], SyncIds.DOOR) +``` + +### Auto-Generated IDs (Player-Spawned Entities) + +Entities created at runtime by players do not need an explicit sync ID: + +```typescript +function createProjectile() { + const projectile = engine.addEntity() + Transform.create(projectile, { position: Vector3.create(4, 1, 4) }) + MeshRenderer.setSphere(projectile) + syncEntity(projectile, [Transform.componentId]) + return projectile +} +``` + +### Syncing Multiple Components + +Pass multiple component IDs to sync several properties: + +```typescript +syncEntity(entity, [ + Transform.componentId, + MeshRenderer.componentId, + Material.componentId +], SyncIds.MY_ENTITY) +``` + +### Parent-Child Sync Relationships + +For synced entities with parent-child relationships, use `parentEntity()` instead of setting `Transform.parent`: + +```typescript +import { syncEntity, parentEntity, getParent, getChildren, removeParent } from '@dcl/sdk/network' + +const parent = engine.addEntity() +const child = engine.addEntity() + +syncEntity(parent, [Transform.componentId], 1) +syncEntity(child, [Transform.componentId], 2) + +// Use parentEntity() — NOT Transform.parent +parentEntity(child, parent) + +// Helper functions +const parentRef = getParent(child) +const childrenArray = Array.from(getChildren(parent)) + +// Remove parent relationship +removeParent(child) +``` + +### Check Sync State + +Wait for synchronization before allowing interactions: + +```typescript +import { isStateSyncronized } from '@dcl/sdk/network' + +function gameStateSystem() { + const isSynced = isStateSyncronized() + if (isSynced) { + enableGameControls() + } else { + disableGameControls() + showSyncingMessage() + } +} + +engine.addSystem(gameStateSystem) +``` + +Note: the function is spelled `isStateSyncronized` (not "Synchronized") in the SDK. + +### Updating Synced Entities + +Mutate synced components normally. Changes propagate automatically: + +```typescript +Transform.getMutable(interactiveEntity).position = Vector3.create(4, 1, 4) +``` + +--- + +## MessageBus Patterns + +### Import and Setup + +```typescript +import { MessageBus } from '@dcl/sdk/message-bus' + +const sceneMessageBus = new MessageBus() +``` + +### Emit and Listen + +```typescript +// Send a message to all players in the scene +sceneMessageBus.emit('player-action', { + playerId: 'player123', + action: 'jump', + timestamp: Date.now(), + position: Vector3.create(8, 1, 8) +}) + +// Listen for messages +sceneMessageBus.on('player-action', (data: PlayerAction) => { + console.log(`Player ${data.playerId} performed ${data.action}`) + handlePlayerAction(data) +}) +``` + +### Typed Payloads + +Define types for message data to keep code safe: + +```typescript +type SpawnMessage = { + position: { x: number; y: number; z: number } + entityEnumId: number +} + +type UpdateMessage = { + entityId: number + position: { x: number; y: number; z: number } +} + +messageBus.emit('spawn', { position: { x: 8, y: 1, z: 8 }, entityEnumId: 1 } as SpawnMessage) + +messageBus.on('spawn', (message: SpawnMessage) => { + const entity = engine.addEntity() + Transform.create(entity, { + position: Vector3.create(message.position.x, message.position.y, message.position.z) + }) + MeshRenderer.setBox(entity) + syncEntity(entity, [Transform.componentId], message.entityEnumId) +}) +``` + +### syncEntity vs MessageBus + +- `syncEntity`: state is persistent, late joiners get current state, automatic conflict resolution +- `MessageBus`: fire-and-forget, late joiners miss past messages, good for transient effects +- Combine both: use `syncEntity` for the door open/closed state, `MessageBus` for the sound effect when it opens + +--- + +## REST API Calls (fetch) + +### Import + +```typescript +import { executeTask } from '@dcl/sdk/ecs' +``` + +All network calls must run inside `executeTask` because the SDK runtime does not support top-level await. + +### GET Request + +```typescript +executeTask(async () => { + try { + const response = await fetch('https://api.example.com/data') + const json = await response.json() + console.log('Response:', json) + } catch (error) { + console.error('Failed to fetch:', error) + } +}) +``` + +### POST Request + +```typescript +executeTask(async () => { + try { + const response = await fetch('https://api.example.com/submit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'player123', score: 1500 }) + }) + const result = await response.json() + console.log('Submission result:', result) + } catch (error) { + console.log('Submission failed:', error) + } +}) +``` + +### Error Handling Pattern + +Always wrap fetch in try/catch inside executeTask. Check response status: + +```typescript +executeTask(async () => { + try { + const response = await fetch('https://api.example.com/data') + if (!response.ok) { + console.error('HTTP error:', response.status) + return + } + const data = await response.json() + // use data + } catch (error) { + console.error('Network error:', error) + } +}) +``` + +--- + +## Signed Fetch (Authenticated Requests) + +`signedFetch` attaches a cryptographic signature proving the player's identity. Use it when your server needs to verify who is making the request. + +```typescript +import { signedFetch } from '@dcl/sdk/network' + +executeTask(async () => { + try { + const response = await signedFetch('https://example.com/api/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'claimReward', amount: 100 }) + }) + const result = await response.json() + console.log('Transaction result:', result) + } catch (error) { + console.log('Transaction failed:', error) + } +}) +``` + +Note: some examples import from `@dcl/sdk/signed-fetch`, others from `@dcl/sdk/network`. Check the SDK version. The `@dcl/sdk/network` import is the more current pattern. + +--- + +## WebSocket Connections + +### Basic Connection + +```typescript +executeTask(async () => { + const ws = new WebSocket('wss://example.com/ws') + + ws.onopen = () => { + console.log('Connected to WebSocket') + ws.send('Hello Server!') + } + + ws.onmessage = (event) => { + console.log('Received:', event.data) + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + } + + ws.onclose = () => { + console.log('Disconnected from WebSocket') + } +}) +``` + +### Reconnection Logic + +```typescript +executeTask(async () => { + let ws: WebSocket | null = null + let reconnectAttempts = 0 + const maxReconnectAttempts = 5 + + function connect() { + ws = new WebSocket('wss://example.com/ws') + + ws.onopen = () => { + console.log('Connected') + reconnectAttempts = 0 + } + + ws.onclose = () => { + if (reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++ + setTimeout(connect, 1000 * reconnectAttempts) // exponential backoff + } + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + } + } + + connect() +}) +``` + +### Heartbeat Pattern + +Send periodic pings to keep the connection alive: + +```typescript +ws.onopen = () => { + const heartbeat = setInterval(() => { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'ping' })) + } else { + clearInterval(heartbeat) + } + }, 30000) +} +``` + +### Message Format Convention + +Use JSON with a `type` field for structured communication: + +```typescript +// Send +ws.send(JSON.stringify({ type: 'playerMove', position: { x: 8, y: 1, z: 8 } })) + +// Receive +ws.onmessage = (event) => { + const msg = JSON.parse(event.data) + switch (msg.type) { + case 'gameState': handleGameState(msg); break + case 'playerJoin': handlePlayerJoin(msg); break + case 'playerLeave': handlePlayerLeave(msg); break + } +} +``` + +--- + +## Multiplayer Testing + +Open multiple browser windows to test multiplayer locally: + +1. Use the Creator Hub Preview button multiple times (each window is a separate player) +2. Or use the URL: `decentraland://realm=http://127.0.0.1:8000&local-scene=true&debug=true` + +### Track Active Players + +```typescript +function multiplayerTestSystem() { + const players = Array.from(engine.getEntitiesWith(PlayerIdentityData)) + console.log(`Active players: ${players.length}`) + + players.forEach(([entity, playerData]) => { + const transform = Transform.getOrNull(entity) + if (transform) { + console.log(`Player ${playerData.address} at:`, transform.position) + } + }) +} + +engine.addSystem(multiplayerTestSystem) +``` + +--- + +## Architecture Patterns + +### Optimistic Updates + +Apply changes locally immediately, then let sync propagate. With `syncEntity`, local mutations are shown instantly while the SDK handles replication: + +```typescript +// Player clicks a door — update locally, sync handles the rest +Transform.getMutable(door).rotation = Quaternion.fromEulerDegrees(0, 90, 0) +``` + +### Conflict Resolution + +`syncEntity` uses last-write-wins. The most recent mutation from any player becomes the authoritative state. For cases requiring stricter control, use an authoritative server via WebSocket. + +### Authority Model + +- **Decentralized (syncEntity):** Any player can mutate synced components. Good for simple shared objects. +- **Authoritative server (WebSocket):** Server validates and broadcasts state. Use for competitive games, economies, or anti-cheat. +- **Hybrid:** Use `syncEntity` for world objects, WebSocket for game logic validation. + +### Single Player Mode (Worlds) + +For Decentraland Worlds that do not need multiplayer, set the scene.json adapter to offline: + +```json +{ + "worldConfiguration": { + "name": "my-world.dcl.eth", + "fixedAdapter": "offline:offline" + } +} +``` + +No `syncEntity` or `MessageBus` needed in offline mode. + +--- + +## Common Recipes + +### Synced Door + +```typescript +import { syncEntity } from '@dcl/sdk/network' + +enum SyncIds { DOOR = 1 } + +const door = engine.addEntity() +Transform.create(door, { position: Vector3.create(8, 1, 8) }) +MeshRenderer.setBox(door) +syncEntity(door, [Transform.componentId], SyncIds.DOOR) + +let doorOpen = false +pointerEventsSystem.onPointerDown( + { entity: door, opts: { button: InputAction.IA_POINTER, hoverText: 'Toggle Door' } }, + () => { + doorOpen = !doorOpen + Transform.getMutable(door).rotation = doorOpen + ? Quaternion.fromEulerDegrees(0, 90, 0) + : Quaternion.fromEulerDegrees(0, 0, 0) + } +) +``` + +### Shared Scoreboard (syncEntity + MessageBus) + +```typescript +import { syncEntity } from '@dcl/sdk/network' +import { MessageBus } from '@dcl/sdk/message-bus' + +const bus = new MessageBus() + +enum SyncIds { SCOREBOARD = 10 } + +const scoreboard = engine.addEntity() +Transform.create(scoreboard, { position: Vector3.create(8, 2, 8) }) +TextShape.create(scoreboard, { text: 'Score: 0' }) +syncEntity(scoreboard, [TextShape.componentId], SyncIds.SCOREBOARD) + +bus.on('score-update', (data: { score: number }) => { + TextShape.getMutable(scoreboard).text = `Score: ${data.score}` +}) + +// Call from game logic: +// bus.emit('score-update', { score: newScore }) +``` + +### Chat System (MessageBus) + +```typescript +import { MessageBus } from '@dcl/sdk/message-bus' + +const bus = new MessageBus() + +type ChatMessage = { sender: string; text: string; timestamp: number } + +bus.on('chat', (msg: ChatMessage) => { + console.log(`[${msg.sender}]: ${msg.text}`) + // Update UI with message +}) + +function sendChat(text: string, playerName: string) { + bus.emit('chat', { sender: playerName, text, timestamp: Date.now() }) +} +``` + +### Multiplayer Game State (Combined) + +```typescript +import { syncEntity } from '@dcl/sdk/network' +import { MessageBus } from '@dcl/sdk/message-bus' + +const bus = new MessageBus() + +// Sync the interactive cube — persistent state +const cube = engine.addEntity() +Transform.create(cube, { position: Vector3.create(8, 1, 8) }) +MeshRenderer.setBox(cube) +Material.setPbrMaterial(cube, { albedoColor: Color4.Blue() }) +syncEntity(cube, [Transform.componentId, Material.componentId], 100) + +// Use MessageBus for ephemeral color-change events +pointerEventsSystem.onPointerDown( + { entity: cube, opts: { button: InputAction.IA_POINTER, hoverText: 'Change Color' } }, + () => { + const newColor = Color4.create(Math.random(), Math.random(), Math.random(), 1) + bus.emit('cube-color-change', { cubeId: 100, color: newColor }) + } +) + +bus.on('cube-color-change', (data: any) => { + Material.getMutable(cube).albedoColor = data.color +}) +``` + +--- + +## Reference Files + +- `references/networking-sync.md` — Full API reference and code examples for syncEntity, MessageBus, fetch, signedFetch, and WebSocket diff --git a/ai-sdk-context/dcl-multiplayer/references/networking-sync.md b/ai-sdk-context/dcl-multiplayer/references/networking-sync.md new file mode 100644 index 0000000..7fe4af7 --- /dev/null +++ b/ai-sdk-context/dcl-multiplayer/references/networking-sync.md @@ -0,0 +1,570 @@ +# Decentraland SDK7 Networking and Sync Reference + +Extracted from sdk7-complete-reference.md and sdk7-examples.mdc. + +--- + +## syncEntity API + +### Import + +```typescript +import { syncEntity } from '@dcl/sdk/network' +``` + +### Signature + +```typescript +syncEntity(entity: Entity, componentIds: number[], syncId?: number): void +``` + +- `entity` — the ECS entity to synchronize across all players +- `componentIds` — array of component IDs to sync (e.g., `[Transform.componentId, MeshRenderer.componentId]`) +- `syncId` — unique numeric ID for predefined entities; omit for player-spawned entities (auto-assigned) + +### Predefined Entity Sync (Explicit IDs) + +Use an enum to manage unique sync IDs and avoid collisions: + +```typescript +import { syncEntity } from '@dcl/sdk/network' +import { engine, Transform, MeshRenderer } from '@dcl/sdk/ecs' +import { Vector3 } from '@dcl/sdk/math' + +enum EntityIds { + DOOR = 1, + ELEVATOR = 2, + DRAWBRIDGE = 3 +} + +const door = engine.addEntity() +Transform.create(door, { position: Vector3.create(8, 1, 8) }) +MeshRenderer.setBox(door) +syncEntity(door, [Transform.componentId, MeshRenderer.componentId], EntityIds.DOOR) +``` + +### Player-Spawned Entity Sync (Auto IDs) + +```typescript +function createProjectile() { + const projectile = engine.addEntity() + Transform.create(projectile, { position: Vector3.create(4, 1, 4) }) + MeshRenderer.setSphere(projectile) + syncEntity(projectile, [Transform.componentId]) + return projectile +} +``` + +### Syncing Multiple Components + +```typescript +const complexEntity = engine.addEntity() +Transform.create(complexEntity, { position: Vector3.create(5, 1, 5) }) +MeshRenderer.setBox(complexEntity) + +syncEntity(complexEntity, [ + Transform.componentId, + MeshRenderer.componentId +], 2) +``` + +### Updating Synced Entities + +Mutate synced components directly. Changes propagate automatically: + +```typescript +Transform.getMutable(interactiveEntity).position = Vector3.create(4, 1, 4) +``` + +### Interactive Synced Entity + +```typescript +const interactiveEntity = engine.addEntity() +Transform.create(interactiveEntity, { position: Vector3.create(3, 1, 3) }) +MeshRenderer.setBox(interactiveEntity) +syncEntity(interactiveEntity, [Transform.componentId], 3) + +// Any mutation is automatically synced +Transform.getMutable(interactiveEntity).position = Vector3.create(4, 1, 4) +``` + +--- + +## Parent-Child Relationships in Multiplayer + +### Import + +```typescript +import { syncEntity, parentEntity, getParent, getChildren, removeParent } from '@dcl/sdk/network' +``` + +### Usage + +Both parent and child entities must be synced. Use `parentEntity()` instead of `Transform.parent`: + +```typescript +const parent = engine.addEntity() +const child = engine.addEntity() + +syncEntity(parent, [Transform.componentId], 1) +syncEntity(child, [Transform.componentId], 2) + +// Establish parent-child relationship +parentEntity(child, parent) + +// Query relationships +const parentRef = getParent(child) // Returns parent entity +const childrenArray = Array.from(getChildren(parent)) // Returns [child] + +// Remove parent relationship (child becomes child of root) +removeParent(child) +``` + +--- + +## Check Sync State + +### Import + +```typescript +import { isStateSyncronized } from '@dcl/sdk/network' +``` + +Note: the function is spelled `isStateSyncronized` (not "Synchronized") in the SDK. + +### Usage + +```typescript +function gameStateSystem() { + const isSynced = isStateSyncronized() + + if (isSynced) { + enableGameControls() + } else { + disableGameControls() + showSyncingMessage() + } +} + +engine.addSystem(gameStateSystem) +``` + +--- + +## MessageBus API + +### Import + +```typescript +import { MessageBus } from '@dcl/sdk/message-bus' +``` + +### Create Instance + +```typescript +const sceneMessageBus = new MessageBus() +``` + +### Emit Messages + +```typescript +sceneMessageBus.emit('player-action', { + playerId: 'player123', + action: 'jump', + timestamp: Date.now(), + position: Vector3.create(8, 1, 8) +}) +``` + +### Listen for Messages + +```typescript +type PlayerAction = { + playerId: string + action: string + timestamp: number + position: Vector3 +} + +sceneMessageBus.on('player-action', (data: PlayerAction) => { + console.log(`Player ${data.playerId} performed ${data.action}`) + handlePlayerAction(data) +}) +``` + +### Typed Message Patterns + +```typescript +type SpawnMessage = { + position: { x: number; y: number; z: number } + entityEnumId: number +} + +type UpdateMessage = { + entityId: number + position: { x: number; y: number; z: number } +} + +// Emit typed message +messageBus.emit('spawn', { + position: { x: 8, y: 1, z: 8 }, + entityEnumId: 1 +} as SpawnMessage) + +// Listen with type +messageBus.on('spawn', (message: SpawnMessage) => { + const entity = engine.addEntity() + Transform.create(entity, { + position: Vector3.create( + message.position.x, + message.position.y, + message.position.z + ) + }) + MeshRenderer.setBox(entity) + syncEntity(entity, [Transform.componentId], message.entityEnumId) +}) + +// Update messages +messageBus.emit('update', { + entityId: 1, + position: { x: 10, y: 1, z: 10 } +} as UpdateMessage) + +messageBus.on('update', (message: UpdateMessage) => { + const entity = engine.getEntityById(message.entityId) + if (entity) { + Transform.getMutable(entity).position = Vector3.create( + message.position.x, + message.position.y, + message.position.z + ) + } +}) +``` + +### Interaction Broadcast + +```typescript +function handlePlayerInteraction(entity: Entity) { + messageBus.emit('interaction', { + entityId: entity, + action: 'click', + timestamp: Date.now() + }) +} + +messageBus.on('interaction', (message) => { + console.log(`Entity ${message.entityId} was ${message.action}ed at ${message.timestamp}`) +}) +``` + +### Combined syncEntity + MessageBus Example + +```typescript +import { syncEntity } from '@dcl/sdk/network' +import { MessageBus } from '@dcl/sdk/message-bus' + +const sceneMessageBus = new MessageBus() + +function createMultiplayerCube() { + const cube = engine.addEntity() + Transform.create(cube, { position: Vector3.create(8, 1, 8) }) + MeshRenderer.setBox(cube) + Material.setPbrMaterial(cube, { albedoColor: Color4.Blue() }) + + syncEntity(cube, [Transform.componentId, Material.componentId], 100) + + pointerEventsSystem.onPointerDown( + { + entity: cube, + opts: { button: InputAction.IA_POINTER, hoverText: 'Change Color' } + }, + () => { + const newColor = Color4.create(Math.random(), Math.random(), Math.random(), 1) + sceneMessageBus.emit('cube-color-change', { + cubeId: 100, + color: newColor, + timestamp: Date.now() + }) + } + ) +} + +sceneMessageBus.on('cube-color-change', (data: any) => { + for (const [entity] of engine.getEntitiesWith(Transform, Material)) { + const material = Material.getMutable(entity) + material.albedoColor = data.color + } +}) +``` + +--- + +## fetch (REST API Calls) + +### Import + +```typescript +import { executeTask } from '@dcl/sdk/ecs' +``` + +All network calls must be wrapped in `executeTask`. + +### GET Request + +```typescript +executeTask(async () => { + try { + const response = await fetch('https://api.example.com/data') + const json = await response.json() + console.log('Response:', json) + } catch (error) { + console.error('Failed to fetch:', error) + } +}) +``` + +### POST Request + +```typescript +executeTask(async () => { + try { + const response = await fetch('https://api.example.com/data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + key: 'value' + }) + }) + const json = await response.json() + console.log('Response:', json) + } catch (error) { + console.error('Failed to fetch:', error) + } +}) +``` + +### POST with Game Data + +```typescript +executeTask(async () => { + try { + const response = await fetch('https://api.example.com/submit', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: 'player123', + score: 1500 + }) + }) + const result = await response.json() + console.log('Submission result:', result) + } catch (error) { + console.log('Submission failed:', error) + } +}) +``` + +--- + +## signedFetch (Authenticated Requests) + +### Import + +```typescript +import { signedFetch } from '@dcl/sdk/network' +``` + +`signedFetch` works like `fetch` but attaches a cryptographic signature that proves the player's Decentraland identity to the server. + +### Signed GET + +```typescript +executeTask(async () => { + try { + const response = await signedFetch('https://api.example.com/data') + const json = await response.json() + console.log('Response:', json) + } catch (error) { + console.error('Failed to fetch:', error) + } +}) +``` + +### Signed POST + +```typescript +executeTask(async () => { + try { + const response = await signedFetch('https://api.example.com/data', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + key: 'value' + }) + }) + const json = await response.json() + console.log('Response:', json) + } catch (error) { + console.error('Failed to fetch:', error) + } +}) +``` + +### Signed Action (Reward Claim) + +```typescript +executeTask(async () => { + try { + const response = await signedFetch('https://example.com/api/action', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + action: 'claimReward', + amount: 100 + }) + }) + const result = await response.json() + console.log('Transaction result:', result) + } catch (error) { + console.log('Transaction failed:', error) + } +}) +``` + +--- + +## WebSocket Connections + +### Basic Connection + +```typescript +import { executeTask } from '@dcl/sdk/ecs' + +executeTask(async () => { + const ws = new WebSocket('wss://example.com/ws') + + ws.onopen = () => { + console.log('Connected to WebSocket') + ws.send('Hello Server!') + } + + ws.onmessage = (event) => { + console.log('Received:', event.data) + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + } + + ws.onclose = () => { + console.log('Disconnected from WebSocket') + } +}) +``` + +### WebSocket with Reconnection Logic + +```typescript +executeTask(async () => { + let ws: WebSocket | null = null + let reconnectAttempts = 0 + const maxReconnectAttempts = 5 + + function connect() { + ws = new WebSocket('wss://example.com/ws') + + ws.onopen = () => { + console.log('Connected to WebSocket') + reconnectAttempts = 0 + ws?.send('Hello Server!') + } + + ws.onmessage = (event) => { + console.log('Received:', event.data) + } + + ws.onerror = (error) => { + console.error('WebSocket error:', error) + } + + ws.onclose = () => { + console.log('Disconnected from WebSocket') + if (reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++ + setTimeout(connect, 1000 * reconnectAttempts) + } + } + } + + connect() +}) +``` + +--- + +## Multiplayer Testing + +### Local Testing + +Open multiple browser windows to test multiplayer: +1. Use the Creator Hub Preview button multiple times +2. Or use URL: `decentraland://realm=http://127.0.0.1:8000&local-scene=true&debug=true` + +### Track Active Players + +```typescript +function multiplayerTestSystem() { + const players = Array.from(engine.getEntitiesWith(PlayerIdentityData)) + console.log(`Active players: ${players.length}`) + + players.forEach(([entity, playerData]) => { + const transform = Transform.getOrNull(entity) + if (transform) { + console.log(`Player ${playerData.address} at position:`, transform.position) + } + }) +} + +engine.addSystem(multiplayerTestSystem) +``` + +--- + +## Single Player Mode (Worlds) + +Configure `scene.json` for offline mode: + +```json +{ + "worldConfiguration": { + "name": "my-world.dcl.eth", + "fixedAdapter": "offline:offline" + } +} +``` + +In offline mode, players do not see each other. No `syncEntity` or `MessageBus` is needed: + +```typescript +function singlePlayerScene() { + const entity = engine.addEntity() + Transform.create(entity, { position: Vector3.create(8, 1, 8) }) + MeshRenderer.setBox(entity) + + pointerEventsSystem.onPointerDown( + { entity, opts: { button: InputAction.IA_POINTER } }, + () => { + const transform = Transform.getMutable(entity) + transform.position.y += 1 + } + ) +} +``` diff --git a/ai-sdk-context/dcl-npc/SKILL.md b/ai-sdk-context/dcl-npc/SKILL.md new file mode 100644 index 0000000..e0fd6a6 --- /dev/null +++ b/ai-sdk-context/dcl-npc/SKILL.md @@ -0,0 +1,519 @@ +--- +name: dcl-npc +description: "Assists with Decentraland SDK7 NPC and dialog systems when the user mentions NPCs, dialog trees, dcl-npc-toolkit, avatar NPCs, conversations, quest NPCs, shop NPCs, NPC movement, NPC interaction, or createNPC." +--- + +# Decentraland SDK7 NPC and Dialog Systems + +This skill covers creating NPCs, designing dialog trees, wiring interactions, and building advanced NPC patterns using the `dcl-npc-toolkit` for Decentraland SDK7 scenes. + +## 1. NPC Setup + +### Installation + +```bash +npm i dcl-npc-toolkit +``` + +### Import + +```typescript +import { createNPC, Dialog } from 'dcl-npc-toolkit' +``` + +### Creating an NPC with AvatarShape (no toolkit) + +Use `AvatarShape` directly on an entity for a humanoid NPC with Decentraland avatar appearance: + +```typescript +const npcEntity = engine.addEntity() +AvatarShape.create(npcEntity, { + id: 'npc-001', + name: 'Guide NPC', + bodyShape: 'urn:decentraland:off-chain:base-avatars:BaseMale', + wearables: [ + 'urn:decentraland:off-chain:base-avatars:blue_tshirt', + 'urn:decentraland:off-chain:base-avatars:brown_pants' + ], + eyeColor: Color3.create(0.3, 0.7, 0.9), + skinColor: Color3.create(0.8, 0.6, 0.5), + hairColor: Color3.create(0.1, 0.1, 0.1), + talking: false +}) +Transform.create(npcEntity, { + position: Vector3.create(8, 0.25, 8), + rotation: { x: 0, y: 0, z: 0, w: 1 } +}) +``` + +### Creating an NPC with a Custom GLB Model (toolkit) + +Use `createNPC` with `type: 'custom'` and a `model` path to use a GLB instead of an avatar: + +```typescript +const guide = createNPC( + { + position: { x: 8, y: 0, z: 8 }, + rotation: { x: 0, y: 0, z: 0, w: 1 }, + scale: { x: 1, y: 1, z: 1 } + }, + { + type: 'custom', + model: 'models/guide.glb', + dialog: [{ text: "Welcome!", isEndOfDialog: true } as Dialog], + onActivate: () => { + console.log('Guide activated') + } + } +) +``` + +The `createNPC` function returns an entity and handles click detection, dialog state, and animation management internally. + +## 2. Dialog Tree Design + +Dialogs are defined as a `Dialog[]` array. Each element is a dialog step, referenced by its array index. The NPC advances through the array sequentially unless redirected by buttons or `goToDialog`. + +### Dialog Structure + +```typescript +const dialogs: Dialog[] = [ + // Index 0: opening line with choices + { + text: 'Welcome, traveler! What do you seek?', + isQuestion: true, + buttons: [ + { label: 'Tell me about this place', goToDialog: 1 }, + { label: 'Give me a quest', goToDialog: 2 }, + { label: 'Goodbye', goToDialog: 3 } + ] + } as unknown as Dialog, + // Index 1: info branch + { text: 'These lands are rich with secrets and stories.', isEndOfDialog: true } as Dialog, + // Index 2: quest branch + { text: 'Take this task and return victorious!', isEndOfDialog: true } as Dialog, + // Index 3: farewell + { text: 'Safe travels!', isEndOfDialog: true } as Dialog +] +``` + +Key design rules: +- Each dialog step is an object with at minimum a `text` field. +- Use `isEndOfDialog: true` to close the dialog window after that step. +- Use `isQuestion: true` with a `buttons` array for branching choices. +- Each button has a `label` (display text) and `goToDialog` (index into the array). +- Without `isQuestion` or `isEndOfDialog`, the dialog auto-advances to the next index. + +## 3. Dialog Types Reference + +| Property | Type | Description | +|---|---|---| +| `text` | `string` | The dialog text displayed to the player. Required. | +| `isEndOfDialog` | `boolean` | If true, closes the dialog window after this step. | +| `isQuestion` | `boolean` | If true, shows button choices instead of auto-advancing. | +| `buttons` | `{ label: string, goToDialog: number }[]` | Choice buttons shown when `isQuestion` is true. | +| `typeSpeed` | `number` | Speed of the typewriter text effect (characters per second). | +| `ifPressE` | `number` | Dialog index to jump to if player presses E. | +| `ifPressF` | `number` | Dialog index to jump to if player presses F. | +| `triggeredByNext` | `() => void` | Callback function executed when this dialog step is reached. | +| `goToDialog` | `number` | Auto-jump to this dialog index (for non-question steps). | + +### Type casting note + +When using `isQuestion` with `buttons`, cast as `as unknown as Dialog` because the toolkit's type definition may not include the buttons field directly. For simple text-only steps, cast as `as Dialog`. + +## 4. NPC Interaction Wiring + +### Click-based activation (toolkit) + +When using `createNPC`, pass an `onActivate` callback: + +```typescript +const npc = createNPC( + { position: { x: 8, y: 0, z: 8 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { + type: 'custom', + model: 'models/npc.glb', + dialog: myDialogs, + onActivate: () => { + console.log('NPC activated') + } + } +) +``` + +### Click-based activation (manual, no toolkit) + +For AvatarShape NPCs not created via `createNPC`, wire clicks manually: + +```typescript +import { pointerEventsSystem, InputAction } from '@dcl/sdk/ecs' + +pointerEventsSystem.onPointerDown( + { + entity: npc, + opts: { button: InputAction.IA_POINTER, hoverText: 'Talk to NPC' } + }, + () => { + handleNPCInteraction(npc) + } +) +``` + +### Toolkit Dialog UI setup + +To display the toolkit's dialog HUD, mount `NpcUtilsUi` in your scene UI: + +```typescript +import { NpcUtilsUi } from 'dcl-npc-toolkit' + +ReactEcsRenderer.setUiRenderer(() => ( + + + +)) +``` + +### Opening dialogs on entities not created via createNPC + +If you need `openDialogWindow` or `talkBubble` on an entity that was NOT created via `createNPC`, you must initialize toolkit internal data first: + +```typescript +import { addDialog } from 'dcl-npc-toolkit/dist/dialog' +import { openDialogWindow } from 'dcl-npc-toolkit' +import { npcDataComponent } from 'dcl-npc-toolkit/dist/npc' + +// Initialize toolkit data +addDialog(npcEntity) +npcDataComponent.set(npcEntity as any, { + introduced: false, inCooldown: false, coolDownDuration: 5, + faceUser: undefined, walkingSpeed: 2, walkingAnim: undefined, + pathData: undefined, currentPathData: [], manualStop: false, + pathIndex: 0, state: 'STANDING', idleAnim: 'Idle', + hasBubble: false, turnSpeed: 2, + theme: 'https://decentraland.org/images/ui/light-atlas-v3.png', + bubbleXOffset: 0, bubbleYOffset: 0, lastPlayedAnim: 'Idle' +}) + +// Then open the dialog +openDialogWindow(npcEntity, dialogs, 0) +``` + +### Bubble dialogs (world-space speech bubbles) + +```typescript +import { addDialog } from 'dcl-npc-toolkit/dist/dialog' +import { createDialogBubble } from 'dcl-npc-toolkit/dist/bubble' +import { talkBubble } from 'dcl-npc-toolkit' + +addDialog(npcEntity) +createDialogBubble(npcEntity) +talkBubble(npcEntity, dialogs, 0) +``` + +### Common errors and fixes + +- **"Cannot set properties of undefined (setting 'script')"** in bubble.js: Call `createDialogBubble(npc)` before `talkBubble`. +- **Error in `` from `getTheme`/`displayDialog`**: The entity is missing `npcDataComponent`. Call `addDialog` and set `npcDataComponent` before `openDialogWindow`. + +### Interaction options + +- `faceUser`: NPC turns to face the player during interaction. +- `cooldownDuration`: Seconds before the NPC can be activated again. +- `onWalkAway`: Callback when player moves away during conversation (toolkit manages this internally when using `createNPC`). + +## 5. NPC Movement + +### Path following with Tweens + +```typescript +function createPatrollingNPC() { + const patrollingNPC = createNPC( + { position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { type: 'custom', model: 'models/guard.glb' } + ) + + const patrolPath = [ + Vector3.create(0, 0.25, 0), + Vector3.create(5, 0.25, 0), + Vector3.create(5, 0.25, 5), + Vector3.create(0, 0.25, 5), + Vector3.create(0, 0.25, 0) + ] + + // Play walk animation via toolkit + npc.playAnimation(patrollingNPC, 'Walk', true) + + // Move along path using Tween + TweenSequence + Tween.setMove(patrollingNPC, patrolPath[0], patrolPath[1], 4000, EasingFunction.EF_LINEAR) + TweenSequence.create(patrollingNPC, { + sequence: [ + { duration: 4000, easingFunction: EasingFunction.EF_LINEAR, mode: Tween.Mode.Move({ start: patrolPath[1], end: patrolPath[2] }) }, + { duration: 4000, easingFunction: EasingFunction.EF_LINEAR, mode: Tween.Mode.Move({ start: patrolPath[2], end: patrolPath[3] }) }, + { duration: 4000, easingFunction: EasingFunction.EF_LINEAR, mode: Tween.Mode.Move({ start: patrolPath[3], end: patrolPath[4] }) } + ], + loop: TweenLoop.TL_RESTART + }) + + return patrollingNPC +} +``` + +### NPC following the player + +```typescript +function createFollowingNPC() { + const followingNPC = createNPC( + { position: { x: 2, y: 0, z: 2 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { type: 'custom', model: 'models/companion.glb' } + ) + + engine.addSystem(() => { + if (!Transform.has(engine.PlayerEntity)) return + const playerPos = Transform.get(engine.PlayerEntity).position + const npcTransform = Transform.getMutable(followingNPC) + const currentPos = npcTransform.position + + const direction = Vector3.subtract(playerPos, currentPos) + const distance = Vector3.length(direction) + + if (distance > 3 && distance < 10) { + const normalizedDir = Vector3.normalize(direction) + npcTransform.position = Vector3.add(currentPos, Vector3.scale(normalizedDir, 0.05)) + npcTransform.rotation = Quaternion.lookRotation(normalizedDir) + } + }) + + return followingNPC +} +``` + +### Walkable area constraints + +Keep NPC positions clamped within scene boundaries. Decentraland parcels are 16x16 meters. Use `Math.max`/`Math.min` to clamp x and z positions within `[0, 16 * parcels]` range in your movement system. + +## 6. Advanced Patterns + +### Shop NPC + +Combine dialog choices with purchase logic. Use `triggeredByNext` on a dialog step to execute the buy operation when the player reaches that step: + +```typescript +const shopDialogs: Dialog[] = [ + { + text: 'Welcome to my shop! What would you like?', + isQuestion: true, + buttons: [ + { label: 'Buy Health Potion (25 coins)', goToDialog: 1 }, + { label: 'Buy Magic Sword (100 coins)', goToDialog: 2 }, + { label: 'Just browsing', goToDialog: 3 } + ] + } as unknown as Dialog, + { text: 'One Health Potion coming up!', triggeredByNext: () => buyItem('potion', 25), isEndOfDialog: true } as Dialog, + { text: 'A fine choice! Here is your sword.', triggeredByNext: () => buyItem('sword', 100), isEndOfDialog: true } as Dialog, + { text: 'Feel free to look around!', isEndOfDialog: true } as Dialog +] + +const shopNPC = createNPC( + { position: { x: 8, y: 0, z: 8 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { type: 'custom', model: 'models/merchant.glb', dialog: shopDialogs } +) +``` + +For crypto/MANA transactions, call the Decentraland crypto API within the `triggeredByNext` callback. + +### Quest Giver NPC + +Track quest state externally and swap dialog arrays based on progress: + +```typescript +let questState: 'not_started' | 'in_progress' | 'completed' = 'not_started' + +const questDialogsNotStarted: Dialog[] = [ + { text: 'I need someone brave. Will you help?', isQuestion: true, buttons: [ + { label: 'Accept quest', goToDialog: 1 }, + { label: 'Not now', goToDialog: 2 } + ]} as unknown as Dialog, + { text: 'Find the lost artifact in the cave to the north!', triggeredByNext: () => { questState = 'in_progress' }, isEndOfDialog: true } as Dialog, + { text: 'Come back when you are ready.', isEndOfDialog: true } as Dialog +] + +const questDialogsInProgress: Dialog[] = [ + { text: 'Have you found the artifact yet? Keep looking!', isEndOfDialog: true } as Dialog +] + +const questDialogsCompleted: Dialog[] = [ + { text: 'You found it! You are a true hero. Here is your reward.', isEndOfDialog: true } as Dialog +] + +const questNPC = createNPC( + { position: { x: 10, y: 0, z: 10 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { + type: 'custom', + model: 'models/quest_giver.glb', + onActivate: () => { + let dialogs = questDialogsNotStarted + if (questState === 'in_progress') dialogs = questDialogsInProgress + if (questState === 'completed') dialogs = questDialogsCompleted + openDialogWindow(questNPC, dialogs, 0) + } + } +) +``` + +### Guard NPC + +Blocks a path and only lets the player pass after a dialog condition is met: + +```typescript +let hasAccess = false + +const guardDialogs: Dialog[] = [ + { text: 'Halt! You cannot pass without the key.', isQuestion: true, buttons: [ + { label: 'I have the key', goToDialog: 1 }, + { label: 'I will find it', goToDialog: 2 } + ]} as unknown as Dialog, + { text: 'Very well, you may pass.', triggeredByNext: () => { hasAccess = true; removeBarrier() }, isEndOfDialog: true } as Dialog, + { text: 'Return when you have it.', isEndOfDialog: true } as Dialog +] +``` + +### Custom GLB Model NPC + +When using `createNPC` with `type: 'custom'`, the toolkit uses `GltfContainer` internally to load the model. Ensure your GLB file is in the scene's file tree (typically under `models/`). The NPC's animations are driven from the GLB's embedded animation clips. Use `npc.playAnimation(entity, 'ClipName', loop)` to trigger them. + +## 7. NPC Configuration Options + +Options passed as the second argument to `createNPC`: + +| Option | Type | Description | +|---|---|---| +| `type` | `'custom'` | Use a custom GLB model. | +| `model` | `string` | Path to the GLB model file (e.g. `'models/npc.glb'`). | +| `dialog` | `Dialog[]` | Array of dialog steps for the NPC. | +| `onActivate` | `() => void` | Callback when the NPC is clicked/activated. | +| `faceUser` | `boolean` | NPC rotates to face the player during interaction. | +| `cooldownDuration` | `number` | Seconds before NPC can be activated again. | +| `onWalkAway` | `() => void` | Callback when the player walks away during dialog. | +| `idleAnim` | `string` | Name of the idle animation clip in the GLB. | +| `walkingAnim` | `string` | Name of the walking animation clip in the GLB. | +| `walkingSpeed` | `number` | Movement speed when following a path. | +| `turnSpeed` | `number` | Rotation speed when turning to face the player. | +| `hasBubble` | `boolean` | Enable world-space speech bubble instead of HUD dialog. | +| `bubbleXOffset` | `number` | Horizontal offset for the speech bubble. | +| `bubbleYOffset` | `number` | Vertical offset for the speech bubble. | + +## 8. Common Recipes + +### Simple Greeter + +```typescript +const greeter = createNPC( + { position: { x: 8, y: 0, z: 8 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { + type: 'custom', + model: 'models/greeter.glb', + dialog: [ + { text: "Hello! Welcome to our scene. Enjoy your visit!", isEndOfDialog: true } as Dialog + ], + onActivate: () => console.log('Greeter activated') + } +) +``` + +### Multi-Branch Dialog + +```typescript +const dialogs: Dialog[] = [ + { text: 'Greetings! I know many things.', isQuestion: true, buttons: [ + { label: 'Tell me about the forest', goToDialog: 1 }, + { label: 'Tell me about the castle', goToDialog: 3 }, + { label: 'Goodbye', goToDialog: 5 } + ]} as unknown as Dialog, + { text: 'The forest is ancient and full of mysteries.' } as Dialog, + { text: 'Many creatures dwell within. Be careful!', isEndOfDialog: true } as Dialog, + { text: 'The castle was built centuries ago by a powerful king.' } as Dialog, + { text: 'It is said treasure still lies in the dungeons.', isEndOfDialog: true } as Dialog, + { text: 'Farewell, traveler!', isEndOfDialog: true } as Dialog +] +``` + +### Walking NPC (Patrol) + +```typescript +import * as npc from 'dcl-npc-toolkit' + +const guard = createNPC( + { position: { x: 2, y: 0, z: 2 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { type: 'custom', model: 'models/guard.glb' } +) + +npc.playAnimation(guard, 'Walk', true) + +// Use Tween + TweenSequence for patrol (see section 5) +``` + +### NPC with Animations + +```typescript +const dancer = createNPC( + { position: { x: 8, y: 0, z: 8 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { + type: 'custom', + model: 'models/dancer.glb', + idleAnim: 'Idle', + onActivate: () => { + npc.playAnimation(dancer, 'Dance', true) + // Return to idle after 5 seconds + utils.timers.setTimeout(() => { + npc.playAnimation(dancer, 'Idle', true) + }, 5000) + } + } +) +``` + +### AvatarShape NPC with Emotes + +For avatar-based NPCs (not GLB), use predefined emotes: + +```typescript +const waver = engine.addEntity() +AvatarShape.create(waver, { + id: 'waver-npc', + name: 'Friendly NPC', + expressionTriggerId: 'wave' +}) +Transform.create(waver, { position: Vector3.create(8, 0.25, 8) }) + +// Change emote dynamically +AvatarShape.getMutable(waver).expressionTriggerId = 'clap' +``` + +Available emotes: `wave`, `fistpump`, `robot`, `raiseHand`, `clap`, `money`, `kiss`, `tik`, `hammer`, `tektonik`, `dontsee`, `handsair`, `shrug`, `disco`, `dab`, `headexplode`, `buttonDown`, `buttonFront`, `getHit`, `knockOut`, `lever`, `openChest`, `openDoor`, `punch`, `push`, `swingWeaponOneHand`, `swingWeaponTwoHands`, `throw`, `sittingChair1`, `sittingChair2`, `sittingGround1`, `sittingGround2`. + +## 9. Multiplayer NPC Sync + +For NPCs whose state must be consistent across all players, use `syncEntity`: + +```typescript +import { syncEntity } from '@dcl/sdk/network' + +syncEntity(npcEntity, [Transform.componentId, AvatarShape.componentId], 1) +``` + +Use `MessageBus` for broadcasting NPC interaction events to all players: + +```typescript +import { MessageBus } from '@dcl/sdk/message-bus' +const messageBus = new MessageBus() + +messageBus.emit('npc-interaction', { npcId: 'guard-01', action: 'talked' }) +messageBus.on('npc-interaction', (msg) => { /* handle */ }) +``` + +## 10. Cross-References + +- **Trigger areas** for starting NPC conversations when a player approaches (proximity-based activation) are covered in the **dcl-scene** skill. +- **UI rendering** (ReactEcsRenderer, UiEntity, Label, Button) used in shop NPC patterns is covered in Decentraland SDK7 UI documentation. +- **Tween and TweenSequence** for NPC movement are part of the core `@dcl/sdk/ecs` module. diff --git a/ai-sdk-context/dcl-npc/references/npc-toolkit.md b/ai-sdk-context/dcl-npc/references/npc-toolkit.md new file mode 100644 index 0000000..1e1a39d --- /dev/null +++ b/ai-sdk-context/dcl-npc/references/npc-toolkit.md @@ -0,0 +1,712 @@ +# DCL NPC Toolkit Reference + +## Installation & Import + +To install the NPC toolkit, run the following command: + +```bash +npm i dcl-npc-toolkit +``` + +Then import the toolkit in your code: + +```typescript +import { createNPC, Dialog } from 'dcl-npc-toolkit' +``` + +Then you can use the toolkit to create NPCs. + + +## Basic NPC Creation + +### Create Simple NPC +```typescript +const npcEntity = engine.addEntity() + +// Add avatar shape component +AvatarShape.create(npcEntity, { + id: 'npc-001', // Required: unique identifier + name: 'Guide NPC', // Display name (optional, default: "NPC") + bodyShape: 'urn:decentraland:off-chain:base-avatars:BaseMale', // Optional + wearables: [ // Optional: array of wearable URNs + 'urn:decentraland:off-chain:base-avatars:blue_tshirt', + 'urn:decentraland:off-chain:base-avatars:brown_pants' + ], + emotes: [], // Optional: array of emote URNs + eyeColor: Color3.create(0.3, 0.7, 0.9), // Optional + skinColor: Color3.create(0.8, 0.6, 0.5), // Optional + hairColor: Color3.create(0.1, 0.1, 0.1), // Optional + talking: false // Optional: shows voice chat indicator +}) + +// Position the NPC +Transform.create(npcEntity, { + position: Vector3.create(8, 0.25, 8), + rotation: { x: 0, y: 0, z: 0, w: 1 } +}) +``` + +### Create NPC with Random Appearance +```typescript +const randomNPC = engine.addEntity() +AvatarShape.create(randomNPC, { + id: 'random-npc-' + Math.random().toString(36).substr(2, 9) +}) +Transform.create(randomNPC, { + position: Vector3.create(4, 0.25, 4) +}) +``` + +### Create NPC with toolkit (GLB + dialog) +```typescript +// Use the toolkit to instantiate an NPC from a GLB with a simple dialog and activation callback +const guide = createNPC( + { + position: { x: 8, y: 0, z: 8 }, + rotation: { x: 0, y: 0, z: 0, w: 1 }, + scale: { x: 1, y: 1, z: 1 } + }, + { + type: 'custom', + model: 'models/guide.glb', + dialog: [{ text: "Welcome!", isEndOfDialog: true } as Dialog], + onActivate: () => { + // Called on click; open dialog or run logic + console.log('Guide activated') + } + } +) +``` + +## NPC Animations & Emotes + +### Play Predefined Emotes +```typescript +// Set NPC to play a predefined emote +AvatarShape.create(npc, { + id: 'animated-npc', + expressionTriggerId: 'wave' // 'clap', 'dance', 'robot', etc. +}) + +// Change emote dynamically +AvatarShape.getMutable(npc).expressionTriggerId = 'clap' +``` + +### Available Predefined Emotes +```typescript +// Social emotes +'wave', 'fistpump', 'robot', 'raiseHand', 'clap', 'money', 'kiss', 'tik', 'hammer', 'tektonik', 'dontsee', 'handsair', 'shrug', 'disco', 'dab', 'headexplode' + +// Action emotes +'buttonDown', 'buttonFront', 'getHit', 'knockOut', 'lever', 'openChest', 'openDoor', 'punch', 'push', 'swingWeaponOneHand', 'swingWeaponTwoHands', 'throw' + +// Sitting emotes +'sittingChair1', 'sittingChair2', 'sittingGround1', 'sittingGround2' +``` + +### Custom Emotes (Advanced) +```typescript +// Note: Custom emotes require .glb files ending with _emote.glb +// This is currently limited and may not work with NPCs +AvatarShape.create(npc, { + id: 'custom-npc', + emotes: [ + 'urn:decentraland:ethereum:erc721:0xcontract:tokenId' // NFT emote URN + ] +}) +``` + +## NPC Appearance Management + +### Copy Player Appearance +```typescript +function copyPlayerAppearance(npcEntity: Entity) { + const playerData = getPlayer() + + if (!playerData || !playerData.wearables) return + + const mutableNPC = AvatarShape.getMutable(npcEntity) + + // Copy wearables + mutableNPC.wearables = playerData.wearables + + // Copy avatar base properties + mutableNPC.bodyShape = playerData.avatar?.bodyShapeUrn + mutableNPC.eyeColor = playerData.avatar?.eyesColor + mutableNPC.skinColor = playerData.avatar?.skinColor + mutableNPC.hairColor = playerData.avatar?.hairColor +} +``` + +### Update NPC Properties +```typescript +// Update NPC name +AvatarShape.getMutable(npc).name = 'New Name' + +// Update talking status +AvatarShape.getMutable(npc).talking = true + +// Update wearables +AvatarShape.getMutable(npc).wearables = [ + 'urn:decentraland:off-chain:base-avatars:red_tshirt', + 'urn:decentraland:off-chain:base-avatars:black_pants' +] + +// Update colors +AvatarShape.getMutable(npc).eyeColor = Color3.create(1, 0, 0) // Red eyes +AvatarShape.getMutable(npc).skinColor = Color3.create(0.9, 0.8, 0.7) +AvatarShape.getMutable(npc).hairColor = Color3.create(0.8, 0.6, 0.4) +``` + +## NPC Interaction Systems + +### Click Interaction +```typescript +import { pointerEventsSystem, InputAction } from '@dcl/sdk/ecs' + +// Add click interaction to NPC +pointerEventsSystem.onPointerDown( + { + entity: npc, + opts: { + button: InputAction.IA_POINTER, + hoverText: 'Talk to NPC' + } + }, + () => { + console.log('NPC clicked!') + // Handle NPC interaction + handleNPCInteraction(npc) + } +) +``` + +### Proximity Interaction (TriggerArea) +```typescript +// Use SDK7 TriggerArea directly (no @dcl-sdk/utils) +import { TriggerArea, triggerAreaEventsSystem, ColliderLayer } from '@dcl/sdk/ecs' + +// Add a spherical trigger around the NPC +TriggerArea.setSphere(npcEntity, ColliderLayer.CL_PLAYER) +Transform.createOrReplace(npcEntity, { + position: Transform.get(npcEntity).position, + scale: Vector3.create(3, 3, 3) // radius ~3 +}) + +// Listen for player entering/leaving +triggerAreaEventsSystem.onTriggerEnter(npcEntity, () => { + showNPCDialogue(npcEntity) +}) +triggerAreaEventsSystem.onTriggerExit(npcEntity, () => { + hideNPCDialogue() +}) +``` + +## NPC Dialogue Systems + +### Simple Text Dialogue +```typescript +// Create a talking NPC using the toolkit's Dialog[] +const greeter = createNPC( + { position: { x: 8, y: 0, z: 8 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { + type: 'custom', + model: 'models/greeter.glb', + dialog: [ + { text: "Hello! I'm your guide. How can I help you today?", isEndOfDialog: true } as Dialog + ], + onActivate: () => { + // Optional extra behavior when clicked + console.log('Greeter activated') + } + } +) +``` + +### Multi-Choice Dialogue +```typescript +// Create an NPC with a multi-choice dialog flow via toolkit Dialog[] +const questGiver = createNPC( + { position: { x: 10, y: 0, z: 10 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { + type: 'custom', + model: 'models/quest_giver.glb', + dialog: [ + { + text: 'Welcome, traveler! What do you seek?', + isQuestion: true, + buttons: [ + { label: 'Tell me about this place', goToDialog: 1 }, + { label: 'Give me a quest', goToDialog: 2 }, + { label: 'Goodbye', goToDialog: 3 } + ] + } as unknown as Dialog, + { text: 'These lands are rich with secrets and stories.', isEndOfDialog: true } as Dialog, + { text: 'Take this task and return victorious!', isEndOfDialog: true } as Dialog, + { text: 'Safe travels!', isEndOfDialog: true } as Dialog + ] + } +) +``` + +### Toolkit Dialog UI vs Bubble UI (SDK7) + +When opening dialogs on an EXISTING entity (not created via `createNPC`), the toolkit expects internal per-NPC data to exist. Otherwise, UI helpers (e.g., `getTheme`, `displayDialog`) may crash. + +- UI Dialog (React HUD) + 1) Mount the toolkit UI once in your scene UI: + ```typescript + import { NpcUtilsUi } from 'dcl-npc-toolkit' + + ReactEcsRenderer.setUiRenderer(() => ( + + + + )) + ``` + 2) Ensure the NPC has toolkit dialog data before opening a window: + ```typescript + import { addDialog } from 'dcl-npc-toolkit/dist/dialog' + import { openDialogWindow } from 'dcl-npc-toolkit' + import { npcDataComponent } from 'dcl-npc-toolkit/dist/npc' + + function ensureNpcToolkitData(entity: Entity) { + if (npcDataComponent.has(entity)) return + npcDataComponent.set(entity as any, { + introduced: false, + inCooldown: false, + coolDownDuration: 5, + faceUser: undefined, + walkingSpeed: 2, + walkingAnim: undefined, + pathData: undefined, + currentPathData: [], + manualStop: false, + pathIndex: 0, + state: 'STANDING', + idleAnim: 'Idle', + hasBubble: false, + turnSpeed: 2, + theme: 'https://decentraland.org/images/ui/light-atlas-v3.png', + bubbleXOffset: 0, + bubbleYOffset: 0, + lastPlayedAnim: 'Idle' + }) + } + + // On setup + addDialog(npcEntity) // required for dialog state + ensureNpcToolkitData(npcEntity) // required for UI helpers + + // On click + openDialogWindow(npcEntity, dialogs, startIndex) + ``` + +- Bubble Dialog (world-space speech bubbles) + 1) Initialize a bubble instance per entity BEFORE calling `talkBubble`: + ```typescript + import { addDialog } from 'dcl-npc-toolkit/dist/dialog' + import { createDialogBubble } from 'dcl-npc-toolkit/dist/bubble' + import { talkBubble } from 'dcl-npc-toolkit' + + addDialog(npcEntity) + createDialogBubble(npcEntity) + talkBubble(npcEntity, dialogs, startIndex) + ``` + +Common symptoms and fixes: +- Error: "Cannot set properties of undefined (setting 'script')" in bubble.js -> Missing `createDialogBubble(npc)` before `talkBubble`. +- Error in `` caused by `getTheme`/`displayDialog` -> Missing `npcDataComponent` for that entity; call `addDialog` and ensure a minimal `npcDataComponent.set(...)` exists before `openDialogWindow`. + +## NPC Movement & Behavior + +### Simple Path Following +```typescript +function createPatrollingNPC() { + // Create NPC via toolkit (handles walk/idle animations) + const patrollingNPC = createNPC( + { position: { x: 0, y: 0, z: 0 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { type: 'custom', model: 'models/guard.glb' } + ) + + // Define patrol path + const patrolPath = [ + Vector3.create(0, 0.25, 0), + Vector3.create(5, 0.25, 0), + Vector3.create(5, 0.25, 5), + Vector3.create(0, 0.25, 5), + Vector3.create(0, 0.25, 0) + ] + + // Start walking animation via toolkit (handles walk/idle clips) + // import * as npc from 'dcl-npc-toolkit' + npc.playAnimation(patrollingNPC, 'Walk', true) + + // Patrol using Tween helpers; keep looping + Tween.setMove(patrollingNPC, patrolPath[0], patrolPath[1], 4000, EasingFunction.EF_LINEAR) + TweenSequence.create(patrollingNPC, { + sequence: [ + { duration: 4000, easingFunction: EasingFunction.EF_LINEAR, mode: Tween.Mode.Move({ start: patrolPath[1], end: patrolPath[2] }) }, + { duration: 4000, easingFunction: EasingFunction.EF_LINEAR, mode: Tween.Mode.Move({ start: patrolPath[2], end: patrolPath[3] }) }, + { duration: 4000, easingFunction: EasingFunction.EF_LINEAR, mode: Tween.Mode.Move({ start: patrolPath[3], end: patrolPath[4] }) } + ], + loop: TweenLoop.TL_RESTART + }) + + return patrollingNPC +} +``` + +### NPC Following Player +```typescript +function createFollowingNPC() { + // Create NPC via toolkit + const followingNPC = createNPC( + { position: { x: 2, y: 0, z: 2 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { type: 'custom', model: 'models/companion.glb' } + ) + + // System to make NPC follow player (read player via engine.PlayerEntity) + engine.addSystem(() => { + if (!Transform.has(engine.PlayerEntity)) return + const playerPos = Transform.get(engine.PlayerEntity).position + + const npcTransform = Transform.getMutable(followingNPC) + const currentPos = npcTransform.position + + // Calculate direction to player + const direction = Vector3.subtract(playerPos, currentPos) + const distance = Vector3.length(direction) + + // Only follow if player is within range + if (distance > 3 && distance < 10) { + const normalizedDir = Vector3.normalize(direction) + const newPos = Vector3.add(currentPos, Vector3.scale(normalizedDir, 0.05)) + npcTransform.position = newPos + + // Make NPC face player + const lookAtRotation = Quaternion.lookRotation(normalizedDir) + npcTransform.rotation = lookAtRotation + } + }) + + return followingNPC +} +``` + +## NPC State Management + +### Toolkit-managed NPC state +```typescript +// The toolkit tracks basic interaction state and dialog progression internally. +// Prefer defining behavior via createNPC options and Dialog[] rather than custom components. + +const merchant = createNPC( + { position: { x: 8, y: 0, z: 8 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { + type: 'custom', + model: 'models/merchant.glb', + dialog: [ + { text: 'Welcome to my shop!', isEndOfDialog: true } as Dialog + ], + onActivate: () => { + // Optional: extra side effects on interaction + console.log('Merchant activated') + } + } +) +``` + +### Interaction flow +```typescript +// Clicks are handled via the toolkit's onActivate; use TriggerArea for proximity prompts if needed. +const helper = createNPC( + { position: { x: 6, y: 0, z: 6 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { + type: 'custom', model: 'models/helper.glb', + dialog: [{ text: 'Need assistance?', isEndOfDialog: true } as Dialog], + onActivate: () => console.log('Helper clicked') + } +) +``` + +## NPC Multiplayer Considerations + +### Syncing NPC State +```typescript +import { syncEntity } from '@dcl/sdk/network' + +function createSyncedNPC() { + const syncedNPC = engine.addEntity() + + AvatarShape.create(syncedNPC, { + id: 'synced-npc', + name: 'Multiplayer NPC' + }) + + Transform.create(syncedNPC, { + position: Vector3.create(4, 0.25, 4) + }) + + // Sync NPC position and state across all players + syncEntity(syncedNPC, [ + Transform.componentId, + AvatarShape.componentId + ], 1) // Unique network ID + + return syncedNPC +} +``` + +### NPC Message Bus Communication +```typescript +import { MessageBus } from '@dcl/sdk/message-bus' + +const messageBus = new MessageBus() + +// Send NPC interaction message +function sendNPCInteraction(npcId: string, playerId: string, action: string) { + messageBus.emit('npc-interaction', { + npcId, + playerId, + action, + timestamp: Date.now() + }) +} + +// Listen for NPC interactions +messageBus.on('npc-interaction', (message) => { + console.log(`NPC ${message.npcId} interacted by ${message.playerId} with action ${message.action}`) + // Handle the interaction for all players +}) +``` + +## Common NPC Patterns + +### NPC with Multiple Dialogue States +```typescript +function createAdvancedNPC() { + // Multi-step dialog with branching via toolkit only + return createNPC( + { position: { x: 8, y: 0, z: 8 }, rotation: { x: 0, y: 0, z: 0, w: 1 }, scale: { x: 1, y: 1, z: 1 } }, + { + type: 'custom', model: 'models/quest_giver.glb', + dialog: [ + { text: 'Hello traveler! I have a quest for you.', isQuestion: true, buttons: [ + { label: 'Tell me about the quest', goToDialog: 1 }, + { label: 'I\'ll pass for now', goToDialog: 2 } + ] } as unknown as Dialog, + { text: 'Retrieve the ancient token from the ruins to the east.', isEndOfDialog: true } as Dialog, + { text: 'Come back if you change your mind!', isEndOfDialog: true } as Dialog + ] + } + ) +} +``` + +### NPC with Inventory/Shop System +```typescript +interface ShopItem { + id: string + name: string + price: number + description: string +} + +function createShopNPC() { + const shopNPC = engine.addEntity() + + AvatarShape.create(shopNPC, { + id: 'shop-npc', + name: 'Merchant' + }) + + Transform.create(shopNPC, { + position: Vector3.create(8, 0.25, 8) + }) + + // Add shop interaction + pointerEventsSystem.onPointerDown( + { + entity: shopNPC, + opts: { button: InputAction.IA_POINTER, hoverText: 'Open Shop' } + }, + () => openShop(shopNPC) + ) + + return shopNPC +} + +function openShop(npcEntity: Entity) { + const shopItems: ShopItem[] = [ + { id: 'sword', name: 'Magic Sword', price: 100, description: 'A powerful weapon' }, + { id: 'potion', name: 'Health Potion', price: 25, description: 'Restores health' }, + { id: 'shield', name: 'Iron Shield', price: 75, description: 'Provides protection' } + ] + + ReactEcsRenderer.setUiRenderer(() => ( + +