- Published on
2048: A Guide to Building High Performance Games on Monad
- Authors
- Name
- Monad Foundation
- @monad_dev
Introduction
We built Monad2048 - an onchain implementation of 2048 - to demonstrate best practices for creating high-throughput, responsive onchain apps.

Important Links
- Game: https://2048.monad.xyz/
- Contracts (Foundry): https://github.com/monad-developers/2048-contracts
- Frontend (Vite React): https://github.com/monad-developers/2048-frontend
- Verified smart contract: 0xe0FA8195AE92b9C473c0c0c12c2D6bCbd245De47
Contents
Background
The logic of smart contract applications is split between the offchain application code and the onchain smart contract.
Some apps encode nearly all of their logic offchain and push occasional updates to the smart contracts. A trivial version would run the entire game in the frontend and only submit a transaction to the smart contract if the 2048 end state is achieved.
Other apps include nearly all of their logic in the smart contracts; the frontend serves as an interface for preparing transactions and visualizing the contract state. An onchain 2048 would store the board state as smart contract state and generate each new tile using a VRF.
These two extremes illustrate the tradeoff between UX and security:
- The first version has fast response times, but can easily be cheated by claiming that a 2048 was achieved or by choosing favorable new tiles.
- The second version is resilient to cheating, but has a worse UX if submitting each move is blocked by receiving a response for the previous move.
Ideally, the game would offer robust cheating detterence and a snappy UX.
Requirements
The baseline expectations for an onchain 2048 game are:
- The game logic should not need a backend
- Each move should be validated onchain according to the rules.
- A new tile should appear instantly upon a completed move.
New Tiles and Limitations
In 2048, whenever a user makes a move, a new tile is added to the board at a randomly chosen empty tile slot. The new tile is randomly assigned a value of 2 (with 90% probability) or 4 (with 10% probability).
We submit each new move to the smart contract, which maintains the game's latest board state. To make the UX responsive, the smart contract's logic is emulated on the frontend.
The key design question, now, is where and how are new tiles generated?
There are a few options:
- Make this choice within the browser by calling a random number generator
- Make this choice within the smart contract, perhaps using an external VRF, in response to seeing a move be committed.
- Make this choice within the browser by calling a deterministic random number generator, perhaps with occasional feedback from the smart contract to prevent the ability to peek too far ahead.
Approach (1) is not viable because it would make cheating trivial - the ideal next tile can be chosen directly by the user.
Approach (2) is ideal because it would mean in each new tile is generated completely randomly. Unfortunately, it's not practical because it would require each transaction to be committed before revealing the next tile. This means gameplay would be unacceptably slow.
Approach (3) is the solution we implemented because it provides a balanced approach. The user commits a seed when starting a new game. This seed is used in a deterministic formula to determine where new tiles are spawned upon a given new move and resultant board.
This does not eliminate all scope of cheating. A bot can still attempt to construct a series of desirable, valid boards using some optimal strategy. However, compared to approach (1), the bot cannot freely choose new tiles, making cheating more difficult.
NOTE
A fancier version of (2) would require input from the smart contract from a few seconds ago (e.g. a VRF output after submitting a move a few moves ago) to be an input to the current hash function.
This would allow a bot slight lookahead while utilizing this lookahead to serve new tiles prior to a move being included in the block. If you are interested, consider forking the code and trying it yourself!
Gameplay Validation
The Monad2048
smart contract contains the logic to validate a board position as a result of applying a legal move (Up, Down, Left or Right) on the current board position of a game.
For all ongoing games, the contract stores the current board position and processes new moves incrementally. This allows a user to resume the same game upon refreshing their browser.
An alternative mechanism we considered was having users only submit winning sequences of boards (e.g. terminating-in-2048). The downside of this approach is that any unexpected interruptions on the browser result in a user losing their entire game.
Smart Contract
The Monad2048
contract serves as an onchain game engine that verifies all user interactions.
Design
The design goals of the smart contract are:
- Immutable and publicly verifiable game rules.
- Exclusive write access to a user’s own game state, preventing impersonation.
- Resistance to common cheating vectors (e.g. move replay and invalid state transitions).
The core game logic resides in a library (LibBoard
). Move generation is delegated to the player and the contract focuses solely on validating submitted state transitions.
Encoding
To minimize storage and computation costs, the 4x4 game board state is encoded into a single uint128
. Each one of the 16 tiles of the board occupies 1 byte, storing log2(tileValue). For example, a tile with value 8 is stored as 0x03
.
Helper functions in LibBoard
allow inspection of the encoded board:
function getTile(uint128 board, uint8 pos) public pure returns (uint8) {
return uint8((board >> ((15 - pos) * 8)) & 0xFF);
}
Storage
The contract avoids redundant data storage by only persisting the latest board for each game. All prior moves can be reconstructed off-chain using indexed events.
struct GameState {
uint8 move;
uint120 nextMove;
uint128 board;
}
/// @notice Mapping from game ID to the latest board state.
mapping(bytes32 gameId => GameState state) public state;
What’s not stored:
- Full move history
- 2D board layouts
Instead, external tools (e.g. indexers, RPC logs) can reconstruct the history for leaderboard or analysis purposes.
API
The contract exposes only two functions for gameplay:
- Start game: The player submits the first four boards of their game (initial board + the first 3 moves and their result). The contract verifies that this exact game has not been played before.
function startGame(bytes32 gameId, uint128[4] calldata boards, uint8[3] calldata moves) external;
- Play: The player submits a new board state resulting from a player move. The contract calls
LibBoard.validateTransformation
to ensureresultBoard
is a valid transition (that honours the game’s randomness seed) from the currentlatestBoard[gameId]
.function play(bytes32 gameId, uint8 move, uint128 resultBoard) external;
The chances of two players playing an identical game after 3 moves is less than 1 in a billion. We prevent playing identical games to prevent the simple cheating strategy of move copying.
It is possible for a malicious actor to grief our smart contract. One can front run in-flight startGame
transactions and prevent games from starting.
One possible solution here is to ask players to request a new game in one transaction, and have the smart contract fulfill this request in a subsequent transaction with a a unique start position.
We use a one-step startGame
solution since there is no direct economic incentive to grief the smart contract. This also lets the client start new games without any latency in waiting for a start position from the smart contract.
Example Game Flow
You can see a winning 1,000+ move game played on the smart contract here. Enter game ID: 0xc2da301cc952f9c35dc1daa96a0c5d2d1d8ff8b366403fa93a946f6e9661edb9
.


