├── .env.local.example ├── .gitignore ├── LICENSE ├── README.md ├── bun.lock ├── components.json ├── eslint.config.js ├── index.html ├── package.json ├── public └── monad-logo.svg ├── src ├── App.tsx ├── components │ ├── Board.tsx │ ├── Container.tsx │ ├── FaucetDialog.tsx │ ├── FunPurpleButton.tsx │ ├── LoginButton.tsx │ ├── Scorecard.tsx │ └── ui │ │ ├── alert-dialog.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ └── sonner.tsx ├── hooks │ └── useTransactions.tsx ├── index.css ├── lib │ └── utils.ts ├── main.tsx ├── utils │ ├── client.ts │ ├── constants.ts │ └── fetch.ts └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.env.local.example: -------------------------------------------------------------------------------- 1 | VITE_APP_ENVIRONMENT= # development or prod 2 | 3 | VITE_PRIVY_APP_ID= 4 | VITE_MONAD_RPC_URL= 5 | VITE_2048_FAUCET_URL= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Gabriele Cirulli 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Monad 2048 Frontend 2 | 3 | **Check out a full writeup of how we built this [here](https://blog.monad.xyz/blog/build-2048).** 4 | 5 | Credits: [2048](https://github.com/gabrielecirulli/2048) 6 | 7 | `Monad 2048` is a fully on-chain game implementation of the popular sliding puzzle game 2048. This game connects with a smart contract 8 | on [Monad testnet](https://testnet.monad.xyz/) and records every game and every move on-chain. 9 | 10 | ## Development 11 | 12 | Clone the repo and run the following to install dependencies: 13 | 14 | ```bash 15 | bun install 16 | ``` 17 | 18 | Set the environment variables in your `.env.local` file (see `.env.local.example`). Then run the following to run the game locally: 19 | 20 | ```bash 21 | bun dev 22 | ``` 23 | 24 | ## Feedback 25 | 26 | Please open issues or PRs on this repository for any feedback. 27 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Monad 2048 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@privy-io/react-auth": "^2.8.2", 14 | "@radix-ui/react-alert-dialog": "^1.1.11", 15 | "@radix-ui/react-slot": "^1.2.0", 16 | "@tailwindcss/vite": "^4.0.17", 17 | "@types/node": "^22.13.14", 18 | "class-variance-authority": "^0.7.1", 19 | "clsx": "^2.1.1", 20 | "lucide-react": "^0.484.0", 21 | "next-themes": "^0.4.6", 22 | "react": "^19.0.0", 23 | "react-dom": "^19.0.0", 24 | "sonner": "^2.0.3", 25 | "tailwind-merge": "^3.0.2", 26 | "tailwindcss": "^4.0.17", 27 | "tw-animate-css": "^1.2.4", 28 | "viem": "^2.26.0" 29 | }, 30 | "devDependencies": { 31 | "@eslint/js": "^9.21.0", 32 | "@types/react": "^19.0.10", 33 | "@types/react-dom": "^19.0.4", 34 | "@vitejs/plugin-react-swc": "^3.8.0", 35 | "eslint": "^9.21.0", 36 | "eslint-plugin-react-hooks": "^5.1.0", 37 | "eslint-plugin-react-refresh": "^0.4.19", 38 | "globals": "^15.15.0", 39 | "typescript": "~5.7.2", 40 | "typescript-eslint": "^8.24.1", 41 | "vite": "^6.2.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /public/monad-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | // Hooks 2 | import { useEffect, useRef, useState } from "react"; 3 | import { usePrivy } from "@privy-io/react-auth"; 4 | import { useTransactions } from "./hooks/useTransactions"; 5 | 6 | // UI 7 | import Board from "./components/Board"; 8 | import Container from "./components/Container"; 9 | import Scorecard from "./components/Scorecard"; 10 | import LoginButton from "./components/LoginButton"; 11 | import { Toaster } from "@/components/ui/sonner"; 12 | 13 | // Utils 14 | import { 15 | encodePacked, 16 | Hex, 17 | hexToBigInt, 18 | isAddress, 19 | keccak256, 20 | toBytes, 21 | toHex, 22 | } from "viem"; 23 | import { FaucetDialog } from "./components/FaucetDialog"; 24 | 25 | // Types 26 | enum Direction { 27 | UP, 28 | DOWN, 29 | LEFT, 30 | RIGHT, 31 | } 32 | type Tile = { 33 | id: string; 34 | value: number; 35 | row: number; 36 | col: number; 37 | mergedFrom?: string[]; 38 | isNew?: boolean; 39 | }; 40 | type EncodedMove = { 41 | board: bigint; // 128 bits 42 | move: number; // 8 bits 43 | }; 44 | type BoardState = { 45 | tiles: Tile[]; 46 | score: number; 47 | }; 48 | 49 | export default function Game2048() { 50 | // =============================================================// 51 | // Custom Hook Values // 52 | // =============================================================// 53 | 54 | const { user } = usePrivy(); 55 | 56 | const { 57 | resetNonceAndBalance, 58 | getLatestGameBoard, 59 | playNewMoveTransaction, 60 | initializeGameTransaction, 61 | } = useTransactions(); 62 | 63 | // =============================================================// 64 | // Game State // 65 | // =============================================================// 66 | 67 | const [gameOver, setGameOver] = useState(false); 68 | const [gameError, setGameError] = useState(false); 69 | const [gameErrorText, setGameErrorText] = useState(""); 70 | const [isAnimating, setIsAnimating] = useState(false); 71 | const [faucetModalOpen, setFaucetModalOpen] = useState(false); 72 | 73 | const [activeGameId, setActiveGameId] = useState("0x"); 74 | const [encodedMoves, setEncodedMoves] = useState([]); 75 | const [playedMovesCount, setPlayedMovesCount] = useState(0); 76 | 77 | const [boardState, setBoardState] = useState({ 78 | tiles: [], 79 | score: 0, 80 | }); 81 | const [resetBoards, setResetBoards] = useState([]); 82 | 83 | // =============================================================// 84 | // Detect and execute moves // 85 | // =============================================================// 86 | 87 | // Reset board on error. 88 | useEffect(() => { 89 | const boards = resetBoards; 90 | 91 | if (boards.length > 0) { 92 | const scores = boards.map((b) => b.score); 93 | const idx = scores.indexOf(Math.min(...scores)); 94 | 95 | setBoardState(boards[idx]); 96 | } 97 | }, [resetBoards]); 98 | 99 | function resetBoardOnError( 100 | premoveBoard: BoardState, 101 | currentMove: number, 102 | error: Error 103 | ) { 104 | if (!gameError) { 105 | setGameError(true); 106 | setGameErrorText(error.message); 107 | 108 | setResetBoards((current) => [...current, premoveBoard]); 109 | setPlayedMovesCount(currentMove); 110 | 111 | setIsAnimating(false); 112 | } 113 | 114 | if (error.message.includes("insufficient balance")) { 115 | setFaucetModalOpen(true); 116 | } 117 | } 118 | 119 | // Handle keyboard / swipe events 120 | const gameContainerRef = useRef(null); 121 | 122 | useEffect(() => { 123 | const container = gameContainerRef.current; 124 | if (!container) return; 125 | 126 | const handleKeyDown = async (event: KeyboardEvent) => { 127 | if (!user || gameOver || isAnimating) return; 128 | 129 | switch (event.key) { 130 | case "ArrowUp": 131 | await move(Direction.UP); 132 | break; 133 | case "ArrowDown": 134 | await move(Direction.DOWN); 135 | break; 136 | case "ArrowLeft": 137 | await move(Direction.LEFT); 138 | break; 139 | case "ArrowRight": 140 | await move(Direction.RIGHT); 141 | break; 142 | } 143 | }; 144 | 145 | let touchStartX = 0; 146 | let touchStartY = 0; 147 | 148 | const handleTouchStart = (e: TouchEvent) => { 149 | e.preventDefault(); // 👈 this is key to prevent scroll 150 | touchStartX = e.changedTouches[0].screenX; 151 | touchStartY = e.changedTouches[0].screenY; 152 | }; 153 | 154 | const handleTouchEnd = async (e: TouchEvent) => { 155 | e.preventDefault(); // 👈 also here 156 | if (!user || gameOver || isAnimating) return; 157 | 158 | const touchEndX = e.changedTouches[0].screenX; 159 | const touchEndY = e.changedTouches[0].screenY; 160 | 161 | const dx = touchEndX - touchStartX; 162 | const dy = touchEndY - touchStartY; 163 | 164 | if (Math.abs(dx) > Math.abs(dy)) { 165 | if (dx > 50) await move(Direction.RIGHT); 166 | else if (dx < -50) await move(Direction.LEFT); 167 | } else { 168 | if (dy > 50) await move(Direction.DOWN); 169 | else if (dy < -50) await move(Direction.UP); 170 | } 171 | }; 172 | 173 | window.addEventListener("keydown", handleKeyDown); 174 | container.addEventListener("touchstart", handleTouchStart, { 175 | passive: false, 176 | }); // 👈 passive: false is REQUIRED 177 | container.addEventListener("touchend", handleTouchEnd, { 178 | passive: false, 179 | }); 180 | 181 | return () => { 182 | window.removeEventListener("keydown", handleKeyDown); 183 | container.removeEventListener("touchstart", handleTouchStart); 184 | container.removeEventListener("touchend", handleTouchEnd); 185 | }; 186 | }, [boardState, gameOver, isAnimating]); 187 | 188 | // Move tiles in the specified direction 189 | const move = async (direction: Direction) => { 190 | const premoveBoard = boardState; 191 | const currentMove = playedMovesCount; 192 | 193 | try { 194 | // Create a copy of the board state 195 | const newBoardState: BoardState = { 196 | tiles: JSON.parse(JSON.stringify(boardState.tiles)), 197 | score: boardState.score, 198 | }; 199 | 200 | // Reset the merged and new flags 201 | newBoardState.tiles.forEach((tile) => { 202 | tile.mergedFrom = undefined; 203 | tile.isNew = false; 204 | }); 205 | 206 | // Get the traversal order based on the direction 207 | const traversals = getTraversalDirections(direction); 208 | 209 | let moved = false; 210 | 211 | // Process the tiles in the correct order 212 | traversals.row.forEach((row) => { 213 | traversals.col.forEach((col) => { 214 | const tile = newBoardState.tiles.find( 215 | (t) => t.row === row && t.col === col 216 | ); 217 | 218 | if (tile) { 219 | const { newRow, newCol, merged } = findFarthestPosition( 220 | newBoardState, 221 | tile, 222 | direction 223 | ); 224 | 225 | if (merged) { 226 | // Merge with the tile at the new position 227 | const targetTile = newBoardState.tiles.find( 228 | (t) => t.row === newRow && t.col === newCol 229 | ); 230 | 231 | if (targetTile) { 232 | // Create a new tile with the merged value 233 | const mergedTile: Tile = { 234 | id: generateTileId(), 235 | value: tile.value * 2, 236 | row: newRow, 237 | col: newCol, 238 | mergedFrom: [tile.id, targetTile.id], 239 | }; 240 | 241 | // Remove the original tiles 242 | newBoardState.tiles = 243 | newBoardState.tiles.filter( 244 | (t) => 245 | t.id !== tile.id && 246 | t.id !== targetTile.id 247 | ); 248 | 249 | // Add the merged tile 250 | newBoardState.tiles.push(mergedTile); 251 | 252 | // Update the score 253 | newBoardState.score += mergedTile.value; 254 | 255 | moved = true; 256 | } 257 | } else if (tile.row !== newRow || tile.col !== newCol) { 258 | // Move the tile to the new position 259 | tile.row = newRow; 260 | tile.col = newCol; 261 | 262 | moved = true; 263 | } 264 | } 265 | }); 266 | }); 267 | 268 | // If the board changed, add a new random tile 269 | if (moved) { 270 | // Pause moves 271 | setIsAnimating(true); 272 | 273 | // Create a new copy to avoid mutation issues 274 | const updatedBoardState = { 275 | tiles: [...newBoardState.tiles], 276 | score: newBoardState.score, 277 | }; 278 | addRandomTileViaSeed( 279 | updatedBoardState, 280 | activeGameId, 281 | currentMove 282 | ); 283 | 284 | // Add move 285 | const encoded = tilesToEncodedMove( 286 | updatedBoardState.tiles, 287 | direction 288 | ); 289 | const newEncodedMoves = [...encodedMoves, encoded]; 290 | const moveCount = playedMovesCount; 291 | 292 | if (moveCount == 3) { 293 | const boards = [ 294 | newEncodedMoves[0].board, 295 | newEncodedMoves[1].board, 296 | newEncodedMoves[2].board, 297 | newEncodedMoves[3].board, 298 | ] as readonly [bigint, bigint, bigint, bigint]; 299 | 300 | const moves = [ 301 | newEncodedMoves[1].move, 302 | newEncodedMoves[2].move, 303 | newEncodedMoves[3].move, 304 | ] as readonly [number, number, number]; 305 | 306 | initializeGameTransaction( 307 | activeGameId, 308 | boards, 309 | moves 310 | ).catch((error) => { 311 | console.error("Error in init transaction:", error); 312 | resetBoardOnError(premoveBoard, currentMove, error); 313 | }); 314 | } 315 | 316 | if (moveCount > 3) { 317 | playNewMoveTransaction( 318 | activeGameId as Hex, 319 | encoded.board, 320 | encoded.move, 321 | moveCount 322 | ).catch((error) => { 323 | console.error("Error in move transaction:", error); 324 | resetBoardOnError(premoveBoard, currentMove, error); 325 | }); 326 | } 327 | 328 | setBoardState(updatedBoardState); 329 | setEncodedMoves(newEncodedMoves); 330 | setPlayedMovesCount(moveCount + 1); 331 | 332 | // Check if the game is over 333 | if (checkGameOver(updatedBoardState)) { 334 | setGameOver(true); 335 | } 336 | 337 | // Resume moves 338 | await new Promise((resolve) => setTimeout(resolve, 150)); 339 | setIsAnimating(false); 340 | } 341 | } catch (error) { 342 | console.error("Error in move operation:", error); 343 | resetBoardOnError(premoveBoard, currentMove, error as Error); 344 | } 345 | }; 346 | 347 | // =============================================================// 348 | // Initialize new game // 349 | // =============================================================// 350 | 351 | const [address, setAddress] = useState(""); 352 | useEffect(() => { 353 | if (!user) { 354 | setAddress(""); 355 | return; 356 | } 357 | 358 | const [privyUser] = user.linkedAccounts.filter( 359 | (account) => 360 | account.type === "wallet" && 361 | account.walletClientType === "privy" 362 | ); 363 | if (!privyUser || !(privyUser as any).address) { 364 | setAddress(""); 365 | return; 366 | } 367 | 368 | setAddress((privyUser as any).address); 369 | }, [user]); 370 | 371 | // Initialize the game with two random tiles 372 | const initializeGame = () => { 373 | setResetBoards([]); 374 | 375 | const newBoardState: BoardState = { 376 | tiles: [], 377 | score: 0, 378 | }; 379 | 380 | // Add two random tiles 381 | addRandomTile(newBoardState); 382 | addRandomTile(newBoardState); 383 | 384 | setPlayedMovesCount(1); 385 | setActiveGameId(randomIDForAddress(address)); 386 | setEncodedMoves([tilesToEncodedMove(newBoardState.tiles, 0)]); 387 | 388 | setBoardState(newBoardState); 389 | setGameError(false); 390 | setGameOver(false); 391 | }; 392 | 393 | function randomIDForAddress(address: string): Hex { 394 | if (!isAddress(address)) { 395 | throw new Error("Invalid Ethereum address"); 396 | } 397 | 398 | const addressBytes = toBytes(address); // 20 bytes (160 bits) 399 | const randomBytes = crypto.getRandomValues(new Uint8Array(12)); // 12 bytes (96 bits) 400 | const fullBytes = new Uint8Array(32); // 32 bytes total 401 | 402 | fullBytes.set(addressBytes, 0); // Set address at start 403 | fullBytes.set(randomBytes, 20); // Set random bits after 404 | 405 | return toHex(fullBytes); 406 | } 407 | 408 | // Add a random tile to the board (2 with 90% chance, 4 with 10% chance) 409 | const addRandomTile = (boardState: BoardState) => { 410 | const emptyCells = []; 411 | 412 | // Find all empty cells 413 | for (let row = 0; row < 4; row++) { 414 | for (let col = 0; col < 4; col++) { 415 | if ( 416 | !boardState.tiles.some( 417 | (tile) => tile.row === row && tile.col === col 418 | ) 419 | ) { 420 | emptyCells.push({ row, col }); 421 | } 422 | } 423 | } 424 | 425 | // If there are no empty cells, return 426 | if (emptyCells.length === 0) return; 427 | 428 | // Choose a random empty cell 429 | const randomCell = 430 | emptyCells[Math.floor(Math.random() * emptyCells.length)]; 431 | 432 | // Create a new tile 433 | const newTile: Tile = { 434 | id: generateTileId(), 435 | value: Math.random() < 0.9 ? 2 : 4, 436 | row: randomCell.row, 437 | col: randomCell.col, 438 | isNew: true, 439 | }; 440 | 441 | boardState.tiles.push(newTile); 442 | }; 443 | 444 | // =============================================================// 445 | // Re-sync ongoing game // 446 | // =============================================================// 447 | 448 | // Resumes a game where it was left off 449 | const resyncGame = async () => { 450 | const newBoardState: BoardState = { 451 | tiles: [], 452 | score: boardState.score, 453 | }; 454 | 455 | const [latestBoard, nextMoveNumber] = await getLatestGameBoard( 456 | activeGameId 457 | ); 458 | 459 | let nonzero = false; 460 | for (let i = 0; i < 4; i++) { 461 | for (let j = 0; j < 4; j++) { 462 | const value = latestBoard[4 * i + j]; 463 | if (value > 0) { 464 | nonzero = true; 465 | 466 | const newTile: Tile = { 467 | id: generateTileId(), 468 | value: 2 ** value, 469 | row: i, 470 | col: j, 471 | isNew: true, 472 | }; 473 | 474 | newBoardState.tiles.push(newTile); 475 | } 476 | } 477 | } 478 | 479 | setResetBoards([]); 480 | await resetNonceAndBalance(); 481 | if (!nonzero) { 482 | initializeGame(); 483 | } else { 484 | setBoardState(newBoardState); 485 | setPlayedMovesCount(parseInt(nextMoveNumber.toString())); 486 | setGameErrorText(""); 487 | setGameError(false); 488 | } 489 | }; 490 | 491 | // =============================================================// 492 | // Board logic helpers // 493 | // =============================================================// 494 | 495 | // Generate a unique ID for tiles 496 | const generateTileId = () => { 497 | return keccak256(toHex(Math.random().toString())); 498 | }; 499 | 500 | // Add a random tile to the board (2 with 90% chance, 4 with 10% chance) 501 | const addRandomTileViaSeed = ( 502 | boardState: BoardState, 503 | gameId: Hex, 504 | moveNumber: number 505 | ) => { 506 | const emptyCells = []; 507 | 508 | // Find all empty cells 509 | for (let row = 0; row < 4; row++) { 510 | for (let col = 0; col < 4; col++) { 511 | if ( 512 | !boardState.tiles.some( 513 | (tile) => tile.row === row && tile.col === col 514 | ) 515 | ) { 516 | emptyCells.push({ row, col }); 517 | } 518 | } 519 | } 520 | 521 | // If there are no empty cells, return 522 | if (emptyCells.length === 0) return; 523 | 524 | // Choose a random empty cell 525 | const seed = hexToBigInt( 526 | keccak256( 527 | encodePacked( 528 | ["bytes32", "uint256"], 529 | [gameId, BigInt(moveNumber)] 530 | ) 531 | ) 532 | ); 533 | const index = parseInt((seed % BigInt(emptyCells.length)).toString()); 534 | const randomCell = emptyCells[index]; 535 | 536 | // Choose random value. 537 | const value = parseInt((seed % BigInt(100)).toString()) > 90 ? 4 : 2; 538 | 539 | // Create a new tile 540 | const newTile: Tile = { 541 | id: generateTileId(), 542 | value, 543 | row: randomCell.row, 544 | col: randomCell.col, 545 | isNew: true, 546 | }; 547 | 548 | boardState.tiles.push(newTile); 549 | }; 550 | 551 | // Convert the tiles array to a 2D grid for easier processing 552 | const getTilesGrid = (tiles: Tile[]): (Tile | null)[][] => { 553 | const grid: (Tile | null)[][] = Array(4) 554 | .fill(null) 555 | .map(() => Array(4).fill(null)); 556 | 557 | tiles.forEach((tile) => { 558 | grid[tile.row][tile.col] = tile; 559 | }); 560 | 561 | return grid; 562 | }; 563 | 564 | // Check if the game is over 565 | const checkGameOver = (boardState: BoardState) => { 566 | // If there are empty cells, the game is not over 567 | if (boardState.tiles.length < 16) return false; 568 | 569 | const grid = getTilesGrid(boardState.tiles); 570 | 571 | // Check if there are any adjacent cells with the same value 572 | for (let row = 0; row < 4; row++) { 573 | for (let col = 0; col < 4; col++) { 574 | const tile = grid[row][col]; 575 | if (tile) { 576 | // Check right 577 | if ( 578 | col < 3 && 579 | grid[row][col + 1] && 580 | grid[row][col + 1]!.value === tile.value 581 | ) { 582 | return false; 583 | } 584 | // Check down 585 | if ( 586 | row < 3 && 587 | grid[row + 1][col] && 588 | grid[row + 1][col]!.value === tile.value 589 | ) { 590 | return false; 591 | } 592 | } 593 | } 594 | } 595 | 596 | return true; 597 | }; 598 | 599 | function tilesToEncodedMove( 600 | tiles: Tile[], 601 | direction: Direction 602 | ): EncodedMove { 603 | const boardArray: number[] = new Array(16).fill(0); 604 | 605 | tiles.forEach((tile) => { 606 | const index = tile.row * 4 + tile.col; 607 | boardArray[index] = Math.log2(tile.value); 608 | }); 609 | 610 | let board = BigInt(0); 611 | for (let i = 0; i < 16; i++) { 612 | board |= BigInt(boardArray[i]) << BigInt((15 - i) * 8); 613 | } 614 | 615 | const move = direction; 616 | 617 | return { board, move }; 618 | } 619 | 620 | // Get the traversal order based on the direction 621 | const getTraversalDirections = (direction: Direction) => { 622 | const traversals = { 623 | row: [0, 1, 2, 3], 624 | col: [0, 1, 2, 3], 625 | }; 626 | 627 | // Process tiles in the correct order based on the direction 628 | if (direction === Direction.RIGHT) traversals.col = [3, 2, 1, 0]; 629 | if (direction === Direction.DOWN) traversals.row = [3, 2, 1, 0]; 630 | 631 | return traversals; 632 | }; 633 | 634 | // Find the farthest position a tile can move in the specified direction 635 | const findFarthestPosition = ( 636 | boardState: BoardState, 637 | tile: Tile, 638 | direction: Direction 639 | ) => { 640 | let { row, col } = tile; 641 | let newRow = row; 642 | let newCol = col; 643 | let merged = false; 644 | 645 | // Calculate the vector for the direction 646 | const vector = getVector(direction); 647 | 648 | // Move as far as possible in the direction 649 | do { 650 | row = newRow; 651 | col = newCol; 652 | newRow = row + vector.row; 653 | newCol = col + vector.col; 654 | } while ( 655 | isWithinBounds(newRow, newCol) && 656 | !isCellOccupied(boardState, newRow, newCol) 657 | ); 658 | 659 | // Check if we can merge with the tile at the new position 660 | if ( 661 | isWithinBounds(newRow, newCol) && 662 | canMergeWithTile(boardState, tile, newRow, newCol) 663 | ) { 664 | merged = true; 665 | } else { 666 | // If we can't merge, use the previous position 667 | newRow = row; 668 | newCol = col; 669 | } 670 | 671 | return { newRow, newCol, merged }; 672 | }; 673 | 674 | // Get the vector for the direction 675 | const getVector = (direction: Direction) => { 676 | const vectors = { 677 | [Direction.UP]: { row: -1, col: 0 }, 678 | [Direction.RIGHT]: { row: 0, col: 1 }, 679 | [Direction.DOWN]: { row: 1, col: 0 }, 680 | [Direction.LEFT]: { row: 0, col: -1 }, 681 | }; 682 | 683 | return vectors[direction]; 684 | }; 685 | 686 | // Check if the position is within the bounds of the board 687 | const isWithinBounds = (row: number, col: number) => { 688 | return row >= 0 && row < 4 && col >= 0 && col < 4; 689 | }; 690 | 691 | // Check if the cell is occupied 692 | const isCellOccupied = ( 693 | boardState: BoardState, 694 | row: number, 695 | col: number 696 | ) => { 697 | return boardState.tiles.some( 698 | (tile) => tile.row === row && tile.col === col 699 | ); 700 | }; 701 | 702 | // Check if the tile can merge with the tile at the specified position 703 | const canMergeWithTile = ( 704 | boardState: BoardState, 705 | tile: Tile, 706 | row: number, 707 | col: number 708 | ) => { 709 | const targetTile = boardState.tiles.find( 710 | (t) => t.row === row && t.col === col 711 | ); 712 | return ( 713 | targetTile && 714 | targetTile.value === tile.value && 715 | !targetTile.mergedFrom 716 | ); 717 | }; 718 | 719 | // Display 720 | 721 | const [isLaptopOrLess, setIsLaptopOrLess] = useState(false); 722 | 723 | useEffect(() => { 724 | const mediaQuery = window.matchMedia("(max-width: 1024px)"); 725 | const handleResize = () => setIsLaptopOrLess(mediaQuery.matches); 726 | 727 | // Set initial value 728 | handleResize(); 729 | 730 | // Listen for changes 731 | mediaQuery.addEventListener("change", handleResize); 732 | return () => mediaQuery.removeEventListener("change", handleResize); 733 | }, []); 734 | 735 | return ( 736 | 737 |
738 |
739 | 740 | 741 |
742 | 743 |
744 | 754 |
755 | 756 | 761 |
762 | 763 | 769 |
770 | ); 771 | } 772 | -------------------------------------------------------------------------------- /src/components/Board.tsx: -------------------------------------------------------------------------------- 1 | import FunPurpleButton from "./FunPurpleButton"; 2 | 3 | type Tile = { 4 | id: string; 5 | value: number; 6 | row: number; 7 | col: number; 8 | mergedFrom?: string[]; 9 | isNew?: boolean; 10 | }; 11 | 12 | type BoardProps = { 13 | containerRef: any; 14 | score: number; 15 | tiles: Tile[]; 16 | gameOver: boolean; 17 | gameError: boolean; 18 | gameErrorText: string; 19 | resyncGame: () => void; 20 | initializeGame: () => void; 21 | }; 22 | 23 | export default function Board({ 24 | containerRef, 25 | tiles, 26 | score, 27 | gameOver, 28 | gameError, 29 | gameErrorText, 30 | resyncGame, 31 | initializeGame, 32 | }: BoardProps) { 33 | // Calculate the position of a tile 34 | const getTilePosition = (row: number, col: number) => { 35 | return { 36 | top: `calc(${row * 25}% + 0.5rem)`, 37 | left: `calc(${col * 25}% + 0.5rem)`, 38 | width: "calc(25% - 1rem)", 39 | height: "calc(25% - 1rem)", 40 | }; 41 | }; 42 | 43 | // Get the background color for a tile based on its value 44 | const getTileColor = (value: number) => { 45 | switch (value) { 46 | case 2: 47 | return "bg-purple-100 text-gray-800"; 48 | case 4: 49 | return "bg-purple-200 text-gray-800"; 50 | case 8: 51 | return "bg-purple-300 text-gray-800"; 52 | case 16: 53 | return "bg-purple-400 text-white"; 54 | case 32: 55 | return "bg-purple-500 text-white"; 56 | case 64: 57 | return "bg-purple-600 text-white"; 58 | case 128: 59 | return "bg-amber-300 text-gray-800"; 60 | case 256: 61 | return "bg-amber-400 text-white"; 62 | case 512: 63 | return "bg-amber-500 text-white"; 64 | case 1024: 65 | return "bg-amber-600 text-white"; 66 | case 2048: 67 | return "bg-amber-700 text-white"; 68 | default: 69 | return "bg-purple-800 text-white"; 70 | } 71 | }; 72 | 73 | // Get the font size for a tile based on its value 74 | const getTileFontSize = (value: number) => { 75 | if (value < 100) return "text-3xl"; 76 | if (value < 1000) return "text-2xl"; 77 | return "text-xl"; 78 | }; 79 | 80 | return ( 81 | <> 82 |
86 | {/* Grid background */} 87 |
88 | {Array(16) 89 | .fill(0) 90 | .map((_, index) => ( 91 |
95 | ))} 96 |
97 | 98 | {/* Tiles */} 99 | {tiles.map((tile: Tile) => ( 100 |
121 | 126 | {tile.value} 127 | 128 |
129 | ))} 130 | 131 | {/* Game over overlay */} 132 | {gameOver && ( 133 |
134 |
135 |

136 | Game Over! 137 |

138 |

Your score: {score}

139 | 143 |
144 |
145 | )} 146 | 147 | {/* Game error overlay */} 148 | {gameError && ( 149 |
150 |
151 |

152 | Oops! Game Error :( 153 |

154 |

155 | 156 | Error 157 | 158 | : {gameErrorText} 159 |

160 |

Your score: {score}

161 | 165 |
166 |
167 | )} 168 |
169 | 170 | 182 | 183 | ); 184 | } 185 | -------------------------------------------------------------------------------- /src/components/Container.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type ContainerProps = { 4 | children: React.ReactNode; // Accepts any valid React element(s) 5 | }; 6 | 7 | export default function Container({ children }: ContainerProps) { 8 | return ( 9 |
10 |
11 |

12 | 2048 13 |

14 |

15 | on MONAD 16 |

17 |
18 | 19 | {/* Main content area */} 20 |
21 | {children} 22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/FaucetDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogAction, 4 | AlertDialogCancel, 5 | AlertDialogContent, 6 | AlertDialogDescription, 7 | AlertDialogFooter, 8 | AlertDialogHeader, 9 | AlertDialogTitle, 10 | } from "@/components/ui/alert-dialog"; 11 | import { Button } from "./ui/button"; 12 | import { Copy, ArrowDownLeft, Loader2 } from "lucide-react"; 13 | import { toast } from "sonner"; 14 | import { useEffect, useState } from "react"; 15 | import { usePrivy } from "@privy-io/react-auth"; 16 | import { publicClient } from "@/utils/client"; 17 | import { formatEther, Hex } from "viem"; 18 | import { post } from "@/utils/fetch"; 19 | 20 | export type FaucetDialogProps = { 21 | isOpen: boolean; 22 | resyncGame: () => Promise; 23 | setIsOpen: (open: boolean) => void; 24 | }; 25 | export function FaucetDialog({ 26 | isOpen, 27 | setIsOpen, 28 | resyncGame, 29 | }: FaucetDialogProps) { 30 | const { user } = usePrivy(); 31 | 32 | const [address, setAddress] = useState(""); 33 | const [balance, setBalance] = useState(0n); 34 | const [resumeLoading, setResumeLoading] = useState(false); 35 | const [faucetLoading, setFaucetLoading] = useState(false); 36 | 37 | async function setupUser() { 38 | if (!user) { 39 | setAddress(""); 40 | setBalance(0n); 41 | return; 42 | } 43 | 44 | const [privyUser] = user.linkedAccounts.filter( 45 | (account) => 46 | account.type === "wallet" && 47 | account.walletClientType === "privy" 48 | ); 49 | if (!privyUser || !(privyUser as any).address) { 50 | setAddress(""); 51 | setBalance(0n); 52 | return; 53 | } 54 | const privyUserAddress = (privyUser as any).address; 55 | 56 | const bal = await publicClient.getBalance({ 57 | address: privyUserAddress as Hex, 58 | }); 59 | 60 | setAddress(privyUserAddress); 61 | setBalance(bal); 62 | } 63 | 64 | const handleClose = async () => { 65 | setResumeLoading(true); 66 | await resyncGame(); 67 | setResumeLoading(false); 68 | setIsOpen(false); 69 | }; 70 | 71 | const handleFaucetRequest = async () => { 72 | if (!user) { 73 | toast.error("Please log-in."); 74 | return; 75 | } 76 | 77 | const [privyUser] = user.linkedAccounts.filter( 78 | (account) => 79 | account.type === "wallet" && 80 | account.walletClientType === "privy" 81 | ); 82 | if (!privyUser || !(privyUser as any).address) { 83 | toast.error("Embedded wallet not found."); 84 | return; 85 | } 86 | const privyUserAddress = (privyUser as any).address; 87 | 88 | if (parseFloat(formatEther(balance)) >= 0.5) { 89 | toast.error("Balance already more than 0.5 MON."); 90 | return; 91 | } 92 | 93 | setFaucetLoading(true); 94 | 95 | try { 96 | const response = await post({ 97 | url: import.meta.env.VITE_2048_FAUCET_URL, 98 | params: { 99 | address: privyUserAddress, 100 | }, 101 | }); 102 | 103 | const transactionHash = response.txHash; 104 | console.log("Funded tx: ", transactionHash); 105 | 106 | toast.success(`Player funded!`); 107 | 108 | await setupUser(); 109 | } catch (e) { 110 | console.log((e as any).message); 111 | console.log("Error fetching testnet MON: ", e); 112 | toast.error(`Please try again or fund wallet directly.`, { 113 | description: `Error: failed to get funds from faucet.`, 114 | }); 115 | } 116 | 117 | setFaucetLoading(false); 118 | }; 119 | 120 | useEffect(() => { 121 | if (!isOpen) return; 122 | handleFaucetRequest(); 123 | }, [user, isOpen]); 124 | 125 | useEffect(() => { 126 | if (!isOpen) return; 127 | setupUser(); 128 | }, [user, isOpen]); 129 | 130 | const abbreviatedAddress = address 131 | ? `${address.slice(0, 4)}...${address.slice(-2)}` 132 | : ""; 133 | 134 | const copyToClipboard = async () => { 135 | if (address) { 136 | await navigator.clipboard.writeText(address); 137 | toast.info("Copied to clipboard."); 138 | } 139 | }; 140 | 141 | const alreadyFunded = parseFloat(formatEther(balance)) >= 0.5; 142 | 143 | return ( 144 | 145 | 146 | 147 | 148 | You need ~0.1 MON more to play moves. 149 | 150 | 151 |
152 |
153 | 154 | {`Player: ${abbreviatedAddress}`} 155 | 156 | 157 | 166 |
167 |
168 | Balance:{" "} 169 | {formatEther(balance)} MON 170 |
171 |

172 | Fund your player address with testnet MON 173 | directly via your external wallet, or get 0.5 174 | MON from the game faucet. 175 |

176 |
177 |
178 |
179 | 180 | 185 | {resumeLoading && ( 186 | 187 | )} 188 | {!resumeLoading ? "Resume" : "Re-sycing..."} 189 | 190 | 191 | 212 | 213 | 214 |
215 |
216 | ); 217 | } 218 | -------------------------------------------------------------------------------- /src/components/FunPurpleButton.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Loader2 } from "lucide-react"; 3 | 4 | interface LoginButtonProps { 5 | text: string; 6 | loadingText?: string; 7 | isLoading?: boolean; 8 | onClick?: () => void; 9 | } 10 | 11 | export default function FunPurpleButton({ 12 | text, 13 | loadingText, 14 | onClick, 15 | isLoading = false, 16 | }: LoginButtonProps) { 17 | const [isPressed, setIsPressed] = useState(false); 18 | 19 | const handleClick = () => { 20 | setIsPressed(true); 21 | onClick?.(); 22 | setTimeout(() => setIsPressed(false), 150); 23 | }; 24 | 25 | return ( 26 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | // Hooks 2 | import { useEffect, useState } from "react"; 3 | import { useLogin, useLogout, usePrivy } from "@privy-io/react-auth"; 4 | 5 | // UI 6 | import { toast } from "sonner"; 7 | import FunPurpleButton from "./FunPurpleButton"; 8 | import { Button } from "./ui/button"; 9 | import { Copy } from "lucide-react"; 10 | import { publicClient } from "@/utils/client"; 11 | import { formatEther, Hex } from "viem"; 12 | import { post } from "@/utils/fetch"; 13 | 14 | type LoginButtonProps = { 15 | resetGame: () => void; 16 | }; 17 | 18 | export default function LoginButton({ resetGame }: LoginButtonProps) { 19 | const { login } = useLogin(); 20 | const { logout } = useLogout(); 21 | const { user, authenticated } = usePrivy(); 22 | 23 | const [loginLoading, setLoginLoading] = useState(false); 24 | const [faucetLoading, setFaucetLoading] = useState(false); 25 | 26 | const handleLogin = async () => { 27 | setLoginLoading(true); 28 | 29 | try { 30 | login(); 31 | setLoginLoading(false); 32 | } catch (err) { 33 | console.log("Problem logging in: ", err); 34 | setLoginLoading(false); 35 | } 36 | }; 37 | 38 | const [address, setAddress] = useState(""); 39 | useEffect(() => { 40 | if (!user) { 41 | setAddress(""); 42 | return; 43 | } 44 | 45 | const [privyUser] = user.linkedAccounts.filter( 46 | (account) => 47 | account.type === "wallet" && 48 | account.walletClientType === "privy" 49 | ); 50 | if (!privyUser || !(privyUser as any).address) { 51 | setAddress(""); 52 | return; 53 | } 54 | 55 | setAddress((privyUser as any).address); 56 | }, [user]); 57 | 58 | const handleFaucetRequest = async () => { 59 | if (!user) { 60 | toast.error("Please log-in."); 61 | return; 62 | } 63 | 64 | const [privyUser] = user.linkedAccounts.filter( 65 | (account) => 66 | account.type === "wallet" && 67 | account.walletClientType === "privy" 68 | ); 69 | if (!privyUser || !(privyUser as any).address) { 70 | toast.error("Embedded wallet not found."); 71 | return; 72 | } 73 | const privyUserAddress = (privyUser as any).address; 74 | 75 | const balance = await publicClient.getBalance({ 76 | address: privyUserAddress as Hex, 77 | }); 78 | if (parseFloat(formatEther(balance)) >= 0.5) { 79 | toast.info("Player has enough MON to play."); 80 | return; 81 | } 82 | 83 | setFaucetLoading(true); 84 | 85 | try { 86 | const response = await post({ 87 | url: import.meta.env.VITE_2048_FAUCET_URL, 88 | params: { 89 | address: privyUserAddress, 90 | }, 91 | }); 92 | 93 | const transactionHash = response.txHash; 94 | console.log("Funded tx: ", transactionHash); 95 | 96 | toast.success(`Player funded!`, { 97 | description: `Funded player with 0.5 MON from faucet.`, 98 | }); 99 | } catch (e) { 100 | console.log((e as any).message); 101 | console.log("Error fetching testnet MON: ", e); 102 | toast.info(`You'll need MON to play this game.`, { 103 | description: `Continue playing and try the in-game faucet or fund directly.`, 104 | }); 105 | } 106 | 107 | setFaucetLoading(false); 108 | }; 109 | 110 | useEffect(() => { 111 | if (!user) return; 112 | handleFaucetRequest(); 113 | }, [user]); 114 | 115 | const copyToClipboard = async () => { 116 | if (address) { 117 | await navigator.clipboard.writeText(address); 118 | toast.info("Copied to clipboard."); 119 | } 120 | }; 121 | 122 | const abbreviatedAddress = address 123 | ? `${address.slice(0, 4)}...${address.slice(-2)}` 124 | : ""; 125 | 126 | return ( 127 | <> 128 | {user && authenticated ? ( 129 |
130 | 136 | 143 |
144 |

145 | Player:{" "} 146 | {abbreviatedAddress} 147 |

148 | 156 |
157 |
158 | ) : ( 159 | 165 | )} 166 | 167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /src/components/Scorecard.tsx: -------------------------------------------------------------------------------- 1 | // Hooks 2 | import { useEffect, useState } from "react"; 3 | 4 | // UI 5 | import { Card } from "./ui/card"; 6 | 7 | type ScorecardProps = { 8 | score: number; 9 | }; 10 | 11 | export default function Scorecard({ score }: ScorecardProps) { 12 | const [displayScore, setDisplayScore] = useState(0); 13 | 14 | useEffect(() => { 15 | const targetScore = score; 16 | if (displayScore !== targetScore) { 17 | const duration = 150; // Total animation duration in ms 18 | const startTime = Date.now(); 19 | const startScore = displayScore; 20 | 21 | const animate = () => { 22 | const currentTime = Date.now(); 23 | const elapsed = currentTime - startTime; 24 | 25 | if (elapsed < duration) { 26 | const progress = elapsed / duration; 27 | const nextScore = Math.round( 28 | startScore + (targetScore - startScore) * progress 29 | ); 30 | setDisplayScore(nextScore); 31 | requestAnimationFrame(animate); 32 | } else { 33 | setDisplayScore(targetScore); 34 | } 35 | }; 36 | 37 | requestAnimationFrame(animate); 38 | } 39 | }, [score, displayScore]); 40 | 41 | return ( 42 | 43 |

