├── .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 |
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 |
--------------------------------------------------------------------------------