This plan is structured so you can hand each phase (or even each task within a phase) to an AI code editor (Cursor, Claude Code, Copilot Workspace, etc.) as a self-contained prompt. Each task includes the exact files to create, the acceptance criteria, and the dependencies from prior tasks. Work through them in order — every phase builds on the previous one's outputs.
Goal: A running Vite + React + TypeScript project with Tailwind, ready for development. Nothing game-specific yet.
Duration: 30 minutes
Prompt to AI editor:
Create a new Vite project with React and TypeScript using pnpm.
- Use Vite 5, React 18, TypeScript 5.4 (strict mode)
- Add Tailwind CSS 3 with PostCSS
- Add Zustand for state management
- Add Vitest for unit testing
- Configure path aliases: "@/" maps to "src/"
- Set up the following folder structure:
src/
├── engine/
├── canvas/
├── components/
├── chain/
├── miniapp/
├── stores/
├── App.tsx
└── main.tsx
- The app should render "Blokaz" centered on screen when started.
- ESLint + Prettier configured.
Acceptance: pnpm dev opens a page showing "Blokaz". pnpm test runs with zero failures.
Goal: A fully tested, deterministic game engine that runs entirely in memory. Zero React, zero Canvas, zero blockchain. Just TypeScript logic with 100% unit test coverage on critical paths.
Duration: 3–4 days
Prompt to AI editor:
Create src/engine/shapes.ts
Define an interface ShapeDefinition with these fields:
id: string
family: "single" | "line" | "square" | "L" | "bigL" | "T" | "zigzag"
cells: [number, number][] — row/col offsets from origin (0,0)
width: number — bounding box width
height: number — bounding box height
cellCount: number — total filled cells
spawnWeight: number — probability weight for RNG selection
Export a const SHAPES array containing all 22 shapes defined below.
Also export a SHAPE_MAP: Record<string, ShapeDefinition> for O(1) lookup by ID.
Also export TOTAL_WEIGHT: number (sum of all spawnWeights).
Shapes to define:
Singles & Dominoes:
S1: cells:[[0,0]] w:1 h:1 count:1 weight:5
D1: cells:[[0,0],[0,1]] w:2 h:1 count:2 weight:8
D2: cells:[[0,0],[1,0]] w:1 h:2 count:2 weight:8
Straight Lines:
I3H: cells:[[0,0],[0,1],[0,2]] w:3 h:1 count:3 weight:10
I3V: cells:[[0,0],[1,0],[2,0]] w:1 h:3 count:3 weight:10
I4H: cells:[[0,0],[0,1],[0,2],[0,3]] w:4 h:1 count:4 weight:8
I4V: cells:[[0,0],[1,0],[2,0],[3,0]] w:1 h:4 count:4 weight:8
I5H: cells:[[0,0],[0,1],[0,2],[0,3],[0,4]] w:5 h:1 count:5 weight:4
I5V: cells:[[0,0],[1,0],[2,0],[3,0],[4,0]] w:1 h:5 count:5 weight:4
Squares:
O2: cells:[[0,0],[0,1],[1,0],[1,1]] w:2 h:2 count:4 weight:10
O3: cells:[[0,0],[0,1],[0,2],[1,0],[1,1],[1,2],[2,0],[2,1],[2,2]] w:3 h:3 count:9 weight:3
O23: cells:[[0,0],[0,1],[1,0],[1,1],[2,0],[2,1]] w:2 h:3 count:6 weight:6
Small L-shapes:
L2A: cells:[[0,0],[1,0],[1,1]] w:2 h:2 count:3 weight:8
L2B: cells:[[0,0],[0,1],[1,0]] w:2 h:2 count:3 weight:8
L2C: cells:[[0,1],[1,0],[1,1]] w:2 h:2 count:3 weight:8
L2D: cells:[[0,0],[0,1],[1,1]] w:2 h:2 count:3 weight:8
Large L-shapes:
L3A: cells:[[0,0],[1,0],[2,0],[2,1],[2,2]] w:3 h:3 count:5 weight:5
L3B: cells:[[0,0],[0,1],[0,2],[1,2]] w:3 h:2 count:4 weight:5
L3C: cells:[[0,0],[0,1],[0,2],[1,0]] w:3 h:2 count:4 weight:5
L3D: cells:[[0,2],[1,2],[2,0],[2,1],[2,2]] w:3 h:3 count:5 weight:5
T-shape:
T1: cells:[[0,0],[0,1],[0,2],[1,1]] w:3 h:2 count:4 weight:6
Zigzag:
S1Z: cells:[[0,1],[0,2],[1,0],[1,1]] w:3 h:2 count:4 weight:6
Z1Z: cells:[[0,0],[0,1],[1,1],[1,2]] w:3 h:2 count:4 weight:6
Write unit tests in src/engine/__tests__/shapes.test.ts:
- All 22 shapes exist
- cellCount matches cells.length for each shape
- width and height match the actual bounding box of the cells
- TOTAL_WEIGHT equals the sum of all spawnWeights
- No duplicate IDs
Acceptance: pnpm test shapes passes all tests.
Prompt to AI editor:
Create src/engine/grid.ts
Export a Grid class (or functional module) for a 9×9 grid:
- Internal storage: Uint8Array(81). Index = row * 9 + col.
- 0 = empty, 1-8 = color IDs.
Functions to export:
createGrid(): Uint8Array — returns a zeroed 81-byte array
getCell(grid, row, col): number — returns cell value
setCell(grid, row, col, val): void — sets cell value
cloneGrid(grid): Uint8Array — returns a copy
canPlace(grid, shape, row, col): boolean
— returns true if ALL cells of the shape fit within 0-8 bounds
AND all target cells are empty (0)
placeShape(grid, shape, row, col, colorId): void
— writes colorId into each cell. Does NOT check validity
(caller must check canPlace first).
findFullLines(grid): { rows: number[], cols: number[] }
— returns indices of all completely filled rows and columns
clearLines(grid, rows, cols): { cellsCleared: number }
— sets all cells in the given rows/cols to 0
— returns the count of unique cells cleared
(cells at row/col intersections count once)
canPlaceAny(grid, shapes: ShapeDefinition[]): boolean
— returns true if at least one shape can be placed
somewhere on the grid. Used for game-over detection.
gridHash(grid): string
— returns a hex string of keccak256(grid bytes).
— Use a simple hash for now (can swap to keccak later).
Write unit tests in src/engine/__tests__/grid.test.ts:
- Place a shape and verify cells are filled
- canPlace returns false for out-of-bounds
- canPlace returns false for overlapping pieces
- Fill an entire row, verify findFullLines detects it
- Fill an entire column, verify findFullLines detects it
- Fill a row AND column, clearLines removes all and counts correctly
- canPlaceAny returns false on a full grid
- canPlaceAny returns true on an empty grid
Acceptance: All grid tests pass. canPlace + clearLines handle intersection overlap correctly.
Prompt to AI editor:
Create src/engine/rng.ts
Implement a deterministic xorshift128+ random number generator:
class DeterministicRNG {
constructor(seed: bigint)
next(): number — returns float in [0, 1)
nextInt(max: number): number — returns integer in [0, max)
}
Also export:
selectShape(rng: DeterministicRNG, shapes: ShapeDefinition[]): ShapeDefinition
— uses rng.next() to select a shape based on spawnWeight distribution
— sum all weights, multiply rng.next() by total, walk the array
accumulating weights until the threshold is reached
dealThree(rng: DeterministicRNG, shapes: ShapeDefinition[]): [ShapeDefinition, ShapeDefinition, ShapeDefinition]
— calls selectShape 3 times, returns the trio
Write unit tests in src/engine/__tests__/rng.test.ts:
- Same seed produces identical sequence of 1000 numbers
- Different seeds produce different sequences
- next() output is always >= 0 and < 1
- selectShape distribution roughly matches weights over 10000 samples
(O3 with weight 3 should appear ~2% of the time,
I3H with weight 10 should appear ~7%)
- dealThree returns exactly 3 shapes
Acceptance: Determinism test is the critical one — same seed, same game, every time.
Prompt to AI editor:
Create src/engine/scoring.ts
Export:
interface ScoreEvent {
basePoints: number; // piece.cellCount
linePoints: number; // linesCleared × 90
comboBonus: number; // linesCleared × comboStreak × 50
totalPoints: number; // sum of above
linesCleared: number;
newComboStreak: number; // updated streak value
}
calculateScore(
piece: ShapeDefinition,
linesCleared: number,
currentComboStreak: number
): ScoreEvent
Rules:
- BASE = piece.cellCount
- LINE = linesCleared × 10 × 9 (90 per line)
- If linesCleared > 0:
newCombo = currentComboStreak + 1
COMBO = linesCleared × newCombo × 50
Else:
newCombo = 0
COMBO = 0
- TOTAL = BASE + LINE + COMBO
Write unit tests:
- Place a 5-cell piece, clear 0 lines → 5 points, combo resets to 0
- Place a 4-cell piece, clear 1 line, combo was 0 → 4 + 90 + 50 = 144, combo = 1
- Place a 3-cell piece, clear 2 lines, combo was 2 → 3 + 180 + 300 = 483, combo = 3
- Place a 1-cell piece, clear 0 lines, combo was 5 → 1 point, combo = 0
Acceptance: All scoring edge cases pass.
Prompt to AI editor:
Create src/engine/game.ts
This is the master game class that ties grid, shapes, rng, and scoring together.
class GameSession {
constructor(seed: bigint)
// State
grid: Uint8Array
score: number
comboStreak: number
currentPieces: [ShapeDefinition, ShapeDefinition, ShapeDefinition]
piecesPlaced: number // 0, 1, or 2 within current deal
moveHistory: MoveRecord[]
isGameOver: boolean
dealCount: number // how many sets of 3 have been dealt
// Methods
deal(): void
— calls dealThree(rng, SHAPES) to get 3 new pieces
— resets piecesPlaced to 0
placePiece(pieceIndex: 0|1|2, row: number, col: number): PlaceResult
— validates the piece hasn't been used this deal
— validates canPlace
— places shape on grid
— runs findFullLines + clearLines
— calculates score
— records move in moveHistory
— marks piece as used
— if piecesPlaced === 3, auto-calls deal()
— after deal or after placement, checks canPlaceAny
for remaining/new pieces. If false → isGameOver = true
— returns PlaceResult with score event, lines cleared, game over flag
getReplayProof(): GameReplay
— packs moveHistory into bit-packed format
— returns seed, packed moves, final score, grid hash
// Internal
private rng: DeterministicRNG
private usedThisDeal: [boolean, boolean, boolean]
}
interface MoveRecord {
pieceIndex: 0|1|2;
shapeId: string;
row: number;
col: number;
scoreEvent: ScoreEvent;
}
interface PlaceResult {
success: boolean;
error?: string;
scoreEvent?: ScoreEvent;
linesCleared?: { rows: number[], cols: number[] };
isGameOver: boolean;
}
Write unit tests in src/engine/__tests__/game.test.ts:
- New game starts with 3 pieces dealt, score 0, combo 0
- Placing a piece returns success and updates score
- Placing at an invalid position returns success:false
- After placing all 3 pieces, new 3 are automatically dealt
- Game over is detected when no pieces fit
- Two games with the same seed produce identical piece sequences
- Replay proof contains correct move count and final score
Acceptance: Full game can be played programmatically in a test. Determinism holds across runs.
Prompt to AI editor:
Create src/engine/replay.ts
Export:
packMoves(moves: MoveRecord[]): bigint[]
— each move is encoded as 10 bits:
pieceIndex: 2 bits (0-2)
row: 4 bits (0-8)
col: 4 bits (0-8)
— pack 25 moves per bigint (250 bits, fits in uint256)
— pad the last bigint with zeros
unpackMoves(packed: bigint[], moveCount: number): { pieceIndex: number, row: number, col: number }[]
— reverse of packMoves
buildProof(session: GameSession): {
seed: bigint;
packedMoves: bigint[];
moveCount: number;
finalScore: number;
gridHash: string;
}
Write unit tests:
- Pack and unpack 1, 10, 50, 200 moves — roundtrip is lossless
- Edge case: move at (0,0) with pieceIndex 0
- Edge case: move at (8,8) with pieceIndex 2
- buildProof on a complete game matches the game's final state
Acceptance: Roundtrip packing works for all move counts. Proof matches game state.
Goal: A fully playable game in the browser — touch-friendly, animated, responsive. No blockchain yet. Just a fun game.
Duration: 4–5 days
Prompt to AI editor:
Create src/canvas/GridRenderer.ts
This class draws the 9×9 game grid onto an HTML5 Canvas.
Constructor takes:
- canvas: HTMLCanvasElement
- gridSize: number (pixel width/height of the entire grid)
Methods:
draw(grid: Uint8Array, ghostCells?: { row: number, col: number, valid: boolean }[]): void
— Clears the grid area
— Draws grid background (dark, subtle rounded-rect container)
— Draws gridlines (1px, semi-transparent)
— For each non-zero cell, draws a filled rounded rectangle
with the color from a COLOR_PALETTE[1-8] array
— If ghostCells provided, draws semi-transparent preview cells
(green tint if valid, red tint if invalid)
getCellSize(): number — returns gridSize / 9
screenToGrid(x: number, y: number): { row: number, col: number } | null
— converts canvas-relative coordinates to grid row/col
— returns null if outside the grid
COLOR_PALETTE should be 8 vibrant, high-contrast colors that look
good on a dark background. Think candy/neon puzzle game aesthetic.
The grid should have:
- 2px rounded corners on each cell
- 1px gap between cells
- Subtle drop shadow on filled cells
Acceptance: Rendering a test grid shows colored cells in the correct positions. Ghost cells overlay correctly.
Prompt to AI editor:
Create src/canvas/PieceRenderer.ts
Renders the 3-piece tray below the grid and handles dragging pieces.
Constructor takes:
- canvas: HTMLCanvasElement
- trayY: number (y-offset where the tray starts)
- cellSize: number (size of each block in the tray, slightly smaller than grid cells)
Methods:
drawTray(pieces: (ShapeDefinition | null)[], activeIndex?: number): void
— Draws 3 piece slots horizontally centered below the grid
— Each piece is drawn at ~70% of grid cell size
— null pieces (already placed) show an empty/dimmed slot
— activeIndex piece is highlighted (being dragged)
drawDragging(shape: ShapeDefinition, x: number, y: number, cellSize: number): void
— Draws the shape centered on (x, y) at full grid cell size
— Used while the player is dragging a piece
— Should have a subtle glow/shadow to indicate it's lifted
hitTestTray(x: number, y: number, pieces: (ShapeDefinition | null)[]): number | null
— Returns the piece index (0,1,2) if the touch point is within
a piece's bounding box. Returns null if no hit.
Each piece in the tray should use the same color the piece will have
when placed. Assign a random color from COLOR_PALETTE per piece.
Acceptance: Three pieces render below the grid. Hit testing correctly identifies which piece was touched.
Prompt to AI editor:
Create src/canvas/TouchController.ts
Handles all touch/mouse input for the game canvas.
Constructor takes:
- canvas: HTMLCanvasElement
- gridRenderer: GridRenderer
- pieceRenderer: PieceRenderer
- onPlace: (pieceIndex: number, row: number, col: number) => void
Behavior:
touchstart / mousedown:
— Hit test the piece tray
— If a piece is hit, enter DRAGGING state
— Record which piece (index) and the initial touch offset
touchmove / mousemove:
— If DRAGGING, update the drag position
— Calculate the ghost position on the grid (snap to nearest cell)
— Call back to the parent to get canPlace validation
— Update ghost cells (valid/invalid)
touchend / mouseup:
— If the ghost position is valid, call onPlace(pieceIndex, row, col)
— If invalid or outside grid, animate piece back to tray (spring)
— Clear dragging state
Important UX details:
— The piece should render ABOVE the finger (offset Y by -40px)
so the player can see what they're placing
— On desktop, support mouse drag as well
— Use requestAnimationFrame for smooth drag movement
— Don't use any React — this is pure Canvas + DOM events
Acceptance: You can drag a piece from the tray and drop it on the grid. Invalid drops spring back.
Prompt to AI editor:
Create src/canvas/AnimationManager.ts
Manages non-blocking visual effects during gameplay.
Animations to support:
1. LINE_CLEAR_FLASH
— When lines are cleared, the affected cells flash white
then fade out over 300ms
— Staggered: rows flash first, then columns (or simultaneously)
2. COMBO_POP
— When a combo occurs, a text popup appears near the cleared area
— Shows "COMBO x3!" (or whatever the streak is)
— Text scales up from 0 to 1 then fades out over 500ms
— Uses a bold, game-style font
3. SCORE_FLY
— "+485" flies up from the placement position and fades out
— Duration: 400ms
4. PIECE_SNAP
— Brief scale-up pulse (1.0 → 1.1 → 1.0) when a piece locks in
— Duration: 150ms
Methods:
trigger(type: AnimationType, params: object): void
update(deltaTime: number): void — advance all active animations
draw(ctx: CanvasRenderingContext2D): void — render all active animations
Use an array of active animation objects. Each has a progress (0→1),
duration, and a draw function. Remove completed animations.
Acceptance: Triggering each animation type renders the correct visual effect on the canvas.
Prompt to AI editor:
Create src/components/GameScreen.tsx
This is the main React component that ties the game engine to the canvas.
Structure:
- Full-screen mobile layout (dark background)
- Top: ScoreBar component (score + combo streak)
- Middle: Canvas element (grid + piece tray + animations)
- The canvas should resize responsively:
GRID_SIZE = min(window.innerWidth - 32, window.innerHeight * 0.55)
State management (use Zustand store at src/stores/gameStore.ts):
- gameSession: GameSession | null
- isPlaying: boolean
- score: number
- comboStreak: number
- currentPieces: (ShapeDefinition | null)[]
- isGameOver: boolean
On mount:
1. Create a new GameSession with a random seed
2. Initialize GridRenderer, PieceRenderer, TouchController, AnimationManager
3. Start the render loop (requestAnimationFrame)
When a piece is placed (onPlace callback):
1. Call gameSession.placePiece(index, row, col)
2. If lines cleared, trigger LINE_CLEAR_FLASH and COMBO_POP animations
3. Trigger SCORE_FLY animation
4. Update Zustand store
5. If game over, show GameOverModal
Also create src/components/ScoreBar.tsx:
- Shows current score (large number, left-aligned)
- Shows combo streak (e.g., "🔥 x3") if combo > 0
- Animated number counter (count up to new score)
Also create src/components/GameOverModal.tsx:
- Overlays the game with a dark semi-transparent background
- Shows "GAME OVER" + final score
- "Play Again" button (resets game with new seed)
- "Submit Score" button (disabled for now, enabled in Phase 3)
- "Share" button (disabled for now, enabled in Phase 4)
Make sure the canvas render loop does NOT cause React re-renders.
Only update Zustand when score/combo/pieces/gameOver actually change.
Acceptance: A fully playable game in the browser. Touch/mouse to drag and place. Lines clear with animations. Game over detected. Play Again works.
Prompt to AI editor:
Create src/miniapp/haptics.ts
For now, create stub functions that will later connect to the Farcaster SDK.
Use console.log as placeholder.
export function hapticSelection(): void — piece pickup
export function hapticImpact(): void — piece placed successfully
export function hapticNotification(): void — line cleared
export function hapticError(): void — invalid placement
Wire these into TouchController and GameScreen:
- hapticSelection() when drag starts
- hapticImpact() when piece is placed
- hapticNotification() when any line is cleared
- hapticError() when piece snaps back (invalid placement)
Acceptance: Console logs appear at the correct moments during gameplay.
Goal: A deployed, tested Solidity contract on Celo Alfajores testnet. Handles game registration, score submission with spot-check verification, weekly leaderboard, and USDC tournament management.
Duration: 5–6 days
Prompt to AI editor:
Create a Foundry project in the contracts/ directory at the project root.
contracts/
├── src/
│ └── BlokzGame.sol
├── test/
│ └── BlokzGame.t.sol
├── script/
│ └── Deploy.s.sol
├── foundry.toml
└── remappings.txt
foundry.toml should:
- Set solc version to 0.8.24
- Set optimizer to true with 200 runs
- Set evm_version to "cancun"
- Add OpenZeppelin contracts via forge install
Install dependencies:
forge install OpenZeppelin/openzeppelin-contracts-upgradeable
forge install OpenZeppelin/openzeppelin-contracts
Acceptance: forge build compiles with no errors.
Prompt to AI editor:
Create contracts/src/BlokzGame.sol
Use UUPS upgradeable pattern. Import from OpenZeppelin:
- UUPSUpgradeable
- OwnableUpgradeable
- ReentrancyGuardUpgradeable
Constants:
address public constant CUSD = 0x01C5C0122039549AD1493B8220cABEdD739BC44E;
uint256 public constant EPOCH_DURATION = 7 days;
uint8 public constant LEADERBOARD_SIZE = 50;
uint256 public constant PROTOCOL_FEE_BPS = 500;
State:
- Game struct: player, seedHash, score, startedAt, submittedAt, status (enum: ACTIVE/SUBMITTED/REJECTED)
- mapping(uint256 => Game) public games
- uint256 public nextGameId
- mapping(address => uint256) public activeGame (max 1 per player)
Functions:
function startGame(bytes32 seedHash) external returns (uint256 gameId)
— require activeGame[msg.sender] == 0 OR game is not ACTIVE
— create new Game, set status to ACTIVE
— set activeGame[msg.sender] = gameId
— emit GameStarted(gameId, msg.sender)
function submitScore(
uint256 gameId,
bytes32 seed,
uint256[] calldata packedMoves,
uint32 score,
uint16 moveCount
) external
— require games[gameId].player == msg.sender
— require games[gameId].status == ACTIVE
— require keccak256(abi.encodePacked(seed, msg.sender)) == games[gameId].seedHash
— perform spot-check verification (see Task 3.3)
— set score, status to SUBMITTED, submittedAt
— update leaderboard (see Task 3.4)
— clear activeGame[msg.sender]
— emit ScoreSubmitted(gameId, msg.sender, score)
Write basic tests:
- startGame creates a game with correct state
- submitScore with valid seed updates the game
- submitScore with wrong seed reverts
- Cannot have two active games simultaneously
- Only the game owner can submit
Acceptance: forge test passes all basic game registry tests.
Prompt to AI editor:
Add score verification logic to BlokzGame.sol.
Internal function:
function _spotCheckMoves(
bytes32 seed,
uint256[] calldata packedMoves,
uint16 moveCount
) internal view returns (bool)
Logic:
1. Derive 3 random check indices from:
uint256 entropy = uint256(keccak256(abi.encodePacked(
block.prevrandao, seed, msg.sender
)));
checkIndex[0] = entropy % moveCount;
checkIndex[1] = (entropy >> 32) % moveCount;
checkIndex[2] = (entropy >> 64) % moveCount;
2. For each check index:
a. Unpack the move at that index from packedMoves:
Each move = 10 bits (2 bit pieceIndex + 4 bit row + 4 bit col)
25 moves per uint256
b. Calculate which "deal" this move belongs to:
dealNumber = checkIndex / 3
pieceIndexInDeal = checkIndex % 3
c. Fast-forward the RNG (xorshift128+ in Solidity) from the seed
by (dealNumber × 3 + pieceIndexInDeal) steps
d. Verify the RNG output maps to a valid shape
e. Verify row < 9 and col < 9
f. Verify pieceIndex is 0, 1, or 2
3. Return true if all checks pass.
Also implement the xorshift128+ RNG in Solidity:
function _xorshift128plus(uint128 s0, uint128 s1)
internal pure returns (uint128, uint128, uint64)
— returns (newS0, newS1, output)
Note: Full placement validation (checking grid state) is NOT done on-chain.
We only verify that the move references a legitimate shape from the
correct deal and targets a valid grid position.
Write tests:
- Valid replay passes spot-check
- Replay with tampered move (wrong row) fails
- Replay with impossible pieceIndex (3) fails
Acceptance: Spot-check catches obviously tampered replays while staying under 100k gas.
Prompt to AI editor:
Add leaderboard functionality to BlokzGame.sol.
State:
struct LeaderboardEntry {
address player;
uint32 score;
uint256 gameId;
}
mapping(uint256 => LeaderboardEntry[]) internal _leaderboards;
Internal function called by submitScore:
function _updateLeaderboard(uint256 epoch, address player, uint32 score, uint256 gameId) internal
— epoch = block.timestamp / EPOCH_DURATION
— If leaderboard has < LEADERBOARD_SIZE entries, push
— Else if score > lowest entry's score, replace lowest
— Keep array sorted (insertion sort — bounded by LEADERBOARD_SIZE = 50)
View function:
function getLeaderboard(uint256 epoch) external view returns (LeaderboardEntry[] memory)
function getCurrentEpoch() external view returns (uint256)
Write tests:
- Submit 3 scores, leaderboard is sorted descending
- Submit 51 scores to a full leaderboard, lowest gets replaced
- Different epochs have independent leaderboards
Acceptance: Leaderboard correctly maintains top 50 per epoch, sorted by score.
Prompt to AI editor:
Add tournament functionality to BlokzGame.sol.
State:
struct Tournament {
address creator;
uint256 entryFee; // USDC amount
uint64 startTime;
uint64 endTime;
uint8 maxPlayers;
uint8 playerCount;
bool finalized;
uint256 prizePool;
}
mapping(uint256 => Tournament) public tournaments;
mapping(uint256 => mapping(address => uint32)) public tournamentScores;
mapping(uint256 => mapping(address => bool)) public inTournament;
mapping(uint256 => address[]) internal _tournamentPlayers;
uint256 public nextTournamentId;
uint256 public protocolRevenue;
uint256 public weeklyRewardPool;
Functions:
function createTournament(uint256 entryFee, uint64 startTime, uint64 endTime, uint8 maxPlayers)
external returns (uint256)
— require startTime > block.timestamp
— require endTime > startTime
— require maxPlayers >= 2 && maxPlayers <= 64
function joinTournament(uint256 tournamentId) external
— require tournament not started yet OR is in progress
— require playerCount < maxPlayers
— require !inTournament[id][msg.sender]
— transferFrom USDC entry fee from player to contract
— increment prizePool and playerCount
function submitTournamentScore(uint256 tournamentId, uint256 gameId, ...) external
— same validation as submitScore
— require inTournament[tournamentId][msg.sender]
— store the player's best score (keep highest)
function finalizeTournament(uint256 tournamentId) external
— require block.timestamp > endTime
— require !finalized
— sort players by score (bounded by maxPlayers <= 64)
— distribute USDC:
1st: 50% of prizePool
2nd: 25%
3rd: 15%
protocol: 5% → protocolRevenue
weekly pool: 5% → weeklyRewardPool
— If < 3 players, adjust distribution accordingly
— Mark finalized
function withdrawProtocolRevenue() external onlyOwner
— transfers accumulated protocolRevenue in USDC to owner
Use IERC20(CUSD).transfer() and transferFrom() for all USDC movement.
Use ReentrancyGuard on all functions that move USDC.
Write tests (use a mock ERC20 for USDC in tests):
- Create tournament, 4 players join, submit scores, finalize
- Verify prize distribution amounts
- Cannot join after max players reached
- Cannot finalize before end time
- Cannot join without sufficient USDC approval
- Protocol revenue accumulates correctly
Acceptance: Full tournament lifecycle works in Forge tests with mock USDC.
Prompt to AI editor:
Create contracts/script/Deploy.s.sol
- Deploy BlokzGame as a UUPS proxy
- Set the deployer as owner
- Verify on CeloScan (Alfajores)
Create a .env.example with:
PRIVATE_KEY=
ALFAJORES_RPC=https://alfajores-forno.celo-testnet.org
CELOSCAN_API_KEY=
Add a Makefile or npm scripts:
deploy-testnet: forge script ... --rpc-url $ALFAJORES_RPC --broadcast --verify
test: forge test -vvv
Document the deployed testnet address in contracts/DEPLOYMENTS.md
Acceptance: Contract is deployed to Alfajores. Verified on CeloScan. Address documented.
Goal: Connect the playable game UI to the deployed smart contract. Seed commit, score submit, leaderboard display, tournament flow — all wired end-to-end.
Duration: 4–5 days
Prompt to AI editor:
Install dependencies:
pnpm add wagmi viem @tanstack/react-query @farcaster/miniapp-sdk @farcaster/miniapp-wagmi-connector
Create src/chain/config.ts:
- Configure Wagmi with celo and celoAlfajores chains
- Use farcasterMiniApp() connector
- HTTP transports for both chains
Create src/chain/contracts.ts:
- Export BLOKZ_GAME_ADDRESS (from testnet deployment)
- Export CUSD_ADDRESS (Celo mainnet: 0x01C5C0122039549AD1493B8220cABEdD739BC44E)
- Export the contract ABI (copy from forge artifacts)
Wrap the App in WagmiProvider + QueryClientProvider in main.tsx.
Create src/components/WalletStatus.tsx:
- Shows connected address (truncated) or "Connect" button
- Uses useAccount() and useConnect() from wagmi
- In MiniApp mode: auto-connects, hides the button
- In browser mode: shows connect button
Acceptance: App connects to wallet inside Warpcast. Shows address in WalletStatus.
Prompt to AI editor:
Create src/chain/useStartGame.ts:
Hook that:
1. Generates a random 32-byte seed using crypto.getRandomValues()
2. Computes seedHash = keccak256(abi.encodePacked(seed, playerAddress))
3. Sends startGame(seedHash) transaction
4. On confirmation, stores seed in gameStore and creates a new GameSession(seed)
5. Returns { start, isPending, isSuccess, error }
Create src/chain/useSubmitScore.ts:
Hook that:
1. Gets the replay proof from GameSession.getReplayProof()
2. Sends submitScore(gameId, seed, packedMoves, score, moveCount)
3. On confirmation, updates UI to show "Score Submitted!"
4. Returns { submit, isPending, isSuccess, error }
Update GameOverModal:
- "Submit Score" button calls useSubmitScore
- Shows loading spinner while tx is pending
- Shows success message with score + tx hash on success
- Shows error message on failure with retry option
Acceptance: Play a game → game over → submit score → see it on-chain in CeloScan.
Prompt to AI editor:
Create src/chain/useLeaderboard.ts:
Hook that:
1. Calls getLeaderboard(epoch) on the contract (read-only)
2. Also calls getCurrentEpoch()
3. Returns { entries, epoch, isLoading, error }
4. Auto-refreshes every 30 seconds
Create src/components/LeaderboardView.tsx:
- Shows current epoch's top 50 scores
- Each row: rank, truncated address, score, time ago
- Highlight the current player's entry (if present)
- Tab to switch between "This Week" and previous epochs
- Pull-to-refresh gesture
- "Your Best" section at the top showing the player's highest score
Acceptance: After submitting a score, it appears on the leaderboard within one block confirmation.
Prompt to AI editor:
Create src/chain/useTournament.ts:
Hooks for:
- useTournamentList(): reads active tournaments, returns array
- useJoinTournament(id, entryFee): batches USDC approve + joinTournament
using useSendCalls (EIP-5792)
- useSubmitTournamentScore(tournamentId, gameId, ...): submits score
- useTournamentResults(id): reads scores after finalization
Create src/components/TournamentLobby.tsx:
- List of active tournaments (entry fee, players, time remaining)
- "Join" button per tournament (triggers batch tx)
- After joining, shows "Play Tournament Game" button
- Tournament games use the same GameScreen but scores go to
submitTournamentScore instead of submitScore
Create src/components/TournamentResults.tsx:
- Shows final standings after tournament ends
- Prize amounts per place
- "Claim Prize" button if the player won (calls finalizeTournament
if not yet finalized, then shows claim)
Acceptance: Create a tournament, have 2+ addresses join, play games, finalize, verify USDC distribution.
Goal: Full Farcaster MiniApp experience — haptics, social sharing, manifest, environment detection, and production deployment.
Duration: 3–4 days
Prompt to AI editor:
Update src/miniapp/init.ts:
- Call sdk.actions.ready() after React app mounts and first render completes
- Store QuickAuth token in walletStore
- Handle the case where sdk is not available (browser mode)
Update src/miniapp/haptics.ts:
- Replace console.log stubs with actual SDK calls:
hapticSelection → sdk.haptics.selection()
hapticImpact → sdk.haptics.impact()
hapticNotification → sdk.haptics.notification()
- Wrap each in try/catch with isInMiniApp() guard
Update src/miniapp/env.ts:
- Export isInMiniApp() from the SDK
- Export a useIsMiniApp() React hook
Update App.tsx:
- If isInMiniApp(): use Farcaster connector, hide wallet UI
- If not: show standard wallet connection (fallback for browser testing)
Acceptance: App loads without infinite splash screen in Warpcast. Haptics fire on piece placement and line clears.
Prompt to AI editor:
Create src/miniapp/share.ts:
function shareScore(score: number, comboMax: number, linesCleared: number): void
— Composes a cast text:
"🧱 I scored {score} in Blokaz! 🔥 Max combo: x{comboMax} | Lines: {linesCleared}
Can you beat me? Play now 👇"
— Uses the Farcaster share mechanism (or clipboard + toast as fallback)
Update GameOverModal:
- "Share" button calls shareScore
- In browser mode, copies text to clipboard with a toast notification
- In MiniApp mode, opens the Farcaster cast composer
Acceptance: After game over, sharing produces a properly formatted score card.
Prompt to AI editor:
1. Create the manifest.json in public/:
{
"name": "Blokaz",
"description": "Block puzzle meets blockchain. Clear lines, chain combos, win USDC on Celo.",
"iconUrl": "https://blokaz.xyz/icon-512.png",
"splashImageUrl": "https://blokaz.xyz/splash-1920x1080.png",
"splashBackgroundColor": "#0F172A",
"url": "https://blokaz.xyz",
"requiredChains": [42220],
"requiredCapabilities": ["wallet_sendCalls"]
}
2. Create placeholder icon (512×512) and splash (1920×1080) images.
Use the game's dark theme with "BLOKAZ" text and a stylized grid.
3. Configure Vite for production build:
- Output to dist/
- Code splitting: vendor chunk for wagmi/viem
- Asset hashing
- Gzip compression
4. Set up Vercel deployment:
- vercel.json with SPA rewrite rules
- Environment variables for contract addresses
- Preview deployments on PRs
5. Switch contract address to Celo mainnet when ready:
- Deploy to mainnet via forge script
- Update DEPLOYMENTS.md
- Update chain/contracts.ts
6. Publish the MiniApp following the Farcaster publishing guide.
Acceptance: Game is live at a public URL, playable inside Warpcast, connected to Celo mainnet.
Goal: Comprehensive testing, edge case handling, and performance optimization before public launch.
Duration: 3–4 days
Prompt to AI editor:
Create src/engine/__tests__/fuzz.test.ts:
Run 1000 automated games with random seeds:
1. Create GameSession with random seed
2. On each deal, pick the first valid placement for each piece
(iterate all positions, take the first where canPlace is true)
3. Continue until game over
4. Assert: score >= 0, moveCount > 0, no runtime errors
5. Assert: replay proof packs/unpacks correctly for each game
6. Assert: two runs with the same seed produce identical move histories
Also write a stress test:
- Play 100 games and measure average game duration, score, move count
- Log distribution of game lengths (to verify the difficulty curve)
Acceptance: 1000 games complete without crashes. Determinism holds for all.
Prompt to AI editor:
Create contracts/test/BlokzGame.invariant.t.sol:
Foundry invariant tests:
- Total USDC in contract >= sum of all unfulfilled tournament prize pools
- Leaderboard size never exceeds LEADERBOARD_SIZE
- No player can have more than 1 active game
- All finalized tournaments have distributed exactly the correct amounts
- protocolRevenue + weeklyRewardPool + distributed = total USDC ever received
Also write fuzz tests for _spotCheckMoves:
- Fuzz with random seeds and move counts
- Verify it doesn't revert on valid inputs
- Verify it reverts on obviously invalid inputs (row >= 9, pieceIndex >= 3)
Acceptance: forge test --invariant passes with 10000 runs.
Prompt to AI editor:
Profile and optimize the canvas rendering:
1. Measure FPS during gameplay on a low-end Android device (or throttled Chrome)
2. Optimizations to apply:
- Only redraw cells that changed (dirty rect tracking)
- Use offscreen canvas for static grid background
- Batch draw calls for filled cells (single path operation)
- Limit animation updates to 30fps for non-critical effects
- Use requestAnimationFrame correctly (no double-RAF)
3. Measure bundle size with `npx vite-bundle-visualizer`
4. Apply code splitting: lazy-load LeaderboardView and TournamentLobby
5. Target: < 200KB initial JS bundle (gzipped), 60fps on iPhone SE 2
Acceptance: Lighthouse performance score > 90 on mobile. No jank during gameplay.
| Phase | Duration | Deliverable |
|---|---|---|
| 0. Scaffold | 0.5 day | Running Vite + React project |
| 1. Game Engine | 3–4 days | Fully tested deterministic engine |
| 2. Canvas UI | 4–5 days | Playable game in browser |
| 3. Smart Contracts | 5–6 days | Deployed + tested contracts on Alfajores |
| 4. Chain Integration | 4–5 days | Game connected to on-chain scoring + tournaments |
| 5. MiniApp Polish | 3–4 days | Live Farcaster MiniApp on Celo mainnet |
| 6. QA & Hardening | 3–4 days | Fuzz tested, performant, production-ready |
| Total | ~24–29 days | Production MVP |
Each phase is independent enough to be parallelized where tasks don't depend on each other (e.g., Phase 3 contracts can be built while Phase 2 UI is in progress).