SCORE

44 |

45 | {displayScore} 46 |

47 | 58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 3 | 4 | import { cn } from "@/lib/utils" 5 | import { buttonVariants } from "@/components/ui/button" 6 | 7 | function AlertDialog({ 8 | ...props 9 | }: React.ComponentProps) { 10 | return 11 | } 12 | 13 | function AlertDialogTrigger({ 14 | ...props 15 | }: React.ComponentProps) { 16 | return ( 17 | 18 | ) 19 | } 20 | 21 | function AlertDialogPortal({ 22 | ...props 23 | }: React.ComponentProps) { 24 | return ( 25 | 26 | ) 27 | } 28 | 29 | function AlertDialogOverlay({ 30 | className, 31 | ...props 32 | }: React.ComponentProps) { 33 | return ( 34 | 42 | ) 43 | } 44 | 45 | function AlertDialogContent({ 46 | className, 47 | ...props 48 | }: React.ComponentProps) { 49 | return ( 50 | 51 | 52 | 60 | 61 | ) 62 | } 63 | 64 | function AlertDialogHeader({ 65 | className, 66 | ...props 67 | }: React.ComponentProps<"div">) { 68 | return ( 69 |
74 | ) 75 | } 76 | 77 | function AlertDialogFooter({ 78 | className, 79 | ...props 80 | }: React.ComponentProps<"div">) { 81 | return ( 82 |
90 | ) 91 | } 92 | 93 | function AlertDialogTitle({ 94 | className, 95 | ...props 96 | }: React.ComponentProps) { 97 | return ( 98 | 103 | ) 104 | } 105 | 106 | function AlertDialogDescription({ 107 | className, 108 | ...props 109 | }: React.ComponentProps) { 110 | return ( 111 | 116 | ) 117 | } 118 | 119 | function AlertDialogAction({ 120 | className, 121 | ...props 122 | }: React.ComponentProps) { 123 | return ( 124 | 128 | ) 129 | } 130 | 131 | function AlertDialogCancel({ 132 | className, 133 | ...props 134 | }: React.ComponentProps) { 135 | return ( 136 | 140 | ) 141 | } 142 | 143 | export { 144 | AlertDialog, 145 | AlertDialogPortal, 146 | AlertDialogOverlay, 147 | AlertDialogTrigger, 148 | AlertDialogContent, 149 | AlertDialogHeader, 150 | AlertDialogFooter, 151 | AlertDialogTitle, 152 | AlertDialogDescription, 153 | AlertDialogAction, 154 | AlertDialogCancel, 155 | } 156 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ); 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ); 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ); 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ); 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ); 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ); 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ); 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | }; 93 | -------------------------------------------------------------------------------- /src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "next-themes"; 2 | import { Toaster as Sonner, ToasterProps } from "sonner"; 3 | 4 | const Toaster = ({ ...props }: ToasterProps) => { 5 | const { theme = "system" } = useTheme(); 6 | 7 | return ( 8 | 20 | ); 21 | }; 22 | 23 | export { Toaster }; 24 | -------------------------------------------------------------------------------- /src/hooks/useTransactions.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { publicClient } from "@/utils/client"; 3 | import { GAME_CONTRACT_ADDRESS } from "@/utils/constants"; 4 | import { post } from "@/utils/fetch"; 5 | import { usePrivy, useWallets } from "@privy-io/react-auth"; 6 | import { ExternalLink } from "lucide-react"; 7 | import { useEffect, useRef } from "react"; 8 | import { toast } from "sonner"; 9 | import { 10 | createWalletClient, 11 | custom, 12 | encodeFunctionData, 13 | formatEther, 14 | Hex, 15 | parseEther, 16 | parseGwei, 17 | } from "viem"; 18 | import { waitForTransactionReceipt } from "viem/actions"; 19 | import { monadTestnet } from "viem/chains"; 20 | 21 | export function useTransactions() { 22 | // User and Wallet objects. 23 | const { user } = usePrivy(); 24 | const { ready, wallets } = useWallets(); 25 | 26 | // Fetch user nonce on new login. 27 | const userNonce = useRef(0); 28 | const userBalance = useRef(0n); 29 | const userAddress = useRef(""); 30 | 31 | // Resets nonce and balance 32 | async function resetNonceAndBalance() { 33 | if (!user) { 34 | return; 35 | } 36 | const [privyUser] = user.linkedAccounts.filter( 37 | (account) => 38 | account.type === "wallet" && 39 | account.walletClientType === "privy" 40 | ); 41 | if (!privyUser || !(privyUser as any).address) { 42 | return; 43 | } 44 | const privyUserAddress = (privyUser as any).address; 45 | 46 | const nonce = await publicClient.getTransactionCount({ 47 | address: privyUserAddress as Hex, 48 | }); 49 | const balance = await publicClient.getBalance({ 50 | address: privyUserAddress as Hex, 51 | }); 52 | 53 | console.log("Setting nonce: ", nonce); 54 | console.log("Setting balance: ", balance.toString()); 55 | 56 | userNonce.current = nonce; 57 | userBalance.current = balance; 58 | userAddress.current = privyUserAddress; 59 | } 60 | 61 | useEffect(() => { 62 | resetNonceAndBalance(); 63 | }, [user]); 64 | 65 | // Fetch provider on new login. 66 | const walletClient = useRef(null); 67 | useEffect(() => { 68 | async function getWalletClient() { 69 | if (!ready || !wallets) return; 70 | 71 | const userWallet = wallets.find( 72 | (w) => w.walletClientType == "privy" 73 | ); 74 | if (!userWallet) return; 75 | 76 | const ethereumProvider = await userWallet.getEthereumProvider(); 77 | const provider = createWalletClient({ 78 | chain: monadTestnet, 79 | transport: custom(ethereumProvider), 80 | }); 81 | 82 | console.log("Setting provider: ", provider); 83 | walletClient.current = provider; 84 | } 85 | 86 | getWalletClient(); 87 | }, [user, ready, wallets]); 88 | 89 | // Sends a transaction and wait for receipt. 90 | async function sendRawTransactionAndConfirm({ 91 | successText, 92 | gas, 93 | data, 94 | nonce, 95 | maxFeePerGas = parseGwei("50"), 96 | maxPriorityFeePerGas = parseGwei("5"), 97 | }: { 98 | successText?: string; 99 | gas: BigInt; 100 | data: Hex; 101 | nonce: number; 102 | maxFeePerGas?: BigInt; 103 | maxPriorityFeePerGas?: BigInt; 104 | }) { 105 | let e: Error | null = null; 106 | 107 | try { 108 | // Sign and send transaction. 109 | const provider = walletClient.current; 110 | if (!provider) { 111 | throw Error("Wallet not found."); 112 | } 113 | const privyUserAddress = userAddress.current; 114 | if (!privyUserAddress) { 115 | throw Error("Privy user not found."); 116 | } 117 | 118 | const startTime = Date.now(); 119 | const signedTransaction = await provider.signTransaction({ 120 | to: GAME_CONTRACT_ADDRESS, 121 | account: privyUserAddress, 122 | data, 123 | nonce, 124 | gas, 125 | maxFeePerGas, 126 | maxPriorityFeePerGas, 127 | }); 128 | 129 | const environment = import.meta.env.VITE_APP_ENVIRONMENT; 130 | const rpc = 131 | environment === "prod" 132 | ? import.meta.env.VITE_MONAD_RPC_URL! || 133 | monadTestnet.rpcUrls.default.http[0] 134 | : monadTestnet.rpcUrls.default.http[0]; 135 | const response = await post({ 136 | url: rpc, 137 | params: { 138 | id: 0, 139 | jsonrpc: "2.0", 140 | method: "eth_sendRawTransaction", 141 | params: [signedTransaction], 142 | }, 143 | }); 144 | const time = Date.now() - startTime; 145 | 146 | if (response.error) { 147 | console.log(`Failed sent in ${time} ms`); 148 | throw Error(response.error.message); 149 | } 150 | 151 | const transactionHash: Hex = response.result; 152 | 153 | // Fire toast info with benchmark and transaction hash. 154 | console.log(`Transaction sent in ${time} ms: ${response.result}`); 155 | toast.info(`Sent transaction.`, { 156 | description: `${successText} Time: ${time} ms`, 157 | action: ( 158 | 173 | ), 174 | }); 175 | 176 | // Confirm transaction 177 | const receipt = await waitForTransactionReceipt(publicClient, { 178 | hash: transactionHash, 179 | }); 180 | 181 | if (receipt.status == "reverted") { 182 | console.log( 183 | `Failed confirmation in ${Date.now() - startTime} ms` 184 | ); 185 | throw Error( 186 | `Failed to confirm transaction: ${transactionHash}` 187 | ); 188 | } 189 | 190 | console.log( 191 | `Transaction confirmed in ${Date.now() - startTime} ms: ${ 192 | response.result 193 | }` 194 | ); 195 | toast.success(`Confirmed transaction.`, { 196 | description: `${successText} Time: ${ 197 | Date.now() - startTime 198 | } ms`, 199 | action: ( 200 | 215 | ), 216 | }); 217 | } catch (error) { 218 | e = error as Error; 219 | 220 | toast.error(`Failed to send transaction.`, { 221 | description: `Error: ${e.message}`, 222 | }); 223 | } 224 | 225 | if (e) { 226 | throw e; 227 | } 228 | } 229 | 230 | // Returns a the latest stored baord of a game as an array. 231 | async function getLatestGameBoard( 232 | gameId: Hex 233 | ): Promise< 234 | readonly [ 235 | readonly [ 236 | number, 237 | number, 238 | number, 239 | number, 240 | number, 241 | number, 242 | number, 243 | number, 244 | number, 245 | number, 246 | number, 247 | number, 248 | number, 249 | number, 250 | number, 251 | number 252 | ], 253 | bigint 254 | ] 255 | > { 256 | const [latestBoard, nextMoveNumber] = await publicClient.readContract({ 257 | address: GAME_CONTRACT_ADDRESS, 258 | abi: [ 259 | { 260 | type: "function", 261 | name: "getBoard", 262 | inputs: [ 263 | { 264 | name: "gameId", 265 | type: "bytes32", 266 | internalType: "bytes32", 267 | }, 268 | ], 269 | outputs: [ 270 | { 271 | name: "boardArr", 272 | type: "uint8[16]", 273 | internalType: "uint8[16]", 274 | }, 275 | { 276 | name: "nextMoveNumber", 277 | type: "uint256", 278 | internalType: "uint256", 279 | }, 280 | ], 281 | stateMutability: "view", 282 | }, 283 | ], 284 | functionName: "getBoard", 285 | args: [gameId], 286 | }); 287 | 288 | return [latestBoard, nextMoveNumber]; 289 | } 290 | 291 | // Initializes a game. Calls `prepareGame` and `startGame`. 292 | async function initializeGameTransaction( 293 | gameId: Hex, 294 | boards: readonly [bigint, bigint, bigint, bigint], 295 | moves: readonly [number, number, number] 296 | ): Promise { 297 | const balance = userBalance.current; 298 | if (parseFloat(formatEther(balance)) < 0.01) { 299 | throw Error("Signer has insufficient balance."); 300 | } 301 | 302 | // Sign and send transaction: start game 303 | console.log("Starting game!"); 304 | 305 | const nonce = userNonce.current; 306 | userNonce.current = nonce + 1; 307 | userBalance.current = balance - parseEther("0.0075"); 308 | 309 | await sendRawTransactionAndConfirm({ 310 | nonce: nonce, 311 | successText: "Started game!", 312 | gas: BigInt(150_000), 313 | data: encodeFunctionData({ 314 | abi: [ 315 | { 316 | type: "function", 317 | name: "startGame", 318 | inputs: [ 319 | { 320 | name: "gameId", 321 | type: "bytes32", 322 | internalType: "bytes32", 323 | }, 324 | { 325 | name: "boards", 326 | type: "uint128[4]", 327 | internalType: "uint128[4]", 328 | }, 329 | { 330 | name: "moves", 331 | type: "uint8[3]", 332 | internalType: "uint8[3]", 333 | }, 334 | ], 335 | outputs: [], 336 | stateMutability: "nonpayable", 337 | }, 338 | ], 339 | functionName: "startGame", 340 | args: [gameId, boards, moves], 341 | }), 342 | }); 343 | } 344 | 345 | async function playNewMoveTransaction( 346 | gameId: Hex, 347 | board: bigint, 348 | move: number, 349 | moveCount: number 350 | ): Promise { 351 | // Sign and send transaction: play move 352 | console.log(`Playing move ${moveCount}!`); 353 | 354 | const balance = userBalance.current; 355 | if (parseFloat(formatEther(balance)) < 0.01) { 356 | throw Error("Signer has insufficient balance."); 357 | } 358 | 359 | const nonce = userNonce.current; 360 | userNonce.current = nonce + 1; 361 | userBalance.current = balance - parseEther("0.005"); 362 | 363 | await sendRawTransactionAndConfirm({ 364 | nonce, 365 | successText: `Played move ${moveCount}`, 366 | gas: BigInt(100_000), 367 | data: encodeFunctionData({ 368 | abi: [ 369 | { 370 | type: "function", 371 | name: "play", 372 | inputs: [ 373 | { 374 | name: "gameId", 375 | type: "bytes32", 376 | internalType: "bytes32", 377 | }, 378 | { 379 | name: "move", 380 | type: "uint8", 381 | internalType: "uint8", 382 | }, 383 | { 384 | name: "resultBoard", 385 | type: "uint128", 386 | internalType: "uint128", 387 | }, 388 | ], 389 | outputs: [], 390 | stateMutability: "nonpayable", 391 | }, 392 | ], 393 | functionName: "play", 394 | args: [gameId, move, board], 395 | }), 396 | }); 397 | } 398 | 399 | return { 400 | resetNonceAndBalance, 401 | initializeGameTransaction, 402 | playNewMoveTransaction, 403 | getLatestGameBoard, 404 | }; 405 | } 406 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import "./index.css"; 4 | 5 | // UI 6 | import App from "./App.tsx"; 7 | import { PrivyProvider } from "@privy-io/react-auth"; 8 | 9 | // Utils 10 | import { monadTestnet } from "viem/chains"; 11 | 12 | createRoot(document.getElementById("root")!).render( 13 | 14 | 29 | 30 | 31 | 32 | ); 33 | -------------------------------------------------------------------------------- /src/utils/client.ts: -------------------------------------------------------------------------------- 1 | import { monadTestnet } from "viem/chains"; 2 | import { createPublicClient, http } from "viem"; 3 | 4 | const environment = import.meta.env.VITE_APP_ENVIRONMENT; 5 | const rpc = 6 | environment === "prod" 7 | ? import.meta.env.VITE_MONAD_RPC_URL! || 8 | monadTestnet.rpcUrls.default.http[0] 9 | : monadTestnet.rpcUrls.default.http[0]; 10 | 11 | export const publicClient = createPublicClient({ 12 | chain: monadTestnet, 13 | transport: http(rpc), 14 | }); 15 | -------------------------------------------------------------------------------- /src/utils/constants.ts: -------------------------------------------------------------------------------- 1 | export const GAME_CONTRACT_ADDRESS = 2 | "0xe0FA8195AE92b9C473c0c0c12c2D6bCbd245De47"; 3 | -------------------------------------------------------------------------------- /src/utils/fetch.ts: -------------------------------------------------------------------------------- 1 | type GetOptions = { 2 | url: string; 3 | params?: Record; 4 | headers?: Record; 5 | }; 6 | 7 | export async function get({ url, params, headers }: GetOptions) { 8 | const response = await fetch(url + "?" + new URLSearchParams(params), { 9 | method: "GET", 10 | headers, 11 | credentials: "include", 12 | }); 13 | 14 | if (!response.ok) { 15 | throw new Error(response.statusText); 16 | } 17 | 18 | return await response.json(); 19 | } 20 | 21 | type PostOptions = { 22 | url: string; 23 | params?: Record | any[]; 24 | headers?: Record; 25 | }; 26 | 27 | export async function post({ url, params, headers }: PostOptions) { 28 | const response = await fetch(url, { 29 | method: "POST", 30 | headers: { 31 | "Content-Type": "application/json", 32 | ...headers, 33 | }, 34 | body: JSON.stringify(params ?? {}), 35 | credentials: "include", 36 | }); 37 | 38 | if (!response.ok) { 39 | throw new Error(response.statusText); 40 | } 41 | 42 | return await response.json(); 43 | } 44 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ] 29 | } 30 | }, 31 | "include": ["src"] 32 | } 33 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react-swc"; 3 | import path from "path"; 4 | import tailwindcss from "@tailwindcss/vite"; 5 | 6 | // https://vite.dev/config/ 7 | export default defineConfig({ 8 | plugins: [react(), tailwindcss()], 9 | resolve: { 10 | alias: { 11 | "@": path.resolve(__dirname, "./src"), 12 | }, 13 | }, 14 | }); 15 | --------------------------------------------------------------------------------