The flow of the gameplay is as follows:
- Client generates a unique
gameId
. - Client simulates the first 3 moves locally, producing
boards[0]
(initial),boards[1]
,boards[2]
,boards[3]
. - Client calls
startGame(gameId, boards)
. - For subsequent moves:
- Client computes the new board (
resultBoard
) state locally based on user input of a move, and thegameId
used as an input in a deterministic formula as the source of randomness. - Client calls
play(gameId, resultBoard)
.
- Client computes the new board (
Frontend
To support gameplay at 'button mashing' speeds, the frontend must handle transactions efficiently without blocking the UI.
User Onboarding
Embedded wallets such as Privy provide a seamless onboarding experience using email or social logins. They allow applications to sign and send transactions frequently without requiring user confirmation for every action (popup fatigue).
This requires careful consideration of security. In this 2048 game, the only actions possible are related to playing the game, limiting potential risks. The open-source nature of the frontend allows verification.
We use Privy’s embedded wallet API for our 2048 demo. For example, the following is a simple login button component using the @privy-io/react-auth
library.
import { usePrivy, useLogin, useLogout } from '@privy-io/react-auth'
export default function LoginButton() {
const { login } = useLogin()
const { logout } = useLogout()
const { user, authenticated } = usePrivy()
return (
<div>
{user && authenticated ? (
<div>
<button onClick={logout}>Logout</button>
<p>Logged in as: {user?.wallet?.address}</p>
</div>
) : (
<button onClick={login}>Login</button>
)}
</div>
)
}
(Simplified component; the actual implementation includes styling and initialization logic)
useTransaction
)
Moving Web3 Logic to a Custom Hook (All wallet interactions and transaction logic are encapsulated within a custom React hook (useTransaction
). This provides several benefits:
- Reusability: Use across different components needing transaction capabilities.
- Scoped Logic: Isolates web3 interactions, simplifying debugging.
- Centralized State Management: Manages essential states like nonce and the wallet provider instance.
// Inside useTransaction hook
const { user } = usePrivy()
const userNonce = useRef(0)
useEffect(() => {
async function getNonce() {
if (!user?.wallet?.address) return
// Fetch the current nonce from the network on login/wallet change
const nonce = await publicClient.getTransactionCount({
address: user.wallet.address as Hex,
})
userNonce.current = nonce
}
getNonce()
}, [user]) // Dependency ensures nonce is fetched when user logs in
// Inside useTransaction hook
const { ready, wallets } = usePrivy()
const walletClient = useRef<any>(null) // Stores the viem WalletClient
useEffect(() => {
async function getWalletClient() {
if (!ready || !wallets || !user?.wallet) return
// Find the embedded Privy wallet
const userWallet = wallets.find((w) => w.address === user.wallet?.address)
if (!userWallet) return
const ethereumProvider = await userWallet.getEthereumProvider()
const provider = createWalletClient({
chain: monadTestnet, // Configure with Monad Testnet details
transport: custom(ethereumProvider),
})
walletClient.current = provider
}
getWalletClient()
}, [user, ready, wallets]) // Re-initialize if wallet context changes
Sending Transactions
A robust strategy is needed to send transactions rapidly without blocking the UI or failures due to nonce mismatches.
General strategy
- A core function
sendRawTransactionAndConfirm
handles signing, broadcasting (via direct RPC call), and optionally awaiting confirmation of a transaction. - Higher-level functions (
initializeGameTransaction
,playNewMoveTransaction
) prepare transaction data (to
,data
,gas
) specific to the game actions and call the core function.
Optimistic UI Updates
- The UI updates immediately upon a user input such as pressing an arrow key.
- The transaction sending function (
initializeGameTransaction
orplayNewMoveTransaction
) is called asynchronously in the background. - Crucially, the game logic does not wait for the transaction to confirm before allowing the next move. However, upon the failure of a transaction in a background async call, the game reverts back to the last valid board state and offers the user to sync their game.
// Inside the move handling logic in App.tsx:
// Block user interactions
setIsAnimating(true)
// Create a new copy to avoid mutation issues
const boardWithRandomTile = {
tiles: [...newBoardState.tiles],
score: newBoardState.score,
}
// Add tile honouring the contract's handling of randomness seed
addRandomTileViaSeed(boardWithRandomTile)
// Non-blocking: send transactions
if (moveCount === 3) {
// Corresponds to startGame call
initializeGameTransaction(/* args */).catch(handleTxError) // Handle potential errors
} else if (moveCount > 3) {
// Corresponds to play call
playNewMoveTransaction(/* args */).catch(handleTxError)
}
// Triggers new tile animation
setBoardState(boardWithRandomTile)
// Resume user interactions
setIsAnimating(false)
Client-side libraries vs Direct RPC
Libraries like viem
or ethers
are excellent for general-purpose use but often include pre-flight simulations before sending a transaction (eth_sendRawTransaction
).
In high-frequency scenarios where transaction n
depends on the state change from transaction n-1
(which may not be confirmed yet), these simulations will fail.
Solution: Use viem
primarily for signing, but broadcast the signed transaction directly using an RPC call (eth_sendRawTransaction
). Test the client’s logic for generating transaction inputs in a smart contract test suite beforehand to ensure smooth gameplay on the client.
// Inside sendRawTransactionAndConfirm function
const provider = walletClient.current // Get WalletClient instance from useRef
const signedTransaction = await provider.signTransaction({
to: GAME_CONTRACT_ADDRESS,
account: user?.wallet?.address as Hex,
data, // Encoded function call
nonce, // Managed locally
gas,
maxFeePerGas: parseGwei('50'), // Set appropriate gas params for Monad testnet
maxPriorityFeePerGas: parseGwei('2'),
})
// Direct RPC call to broadcast
const response = await post({
url: monadTestnet.rpcUrls.default.http[0],
params: {
id: 0,
jsonrpc: '2.0',
method: 'eth_sendRawTransaction',
params: [signedTransaction],
},
})
// ... handle response and potential confirmation waiting ...
Nonce Management
- The
userNonce
stored inuseRef
is incremented synchronously immediately before a transaction is signed and sent. - If a function sends multiple transactions the nonce must be incremented appropriately for each transaction.
const nonce = userNonce.current;
userNonce.current = nonce + 1; // Increment ref *before* async calls
// Send prepareGame with nonce
await sendRawTransactionAndConfirm({ ..., nonce });
Handling Errors

The lowest level function (sendRawTransactionAndConfirm
) is wrapped in a try-catch block. On failure, it
- resets the locally stored nonce by re-fetching the active user nonce from the provider (
eth_getTransactionCount
). - Notifies the user via a toast.
- Propagates the error upwards.
Higher-level functions (initializeGameTransaction
, playNewMoveTransaction
) do not handle any errors as they do not update any app state by themselves. They simply propagate errors up to the UI component.
The top-level UI component catches any error and pauses the game with an error state UI.
const [gameError, setGameError] = useState<boolean>(false)
const [gameErrorText, setGameErrorText] = useState<string>('')
Since transaction functions (initializeGameTransaction
, playNewMoveTransaction
) run asynchronously in the background with catch clauses, multiple such catch clauses can run in case even one transaction errors (i.e. the first errored transaction and all future transactions already broadcasted to RPC).
Therefore, whenever a transaction errors, we store the board state associated with that transaction in a list and identify the board with the lowest score as the one to reset to.
// Stores all board states that have errored out.
const [resetBoards, setResetBoards] = useState<BoardState[]>([])
// Identifies the board with the lowest score.
useEffect(() => {
const boards = resetBoards
if (boards.length > 0) {
const scores = boards.map((b) => b.score)
const idx = scores.indexOf(Math.min(...scores))
setBoardState(boards[idx])
}
}, [resetBoards])
// Called whenever a transaction errors. Stores the board state associated
// associated with the transaction in a list.
function resetBoardOnError(premoveBoard: BoardState, currentMove: number, error: Error) {
if (!gameError) {
setGameError(true)
setGameErrorText(error.message)
setResetBoards((current) => [...current, premoveBoard])
setPlayedMovesCount(currentMove)
setIsAnimating(false)
}
}
We then offer the user to re-sync and continue with their current game. On re-syncing the game, the client fetches the game’s latest board state to ensure it is correct.
Summary
We've just built an onchain implementation of 2048 where every move is played and validated on a smart contract, and the game frontend delivers a snappy UX while handling a high throughput of transactions.
The blockchain as an execution environment creates certain limitations — no atomically available true randomness, potential front-running of in-flight transactions / griefing, etc. It is important to understand and design around these limitations.
Selecting deterministic game rules allowed for optimistic UI updates before transactions are confirmed on the blockchain, delivering a great user experience. Monad Testnet blocks are as fast as the blink of an eye; you should endeavor to make your app updates at least as fast.
The full game is open source, and the contract is deployed on Monad testnet.
- Play the game: https://2048.monad.xyz/
- Explore contracts: https://github.com/monad-developers/2048-contracts
- Explore frontend: https://github.com/monad-developers/2048-frontend
We’d love to see what you build next.