An onchain Tic-Tac-Toe game built with Solidity
Report Bug
·
Request Feature
Table of Contents
Sharks & Tigers is an onchain Tic-Tac-Toe game built with Solidity. Each game is deployed as its own immutable smart contract, with all state transitions and outcomes enforced onchain.
The game replaces traditional X and O marks with Shark and Tiger symbols, giving each match a simple identity while keeping the underlying rules unchanged.
- Factory Pattern: A single factory contract deploys and tracks game instances
- Onchain Game State: All moves and outcomes are enforced by smart contracts
- One Game = One Contract: Each match is its own immutable contract
- Event-Based Updates: Game lifecycle events are emitted for indexing and UI use
- Fixed Rules per Game: Rules are set at creation and cannot change mid-game
- USDC Staking: Players stake USDC in a central EscrowManager until the game ends
This section lists the major frameworks and libraries used in this project.
This section provides instructions on setting up the project locally. To get a local copy up and running, follow these steps.
- Foundry - Solidity development framework
-
Install Foundry
curl -L https://foundry.paradigm.xyz | bash foundryup -
Clone the repo
git clone https://github.com/am-hernandez/SharksAndTigers.git cd SharksAndTigers -
Install dependencies
forge install
-
Build the project
forge build
-
Run tests
forge test
-
Create Game
- Player One creates a game through the factory
- Chooses a mark (Shark or Tiger) and an opening position
- Approves USDC to the EscrowManager; the factory registers the game with escrow and deposits Player One’s stake there
- Game enters the
Openstate
-
Join Game
- Player Two joins the game and makes their first move
- Approves and transfers matching USDC stake to EscrowManager
- Game transitions to
Active
-
Play
- Players alternate turns
- Each move is validated and recorded onchain
- Play clock enforces liveness guarantees, preventing griefing where funds could otherwise be locked indefinitely
-
End Game
- A player wins by completing three in a row, column, or diagonal
- The game ends in a draw when the board is full
- Game state transitions to
Ended - EscrowManager credits the winner; the winner claims both escrowed stakes from EscrowManager
Board positions are indexed as follows:
0 | 1 | 2
--+---+--
3 | 4 | 5
--+---+--
6 | 7 | 8
The system has three main contracts:
-
SharksAndTigersFactory Deploys a single EscrowManager and creates/tracks individual game contracts. Holds
FACTORY_ROLEon the escrow. -
EscrowManager Custodies all USDC stakes and tracks per-game escrow state. Registers games, receives deposits from both players, and records outcomes when games call
finalize. Players claim winnings or withdraw refunds from here. -
SharksAndTigers One contract per game. Manages board state and moves; has
GAME_ROLEon the escrow and finalizes the escrow position when the game ends. Players claim reward or withdraw refundable stake from EscrowManager.
The EscrowManager is deployed once in the Factory constructor and is immutable. All games created by that factory register with this escrow instance.
A global EscrowManager is used instead of per-game escrow contracts to reduce token approval friction and allow players to withdraw accumulated winnings/refunds across multiple games in a single transaction.
The factory deploys one EscrowManager and creates new games, registering each game with the escrow and keeping a registry of game contracts.
State Variables
i_usdcToken(IERC20 immutable): USDC token contracti_escrowManager(EscrowManager immutable): Central escrow that holds stakes and pays out winners/refundss_gameCount(uint256): Total number of games createds_games(mapping(uint256 => address)): Maps game IDs to game addresses
Functions
Creates a new game instance with Player One's initial move.
Parameters
position: Board position (0–8)_playerOneMark: Mark choice (1 = Shark,2 = Tiger)playClock: Time limit in seconds for each movestake: USDC stake amount (must match for player two)
Requirements
positionmust be within range_playerOneMarkmust be Shark or Tiger- Player must have approved the EscrowManager to spend USDC
- Player must have sufficient USDC balance
Effects
- Deploys a new
SharksAndTigerscontract - Registers the game with the EscrowManager and deposits Player One’s USDC stake there
- Increments the game counter and stores the game address
- Emits
GameCreated
Public state: use s_games(gameId) to get the game address and s_gameCount for the latest game ID.
A single game contract that contains all logic and state for one match.
State Variables
BOARD_SIZE(uint256 constant): The number of position spaces on the game boardi_gameId(uint256 immutable): Unique game identifieri_stake(uint256 immutable): USDC stake amount per playeri_playClock(uint256 immutable): Time limit per move in secondsi_usdcToken(address immutable): USDC token contract addresss_lastPlayTime(uint256): Timestamp of the last movei_playerOne(address immutable): Address of player ones_playerTwo(address): Address of player twos_currentPlayer(address): Address of the player whose turn it iss_winner(address): Address of the winner (if any)s_isDraw(bool): Whether the game ended in a draws_gameState(GameState): Current state of the gamei_playerOneMark(Mark immutable): Mark assigned to player onei_playerTwoMark(Mark immutable): Mark assigned to player twos_gameBoard(Mark[9]): The game boardi_escrowManager(address immutable): EscrowManager address; game calls it to finalize outcomes
Enums
enum GameState {
Open,
Active,
Ended
}
enum Mark {
Empty,
Shark,
Tiger
}Functions
Allows player two to join the game and make their first move.
Allows the current player to make a move. If the move completes three in a row, column, or diagonal, the game ends and EscrowManager is finalized for the winner; if the board is full, the game ends in a draw and escrow is finalized. Uses internal _isWinningMove and _isBoardFull for outcome detection.
Callable when the play clock has expired (current player did not move in time). Requires game in Active state and Player Two already joined. Awards the win to the opponent, updates game state to Ended, and calls EscrowManager finalize(winner, false, false).
Allows Player One to cancel before anyone joins. Requires game in Open state and Player Two not set. Marks game as Ended and calls EscrowManager finalize(address(0), false, true) so Player One’s stake becomes refundable.
Returns the full game state (gameId, stake, playClock, lastPlayTime, playerOne, playerTwo, currentPlayer, winner, isDraw, gameState, playerOneMark, playerTwoMark, gameBoard, escrowManager).
Central contract that custodies all USDC stakes and tracks per-game escrow state.
Access Controls
FACTORY_ROLE: granted to the game factoryGAME_ROLE: granted to each registered game
Key functions
- Factory:
registerGame(game, gameId, player1, stake)thendepositPlayer1(game)after creating a game. - Game:
setPlayer2(player2),depositPlayer2(), andfinalize(winner, isDraw, isCancelled)when the game ends. - Players:
claimReward()(winners) andwithdrawRefundableStake()(draw/cancel refunds).
State includes escrows(gameAddress), claimable[player], and refundable[player].
Foundry scripts under script/Interactions/ can be run via the Makefile against a local node (e.g. make anvil in another terminal) or a configured network.
| Command | Description |
|---|---|
make deploy |
Deploy the factory (and its EscrowManager). |
make create-game |
Player One creates a game (default key). |
make join-game |
Player Two joins the latest game. |
make play-game PLAYER=1 POS=2 |
Play one move; PLAYER 1 or 2, POS 0–8. |
make claim-reward PLAYER=1 |
Winner claims winnings from EscrowManager. |
make withdraw-refundable PLAYER=1 |
Withdraw refundable stake (draw/cancel). |
make get-game-state |
Print latest game state (read-only). |
make get-claimable PLAYER=1 |
Print claimable balance for player (read-only). |
make get-refundable PLAYER=1 |
Print refundable balance for player (read-only). |
Use ARGS=base-sepolia (or similar) with deploy/create-game/join-game/play-game/claim-reward/withdraw-refundable for a live testnet; ensure .env and keys are set.
- Factory pattern for game deployment
- Onchain game state management
- USDC staking with escrow
- Play clock with liveness guarantees
- Event-based updates
- Open game cancellation
- Game statistics and leaderboards
- Additional game versions via an upgraded factory
- Frontend UI integration
See the open issues for a full list of proposed features (and known issues).
This project implements several security best practices:
- Input Validation: All inputs are validated before processing
- State Machine: State transitions are enforced through a finite state machine
- Immutability: Game parameters are immutable once deployed
- CEI Pattern: Checks-Effects-Interactions (CEI) pattern enforced before all external calls to EscrowManager
- ReentrancyGuard: OpenZeppelin's ReentrancyGuardTransient used for critical functions
- Centralized escrow: Centralized escrow accounting reduces per-game attack surface while isolating game logic
- SafeERC20: OpenZeppelin's SafeERC20 used for all token transfers
- Event Logging: Events emitted for all critical actions for transparency
- Each game is deployed as its own contract to guarantee isolation and immutability at the cost of higher deployment overhead.
- No admin or upgrade hooks are included to avoid trust assumptions and governance complexity.
- USDC is used instead of ETH to reduce volatility and simplify escrow accounting.
- Play clock enforces liveness rather than relying on offchain arbitration or admin intervention.
- Centralized escrow is used to improve user expeerience in setting USDC approval for one contract instead of approving the factory and each game contract.
- No upgradeability or proxy patterns.
- No offchain arbitration or admin intervention.
- No shared game state across instances.
- No support for multiple token types per game.
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated.
If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". Don't forget to give the project a star! Thanks again!
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit your Changes (
git commit -m 'Add some AmazingFeature') - Push to the Branch (
git push origin feature/AmazingFeature) - Open a Pull Request
Distributed under the MIT License. See LICENSE for more information.
AM - @am-hernandez
Project Link: https://github.com/am-hernandez/SharksAndTigers
- Foundry - The Solidity development framework
- OpenZeppelin Contracts - Secure smart contract libraries
- Solidity Style Guide - Official Solidity style guide
- Best README Template - README template inspiration